drawer · glass-apple · 灵感来源:HIG Sheets / M3 Navigation drawer(复用 modal 的焦点纪律)
Tab 在抽屉内循环 · Esc 或点遮罩关闭
Side Sheet / Drawer · 玻璃抽屉 · 抽屉 / Glass
做一个 Side Sheet / Drawer(玻璃抽屉):
- role=dialog + aria-modal + 标题命名;打开移入首个可聚焦,Tab 首尾循环
- Esc / 遮罩 / 关闭按钮三条关闭路径,关闭归还焦点
- 进出成对:侧边滑入滑出,遮罩同步淡入淡出
- 宽 min(420px, 100vw),正文内滚、页头页脚固定;不做嵌套抽屉
用这些 CSS 变量:--color-accent --color-text --color-text-muted --color-text-subtle --dur-fast --ease-standard --glass-bg --glass-border --shadow-lg --shadow-scrim --text-2xs
灵感参考:HIG Sheets / M3 Navigation drawer(复用 modal 的焦点纪律)--color-accent --color-text --color-text-muted --color-text-subtle --dur-fast --ease-standard --glass-bg --glass-border --shadow-lg --shadow-scrim --text-2xs
焦点 / 禁用
/r/drawer--glass-apple.json
免外壳的 markdown 版:/llms/components/drawer--glass-apple.mdrole="dialog" + aria-modal + 标题命名useOverlay 成对:进场从侧边滑入,退场滑回去再卸载;遮罩同步淡入淡出min(420px, 100vw);正文自己滚,页头与页脚固定import { useEffect, useId, useRef, type ReactNode } from 'react';
import { X } from 'lucide-react';
import { Icon } from '~/components/Icon';
import { useOverlay } from '~/lib/overlay';
import { Portal } from '~/lib/layer';
interface Props {
open: boolean;
onClose: () => void;
title?: string;
side?: 'right' | 'left';
children?: ReactNode;
footer?: ReactNode;
}
/**
* 抽屉:复用 modal 的焦点纪律(打开移入、Tab 首尾循环、关闭归还),
* 视觉上从侧边滑入,遮罩点击与 Esc 都能关。
*/
export function GlassAppleDrawer({
open,
onClose,
title = '详情',
side = 'right',
children,
footer,
}: Props) {
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const restoreRef = useRef<HTMLElement | null>(null);
const { mounted, entering, leaving } = useOverlay(open);
useEffect(() => {
if (!open) return;
restoreRef.current = document.activeElement as HTMLElement | null;
}, [open]);
useEffect(() => {
// 面板要等 useOverlay 挂上去才存在,按 mounted 触发才能把焦点送进去
if (!mounted) return;
const focusables = () =>
Array.from(
panelRef.current?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
) ?? [],
);
focusables()[0]?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
return;
}
if (e.key !== 'Tab') return;
const list = focusables();
if (!list.length) return;
const first = list[0];
const last = list[list.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [mounted, onClose]);
useEffect(() => {
if (!open) restoreRef.current?.focus();
}, [open]);
if (!mounted) return null;
const sideClasses = side === 'right' ? 'inset-y-0 right-0 rounded-l-2xl' : 'inset-y-0 left-0 rounded-r-2xl';
const hiddenTransform = side === 'right' ? 'translate-x-full' : '-translate-x-full';
return (
<Portal>
<div
onClick={onClose}
aria-hidden="true"
className={`
fixed inset-0 z-40 bg-[var(--shadow-scrim)]
transition-opacity duration-[var(--dur-fast)] ease-[var(--ease-standard)]
${entering || leaving ? 'opacity-0' : 'opacity-100'}
`}
/>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className={`
fixed ${sideClasses} z-50
w-[min(420px,100vw)] flex flex-col
bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)]
shadow-[var(--shadow-lg)]
transition-transform duration-[var(--dur-fast)] ease-[var(--ease-standard)]
${entering || leaving ? hiddenTransform : 'translate-x-0'}
`}
>
<header className="flex items-center justify-between gap-3 px-5 py-4 border-b border-[var(--glass-border)]">
<h2 id={titleId} className="text-sm font-medium">
{title}
</h2>
<button
type="button"
onClick={onClose}
aria-label="关闭"
className="press-feedback grid place-items-center size-7 rounded-full text-[var(--color-text-subtle)] hover:text-[var(--color-text)] cursor-pointer"
>
<Icon icon={X} size={14} />
</button>
</header>
<div className="flex-1 overflow-y-auto px-5 py-4 text-sm">{children}</div>
{footer && (
<footer className="px-5 py-3 border-t border-[var(--glass-border)] flex items-center justify-end gap-2">
{footer}
</footer>
)}
</div>
</Portal>
);
}
src/components/drawer/glass-apple/