menu · glass-apple · 灵感来源:WAI-ARIA Menu Button(含 APG 键位)
点开或 ↓ 进入;↑ ↓ / Home / End 移动,Enter 选中,Esc 关闭并归还焦点
最近选择:还没有选择
Menu Button (APG) · 玻璃菜单 · 菜单 / Glass
做一个 Menu Button (APG)(玻璃菜单):
- 触发器 aria-haspopup=menu + aria-expanded + aria-controls;菜单 role=menu,项 role=menuitem
- 点开或 ↓ 打开并聚焦首项;↑ 打开聚焦末项
- ↑ ↓ 环绕并跳过禁用项,Home / End 到两端,Enter / Space 选中,Esc 关闭并归还焦点
- 不做 typeahead;danger 只换文字色,不换底色
用这些 CSS 变量:--color-surface --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --ease-standard --glass-bg --glass-border --glass-highlight --shadow-lg
灵感参考:WAI-ARIA Menu Button(含 APG 键位)--color-surface --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --ease-standard --glass-bg --glass-border --glass-highlight --shadow-lg
焦点 / 按压 / 禁用
/r/menu--glass-apple.json
免外壳的 markdown 版:/llms/components/menu--glass-apple.mdaria-haspopup="menu" + aria-expanded + aria-controls;菜单 role="menu",项 role="menuitem"danger 只换文字色,不换底色——破坏性操作不该靠一块红底才被认出import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { useOverlay } from '~/lib/overlay';
import { Portal, layerStyle, useDismiss, useLayerPosition } from '~/lib/layer';
export interface MenuItem {
label: string;
onSelect?: () => void;
disabled?: boolean;
/** 破坏性操作:文字转语义色,不换底色 */
danger?: boolean;
}
interface Props {
/** 触发器文字 */
label: ReactNode;
items: MenuItem[];
placement?: 'top' | 'bottom';
className?: string;
}
export function GlassAppleMenu({ label, items, placement = 'bottom', className = '' }: Props) {
const id = useId();
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const { mounted, entering, leaving } = useOverlay(open);
const { triggerRef, layerRef, pos } = useLayerPosition(mounted, { placement });
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const initialFocus = useRef(0);
useDismiss({
open,
onClose: () => setOpen(false),
trigger: triggerRef,
layer: layerRef,
});
const enabled = (i: number) => !items[i]?.disabled;
const firstEnabled = () => items.findIndex((_, i) => enabled(i));
const lastEnabled = () => items.map((_, i) => i).reverse().find(enabled) ?? 0;
const focusAt = (index: number) => {
setActive(index);
itemRefs.current[index]?.focus();
};
const move = (dir: 1 | -1) => {
const n = items.length;
let next = active;
for (let step = 0; step < n; step += 1) {
next = (next + dir + n) % n;
if (enabled(next)) return focusAt(next);
}
};
const openWith = (index: number) => {
initialFocus.current = index;
setOpen(true);
setActive(index);
};
// 打开后把焦点送进菜单:键盘用户不会掉在触发器上按空。
// 等定位算完(pos 就绪、菜单可见)再给焦点,否则 focus() 会被 hidden 吞掉。
useEffect(() => {
if (!mounted || !pos) return;
const target = enabled(initialFocus.current) ? initialFocus.current : firstEnabled();
if (target !== -1) focusAt(target);
}, [mounted, pos]);
const onMenuKey = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
move(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
move(-1);
} else if (e.key === 'Home') {
e.preventDefault();
const first = firstEnabled();
if (first !== -1) focusAt(first);
} else if (e.key === 'End') {
e.preventDefault();
focusAt(lastEnabled());
}
};
const select = (index: number) => {
const item = items[index];
if (!item || item.disabled) return;
item.onSelect?.();
setOpen(false);
};
return (
<>
<button
ref={(node) => {
triggerRef.current = node;
}}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? id : undefined}
onClick={() => (open ? setOpen(false) : openWith(0))}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
openWith(0);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
openWith(lastEnabled());
}
}}
className="
press-feedback px-3.5 py-2 rounded-lg text-xs
border border-[var(--glass-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)]
hover:text-[var(--color-text)] transition-colors cursor-pointer
"
>
{label}
</button>
{mounted && (
<Portal>
<div
ref={layerRef}
id={id}
role="menu"
aria-label={typeof label === 'string' ? label : '菜单'}
style={layerStyle(pos)}
onKeyDown={onMenuKey}
className={`
z-50 min-w-[168px] p-1 rounded-lg
bg-[var(--glass-bg)] border border-[var(--glass-border)] backdrop-blur-xl
shadow-[var(--shadow-lg)]
transition-[opacity,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]
${entering || leaving ? 'opacity-0 translate-y-1' : 'opacity-100 translate-y-0'}
${className}
`}
>
{items.map((item, i) => (
<button
key={item.label}
ref={(node) => {
itemRefs.current[i] = node;
}}
type="button"
role="menuitem"
tabIndex={active === i ? 0 : -1}
disabled={item.disabled}
onPointerEnter={() => setActive(i)}
onClick={() => select(i)}
className={`
w-full text-left px-3 py-2 rounded-md text-xs
transition-colors cursor-pointer
${item.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-[var(--glass-highlight)]'}
${item.danger ? 'text-[var(--color-warning-fg)]' : 'text-[var(--color-text)]'}
`}
>
{item.label}
</button>
))}
</div>
</Portal>
)}
</>
);
}
src/components/menu/glass-apple/