@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/checkbox.js +79 -0
- package/combobox.js +546 -0
- package/dialog.js +482 -0
- package/{internal/field.js → field.js} +24 -13
- package/index.js +182 -26
- package/internal/controlled-state.js +65 -0
- package/internal/{props.js → merge-props.js} +19 -11
- package/internal/roving-focus.js +236 -0
- package/menu.js +628 -0
- package/package.json +11 -7
- package/switch.js +72 -0
- package/tabs.js +286 -0
- package/internal/dialog.js +0 -236
- package/internal/switch.js +0 -122
- package/internal/tabs.js +0 -270
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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;
|