modal · glass-apple · 灵感来源:visionOS 浮层 + Apple system sheet
按 ESC 或点击背景关闭
Sheet / Modal Dialog (APG) · 玻璃模态 · 模态 / Glass
做一个 Sheet / Modal Dialog (APG)(玻璃模态):
- role=dialog + aria-modal + aria-labelledby
- 焦点陷阱:Tab 在内部循环,关闭后焦点归还触发元素
- Esc 关闭 + 点遮罩关闭
- 入场淡入上移,出场缩 0.98 淡出
用这些 CSS 变量:--color-accent --color-bg --color-surface --color-text --color-text-muted --fw-semibold --glass-bg --glass-border --shadow-lg --shadow-scrim
灵感参考:visionOS 浮层 + Apple system sheet--color-accent --color-bg --color-surface --color-text --color-text-muted --fw-semibold --glass-bg --glass-border --shadow-lg --shadow-scrim
焦点 / 禁用
(尚未接进整页场景)
/r/modal--glass-apple.json
免外壳的 markdown 版:/llms/components/modal--glass-apple.md--shadow-scrim),背后内容模糊anim-fade-up),退场缩 0.98 淡出(--dur-exit);Esc 关闭;点背景关闭变量:--glass-bg(surface 28%)/ --color-surface #ffffff。
import { type ReactNode, useEffect, useId, useRef } from 'react';
import { useOverlay } from '~/lib/overlay';
interface Props {
open: boolean;
onClose: () => void;
title?: string;
children?: ReactNode;
actions?: ReactNode;
}
export function GlassAppleModal({
open,
onClose,
title = '玻璃模态',
children,
actions,
}: Props) {
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const { mounted, entering, leaving } = useOverlay(open);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
// APG Dialog:焦点进模态 → Tab 在里面循环 → 关闭后焦点归还给触发元素
useEffect(() => {
if (!open) return;
const restore = document.activeElement as HTMLElement | null;
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 !== 'Tab') return;
const list = focusables();
if (list.length === 0) 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();
}
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
restore?.focus();
};
}, [open]);
if (!mounted) return null;
return (
<div
role="dialog"
aria-modal="true"
className="overlay-scrim fixed inset-0 z-50 flex items-center justify-center"
data-leaving={leaving || undefined}
style={{ background: 'var(--shadow-scrim)', backdropFilter: 'blur(8px)' }}
onClick={onClose}
>
<div
ref={panelRef}
aria-labelledby={titleId}
onClick={(e) => e.stopPropagation()}
data-entering={entering || undefined}
data-leaving={leaving || undefined}
className="
overlay-panel
w-[480px] max-w-[92vw]
rounded-3xl
bg-[var(--color-surface)]
border border-[var(--glass-border)] backdrop-blur-xl
shadow-[var(--shadow-lg)]
p-6
"
>
<div className="flex items-start justify-between mb-4">
<h3
id={titleId}
className="text-xl"
style={{ fontWeight: 'var(--fw-semibold)' }}
>
{title}
</h3>
<button
type="button"
onClick={onClose}
aria-label="关闭"
className="
press-feedback
size-8 rounded-xl
bg-[var(--glass-bg)] border border-[var(--glass-border)]
text-[var(--color-text-muted)] hover:text-[var(--color-text)]
"
>
✕
</button>
</div>
<div className="
p-4 rounded-2xl mb-5
bg-[var(--glass-bg)] border border-[var(--glass-border)]
text-sm text-[var(--color-text)] min-h-[60px]
">
{children ?? '模态内容区'}
</div>
<div className="flex items-center justify-end gap-2">
{actions ?? (
<button
type="button"
onClick={onClose}
className="
press-feedback
px-4 py-2 rounded-full text-sm
bg-[var(--color-accent)] text-[var(--color-bg)] font-medium
"
>
确定
</button>
)}
</div>
</div>
</div>
);
}
src/components/modal/glass-apple/