dropdown · neumorphic · 灵感来源:APG Select-Only Combobox + macOS Pop-up Button
当前:staging · ↑↓ 移动,Enter 选中,Esc 收起;「生产」是禁用项
Select-Only Combobox (APG) · 拟态下拉 · 下拉 / Neumorphic
做一个 Select-Only Combobox (APG)(拟态下拉):
- 触发器 role=combobox,弹层 role=listbox,aria-activedescendant 指向高亮项
- ↑↓ / Home / End 移动,Enter / 空格选中,Esc / Tab 收起,点外关闭
- 焦点始终留在触发器;禁用项跳过,选中项 accent 文字 + 勾
- 触发器浮起、打开换内阴影;弹层同色外双阴影,高亮项压成凹槽
用这些 CSS 变量:--color-accent --color-text --color-text-muted --color-text-subtle --dur-fast --neuro-bg --neuro-dark --neuro-light --neuro-shadow --text-xs
灵感参考:APG Select-Only Combobox + macOS Pop-up Button--color-accent --color-text --color-text-muted --color-text-subtle --dur-fast --neuro-bg --neuro-dark --neuro-light --neuro-shadow --text-xs
焦点 / 按压 / 禁用
(尚未接进整页场景)
/r/dropdown--neumorphic.json
免外壳的 markdown 版:/llms/components/dropdown--neumorphic.mdrole="combobox"、弹层 role="listbox"、
aria-activedescendant 跟高亮项走,焦点不离开触发器rounded-lg,与拟态输入框同一档变量:--neuro-bg / --neuro-light / --neuro-dark / --neuro-shadow / --color-accent。
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 NeumorphicDropdown({
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]);
const triggerShadow = open
? 'shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light)]'
: 'shadow-[var(--neuro-shadow)]';
const optionActive =
'shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light)]';
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-lg text-sm text-left text-[var(--color-text)]
bg-[var(--neuro-bg)] ${triggerShadow}
press-feedback
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.5
rounded-xl bg-[var(--neuro-bg)]
shadow-[var(--neuro-shadow)]
"
>
{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-lg text-sm select-none
${option.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
${isActive && !option.disabled ? optionActive : ''}
${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>
);
}
src/components/dropdown/neumorphic/