# 极简下拉（Select-Only Combobox (APG)）

- 类型 / 材质：`dropdown` / `minimal`（极简）
- 状态：done ｜ 最后提交：2026-10-31
- 灵感来源：APG Select-Only Combobox + macOS Pop-up Button
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/dropdown/minimal` ｜ 条目：`/r/dropdown--minimal.json`
- 必缺状态：焦点 / 按压 / 禁用
- 整页场景：尚未接入

## 规格

- 触发器 role=combobox，弹层 role=listbox，aria-activedescendant 指向高亮项
- ↑↓ / Home / End 移动，Enter / 空格选中，Esc / Tab 收起，点外关闭
- 焦点始终留在触发器；禁用项跳过，选中项 accent 文字 + 勾
- 弹层：白底 + 发丝边 + shadow-md，高亮项用凹陷底色

## 参数（CSS 变量）

`--color-accent` `--color-divider` `--color-surface` `--color-text` `--color-text-muted` `--color-text-subtle` `--dur-fast` `--glass-border` `--glass-recess` `--shadow-md` `--shadow-sm` `--text-xs`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `options` | `DropdownOption[]` | 必填 | — |
| `value` | `string` | 必填 | — |
| `onChange` | `(value: string) => void` | 可选 | — |
| `label` | `string` | 可选 | — |
| `placeholder` | `string` | 可选 | — |
| `disabled` | `boolean` | 可选 | — |
| `className` | `string` | 可选 | — |

## 提示词（可直接粘给智能体）

```
复现「极简下拉」— Select-Only Combobox (APG)（下拉 / Minimal）
灵感来源：APG Select-Only Combobox + macOS Pop-up Button
规格：
- 触发器 role=combobox，弹层 role=listbox，aria-activedescendant 指向高亮项
- ↑↓ / Home / End 移动，Enter / 空格选中，Esc / Tab 收起，点外关闭
- 焦点始终留在触发器；禁用项跳过，选中项 accent 文字 + 勾
- 弹层：白底 + 发丝边 + shadow-md，高亮项用凹陷底色
参数（CSS 变量）：--color-accent --color-divider --color-surface --color-text --color-text-muted --color-text-subtle --dur-fast --glass-border --glass-recess --shadow-md --shadow-sm --text-xs
必缺状态：焦点 / 按压 / 禁用
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/dropdown/minimal · 标准 interaction /design#interaction · 调研笔记 /showcase/component/dropdown/minimal#notes
```

## 调研笔记

import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { Icon } from '@/components/ui-echo/Icon';

export interface DropdownOption {
  value: string;
  label: string;
  disabled?: boolean;
}

interface Props {
  options: DropdownOption[];
  value: string;
  onChange?: (value: string) => void;
  label?: string;
  placeholder?: string;
  disabled?: boolean;
  className?: string;
}

/**
 * 极简下拉：APG Select-Only Combobox。
 * 焦点始终留在触发器上，用 aria-activedescendant 指向高亮项；弹层不抢焦点。
 */
export function MinimalDropdown({
  options,
  value,
  onChange,
  label,
  placeholder = '请选择',
  disabled = false,
  className = '',
}: Props) {
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(0);
  const rootRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const optionRefs = useRef<(HTMLDivElement | null)[]>([]);
  const listId = useId();
  const labelId = useId();
  const selected = options.find((o) => o.value === value);
  const selectedIndex = options.findIndex((o) => o.value === value);

  const firstEnabled = () => options.findIndex((o) => !o.disabled);
  const lastEnabled = () => {
    for (let i = options.length - 1; i >= 0; i--) if (!options[i].disabled) return i;
    return -1;
  };

  const openList = (edge: 'first' | 'last' = 'first') => {
    if (disabled) return;
    const start =
      selectedIndex >= 0 && !options[selectedIndex]?.disabled
        ? selectedIndex
        : edge === 'last'
          ? lastEnabled()
          : firstEnabled();
    setActive(start);
    setOpen(true);
  };

  const select = (index: number) => {
    const option = options[index];
    if (!option || option.disabled) return;
    onChange?.(option.value);
    setOpen(false);
    triggerRef.current?.focus();
  };

  const move = (dir: 1 | -1) => {
    let next = active;
    for (let i = 0; i < options.length; i++) {
      next = (next + dir + options.length) % options.length;
      if (!options[next]?.disabled) break;
    }
    setActive(next);
  };

  const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
    if (disabled) return;
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      e.preventDefault();
      if (!open) openList(e.key === 'ArrowUp' ? 'last' : 'first');
      else move(e.key === 'ArrowDown' ? 1 : -1);
    } else if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      if (open) select(active);
      else openList();
    } else if (e.key === 'Escape' && open) {
      e.preventDefault();
      setOpen(false);
    } else if (e.key === 'Home' && open) {
      e.preventDefault();
      setActive(firstEnabled());
    } else if (e.key === 'End' && open) {
      e.preventDefault();
      setActive(lastEnabled());
    } else if (e.key === 'Tab' && open) {
      setOpen(false);
    }
  };

  useEffect(() => {
    if (!open) return;
    const onPointerDown = (e: PointerEvent) => {
      if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener('pointerdown', onPointerDown, true);
    return () => document.removeEventListener('pointerdown', onPointerDown, true);
  }, [open]);

  useEffect(() => {
    if (open) optionRefs.current[active]?.scrollIntoView({ block: 'nearest' });
  }, [open, active]);

  return (
    <div ref={rootRef} className={`relative ${className}`}>
      {label && (
        <span
          id={labelId}
          className="block text-xs uppercase tracking-[0.18em] text-[var(--color-text-muted)] mb-2"
        >
          {label}
        </span>
      )}

      <button
        ref={triggerRef}
        type="button"
        role="combobox"
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-controls={open ? listId : undefined}
        aria-labelledby={label ? labelId : undefined}
        aria-activedescendant={open ? `${listId}-${active}` : undefined}
        disabled={disabled}
        onClick={() => (open ? setOpen(false) : openList())}
        onKeyDown={onKeyDown}
        className="
          w-full h-10 px-3.5 flex items-center justify-between gap-3
          rounded-md text-sm text-left text-[var(--color-text)]
          bg-[var(--color-surface)] border border-[var(--color-divider)]
          shadow-[var(--shadow-sm)]
          press-feedback
          transition-[background-color,border-color] duration-[var(--dur-fast)]
          hover:border-[var(--color-accent)]/40
          disabled:opacity-50 disabled:cursor-not-allowed
          cursor-pointer
        "
      >
        <span className={selected ? 'truncate' : 'truncate text-[var(--color-text-subtle)]'}>
          {selected?.label ?? placeholder}
        </span>
        <Icon
          icon={ChevronDown}
          size={14}
          className={`
            shrink-0 text-[var(--color-text-subtle)]
            transition-transform duration-[var(--dur-fast)]
            ${open ? 'rotate-180' : ''}
          `}
        />
      </button>

      {open && (
        <div
          id={listId}
          role="listbox"
          aria-labelledby={label ? labelId : undefined}
          aria-label={label ? undefined : '选项'}
          className="
            anim-fade-up
            absolute left-0 right-0 top-full z-20 mt-2 p-1
            rounded-lg bg-[var(--color-surface)]
            border border-[var(--glass-border)]
            shadow-[var(--shadow-md)]
          "
        >
          {options.map((option, index) => {
            const isActive = index === active;
            const isSelected = option.value === value;
            return (
              <div
                key={option.value}
                id={`${listId}-${index}`}
                ref={(el) => {
                  optionRefs.current[index] = el;
                }}
                role="option"
                aria-selected={isSelected}
                aria-disabled={option.disabled || undefined}
                onPointerDown={(e) => e.preventDefault()}
                onPointerMove={() => {
                  if (!option.disabled) setActive(index);
                }}
                onClick={() => select(index)}
                className={`
                  flex items-center justify-between gap-3 px-3 py-2 rounded-md text-sm select-none
                  ${option.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
                  ${isActive && !option.disabled ? 'bg-[var(--glass-recess)]' : ''}
                  ${isSelected ? 'text-[var(--color-accent)]' : 'text-[var(--color-text)]'}
                `}
              >
                <span className="truncate">{option.label}</span>
                {isSelected && (
                  <Icon icon={Check} size={14} strokeWidth={2.25} className="shrink-0" />
                )}
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}
