combobox · neumorphic · 灵感来源:APG list-filtering combobox / Polaris autocomplete
输入框是槽,列表浮起,活动项再压回槽里
还没选
List-filtering Combobox · 拟态可搜索下拉 · 搜索下拉 / Neumorphic
做一个 List-filtering Combobox(拟态可搜索下拉):
- 同一份 props 与行为(~/lib/combobox)
- 输入框是挤进去的槽(inset 双阴影无边框),列表是同色浮起的面板(外双阴影)
- 活动项再压回槽里:用内阴影表示选中,不加边框不换色块
- 键盘与 aria 口径同玻璃可搜索下拉
用这些 CSS 变量:--color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-border --glass-recess --neuro-bg --neuro-dark --neuro-light --text-2xs --text-base --text-xs
灵感参考:APG list-filtering combobox / Polaris autocomplete--color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-border --glass-recess --neuro-bg --neuro-dark --neuro-light --text-2xs --text-base --text-xs
焦点 / 禁用
(尚未接进整页场景)
/r/combobox--neumorphic.json
免外壳的 markdown 版:/llms/components/combobox--neumorphic.md~/lib/combobox),键盘与 aria 口径见玻璃那支的 notesaria-disabled 与文案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;
}
/** 拟态可搜索下拉:输入框是挤进去的槽,建议列表是同色浮起的面板,活动项再压回槽里 */
export function NeumorphicCombobox({
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(--neuro-bg)] border border-transparent
shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light)]
transition-shadow duration-[var(--dur-fast)]
${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-2xl bg-[var(--neuro-bg)] border border-transparent
shadow-[6px_6px_14px_var(--neuro-dark),-6px_-6px_14px_var(--neuro-light)]
"
>
{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-xl text-sm select-none
${o.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
${i === cb.active ? 'shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light)] 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/neumorphic/