@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/accordion.js +335 -0
- package/alert-dialog.js +256 -0
- package/carousel.js +410 -0
- package/checkbox.js +80 -0
- package/collapsible.js +147 -0
- package/combobox.js +557 -0
- package/dialog.js +499 -0
- package/drawer.js +458 -0
- package/field.js +170 -0
- package/hover-card.js +334 -0
- package/index.js +1012 -0
- package/input-otp.js +218 -0
- package/internal/anchor.js +500 -0
- package/internal/controlled-state.js +65 -0
- package/internal/disclosure.js +97 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +201 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +654 -0
- package/navigation-menu.js +251 -0
- package/package.json +57 -0
- package/pagination.js +197 -0
- package/popover.js +326 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/scroll-area.js +283 -0
- package/select.js +855 -0
- package/sheet.js +165 -0
- package/sidebar.js +300 -0
- package/slider.js +405 -0
- package/switch.js +73 -0
- package/table.js +479 -0
- package/tabs.js +280 -0
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
- package/tooltip.js +411 -0
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
|
+
}
|