combobox · minimal · 灵感来源:APG list-filtering combobox / Polaris autocomplete
列表贴左、与输入框同宽,不居中飘走
还没选
List-filtering Combobox · 极简可搜索下拉 · 搜索下拉 / Minimal
做一个 List-filtering Combobox(极简可搜索下拉):
- 同一份 props 与行为(~/lib/combobox)
- 平底 + 发丝边 + 软阴影,零模糊零 inset;圆角退到 rounded-md
- 活动项用底色而不是阴影
- 键盘与 aria 口径同玻璃可搜索下拉
用这些 CSS 变量:--color-accent --color-bg --color-divider --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-border --glass-recess --shadow-md --shadow-sm --text-2xs --text-base --text-xs
灵感参考:APG list-filtering combobox / Polaris autocomplete--color-accent --color-bg --color-divider --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-border --glass-recess --shadow-md --shadow-sm --text-2xs --text-base --text-xs
焦点 / 禁用
(尚未接进整页场景)
/r/combobox--minimal.json
免外壳的 markdown 版:/llms/components/combobox--minimal.md~/lib/combobox),键盘与 aria 口径见玻璃那支的 notes--shadow-md 与一条边框浮起,活动项用底色而不是阴影rounded-md:极简的层级来自字号与留白,不来自半径import { ChevronDown, Search } from 'lucide-react';
import { Icon } from '~/components/Icon';
import { Field } from '~/components/Field';
import { Portal } from '~/lib/layer';
import { useCombobox, type ComboboxOption } from '~/lib/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;
}
/** 极简可搜索下拉:平底 + 发丝边,零模糊零 inset;建议列表靠一条边框和软阴影浮起来 */
export function MinimalCombobox({
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-md text-sm text-[var(--color-text)]
bg-[var(--color-surface)] border border-[var(--color-divider)]
shadow-[var(--shadow-sm)]
transition-[border-color] duration-[var(--dur-fast)]
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
rounded-md bg-[var(--color-surface)]
border border-[var(--color-divider)] shadow-[var(--shadow-md)]
"
>
{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 text-sm select-none
${o.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
${i === cb.active ? 'bg-[var(--color-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>
);
}
src/components/combobox/minimal/