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

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.
Files changed (58) hide show
  1. package/accordion.js +360 -0
  2. package/alert-dialog.js +282 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +276 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +550 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +264 -0
  9. package/collapsible.js +169 -0
  10. package/combobox.js +728 -0
  11. package/context-menu.js +206 -0
  12. package/date-picker.js +346 -0
  13. package/dialog.js +523 -0
  14. package/drawer.js +490 -0
  15. package/field.js +387 -0
  16. package/hover-card.js +330 -0
  17. package/index.js +1699 -22
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2163 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/controlled-state.js +65 -0
  22. package/internal/date-grid.js +260 -0
  23. package/internal/disclosure.js +298 -0
  24. package/internal/focus.js +64 -0
  25. package/internal/form-value.js +83 -0
  26. package/internal/hover-intent.js +259 -0
  27. package/internal/menu-tree.js +228 -0
  28. package/internal/merge-props.js +285 -0
  29. package/internal/range.js +147 -0
  30. package/internal/roving-focus.js +430 -0
  31. package/menu.js +823 -0
  32. package/menubar.js +287 -0
  33. package/navigation-menu.js +251 -0
  34. package/package.json +9 -9
  35. package/pagination.js +209 -0
  36. package/popover.js +343 -0
  37. package/progress.js +91 -0
  38. package/radio-group.js +302 -0
  39. package/resizable.js +447 -0
  40. package/scroll-area.js +283 -0
  41. package/select.js +902 -0
  42. package/separator.js +97 -0
  43. package/sheet.js +189 -0
  44. package/sidebar.js +300 -0
  45. package/skeleton.js +159 -0
  46. package/slider.js +405 -0
  47. package/switch.js +81 -0
  48. package/table.js +502 -0
  49. package/tabs.js +289 -0
  50. package/toast.js +592 -0
  51. package/toggle-group.js +283 -0
  52. package/toggle.js +105 -0
  53. package/tooltip.js +400 -0
  54. package/internal/dialog.js +0 -236
  55. package/internal/field.js +0 -161
  56. package/internal/props.js +0 -78
  57. package/internal/switch.js +0 -122
  58. package/internal/tabs.js +0 -270
package/tooltip.js ADDED
@@ -0,0 +1,400 @@
1
+ // @flow
2
+ //
3
+ // A tooltip: the component most likely to make a page *worse* than the plain
4
+ // HTML it replaced.
5
+ //
6
+ // The failure is specified rather than a matter of taste. WCAG 2.1 SC 1.4.13,
7
+ // *Content on Hover or Focus*, requires content shown that way to be
8
+ // dismissible with `Escape`, hoverable — the pointer can travel onto it — and
9
+ // persistent until one of those things ends it. A `title` attribute fails all
10
+ // three: it is on the browser's clock, it cannot be hovered, and `Escape` does
11
+ // nothing. A `div` that appears on `:hover` fails the first two. Those are the
12
+ // two things a project without this component writes.
13
+ //
14
+ // `internal/hover-intent.js` holds all three clauses, because they are one
15
+ // mechanism and separating them is how a component ends up with two of them.
16
+ //
17
+ // # What a tooltip is not
18
+ //
19
+ // It is **not focusable**, and nothing in it may be. A tooltip that takes focus
20
+ // puts a stop in the tab order the reader did not ask for and cannot leave in
21
+ // the direction they expect; the APG says so, and it is why anything with a
22
+ // link or a button in it is a `HoverCard` and not this. It carries
23
+ // `role="tooltip"` and describes its trigger with `aria-describedby` — and only
24
+ // while it is in the document, because a reference to an element that is not
25
+ // there makes a screen reader announce nothing at all.
26
+ //
27
+ // It has **no `aria-haspopup`**. That attribute promises an interactive popup,
28
+ // and a reader who is told a button has one and then finds nothing to interact
29
+ // with has been sent somewhere that does not exist.
30
+ //
31
+ // # The delay, and the one that must not be there
32
+ //
33
+ // A pointer resting on a trigger opens the tooltip after a delay; a pointer
34
+ // crossing a toolbar on its way elsewhere passes six triggers and must open
35
+ // none of them. **Focus opens it with no delay at all** — a reader who tabbed
36
+ // to a control has already said what they want, and a wait exists to filter out
37
+ // intent nobody expressed.
38
+ //
39
+ // `Tooltip.Provider` shares one clock between a group of tooltips, so the
40
+ // second icon in a toolbar answers immediately once the reader has waited out
41
+ // the first. A tooltip outside a provider is a complete tooltip and keeps its
42
+ // own delay.
43
+ //
44
+ // # Touch, which is a decision rather than an accident
45
+ //
46
+ // **This does not open on touch.** There is no hover on a touch screen, so the
47
+ // only gesture left is the tap — and a tooltip that opens on tap either
48
+ // swallows the tap the control needed or shows content the next tap dismisses
49
+ // before it has been read. Both are worse than nothing.
50
+ //
51
+ // What follows from that is a rule for the caller rather than for this module:
52
+ // a tooltip may not be the only place something is said. If the trigger is an
53
+ // icon button, the tooltip's text is what a reader on a phone needs *as the
54
+ // button's name* — so give the button an `aria-label` and let the tooltip
55
+ // repeat it. `pointerenter` is ignored for a touch pointer here, which is what
56
+ // stops the tap being eaten.
57
+ //
58
+ // # Why the pointer is watched natively
59
+ //
60
+ // `pointerenter` and `pointerleave` are the platform's way of saying the
61
+ // pointer arrived at *this* element; React's `onPointerEnter` is its own
62
+ // reconstruction of them from `pointerover` and `pointerout`. The reconstruction
63
+ // is faithful and it is not the same event — and the property this component
64
+ // has to read to know a tap from a hover, `pointerType`, belongs to the pointer
65
+ // event the platform sent. So the listeners go on the element, through
66
+ // `@uniflowed/hooks`' `useEventListener`, which is also what stops a caller's
67
+ // `render` function from dropping a handler by forgetting to spread it.
68
+
69
+ "use client";
70
+
71
+ import * as React from "@uniflowed/react";
72
+ import { createContext, useContext, useEffect, useId, useMemo, useRef } from "@uniflowed/react";
73
+ import { useEventListener } from "@uniflowed/hooks/dom";
74
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
75
+
76
+ import type { Align, LogicalSide } from "./internal/anchor.js";
77
+ import type { DelayGroup, HoverIntent } from "./internal/hover-intent.js";
78
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
79
+ import { composeRefs, withProps, withoutComposed } from "./internal/merge-props.js";
80
+ import {
81
+ DEFAULT_CLOSE_DELAY,
82
+ DEFAULT_OPEN_DELAY,
83
+ DEFAULT_SKIP_DELAY,
84
+ useDelayGroup,
85
+ useDismissOnEscape,
86
+ useFocusableTrigger,
87
+ useHoverIntent,
88
+ } from "./internal/hover-intent.js";
89
+ import { useAnchor } from "./internal/anchor.js";
90
+ import { useControlled } from "./internal/controlled-state.js";
91
+
92
+ export type { Align, LogicalSide, Side } from "./internal/anchor.js";
93
+
94
+ /** What a `Tooltip.Provider` shares with the tooltips inside it. */
95
+ type TooltipScope = {|
96
+ /** The default `openDelay` for every tooltip in the group. */
97
+ readonly delayDuration: number,
98
+ readonly group: DelayGroup,
99
+ |};
100
+
101
+ const TooltipScopeContext: React.Context<TooltipScope | null> = createContext(null);
102
+
103
+ type TooltipState = {|
104
+ readonly base: string,
105
+ readonly open: boolean,
106
+ readonly setOpen: (open: boolean) => void,
107
+ readonly triggerRef: { current: HTMLElement | null },
108
+ readonly intent: HoverIntent,
109
+ /**
110
+ * How long a pointer must rest before this tooltip opens, asked at the moment
111
+ * it arrives rather than read from a render.
112
+ *
113
+ * A function because the answer is the group's and changes as the reader
114
+ * moves: the same tooltip waits the full delay when it is the first one
115
+ * touched and opens at once when it is the second.
116
+ */
117
+ readonly openDelay: () => number,
118
+ readonly closeDelay: number,
119
+ /**
120
+ * Whether `Escape` has dismissed it while the reader is still here.
121
+ *
122
+ * A ref rather than state because nothing renders it, and because what it
123
+ * guards happens inside an event handler: without it, a dismissal is undone
124
+ * by the very next thing the pointer or focus does — which for a hover card
125
+ * is the focus it hands back to its own trigger, and is a component that
126
+ * cannot be closed. It is cleared when the reader leaves the trigger, so
127
+ * coming back opens it again.
128
+ */
129
+ readonly dismissed: { current: boolean },
130
+ |};
131
+
132
+ const TooltipContext: React.Context<TooltipState | null> = createContext(null);
133
+
134
+ hook useTooltip(part: string): TooltipState {
135
+ const state = useContext(TooltipContext);
136
+ if (state == null) {
137
+ throw new Error(`${part} must be rendered inside a Tooltip.Root`);
138
+ }
139
+ return state;
140
+ }
141
+
142
+ /**
143
+ * One clock for a group of tooltips.
144
+ *
145
+ * `delayDuration` is the delay each tooltip inside uses unless it sets its own,
146
+ * so a toolbar says it once. `skipDelayDuration` is the window after one closes
147
+ * during which the next opens immediately — the interaction that makes a row of
148
+ * icon buttons readable rather than tedious.
149
+ *
150
+ * Renders no element: it is a context and a clock, and a `<div>` around a
151
+ * toolbar is the caller's business.
152
+ */
153
+ export component TooltipProvider(
154
+ children: React.Node,
155
+ delayDuration?: number = DEFAULT_OPEN_DELAY,
156
+ skipDelayDuration?: number = DEFAULT_SKIP_DELAY,
157
+ ) {
158
+ const group = useDelayGroup(skipDelayDuration);
159
+ const scope = useMemo(() => ({ delayDuration, group }), [delayDuration, group]);
160
+
161
+ return <TooltipScopeContext.Provider value={scope}>{children}</TooltipScopeContext.Provider>;
162
+ }
163
+
164
+ /**
165
+ * One tooltip and its trigger.
166
+ *
167
+ * `openDelay` falls back to the enclosing `Tooltip.Provider`'s
168
+ * `delayDuration`, and to 700ms when there is no provider — a tooltip on its
169
+ * own is a complete tooltip and needs nothing around it.
170
+ */
171
+ export component TooltipRoot(
172
+ children: React.Node,
173
+ closeDelay?: number = DEFAULT_CLOSE_DELAY,
174
+ defaultOpen?: boolean = false,
175
+ onOpenChange?: (open: boolean) => void,
176
+ open?: boolean,
177
+ openDelay?: number,
178
+ ) {
179
+ const base = useId();
180
+ const scope = useContext(TooltipScopeContext);
181
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
182
+ const triggerRef = useRef<HTMLElement | null>(null);
183
+ const dismissed = useRef(false);
184
+ const intent = useHoverIntent(setOpen);
185
+ const own = openDelay ?? scope?.delayDuration ?? DEFAULT_OPEN_DELAY;
186
+ const group = scope?.group;
187
+
188
+ // What this tooltip last told the group, so that mounting closed says
189
+ // nothing — a page of six tooltips would otherwise open six skip windows
190
+ // before the reader had touched anything.
191
+ const told = useRef(false);
192
+ useEffect(() => {
193
+ if (group == null || told.current === isOpen) {
194
+ return;
195
+ }
196
+ told.current = isOpen;
197
+ if (isOpen) {
198
+ group.opened();
199
+ } else {
200
+ group.closed();
201
+ }
202
+ }, [group, isOpen]);
203
+
204
+ // A tooltip taken away while it was showing has to say so, or the group is
205
+ // left believing one is open and answers every later hover instantly.
206
+ useEffect(
207
+ () => () => {
208
+ if (told.current) {
209
+ told.current = false;
210
+ group?.closed();
211
+ }
212
+ },
213
+ [group],
214
+ );
215
+
216
+ const state = useMemo(
217
+ () => ({
218
+ base,
219
+ closeDelay,
220
+ dismissed,
221
+ intent,
222
+ open: isOpen,
223
+ openDelay: () => group?.delayFor(own) ?? own,
224
+ setOpen,
225
+ triggerRef,
226
+ }),
227
+ [base, closeDelay, group, intent, isOpen, own, setOpen],
228
+ );
229
+
230
+ return <TooltipContext.Provider value={state}>{children}</TooltipContext.Provider>;
231
+ }
232
+
233
+ /**
234
+ * What the tooltip describes.
235
+ *
236
+ * A `<button>` by default, and whatever the caller renders when they pass
237
+ * `render` — a link, a menu item, an icon button of their own. Either way it
238
+ * has to be something the keyboard can reach, and `useFocusableTrigger` refuses
239
+ * anything else: a tooltip on a `<span>` is a tooltip only a mouse can find,
240
+ * and it looks perfect in the markup.
241
+ *
242
+ * Every handler is attached to the element rather than passed as a prop, so a
243
+ * `render` function that forgets to spread something still gets a working
244
+ * tooltip; see the module header.
245
+ */
246
+ export component TooltipTrigger(children?: React.Node, render?: RenderProp, ...rest: Rest) {
247
+ const tooltip = useTooltip("Tooltip.Trigger");
248
+ const { closeDelay, dismissed, intent, openDelay, setOpen, triggerRef } = tooltip;
249
+ useFocusableTrigger(triggerRef, "Tooltip.Trigger");
250
+
251
+ // Whether the pointer put focus here. A click focuses the button, and
252
+ // reopening the tooltip the click just dismissed would put it back over the
253
+ // thing the reader pressed — so a focus that arrived with a press opens
254
+ // nothing, and the next one, which is the keyboard's, does.
255
+ const pressed = useRef(false);
256
+
257
+ useEventListener(triggerRef, "pointerenter", (event: $FlowFixMe) => {
258
+ // A tap is not a hover. See the module header: opening here is what eats
259
+ // the tap the control was there to receive.
260
+ if (event.pointerType === "touch" || dismissed.current) {
261
+ return;
262
+ }
263
+ intent.openAfter(openDelay());
264
+ });
265
+ useEventListener(triggerRef, "pointerleave", () => {
266
+ // Leaving is what makes a dismissal stop applying: coming back is a fresh
267
+ // gesture and deserves a fresh answer.
268
+ dismissed.current = false;
269
+ intent.closeAfter(closeDelay);
270
+ });
271
+ useEventListener(triggerRef, "pointerdown", () => {
272
+ pressed.current = true;
273
+ intent.cancel();
274
+ setOpen(false);
275
+ });
276
+ useEventListener(triggerRef, "focusin", () => {
277
+ if (pressed.current) {
278
+ pressed.current = false;
279
+ return;
280
+ }
281
+ if (dismissed.current) {
282
+ return;
283
+ }
284
+ // No delay: the reader has already said what they want by arriving here.
285
+ intent.openAfter(0);
286
+ });
287
+ useEventListener(triggerRef, "focusout", () => {
288
+ pressed.current = false;
289
+ dismissed.current = false;
290
+ intent.closeAfter(0);
291
+ });
292
+
293
+ // Annotated because this one is not written inside a `ref={...}`, and there
294
+ // is nothing else here for Flow to infer the element's type from.
295
+ const attach = composeRefs(rest.ref, (element: HTMLElement | null) => {
296
+ triggerRef.current = element;
297
+ });
298
+ // Only while it is there. `aria-describedby` pointing at an element that has
299
+ // been removed is the dangling reference this package keeps coming back to.
300
+ const props = withProps(withoutComposed(rest, ["ref"]), {
301
+ "aria-describedby": tooltip.open ? `${tooltip.base}-body` : undefined,
302
+ children,
303
+ ref: attach,
304
+ });
305
+
306
+ if (render != null) {
307
+ return render(props);
308
+ }
309
+ return <button {...props} type="button" />;
310
+ }
311
+
312
+ /**
313
+ * The tooltip itself.
314
+ *
315
+ * Hoverable, which is the clause every hand-written tooltip fails: the pointer
316
+ * arriving here calls off the close the trigger scheduled when the pointer
317
+ * left it, so the trip across the gap does not take the content away. It never
318
+ * takes focus, holds no tab stop, and answers `Escape` from wherever focus
319
+ * happens to be.
320
+ */
321
+ export component TooltipBody(
322
+ children: React.Node,
323
+ align?: Align = "center",
324
+ alignOffset?: number = 0,
325
+ avoidCollisions?: boolean = true,
326
+ collisionPadding?: number = 0,
327
+ render?: RenderProp,
328
+ side?: LogicalSide = "top",
329
+ sideOffset?: number = 0,
330
+ ...rest: Rest
331
+ ) {
332
+ const tooltip = useTooltip("Tooltip.Body");
333
+ const { closeDelay, intent, open, triggerRef } = tooltip;
334
+ const bodyRef = useRef<HTMLElement | null>(null);
335
+ const close = useStableCallback(() => {
336
+ // `Escape` dismisses it *and* keeps it dismissed while the reader is still
337
+ // on the trigger. Without the flag the pointer that is still resting there
338
+ // — or, for a hover card, the focus it hands back — reopens it at once,
339
+ // and the key does nothing a reader can see.
340
+ tooltip.dismissed.current = true;
341
+ intent.cancel();
342
+ tooltip.setOpen(false);
343
+ });
344
+
345
+ const anchored = useAnchor({
346
+ align,
347
+ alignOffset,
348
+ anchorRef: triggerRef,
349
+ avoidCollisions,
350
+ collisionPadding,
351
+ open,
352
+ overlayRef: bodyRef,
353
+ side,
354
+ sideOffset,
355
+ });
356
+
357
+ useDismissOnEscape(open, bodyRef, close);
358
+
359
+ // Keyed on `open` rather than written with `useEventListener`, and the
360
+ // difference is load-bearing: that hook reads its target once, when it
361
+ // attaches, and this element does not exist until the tooltip opens. It is
362
+ // the same trap `select.js` documents about its outside-press listener.
363
+ useEffect(() => {
364
+ const body = bodyRef.current;
365
+ if (!open || body == null) {
366
+ return;
367
+ }
368
+ const stay = () => intent.cancel();
369
+ const go = () => intent.closeAfter(closeDelay);
370
+ body.addEventListener("pointerenter", stay);
371
+ body.addEventListener("pointerleave", go);
372
+ return () => {
373
+ body.removeEventListener("pointerenter", stay);
374
+ body.removeEventListener("pointerleave", go);
375
+ };
376
+ }, [open, intent, closeDelay]);
377
+
378
+ if (!open) {
379
+ return null;
380
+ }
381
+
382
+ const props = withProps(withoutComposed(rest, ["ref"]), {
383
+ children,
384
+ "data-align": anchored.align,
385
+ "data-side": anchored.side,
386
+ "data-state": "open",
387
+ id: `${tooltip.base}-body`,
388
+ ref: composeRefs(rest.ref, (element) => {
389
+ bodyRef.current = element;
390
+ }),
391
+ role: "tooltip",
392
+ // No `tabIndex`. A tooltip the keyboard can land in is a stop the reader
393
+ // did not ask for and cannot leave the way they expect.
394
+ });
395
+
396
+ if (render != null) {
397
+ return render(props);
398
+ }
399
+ return <div {...props} />;
400
+ }
@@ -1,236 +0,0 @@
1
- // @flow
2
- //
3
- // A modal dialog, which is the component people most often get wrong.
4
- //
5
- // Four things have to be true for a dialog to be usable by someone who is not
6
- // using a mouse, and a hand-written one usually has one or two of them:
7
- //
8
- // * Focus moves into the dialog when it opens, and to the first thing worth
9
- // acting on rather than to whatever happens to be first in the document.
10
- // * Tab cannot leave. A dialog you can Tab out of leaves the reader
11
- // somewhere in a page they cannot see, with no way back.
12
- // * Escape closes it.
13
- // * Focus returns to whatever opened it. Otherwise it restarts at the top of
14
- // the document, and the reader has to find their place again.
15
- //
16
- // The dialog is rendered where it is declared rather than through a portal.
17
- // A portal solves a stacking-context problem that belongs to CSS, and it costs
18
- // the thing this component is for: rendered in place, the dialog is next to its
19
- // trigger in the accessibility tree, which is where a screen reader looks.
20
-
21
- import * as React from "@uniflowed/react";
22
-
23
- import { composeHandlers, composeRefs, withoutComposed } from "./props.js";
24
- import {
25
- createContext,
26
- useCallback,
27
- useContext,
28
- useEffect,
29
- useId,
30
- useMemo,
31
- useRef,
32
- useState,
33
- } from "@uniflowed/react";
34
-
35
- type DialogState = {|
36
- readonly base: string,
37
- readonly open: boolean,
38
- readonly setOpen: (open: boolean) => void,
39
- readonly triggerRef: { current: HTMLElement | null },
40
- |};
41
-
42
- const DialogContext: React.Context<DialogState | null> = createContext(null);
43
-
44
- function useDialog(part: string): DialogState {
45
- const state = useContext(DialogContext);
46
- if (state == null) {
47
- throw new Error(`${part} must be rendered inside a Dialog.Root`);
48
- }
49
- return state;
50
- }
51
-
52
- /** The dialog, open or closed. Uncontrolled unless `open` is given. */
53
- export component DialogRoot(
54
- children: React.Node,
55
- defaultOpen?: boolean = false,
56
- open?: boolean,
57
- onOpenChange?: (open: boolean) => void,
58
- ) {
59
- const base = useId();
60
- const [internal, setInternal] = useState(defaultOpen);
61
- const triggerRef = useRef<HTMLElement | null>(null);
62
- const isOpen = open ?? internal;
63
-
64
- const setOpen = useCallback(
65
- (next: boolean) => {
66
- if (open == null) {
67
- setInternal(next);
68
- }
69
- onOpenChange?.(next);
70
- },
71
- [open, onOpenChange],
72
- );
73
-
74
- const state = useMemo(
75
- () => ({ base, open: isOpen, setOpen, triggerRef }),
76
- [base, isOpen, setOpen],
77
- );
78
-
79
- return <DialogContext.Provider value={state}>{children}</DialogContext.Provider>;
80
- }
81
-
82
- /** What opens the dialog, and what focus comes back to when it closes. */
83
- export component DialogTrigger(children: React.Node, ...rest: { readonly [string]: mixed }) {
84
- const dialog = useDialog("Dialog.Trigger");
85
- const passed = withoutComposed(rest, ["onClick", "ref"]);
86
-
87
- return (
88
- <button
89
- {...passed}
90
- aria-expanded={dialog.open ? "true" : "false"}
91
- aria-haspopup="dialog"
92
- onClick={composeHandlers(rest.onClick, () => dialog.setOpen(true))}
93
- ref={composeRefs(rest.ref, (element) => {
94
- dialog.triggerRef.current = element;
95
- })}
96
- type="button"
97
- >
98
- {children}
99
- </button>
100
- );
101
- }
102
-
103
- /**
104
- * The dialog itself: focus moved in, Tab kept inside, Escape closing it.
105
- *
106
- * `aria-modal` tells a screen reader that the rest of the page is not
107
- * available, which is the half of "modal" that CSS cannot express.
108
- */
109
- export component DialogContent(children: React.Node, ...rest: { readonly [string]: mixed }) {
110
- const dialog = useDialog("Dialog.Content");
111
- const contentRef = useRef<HTMLElement | null>(null);
112
-
113
- useEffect(() => {
114
- if (!dialog.open) {
115
- return;
116
- }
117
- const opener = dialog.triggerRef.current;
118
- const content = contentRef.current;
119
- // The first thing worth acting on, not the first thing in the document —
120
- // and the dialog itself if it contains nothing focusable, so focus is
121
- // inside it either way.
122
- const target = content == null ? null : (focusable(content)[0] ?? content);
123
- target?.focus();
124
-
125
- return () => {
126
- // Back to the trigger. Leaving focus on a removed node sends it to the
127
- // top of the document, and the reader has to find their place again.
128
- opener?.focus();
129
- };
130
- }, [dialog.open, dialog.triggerRef]);
131
-
132
- if (!dialog.open) {
133
- return null;
134
- }
135
-
136
- const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
137
-
138
- return (
139
- <div
140
- // `passed` first. A caller `ref` used to replace `contentRef`, which
141
- // left it null, made the Tab branch below return early, and turned the
142
- // focus trap off while the dialog still announced `aria-modal="true"`.
143
- // A caller `onKeyDown` used to replace this one, and Escape stopped
144
- // closing the dialog.
145
- {...passed}
146
- aria-labelledby={`${dialog.base}-title`}
147
- aria-modal="true"
148
- id={`${dialog.base}-content`}
149
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
150
- if (event.key === "Escape") {
151
- event.preventDefault();
152
- dialog.setOpen(false);
153
- return;
154
- }
155
- if (event.key !== "Tab") {
156
- return;
157
- }
158
- const content = contentRef.current;
159
- if (content == null) {
160
- return;
161
- }
162
- const stops = focusable(content);
163
- if (stops.length === 0) {
164
- // Nothing to move to, so Tab must not leave either.
165
- event.preventDefault();
166
- return;
167
- }
168
- const first = stops[0];
169
- const last = stops[stops.length - 1];
170
- const active = content.ownerDocument?.activeElement;
171
- // Wrap at the ends. This is the whole of "focus cannot leave": every
172
- // other Tab press is the browser's own business.
173
- if (event.shiftKey && (active === first || active === content)) {
174
- event.preventDefault();
175
- last.focus();
176
- } else if (!event.shiftKey && active === last) {
177
- event.preventDefault();
178
- first.focus();
179
- }
180
- })}
181
- ref={composeRefs(rest.ref, (element) => {
182
- contentRef.current = element;
183
- })}
184
- role="dialog"
185
- tabIndex={-1}
186
- >
187
- {children}
188
- </div>
189
- );
190
- }
191
-
192
- /** The dialog's accessible name, which `aria-labelledby` points at. */
193
- export component DialogTitle(children: React.Node, ...rest: { readonly [string]: mixed }) {
194
- const dialog = useDialog("Dialog.Title");
195
- return (
196
- <h2 {...rest} id={`${dialog.base}-title`}>
197
- {children}
198
- </h2>
199
- );
200
- }
201
-
202
- /** A button that closes the dialog. */
203
- export component DialogClose(children: React.Node, ...rest: { readonly [string]: mixed }) {
204
- const dialog = useDialog("Dialog.Close");
205
- const passed = withoutComposed(rest, ["onClick"]);
206
-
207
- return (
208
- <button
209
- {...passed}
210
- onClick={composeHandlers(rest.onClick, () => dialog.setOpen(false))}
211
- type="button"
212
- >
213
- {children}
214
- </button>
215
- );
216
- }
217
-
218
- /**
219
- * The focus stops inside an element, in document order.
220
- *
221
- * Disabled controls and `tabindex="-1"` are excluded because the browser
222
- * excludes them, and anything inside `[hidden]` or `aria-hidden` is excluded
223
- * because a reader cannot see it.
224
- */
225
- function focusable(root: HTMLElement): Array<HTMLElement> {
226
- const selector =
227
- 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
228
- return Array.from(root.querySelectorAll(selector)).filter(
229
- (element: any) =>
230
- // Both attributes hide a whole subtree, so both are checked on the
231
- // ancestors. Reading `aria-hidden` off the element alone returned a
232
- // button inside `<div aria-hidden="true">` as a focus stop, and the trap
233
- // then moved focus to a control no screen reader exposes.
234
- element.closest("[hidden]") == null && element.closest('[aria-hidden="true"]') == null,
235
- );
236
- }