-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathModal.tsx
More file actions
160 lines (152 loc) · 5.94 KB
/
Modal.tsx
File metadata and controls
160 lines (152 loc) · 5.94 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import { useEffect, useId, useRef } from "react";
import type { ReactNode } from "react";
interface ModalProps {
title: string;
open: boolean;
onClose: () => void;
children: ReactNode;
// Keeps the close affordances (Esc, backdrop) wired off when a
// background save is in flight so the user cannot accidentally
// dismiss a half-applied operation.
busy?: boolean;
}
// Lightweight modal: no portal, no animation library. shadcn/ui's
// Dialog primitive would pull in @radix-ui/react-dialog (~10KB gzip)
// and we only need a single confirmation/edit surface per page.
//
// Accessibility (per the WAI-ARIA Authoring Practices for dialogs):
// - role="dialog" + aria-modal="true" so AT announce the dialog
// and treat the rest of the page as inert.
// - aria-labelledby on the title <div> so the dialog is named.
// - Focus is moved into the dialog on open and restored to the
// previously-focused element on close.
// - Tab and Shift+Tab are wrapped to keep focus inside the dialog
// until it closes, so keyboard users cannot accidentally tab to
// the page underneath.
export function Modal({ title, open, onClose, children, busy }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
const titleId = useId();
// Focus capture / restore is tied to `open` ONLY. Folding `busy`
// into the same effect as the previous version did caused the
// cleanup to fire on every busy toggle (e.g. the user clicks Save
// and the parent flips busy=true): focus would briefly leave the
// dialog, previouslyFocusedRef would get clobbered with the
// trigger button, then the next run would snap focus back. Real
// bug for screen-reader users — the dialog became "exited" mid-
// operation. Splitting the effects keeps each invariant tied to
// its own state slice (Claude review on #650).
useEffect(() => {
if (!open) return;
previouslyFocusedRef.current = (document.activeElement as HTMLElement | null) ?? null;
// queueMicrotask defers focus until after sibling useEffect
// hooks (notably any autofocus on form fields inside the
// dialog children) have settled, so we focus the truly-first
// tab stop rather than racing with autofocus.
queueMicrotask(() => focusFirstFocusable(dialogRef.current));
return () => {
// Restore focus to whoever opened the dialog. Guard against
// the trigger being unmounted (e.g. the dialog deleted the
// row whose button opened it) by checking isConnected.
const restore = previouslyFocusedRef.current;
previouslyFocusedRef.current = null;
if (restore && restore.isConnected) {
restore.focus();
}
};
}, [open]);
// Keyboard handler legitimately needs `busy` (Esc gates on it) and
// `onClose`, so it re-binds when either changes. Re-binding is
// cheap — one window listener swap per change — and unlike the
// focus effect it has no observable side effect on the user.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !busy) {
onClose();
return;
}
if (e.key !== "Tab") return;
const root = dialogRef.current;
if (!root) return;
const focusables = focusableElements(root);
if (focusables.length === 0) {
// Empty dialog: keep Tab from leaving via the page.
e.preventDefault();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, busy, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onMouseDown={(e) => {
if (e.target === e.currentTarget && !busy) onClose();
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="w-full max-w-md rounded-lg border border-border bg-surface shadow-xl"
>
<div className="border-b border-border px-4 py-3 flex items-center">
<div id={titleId} className="font-semibold text-sm">{title}</div>
<button
type="button"
onClick={onClose}
disabled={busy}
className="ml-auto text-muted hover:text-ink disabled:opacity-50"
aria-label="Close"
>
×
</button>
</div>
<div className="px-4 py-4">{children}</div>
</div>
</div>
);
}
// focusableSelector targets the elements an end user can tab to.
// Excludes [tabindex="-1"] (programmatic-only focus targets) and
// disabled / hidden inputs. Kept narrow on purpose: the modal only
// hosts buttons / form fields / links, not embedded media or
// content-editable surfaces.
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled]):not([type='hidden'])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(",");
function focusableElements(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(focusableSelector));
}
function focusFirstFocusable(root: HTMLElement | null): void {
if (!root) return;
const focusables = focusableElements(root);
if (focusables.length > 0) {
focusables[0].focus();
return;
}
// Fallback: focus the dialog container itself so screen readers
// still announce it. Add tabindex=-1 dynamically so the browser
// accepts the focus call without making the dialog tab-stop bait.
root.setAttribute("tabindex", "-1");
root.focus();
}