# 玻璃可搜索下拉（List-filtering Combobox）

- 类型 / 材质：`combobox` / `glass-apple`（玻璃）
- 状态：done ｜ 最后提交：2026-11-01
- 灵感来源：APG list-filtering combobox / Polaris autocomplete
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/combobox/glass-apple` ｜ 条目：`/r/combobox--glass-apple.json`
- 必缺状态：焦点 / 禁用
- 整页场景：
  - 组件目录（`/r/scene--list.json`）

## 规格

- input role=combobox + aria-expanded / aria-controls / aria-autocomplete=list / aria-activedescendant
- 列表 role=listbox，项 role=option + aria-selected；焦点留在输入框，不跳进列表
- 上下键在关闭时先打开，Home / End 跳两端，Enter 选中并阻止表单提交，Esc 只收弹层
- 结果数 aria-live=polite 播报；禁用项可见不可选
- 建议列表 portal 到 body + fixed，贴左且与输入框同宽（面板有 overflow 裁切）

## 参数（CSS 变量）

`--color-accent` `--color-text` `--color-text-muted` `--color-text-subtle` `--color-warning-fg` `--dur-fast` `--glass-bg` `--glass-border` `--glass-highlight` `--glass-inset` `--glass-recess` `--shadow-lg` `--text-2xs` `--text-base` `--text-xs`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `options` | `ComboboxOption[]` | 必填 | — |
| `value` | `string` | 必填 | 输入框里的文本：受控，过滤由组件做 |
| `onChange` | `(value: string) => void` | 可选 | — |
| `onSelect` | `(option: ComboboxOption) => void` | 可选 | 选中一条建议 |
| `label` | `string` | 可选 | — |
| `placeholder` | `string` | 可选 | — |
| `noResults` | `string` | 可选 | 没有匹配时的文案 |
| `disabled` | `boolean` | 可选 | — |
| `className` | `string` | 可选 | — |

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

```
复现「玻璃可搜索下拉」— List-filtering Combobox（搜索下拉 / Glass）
灵感来源：APG list-filtering combobox / Polaris autocomplete
规格：
- input role=combobox + aria-expanded / aria-controls / aria-autocomplete=list / aria-activedescendant
- 列表 role=listbox，项 role=option + aria-selected；焦点留在输入框，不跳进列表
- 上下键在关闭时先打开，Home / End 跳两端，Enter 选中并阻止表单提交，Esc 只收弹层
- 结果数 aria-live=polite 播报；禁用项可见不可选
- 建议列表 portal 到 body + fixed，贴左且与输入框同宽（面板有 overflow 裁切）
参数（CSS 变量）：--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-bg --glass-border --glass-highlight --glass-inset --glass-recess --shadow-lg --text-2xs --text-base --text-xs
必缺状态：焦点 / 禁用
整页场景：组件目录
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/combobox/glass-apple · 标准 interaction /design#interaction · 调研笔记 /showcase/component/combobox/glass-apple#notes
```

## 调研笔记

import { ChevronDown, Search } from 'lucide-react';
import { Icon } from '@/components/ui-echo/Icon';
import { Field } from '@/components/ui-echo/Field';
import { Portal } from '@/lib/ui-echo/layer';
import { useCombobox, type ComboboxOption } from '@/lib/ui-echo/combobox';

export type { ComboboxOption };

interface Props {
  options: ComboboxOption[];
  /** 输入框里的文本：受控，过滤由组件做 */
  value: string;
  onChange?: (value: string) => void;
  /** 选中一条建议 */
  onSelect?: (option: ComboboxOption) => void;
  label?: string;
  placeholder?: string;
  /** 没有匹配时的文案 */
  noResults?: string;
  disabled?: boolean;
  className?: string;
}

/** 玻璃可搜索下拉：半透明底 + 模糊 + 发丝边；建议列表用亮一档的玻璃面浮起来 */
export function GlassAppleCombobox({
  options,
  value,
  onChange,
  onSelect,
  label,
  placeholder = '搜索…',
  noResults = '没有匹配项',
  disabled = false,
  className = '',
}: Props) {
  const cb = useCombobox({ options, value, disabled, onSelect });

  return (
    <Field label={label} className={className}>
      {(field) => (
        <div
          ref={(n) => {
            cb.triggerRef.current = n;
          }}
          className="relative w-full"
        >
          <div
            className={`
              flex items-center gap-2 w-full h-10 px-3.5
              rounded-xl text-sm text-[var(--color-text)]
              bg-[var(--glass-bg)] border border-[var(--glass-border)]
              backdrop-blur-xl shadow-[var(--glass-inset)]
              transition-[background-color,border-color] duration-[var(--dur-fast)]
              focus-within:bg-[var(--glass-highlight)] focus-within:border-[var(--color-accent)]
              ${disabled ? 'opacity-50' : ''}
            `}
          >
            <Icon icon={Search} size={14} className="shrink-0 text-[var(--color-text-subtle)]" />
            <input
              ref={cb.inputRef}
              id={field.id}
              type="text"
              role="combobox"
              aria-expanded={cb.open}
              aria-controls={cb.open ? cb.listId : undefined}
              aria-autocomplete="list"
              aria-activedescendant={cb.open && cb.filtered[cb.active] ? cb.optionId(cb.active) : undefined}
              aria-describedby={field.describedBy}
              aria-label={label ? undefined : placeholder}
              autoComplete="off"
              disabled={disabled}
              value={value}
              placeholder={placeholder}
              onChange={(e) => {
                onChange?.(e.target.value);
                cb.setOpen(true);
              }}
              onFocus={() => cb.setOpen(true)}
              onKeyDown={cb.onKeyDown}
              className={`
                flex-1 min-w-0 bg-transparent text-[var(--color-text)]
                placeholder:text-[var(--color-text-subtle)] focus:outline-none
                disabled:cursor-not-allowed
              `}
            />
            <Icon
              icon={ChevronDown}
              size={14}
              className={`
                shrink-0 text-[var(--color-text-subtle)]
                transition-transform duration-[var(--dur-fast)] ${cb.open ? 'rotate-180' : ''}
              `}
            />
          </div>

          <p className="sr-only" aria-live="polite">
            {cb.open ? `${cb.filtered.length} 个匹配` : ''}
          </p>

          {cb.open && (
            <Portal>
              <div
                ref={(n) => {
                  cb.layerRef.current = n;
                }}
                style={cb.layerStyle}
                className="z-30"
              >
                <ul
                  id={cb.listId}
                  role="listbox"
                  aria-label={label ?? '建议'}
                  className="
                    anim-fade-up max-h-72 overflow-y-auto p-1.5
                    rounded-xl bg-[var(--glass-highlight)]
                    border border-[var(--glass-border)] backdrop-blur-xl
                    shadow-[var(--shadow-lg)]
                  "
                >
                  {cb.filtered.length === 0 ? (
                    <li className="px-3 py-2.5 text-sm text-[var(--color-text-subtle)]">{noResults}</li>
                  ) : (
                    cb.filtered.map((o, i) => (
                      <li
                        key={o.value}
                        id={cb.optionId(i)}
                        ref={(el) => {
                          cb.optionRefs.current[i] = el;
                        }}
                        role="option"
                        aria-selected={i === cb.active}
                        aria-disabled={o.disabled || undefined}
                        onPointerDown={(e) => e.preventDefault()}
                        onPointerMove={() => {
                          if (!o.disabled) cb.setActive(i);
                        }}
                        onClick={() => cb.pick(o)}
                        className={`
                          flex items-center justify-between gap-3 px-3 py-2 rounded-lg text-sm select-none
                          ${o.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
                          ${i === cb.active ? 'bg-[var(--glass-bg)] text-[var(--color-text)]' : 'text-[var(--color-text-muted)]'}
                        `}
                      >
                        <span className="truncate">{o.label}</span>
                        {o.hint && (
                          <span className="shrink-0 font-mono text-[length:var(--text-2xs)] text-[var(--color-text-subtle)]">
                            {o.hint}
                          </span>
                        )}
                      </li>
                    ))
                  )}
                </ul>
              </div>
            </Portal>
          )}
        </div>
      )}
    </Field>
  );
}
