极简下拉

dropdown · minimal · 灵感来源:APG Select-Only Combobox + macOS Pop-up Button

部署环境

当前:staging · ↑↓ 移动,Enter 选中,Esc 收起;「生产」是禁用项

运行区域(未选择)
已停用

复用卡

Select-Only Combobox (APG) · 极简下拉 · 下拉 / Minimal

规格 · 术语提示词(给 AI 用)

做一个 Select-Only Combobox (APG)(极简下拉):
- 触发器 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
灵感参考:APG Select-Only Combobox + macOS Pop-up Button

参数(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

必缺状态

焦点 / 按压 / 禁用

整页场景

(尚未接进整页场景)

安装(一条链接)

调研笔记

极简下拉

拆解

  • 与玻璃版同一套 APG 行为:触发器 role="combobox",弹层 role="listbox", aria-activedescendant 指向高亮项,焦点始终在触发器
  • ↑↓ / Home / End / Enter / 空格 / Esc / Tab 全部覆盖;禁用项在移动与点选时都跳过
  • 点击外部关闭,弹层宽度跟随触发器;选中项 accent 文字 + 勾

材质

  • 触发器:实底白 + 一条发丝边 + --shadow-sm,没有模糊和内阴影
  • 弹层:白底 + 发丝边 + --shadow-md;高亮项用 --glass-recess
  • 没有玻璃高光,打开与选中的层级只靠边线与阴影深度表达

边界

  • 无 typeahead;不挂 portal、不做翻转
  • 关闭立即卸载,不做退场排队
  • 圆角走 rounded-md,与极简输入框同一档

变量:--color-surface / --color-divider / --glass-border / --shadow-sm / --shadow-md / --color-accent。

实现

dropdown/minimal
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { Icon } from '~/components/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>
  );
}

元信息

标识
dropdown/minimal
状态
done
最后提交
2026-10-31
风格轴
minimal
依赖 token
--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
在工作台打开 源码与 notes 都在 src/components/dropdown/minimal/