@uniflowed/ui 0.0.0-alpha.2 → 0.0.0-alpha.4

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,482 @@
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
+ // # Composition
35
+ //
36
+ // The parts are one namespace — `Dialog.Root`, `Dialog.Body`, `Dialog.Title` —
37
+ // because they only work together: `Body` cannot label itself without `Title`,
38
+ // and `Title` has nothing to label without `Body`. See `index.js`.
39
+
40
+ "use client";
41
+
42
+ import * as React from "@uniflowed/react";
43
+ import {
44
+ createContext,
45
+ useContext,
46
+ useEffect,
47
+ useId,
48
+ useMemo,
49
+ useRef,
50
+ useState,
51
+ } from "@uniflowed/react";
52
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
53
+
54
+ import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
55
+ import { useControlled } from "./internal/controlled-state.js";
56
+
57
+ type DialogState = {|
58
+ readonly base: string,
59
+ readonly open: boolean,
60
+ readonly setOpen: (open: boolean) => void,
61
+ readonly triggerRef: { current: HTMLElement | null },
62
+ /** Whether a `Dialog.Title` is rendered, so `aria-labelledby` names one. */
63
+ readonly titled: boolean,
64
+ /** Whether a `Dialog.Description` is rendered. */
65
+ readonly described: boolean,
66
+ readonly registerTitle: (present: boolean) => void,
67
+ readonly registerDescription: (present: boolean) => void,
68
+ |};
69
+
70
+ const DialogContext: React.Context<DialogState | null> = createContext(null);
71
+
72
+ /**
73
+ * The dialog a part belongs to.
74
+ *
75
+ * Raising rather than returning null: a `Dialog.Title` outside a `Dialog.Root`
76
+ * would render a heading with an id nothing points at, and would look correct.
77
+ */
78
+ hook useDialog(part: string): DialogState {
79
+ const state = useContext(DialogContext);
80
+ if (state == null) {
81
+ throw new Error(`${part} must be rendered inside a Dialog.Root`);
82
+ }
83
+ return state;
84
+ }
85
+
86
+ /** The dialog, open or closed. Uncontrolled unless `open` is given. */
87
+ export component DialogRoot(
88
+ children: React.Node,
89
+ defaultOpen?: boolean = false,
90
+ open?: boolean,
91
+ onOpenChange?: (open: boolean) => void,
92
+ ) {
93
+ const base = useId();
94
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
95
+ const triggerRef = useRef<HTMLElement | null>(null);
96
+ const [titled, setTitled] = useState(false);
97
+ const [described, setDescribed] = useState(false);
98
+
99
+ const state = useMemo(
100
+ () => ({
101
+ base,
102
+ open: isOpen,
103
+ setOpen,
104
+ triggerRef,
105
+ titled,
106
+ described,
107
+ registerTitle: setTitled,
108
+ registerDescription: setDescribed,
109
+ }),
110
+ [base, isOpen, setOpen, titled, described],
111
+ );
112
+
113
+ return <DialogContext.Provider value={state}>{children}</DialogContext.Provider>;
114
+ }
115
+
116
+ /** What opens the dialog, and what focus comes back to when it closes. */
117
+ export component DialogTrigger(children: React.Node, ...rest: { readonly [string]: mixed }) {
118
+ const dialog = useDialog("Dialog.Trigger");
119
+ const passed = withoutComposed(rest, ["onClick", "ref"]);
120
+
121
+ return (
122
+ <button
123
+ {...passed}
124
+ // Only while it is open. An `aria-controls` naming an element that is not
125
+ // in the document is worse than no `aria-controls`: a reader is told
126
+ // there is somewhere to go and there is not.
127
+ aria-controls={dialog.open ? `${dialog.base}-body` : undefined}
128
+ aria-expanded={dialog.open ? "true" : "false"}
129
+ aria-haspopup="dialog"
130
+ onClick={composeHandlers(rest.onClick, () => dialog.setOpen(true))}
131
+ ref={composeRefs(rest.ref, (element) => {
132
+ dialog.triggerRef.current = element;
133
+ })}
134
+ type="button"
135
+ >
136
+ {children}
137
+ </button>
138
+ );
139
+ }
140
+
141
+ /**
142
+ * The backdrop.
143
+ *
144
+ * Deliberately does nothing but exist and stay out of the accessibility tree:
145
+ * it is `aria-hidden` because a reader has no use for a rectangle, and it does
146
+ * *not* own the close-on-outside-press behaviour, because a caller who styles
147
+ * their own backdrop or omits one entirely must still get it. That lives on
148
+ * `Dialog.Body`, which is the part that knows where "outside" is.
149
+ */
150
+ export component DialogOverlay(...rest: { readonly [string]: mixed }) {
151
+ const dialog = useDialog("Dialog.Overlay");
152
+ if (!dialog.open) {
153
+ return null;
154
+ }
155
+ return <div {...rest} aria-hidden="true" data-state="open" />;
156
+ }
157
+
158
+ /**
159
+ * The dialog itself: focus moved in, kept in, and given back.
160
+ *
161
+ * `aria-modal` tells a screen reader that the rest of the page is unavailable,
162
+ * which is the half of "modal" that CSS cannot express; `inert` on everything
163
+ * outside is the half the browser enforces.
164
+ */
165
+ export component DialogBody(children: React.Node, ...rest: { readonly [string]: mixed }) {
166
+ const dialog = useDialog("Dialog.Body");
167
+ const bodyRef = useRef<HTMLElement | null>(null);
168
+ // Stable, so the effect below depends on `open` and on nothing else. Keyed on
169
+ // `setOpen` it re-ran whenever the caller passed a fresh `onOpenChange`
170
+ // closure — which is every render — and re-running it re-took focus, so a
171
+ // parent that re-rendered stole focus back from whatever the reader had
172
+ // moved it to inside the dialog.
173
+ const close = useStableCallback(() => dialog.setOpen(false));
174
+
175
+ useEffect(() => {
176
+ const body = bodyRef.current;
177
+ if (!dialog.open || body == null) {
178
+ return;
179
+ }
180
+ const document = body.ownerDocument;
181
+ const trigger = dialog.triggerRef.current;
182
+ // Whatever had focus, which is the trigger for a dialog that was opened
183
+ // and the previously focused element for one that opened itself.
184
+ const opener = trigger ?? (document.activeElement as $FlowFixMe);
185
+
186
+ const restorePage = concealOutside(body);
187
+ const releaseScroll = lockScroll(document);
188
+
189
+ const onOutsidePress = (event: Event) => {
190
+ const target: $FlowFixMe = event.target;
191
+ if (target == null || body.contains(target)) {
192
+ return;
193
+ }
194
+ // The trigger is outside the dialog and is not "outside" for this
195
+ // purpose: closing here and letting the trigger's own click reopen made
196
+ // a press on the trigger a no-op that flickered.
197
+ if (trigger != null && trigger.contains(target)) {
198
+ return;
199
+ }
200
+ close();
201
+ };
202
+ // Capture, so a press is seen even where something below it stops the
203
+ // event — a menu inside the dialog, for instance.
204
+ document.addEventListener("pointerdown", onOutsidePress, true);
205
+
206
+ // The first thing worth acting on, and the dialog itself when it holds
207
+ // nothing focusable, so focus is inside it either way.
208
+ const target = focusable(body)[0] ?? body;
209
+ target.focus();
210
+
211
+ return () => {
212
+ document.removeEventListener("pointerdown", onOutsidePress, true);
213
+ // Order matters: the page comes back before focus is restored, because
214
+ // the trigger is one of the elements that was made `inert` and an inert
215
+ // element cannot take focus.
216
+ restorePage();
217
+ releaseScroll();
218
+ opener?.focus?.();
219
+ };
220
+ }, [dialog.open, dialog.triggerRef, close]);
221
+
222
+ if (!dialog.open) {
223
+ return null;
224
+ }
225
+
226
+ const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
227
+
228
+ return (
229
+ <div
230
+ // `passed` first. A caller `ref` used to replace `bodyRef`, which left it
231
+ // null, made the Tab branch below return early, and turned the focus trap
232
+ // off while the dialog still announced `aria-modal="true"`. A caller
233
+ // `onKeyDown` used to replace this one, and Escape stopped closing it.
234
+ {...passed}
235
+ // Only ids that are in the document: an `aria-labelledby` naming a
236
+ // missing element makes a screen reader announce nothing at all, so a
237
+ // dialog without a `Dialog.Title` falls through to whatever `aria-label`
238
+ // the caller passed instead.
239
+ aria-describedby={dialog.described ? `${dialog.base}-description` : undefined}
240
+ aria-labelledby={dialog.titled ? `${dialog.base}-title` : undefined}
241
+ aria-modal="true"
242
+ id={`${dialog.base}-body`}
243
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
244
+ if (event.key === "Escape") {
245
+ event.preventDefault();
246
+ // The dialog behind this one must not also close. Two stacked
247
+ // dialogs nest in the DOM, so without this the event bubbled to the
248
+ // outer dialog's handler and one Escape closed both.
249
+ event.stopPropagation();
250
+ close();
251
+ return;
252
+ }
253
+ if (event.key !== "Tab") {
254
+ return;
255
+ }
256
+ const body = bodyRef.current;
257
+ if (body == null) {
258
+ return;
259
+ }
260
+ const stops = focusable(body);
261
+ // An outer dialog must not also run its trap on this key.
262
+ event.stopPropagation();
263
+ if (stops.length === 0) {
264
+ // Nothing to move to, so Tab must not leave either.
265
+ event.preventDefault();
266
+ return;
267
+ }
268
+ const first = stops[0];
269
+ const last = stops[stops.length - 1];
270
+ const active = body.ownerDocument?.activeElement;
271
+ // Wrap at the ends. This is the whole of "focus cannot leave"; every
272
+ // other Tab press is the browser's own business.
273
+ if (event.shiftKey && (active === first || active === body)) {
274
+ event.preventDefault();
275
+ last.focus();
276
+ } else if (!event.shiftKey && active === last) {
277
+ event.preventDefault();
278
+ first.focus();
279
+ }
280
+ })}
281
+ ref={composeRefs(rest.ref, (element) => {
282
+ bodyRef.current = element;
283
+ })}
284
+ role="dialog"
285
+ // So the dialog can hold focus itself when it contains nothing focusable,
286
+ // and so the trap has somewhere to put focus that is still inside.
287
+ tabIndex={-1}
288
+ >
289
+ {children}
290
+ </div>
291
+ );
292
+ }
293
+
294
+ /**
295
+ * The dialog's accessible name, which `aria-labelledby` points at.
296
+ *
297
+ * It registers itself so `Dialog.Body` only claims a name when one is actually
298
+ * rendered — a conditional title that is absent used to leave the dialog
299
+ * pointing at an id nothing had.
300
+ */
301
+ export component DialogTitle(children: React.Node, ...rest: { readonly [string]: mixed }) {
302
+ const dialog = useDialog("Dialog.Title");
303
+ const register = dialog.registerTitle;
304
+ useEffect(() => {
305
+ register(true);
306
+ return () => register(false);
307
+ }, [register]);
308
+
309
+ return (
310
+ <h2 {...rest} id={`${dialog.base}-title`}>
311
+ {children}
312
+ </h2>
313
+ );
314
+ }
315
+
316
+ /**
317
+ * What the dialog is for, announced after its name.
318
+ *
319
+ * A screen reader reads the description when focus enters the dialog, which is
320
+ * the one moment the reader has to decide whether they care — so this is where
321
+ * "this cannot be undone" belongs, not in body text further down.
322
+ */
323
+ export component DialogDescription(children: React.Node, ...rest: { readonly [string]: mixed }) {
324
+ const dialog = useDialog("Dialog.Description");
325
+ const register = dialog.registerDescription;
326
+ useEffect(() => {
327
+ register(true);
328
+ return () => register(false);
329
+ }, [register]);
330
+
331
+ return (
332
+ <p {...rest} id={`${dialog.base}-description`}>
333
+ {children}
334
+ </p>
335
+ );
336
+ }
337
+
338
+ /**
339
+ * The top of the dialog, as a place to put styles.
340
+ *
341
+ * A `<div>` rather than a `<header>` on purpose: a `<header>` is a `banner`
342
+ * landmark, and a second banner inside a dialog is a landmark a reader will
343
+ * find in the landmark list and be unable to explain. The part exists so the
344
+ * styling layer has a name to attach to, and contributes no semantics because
345
+ * it has none to contribute.
346
+ */
347
+ export component DialogHeader(children: React.Node, ...rest: { readonly [string]: mixed }) {
348
+ return <div {...rest}>{children}</div>;
349
+ }
350
+
351
+ /** The bottom of the dialog, where the actions go. See `Dialog.Header`. */
352
+ export component DialogFooter(children: React.Node, ...rest: { readonly [string]: mixed }) {
353
+ return <div {...rest}>{children}</div>;
354
+ }
355
+
356
+ /** A button that closes the dialog. */
357
+ export component DialogClose(children: React.Node, ...rest: { readonly [string]: mixed }) {
358
+ const dialog = useDialog("Dialog.Close");
359
+ const passed = withoutComposed(rest, ["onClick"]);
360
+
361
+ return (
362
+ <button
363
+ {...passed}
364
+ onClick={composeHandlers(rest.onClick, () => dialog.setOpen(false))}
365
+ type="button"
366
+ >
367
+ {children}
368
+ </button>
369
+ );
370
+ }
371
+
372
+ /**
373
+ * The focus stops inside an element, in document order.
374
+ *
375
+ * Disabled controls and `tabindex="-1"` are excluded because the browser
376
+ * excludes them, and anything inside `[hidden]`, `[inert]` or `aria-hidden` is
377
+ * excluded because a reader cannot reach it.
378
+ */
379
+ function focusable(root: HTMLElement): Array<HTMLElement> {
380
+ const selector =
381
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
382
+ return Array.from(root.querySelectorAll(selector)).filter(
383
+ (element: $FlowFixMe) =>
384
+ // All three attributes hide a whole subtree, so all three are checked on
385
+ // the ancestors. Reading `aria-hidden` off the element alone returned a
386
+ // button inside `<div aria-hidden="true">` as a focus stop, and the trap
387
+ // then moved focus to a control no screen reader exposes.
388
+ element.closest("[hidden]") == null &&
389
+ element.closest("[inert]") == null &&
390
+ element.closest('[aria-hidden="true"]') == null,
391
+ );
392
+ }
393
+
394
+ /**
395
+ * Take everything outside `element` out of the page, and give it back.
396
+ *
397
+ * Walking up from the dialog and hiding each level's *siblings*, rather than
398
+ * hiding the top-level children of `<body>`, because that is what makes two
399
+ * stacked dialogs work: the inner one is inside the outer one's subtree, so
400
+ * hiding body's children would hide nothing new and the outer dialog's own
401
+ * content would stay readable behind the inner one.
402
+ *
403
+ * Both attributes, because they address different audiences. `aria-hidden`
404
+ * removes the subtree from the accessibility tree; `inert` also stops clicks
405
+ * and takes it out of the tab order, which is the browser's own enforcement of
406
+ * the focus trap and does not depend on this component's key handling being
407
+ * reached.
408
+ */
409
+ function concealOutside(element: HTMLElement): () => void {
410
+ const document = element.ownerDocument;
411
+ const restore: Array<{| element: Element, hidden: string | null, inert: boolean |}> = [];
412
+
413
+ let node: Element | null = element;
414
+ while (node != null && node !== document.body) {
415
+ const parent = node.parentElement;
416
+ if (parent == null) {
417
+ break;
418
+ }
419
+ for (const sibling of Array.from(parent.children)) {
420
+ if (sibling === node) {
421
+ continue;
422
+ }
423
+ restore.push({
424
+ element: sibling,
425
+ hidden: sibling.getAttribute("aria-hidden"),
426
+ inert: sibling.hasAttribute("inert"),
427
+ });
428
+ sibling.setAttribute("aria-hidden", "true");
429
+ sibling.setAttribute("inert", "");
430
+ }
431
+ node = parent;
432
+ }
433
+
434
+ return () => {
435
+ // In reverse, so an element concealed by two nested dialogs is handed back
436
+ // the state the outer one found rather than the state the inner one did.
437
+ for (let index = restore.length - 1; index >= 0; index -= 1) {
438
+ const entry = restore[index];
439
+ if (entry.hidden == null) {
440
+ entry.element.removeAttribute("aria-hidden");
441
+ } else {
442
+ entry.element.setAttribute("aria-hidden", entry.hidden);
443
+ }
444
+ if (!entry.inert) {
445
+ entry.element.removeAttribute("inert");
446
+ }
447
+ }
448
+ };
449
+ }
450
+
451
+ /**
452
+ * How many dialogs are holding the page still, and what it looked like before.
453
+ *
454
+ * A count rather than each dialog saving and restoring, because two dialogs
455
+ * that open and close in any order but the strictest nesting would otherwise
456
+ * hand the page back a value the other one had already replaced.
457
+ */
458
+ let scrollLocks = 0;
459
+ let overflowBeforeLock: string = "";
460
+
461
+ /** Stop the page behind the dialog from scrolling, and undo exactly that. */
462
+ function lockScroll(document: Document): () => void {
463
+ const body: $FlowFixMe = document.body;
464
+ if (scrollLocks === 0) {
465
+ overflowBeforeLock = body.style.overflow;
466
+ body.style.overflow = "hidden";
467
+ }
468
+ scrollLocks += 1;
469
+
470
+ let released = false;
471
+ return () => {
472
+ if (released) {
473
+ return;
474
+ }
475
+ released = true;
476
+ scrollLocks -= 1;
477
+ if (scrollLocks === 0) {
478
+ body.style.overflow = overflowBeforeLock;
479
+ overflowBeforeLock = "";
480
+ }
481
+ };
482
+ }
@@ -16,9 +16,20 @@
16
16
  // actually rendered — pointing `aria-describedby` at an id that is not in the
17
17
  // document makes a screen reader announce nothing at all, which is worse than
18
18
  // omitting the attribute.
19
+ //
20
+ // # Why it takes a render function
21
+ //
22
+ // `Field.Control` hands the attributes to a callback rather than rendering an
23
+ // `<input>`, because a field wraps a select, a textarea, a `Combobox.Input` or
24
+ // somebody else's component just as often, and each of those needs the same
25
+ // four attributes on whatever element it eventually renders. A component that
26
+ // rendered the input itself would have to grow a prop for every element anyone
27
+ // might want, and would still be wrong for the next one.
28
+
29
+ "use client";
19
30
 
20
31
  import * as React from "@uniflowed/react";
21
- import { createContext, useContext, useId, useMemo, useState } from "@uniflowed/react";
32
+ import { createContext, useContext, useEffect, useId, useMemo, useState } from "@uniflowed/react";
22
33
 
23
34
  type FieldState = {|
24
35
  readonly controlId: string,
@@ -39,7 +50,7 @@ const FieldContext: React.Context<FieldState | null> = createContext(null);
39
50
  * Raising rather than returning null: a `Field.Label` outside a `Field.Root`
40
51
  * would render a label pointing at nothing, and would look correct.
41
52
  */
42
- function useField(part: string): FieldState {
53
+ hook useField(part: string): FieldState {
43
54
  const state = useContext(FieldContext);
44
55
  if (state == null) {
45
56
  throw new Error(`${part} must be rendered inside a Field.Root`);
@@ -108,9 +119,7 @@ export component FieldLabel(children: React.Node, ...rest: { readonly [string]:
108
119
  /**
109
120
  * The control, given every attribute the rest of the field implies.
110
121
  *
111
- * `render` takes the element rather than this rendering an `<input>`, because a
112
- * field wraps a select, a textarea, a combobox or somebody else's component
113
- * just as often, and each of those needs the same six attributes.
122
+ * See the module header for why this takes a render function.
114
123
  */
115
124
  export component FieldControl(render: (props: { readonly [string]: mixed }) => React.Node) {
116
125
  const field = useField("Field.Control");
@@ -125,10 +134,11 @@ export component FieldControl(render: (props: { readonly [string]: mixed }) => R
125
134
  /** Help text, which the control points at while it is rendered. */
126
135
  export component FieldDescription(children: React.Node, ...rest: { readonly [string]: mixed }) {
127
136
  const field = useField("Field.Description");
128
- React.useEffect(() => {
129
- field.registerDescription(true);
130
- return () => field.registerDescription(false);
131
- }, [field]);
137
+ const register = field.registerDescription;
138
+ useEffect(() => {
139
+ register(true);
140
+ return () => register(false);
141
+ }, [register]);
132
142
 
133
143
  return (
134
144
  <p {...rest} id={field.descriptionId}>
@@ -145,10 +155,11 @@ export component FieldDescription(children: React.Node, ...rest: { readonly [str
145
155
  */
146
156
  export component FieldError(children: React.Node, ...rest: { readonly [string]: mixed }) {
147
157
  const field = useField("Field.Error");
148
- React.useEffect(() => {
149
- field.registerError(true);
150
- return () => field.registerError(false);
151
- }, [field]);
158
+ const register = field.registerError;
159
+ useEffect(() => {
160
+ register(true);
161
+ return () => register(false);
162
+ }, [register]);
152
163
 
153
164
  if (!field.invalid) {
154
165
  return null;