@uniflowed/ui 0.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dialog.js ADDED
@@ -0,0 +1,499 @@
1
+ // @flow
2
+ //
3
+ // A modal dialog, which is the component people most often get wrong.
4
+ //
5
+ // "Modal" is a promise made to somebody who cannot see the page. Dimming the
6
+ // background makes the promise to everyone else; these are the parts that make
7
+ // it to a reader using a keyboard and a screen reader, and a dialog that skips
8
+ // any one of them is a trap:
9
+ //
10
+ // * **Focus moves in**, to the first thing worth acting on rather than to
11
+ // whatever happens to be first in the document.
12
+ // * **Tab cannot leave.** A dialog you can Tab out of leaves the reader
13
+ // somewhere in a page they cannot see, with no way back in.
14
+ // * **Escape closes it**, and closes *this* one rather than the one behind
15
+ // it when two are stacked.
16
+ // * **Focus returns to whatever opened it.** Otherwise focus falls to
17
+ // `<body>`, the next Tab starts at the top of the page, and the reader has
18
+ // to find their place again — which is the single most common complaint
19
+ // about hand-written dialogs.
20
+ // * **The rest of the page is gone**, not merely covered. `aria-modal` says
21
+ // so to a screen reader and `inert` says so to the browser; a dimmed
22
+ // backdrop says it only to people who can see the dim.
23
+ // * **The page behind does not scroll**, because a wheel over a modal that
24
+ // scrolls the document loses the reader's position in it.
25
+ //
26
+ // # Why it is not rendered through a portal
27
+ //
28
+ // A portal solves a stacking-context problem that belongs to CSS, and it costs
29
+ // the thing this component is for: rendered where it is written, the dialog is
30
+ // next to its trigger in the accessibility tree, which is where a screen reader
31
+ // looks. The page behind is hidden by marking it inert rather than by moving
32
+ // the dialog out of it, which gets the same guarantee without the move.
33
+ //
34
+ // # Three things a caller decides, and why they are props rather than four
35
+ // components
36
+ //
37
+ // A modal dialog is one pattern with three places a *different* modal dialog
38
+ // differs, and each of the three fails silently when it is hard-coded:
39
+ //
40
+ // * **`role`** — `dialog` or `alertdialog`. The second tells a screen reader
41
+ // the dialog is interrupting to say something urgent and makes it announce
42
+ // the description immediately, which is the difference between "dialog,
43
+ // Delete this project?" and an alert the reader is expected to answer.
44
+ // * **`dismissOnOutsidePress`** — whether a press beside the dialog closes
45
+ // it. A confirmation that vanishes when the reader clicks slightly beside
46
+ // it, losing what they were about to confirm and saying nothing about which
47
+ // way it went, is the single behaviour an alert dialog exists to prevent.
48
+ // `Escape` keeps closing it either way: a modal a reader cannot leave from
49
+ // the keyboard is a trap, and declining an alert is what `Escape` means.
50
+ // * **`initialFocus`** — where focus lands. "The first thing worth acting on"
51
+ // is right for a dialog and wrong for a confirmation, where the APG puts
52
+ // focus on the *least* destructive action: Cancel, not Delete.
53
+ //
54
+ // They are three props on `Dialog.Body` and not a `variant` flag, because a
55
+ // flag is a name for a bundle and the bundles differ: `alert-dialog.js` sets
56
+ // all three, `sheet.js` sets none of them and adds an edge, `sidebar.js` is
57
+ // most often not modal at all. What each of those components adds is written
58
+ // where it lives; what they share is here, once.
59
+ //
60
+ // # Composition
61
+ //
62
+ // The parts are one namespace — `Dialog.Root`, `Dialog.Body`, `Dialog.Title` —
63
+ // because they only work together: `Body` cannot label itself without `Title`,
64
+ // and `Title` has nothing to label without `Body`. See `index.js`.
65
+
66
+ "use client";
67
+
68
+ import * as React from "@uniflowed/react";
69
+ import {
70
+ createContext,
71
+ useContext,
72
+ useEffect,
73
+ useId,
74
+ useMemo,
75
+ useRef,
76
+ useState,
77
+ } from "@uniflowed/react";
78
+ import { useScrollLock } from "@uniflowed/hooks/browser";
79
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
80
+
81
+ import type { Rest } from "./internal/merge-props.js";
82
+ import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
83
+ import { focusable } from "./internal/focus.js";
84
+ import { useControlled } from "./internal/controlled-state.js";
85
+
86
+ /**
87
+ * What a screen reader is told the dialog is.
88
+ *
89
+ * A union rather than a string, so `role="alertdailog"` is a type error at the
90
+ * call rather than a dialog announced as a `div` with a name — which is what a
91
+ * misspelt role produces, silently, in markup that looks correct.
92
+ *
93
+ * Two members and not the whole of ARIA: these are the two roles that carry
94
+ * `aria-modal`, and a `Dialog.Body` that is a `region` or a `complementary` is
95
+ * a different component rather than this one with another string.
96
+ */
97
+ export type DialogRole = "dialog" | "alertdialog";
98
+
99
+ type DialogState = {|
100
+ readonly base: string,
101
+ readonly open: boolean,
102
+ readonly setOpen: (open: boolean) => void,
103
+ readonly triggerRef: { current: HTMLElement | null },
104
+ /** Whether a `Dialog.Title` is rendered, so `aria-labelledby` names one. */
105
+ readonly titled: boolean,
106
+ /** Whether a `Dialog.Description` is rendered. */
107
+ readonly described: boolean,
108
+ readonly registerTitle: (present: boolean) => void,
109
+ readonly registerDescription: (present: boolean) => void,
110
+ |};
111
+
112
+ const DialogContext: React.Context<DialogState | null> = createContext(null);
113
+
114
+ /**
115
+ * The dialog a part belongs to.
116
+ *
117
+ * Raising rather than returning null: a `Dialog.Title` outside a `Dialog.Root`
118
+ * would render a heading with an id nothing points at, and would look correct.
119
+ */
120
+ hook useDialog(part: string): DialogState {
121
+ const state = useContext(DialogContext);
122
+ if (state == null) {
123
+ throw new Error(`${part} must be rendered inside a Dialog.Root`);
124
+ }
125
+ return state;
126
+ }
127
+
128
+ /** The dialog, open or closed. Uncontrolled unless `open` is given. */
129
+ export component DialogRoot(
130
+ children: React.Node,
131
+ defaultOpen?: boolean = false,
132
+ open?: boolean,
133
+ onOpenChange?: (open: boolean) => void,
134
+ ) {
135
+ const base = useId();
136
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
137
+ const triggerRef = useRef<HTMLElement | null>(null);
138
+ const [titled, setTitled] = useState(false);
139
+ const [described, setDescribed] = useState(false);
140
+
141
+ const state = useMemo(
142
+ () => ({
143
+ base,
144
+ open: isOpen,
145
+ setOpen,
146
+ triggerRef,
147
+ titled,
148
+ described,
149
+ registerTitle: setTitled,
150
+ registerDescription: setDescribed,
151
+ }),
152
+ [base, isOpen, setOpen, titled, described],
153
+ );
154
+
155
+ return <DialogContext.Provider value={state}>{children}</DialogContext.Provider>;
156
+ }
157
+
158
+ /** What opens the dialog, and what focus comes back to when it closes. */
159
+ export component DialogTrigger(children: React.Node, ...rest: Rest) {
160
+ const dialog = useDialog("Dialog.Trigger");
161
+ const passed = withoutComposed(rest, ["onClick", "ref"]);
162
+
163
+ return (
164
+ <button
165
+ {...passed}
166
+ // Only while it is open. An `aria-controls` naming an element that is not
167
+ // in the document is worse than no `aria-controls`: a reader is told
168
+ // there is somewhere to go and there is not.
169
+ aria-controls={dialog.open ? `${dialog.base}-body` : undefined}
170
+ aria-expanded={dialog.open ? "true" : "false"}
171
+ aria-haspopup="dialog"
172
+ onClick={composeHandlers(rest.onClick, () => dialog.setOpen(true))}
173
+ ref={composeRefs(rest.ref, (element) => {
174
+ dialog.triggerRef.current = element;
175
+ })}
176
+ type="button"
177
+ >
178
+ {children}
179
+ </button>
180
+ );
181
+ }
182
+
183
+ /**
184
+ * The backdrop.
185
+ *
186
+ * Deliberately does nothing but exist and stay out of the accessibility tree:
187
+ * it is `aria-hidden` because a reader has no use for a rectangle, and it does
188
+ * *not* own the close-on-outside-press behaviour, because a caller who styles
189
+ * their own backdrop or omits one entirely must still get it. That lives on
190
+ * `Dialog.Body`, which is the part that knows where "outside" is.
191
+ */
192
+ export component DialogOverlay(...rest: Rest) {
193
+ const dialog = useDialog("Dialog.Overlay");
194
+ if (!dialog.open) {
195
+ return null;
196
+ }
197
+ return <div {...rest} aria-hidden="true" data-state="open" />;
198
+ }
199
+
200
+ /**
201
+ * The dialog itself: focus moved in, kept in, and given back.
202
+ *
203
+ * `aria-modal` tells a screen reader that the rest of the page is unavailable,
204
+ * which is the half of "modal" that CSS cannot express; `inert` on everything
205
+ * outside is the half the browser enforces.
206
+ */
207
+ export component DialogBody(
208
+ children: React.Node,
209
+ dismissOnOutsidePress?: boolean = true,
210
+ initialFocus?: { current: HTMLElement | null },
211
+ role?: DialogRole = "dialog",
212
+ ...rest: Rest
213
+ ) {
214
+ const dialog = useDialog("Dialog.Body");
215
+ const bodyRef = useRef<HTMLElement | null>(null);
216
+ // Stable, so the effect below depends on `open` and on nothing else. Keyed on
217
+ // `setOpen` it re-ran whenever the caller passed a fresh `onOpenChange`
218
+ // closure — which is every render — and re-running it re-took focus, so a
219
+ // parent that re-rendered stole focus back from whatever the reader had
220
+ // moved it to inside the dialog.
221
+ const close = useStableCallback(() => dialog.setOpen(false));
222
+ // Asked at the moment of the press rather than named in the dependencies
223
+ // below, because naming it there re-runs the effect when it changes and
224
+ // re-running the effect re-takes focus. A caller who flips this on a
225
+ // breakpoint would otherwise have focus dragged back to the top of the
226
+ // dialog underneath the reader.
227
+ const dismissable = useStableCallback(() => dismissOnOutsidePress);
228
+
229
+ // The page is held still by `@uniflowed/hooks`' reference-counted lock rather
230
+ // than by a second one written here. Two implementations of this in one
231
+ // repository is the duplication "build uf with uf" exists to catch, and the
232
+ // shared one is the one that also pads out the scrollbar's width — a page
233
+ // that jumps sideways when a dialog opens is this component's doing.
234
+ useScrollLock(dialog.open);
235
+
236
+ useEffect(() => {
237
+ const body = bodyRef.current;
238
+ if (!dialog.open || body == null) {
239
+ return;
240
+ }
241
+ const document = body.ownerDocument;
242
+ const trigger = dialog.triggerRef.current;
243
+ // Whatever had focus, which is the trigger for a dialog that was opened
244
+ // and the previously focused element for one that opened itself.
245
+ const opener = trigger ?? (document.activeElement as $FlowFixMe);
246
+
247
+ const restorePage = concealOutside(body);
248
+
249
+ const onOutsidePress = (event: Event) => {
250
+ const target: $FlowFixMe = event.target;
251
+ if (target == null || body.contains(target)) {
252
+ return;
253
+ }
254
+ // The trigger is outside the dialog and is not "outside" for this
255
+ // purpose: closing here and letting the trigger's own click reopen made
256
+ // a press on the trigger a no-op that flickered.
257
+ if (trigger != null && trigger.contains(target)) {
258
+ return;
259
+ }
260
+ // A confirmation declines to close here, and `Escape` still does. See
261
+ // the module header: there is a difference between a dialog the reader
262
+ // dismissed and one that went away while they were reaching for it.
263
+ if (!dismissable()) {
264
+ return;
265
+ }
266
+ close();
267
+ };
268
+ // Capture, so a press is seen even where something below it stops the
269
+ // event — a menu inside the dialog, for instance.
270
+ document.addEventListener("pointerdown", onOutsidePress, true);
271
+
272
+ // Where the caller said, then the first thing worth acting on, then the
273
+ // dialog itself when it holds nothing focusable — so focus is inside it
274
+ // whichever of the three answers.
275
+ //
276
+ // The named element has to still be *in* this dialog: a ref left over from
277
+ // a previous opening, or one pointing at something the caller renders
278
+ // elsewhere, would move focus out of a dialog that announces the rest of
279
+ // the page is unavailable.
280
+ const named = initialFocus?.current ?? null;
281
+ const target = named != null && body.contains(named) ? named : (focusable(body)[0] ?? body);
282
+ target.focus();
283
+
284
+ return () => {
285
+ document.removeEventListener("pointerdown", onOutsidePress, true);
286
+ // Order matters: the page comes back before focus is restored, because
287
+ // the trigger is one of the elements that was made `inert` and an inert
288
+ // element cannot take focus.
289
+ restorePage();
290
+ opener?.focus?.();
291
+ };
292
+ }, [dialog.open, dialog.triggerRef, close, dismissable, initialFocus]);
293
+
294
+ if (!dialog.open) {
295
+ return null;
296
+ }
297
+
298
+ const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
299
+
300
+ return (
301
+ <div
302
+ // `passed` first. A caller `ref` used to replace `bodyRef`, which left it
303
+ // null, made the Tab branch below return early, and turned the focus trap
304
+ // off while the dialog still announced `aria-modal="true"`. A caller
305
+ // `onKeyDown` used to replace this one, and Escape stopped closing it.
306
+ {...passed}
307
+ // Only ids that are in the document: an `aria-labelledby` naming a
308
+ // missing element makes a screen reader announce nothing at all, so a
309
+ // dialog without a `Dialog.Title` falls through to whatever `aria-label`
310
+ // the caller passed instead.
311
+ aria-describedby={dialog.described ? `${dialog.base}-description` : undefined}
312
+ aria-labelledby={dialog.titled ? `${dialog.base}-title` : undefined}
313
+ aria-modal="true"
314
+ id={`${dialog.base}-body`}
315
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
316
+ if (event.key === "Escape") {
317
+ event.preventDefault();
318
+ // The dialog behind this one must not also close. Two stacked
319
+ // dialogs nest in the DOM, so without this the event bubbled to the
320
+ // outer dialog's handler and one Escape closed both.
321
+ event.stopPropagation();
322
+ close();
323
+ return;
324
+ }
325
+ if (event.key !== "Tab") {
326
+ return;
327
+ }
328
+ const body = bodyRef.current;
329
+ if (body == null) {
330
+ return;
331
+ }
332
+ const stops = focusable(body);
333
+ // An outer dialog must not also run its trap on this key.
334
+ event.stopPropagation();
335
+ if (stops.length === 0) {
336
+ // Nothing to move to, so Tab must not leave either.
337
+ event.preventDefault();
338
+ return;
339
+ }
340
+ const first = stops[0];
341
+ const last = stops[stops.length - 1];
342
+ const active = body.ownerDocument?.activeElement;
343
+ // Wrap at the ends. This is the whole of "focus cannot leave"; every
344
+ // other Tab press is the browser's own business.
345
+ if (event.shiftKey && (active === first || active === body)) {
346
+ event.preventDefault();
347
+ last.focus();
348
+ } else if (!event.shiftKey && active === last) {
349
+ event.preventDefault();
350
+ first.focus();
351
+ }
352
+ })}
353
+ ref={composeRefs(rest.ref, (element) => {
354
+ bodyRef.current = element;
355
+ })}
356
+ role={role}
357
+ // So the dialog can hold focus itself when it contains nothing focusable,
358
+ // and so the trap has somewhere to put focus that is still inside.
359
+ tabIndex={-1}
360
+ >
361
+ {children}
362
+ </div>
363
+ );
364
+ }
365
+
366
+ /**
367
+ * The dialog's accessible name, which `aria-labelledby` points at.
368
+ *
369
+ * It registers itself so `Dialog.Body` only claims a name when one is actually
370
+ * rendered — a conditional title that is absent used to leave the dialog
371
+ * pointing at an id nothing had.
372
+ */
373
+ export component DialogTitle(children: React.Node, ...rest: Rest) {
374
+ const dialog = useDialog("Dialog.Title");
375
+ const register = dialog.registerTitle;
376
+ useEffect(() => {
377
+ register(true);
378
+ return () => register(false);
379
+ }, [register]);
380
+
381
+ return (
382
+ <h2 {...rest} id={`${dialog.base}-title`}>
383
+ {children}
384
+ </h2>
385
+ );
386
+ }
387
+
388
+ /**
389
+ * What the dialog is for, announced after its name.
390
+ *
391
+ * A screen reader reads the description when focus enters the dialog, which is
392
+ * the one moment the reader has to decide whether they care — so this is where
393
+ * "this cannot be undone" belongs, not in body text further down.
394
+ */
395
+ export component DialogDescription(children: React.Node, ...rest: Rest) {
396
+ const dialog = useDialog("Dialog.Description");
397
+ const register = dialog.registerDescription;
398
+ useEffect(() => {
399
+ register(true);
400
+ return () => register(false);
401
+ }, [register]);
402
+
403
+ return (
404
+ <p {...rest} id={`${dialog.base}-description`}>
405
+ {children}
406
+ </p>
407
+ );
408
+ }
409
+
410
+ /**
411
+ * The top of the dialog, as a place to put styles.
412
+ *
413
+ * A `<div>` rather than a `<header>` on purpose: a `<header>` is a `banner`
414
+ * landmark, and a second banner inside a dialog is a landmark a reader will
415
+ * find in the landmark list and be unable to explain. The part exists so the
416
+ * styling layer has a name to attach to, and contributes no semantics because
417
+ * it has none to contribute.
418
+ */
419
+ export component DialogHeader(children: React.Node, ...rest: Rest) {
420
+ return <div {...rest}>{children}</div>;
421
+ }
422
+
423
+ /** The bottom of the dialog, where the actions go. See `Dialog.Header`. */
424
+ export component DialogFooter(children: React.Node, ...rest: Rest) {
425
+ return <div {...rest}>{children}</div>;
426
+ }
427
+
428
+ /** A button that closes the dialog. */
429
+ export component DialogClose(children: React.Node, ...rest: Rest) {
430
+ const dialog = useDialog("Dialog.Close");
431
+ const passed = withoutComposed(rest, ["onClick"]);
432
+
433
+ return (
434
+ <button
435
+ {...passed}
436
+ onClick={composeHandlers(rest.onClick, () => dialog.setOpen(false))}
437
+ type="button"
438
+ >
439
+ {children}
440
+ </button>
441
+ );
442
+ }
443
+
444
+ /**
445
+ * Take everything outside `element` out of the page, and give it back.
446
+ *
447
+ * Walking up from the dialog and hiding each level's *siblings*, rather than
448
+ * hiding the top-level children of `<body>`, because that is what makes two
449
+ * stacked dialogs work: the inner one is inside the outer one's subtree, so
450
+ * hiding body's children would hide nothing new and the outer dialog's own
451
+ * content would stay readable behind the inner one.
452
+ *
453
+ * Both attributes, because they address different audiences. `aria-hidden`
454
+ * removes the subtree from the accessibility tree; `inert` also stops clicks
455
+ * and takes it out of the tab order, which is the browser's own enforcement of
456
+ * the focus trap and does not depend on this component's key handling being
457
+ * reached.
458
+ */
459
+ function concealOutside(element: HTMLElement): () => void {
460
+ const document = element.ownerDocument;
461
+ const restore: Array<{| element: Element, hidden: string | null, inert: boolean |}> = [];
462
+
463
+ let node: Element | null = element;
464
+ while (node != null && node !== document.body) {
465
+ const parent = node.parentElement;
466
+ if (parent == null) {
467
+ break;
468
+ }
469
+ for (const sibling of Array.from(parent.children)) {
470
+ if (sibling === node) {
471
+ continue;
472
+ }
473
+ restore.push({
474
+ element: sibling,
475
+ hidden: sibling.getAttribute("aria-hidden"),
476
+ inert: sibling.hasAttribute("inert"),
477
+ });
478
+ sibling.setAttribute("aria-hidden", "true");
479
+ sibling.setAttribute("inert", "");
480
+ }
481
+ node = parent;
482
+ }
483
+
484
+ return () => {
485
+ // In reverse, so an element concealed by two nested dialogs is handed back
486
+ // the state the outer one found rather than the state the inner one did.
487
+ for (let index = restore.length - 1; index >= 0; index -= 1) {
488
+ const entry = restore[index];
489
+ if (entry.hidden == null) {
490
+ entry.element.removeAttribute("aria-hidden");
491
+ } else {
492
+ entry.element.setAttribute("aria-hidden", entry.hidden);
493
+ }
494
+ if (!entry.inert) {
495
+ entry.element.removeAttribute("inert");
496
+ }
497
+ }
498
+ };
499
+ }