modal · minimal · 灵感来源:Refactoring UI(用留白与尺寸分层,不靠模糊与光晕)
按 ESC 或点击背景关闭
Flat Minimal Dialog (APG) · 极简模态 · 模态 / Minimal
做一个 Flat Minimal Dialog (APG)(极简模态):
- role=dialog + aria-modal + aria-labelledby
- 焦点陷阱:Tab 在内部循环,关闭后焦点归还触发元素
- Esc 关闭 + 点遮罩关闭;遮罩不模糊
- 面板白底 + 发丝边 + 软阴影,内容区退到页面底色
用这些 CSS 变量:--color-accent --color-bg --color-divider --color-surface --color-text --color-text-muted --fw-semibold --shadow-lg --shadow-scrim --shadow-sm
灵感参考:Refactoring UI(用留白与尺寸分层,不靠模糊与光晕)--color-accent --color-bg --color-divider --color-surface --color-text --color-text-muted --fw-semibold --shadow-lg --shadow-scrim --shadow-sm
焦点 / 禁用
(尚未接进整页场景)
/r/modal--minimal.json
免外壳的 markdown 版:/llms/components/modal--minimal.md--color-divider 发丝边 + --shadow-lg--color-bg)再退一层,形成两级平面,而不是两级玻璃rounded-lg;关闭钮与确定钮同步收角对比玻璃模态:少掉 backdrop-blur 与内高光,面板在任何背景上都稳定;代价是遮罩背后的上下文不再"透"出来。
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;
}
/**
* 极简模态:与玻璃模态同一份 props,
* 遮罩不做模糊,面板是白底 + 发丝边 + 一档软阴影;层级由尺寸与留白给出。
*/
export function MinimalModal({
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)' }}
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-lg
bg-[var(--color-surface)]
border border-[var(--color-divider)]
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-md
bg-[var(--color-surface)] border border-[var(--color-divider)]
text-[var(--color-text-muted)] hover:text-[var(--color-text)]
"
>
✕
</button>
</div>
<div className="
p-4 rounded-md mb-5
bg-[var(--color-bg)] border border-[var(--color-divider)]
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-md text-sm
bg-[var(--color-accent)] text-[var(--color-bg)] font-medium
"
>
确定
</button>
)}
</div>
</div>
</div>
);
}
src/components/modal/minimal/