@uniflowed/ui 0.0.0-alpha.9 → 0.2.0
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 +84 -57
- package/alert-dialog.js +284 -0
- package/alert.js +142 -0
- package/avatar.js +280 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +587 -0
- package/carousel.js +410 -0
- package/checkbox.js +215 -31
- package/collapsible.js +72 -48
- package/color-picker.js +172 -0
- package/combobox.js +216 -39
- package/context-menu.js +215 -0
- package/date-field.js +9 -0
- package/date-picker.js +357 -0
- package/date-range-picker.js +120 -0
- package/dialog.js +243 -178
- package/drag-drop.js +125 -0
- package/drawer.js +504 -0
- package/field.js +260 -43
- package/grid-list.js +8 -0
- package/hover-card.js +52 -52
- package/i18n-provider.js +89 -0
- package/index.js +1177 -31
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +71 -6
- package/internal/collection.js +562 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +85 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +317 -0
- package/internal/selection.js +171 -0
- package/internal/visually-hidden-style.js +41 -0
- package/list-box.js +13 -0
- package/menu.js +553 -361
- package/menubar.js +295 -0
- package/number-field.js +263 -0
- package/package.json +8 -28
- package/pagination.js +34 -22
- package/popover.js +116 -75
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +79 -0
- package/resizable.js +155 -9
- package/scroll-area.js +283 -0
- package/select.js +83 -37
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +320 -0
- package/skeleton.js +163 -0
- package/slider.js +95 -89
- package/switch.js +42 -34
- package/table.js +100 -71
- package/tabs.js +100 -91
- package/tag-group.js +8 -0
- package/time-field.js +8 -0
- package/toast.js +36 -66
- package/toggle-group.js +53 -49
- package/toggle.js +41 -27
- package/tooltip.js +48 -55
- package/tree.js +8 -0
- package/visually-hidden.js +259 -0
package/toggle.js
CHANGED
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
//
|
|
29
29
|
// Because a toggle button is a button, and a button activates on both. That is
|
|
30
30
|
// the same reasoning `switch.js` gives for `Enter`, and the opposite of
|
|
31
|
-
// `checkbox.js`,
|
|
32
|
-
// reader answers on their way to
|
|
31
|
+
// `checkbox.js`, where `Enter` submits the form rather than touching the
|
|
32
|
+
// control — because a checkbox is something a reader answers on their way to
|
|
33
|
+
// submitting a form, and that is what the native one does with the key.
|
|
33
34
|
//
|
|
34
35
|
// # It is `disabled`, not `aria-disabled`
|
|
35
36
|
//
|
|
@@ -43,49 +44,62 @@
|
|
|
43
44
|
|
|
44
45
|
import * as React from "@uniflowed/react";
|
|
45
46
|
|
|
46
|
-
import type { Rest } from "./internal/merge-props.js";
|
|
47
|
-
import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
|
|
47
|
+
import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
|
|
48
|
+
import { composeHandlers, withProps, withoutComposed } from "./internal/merge-props.js";
|
|
48
49
|
import { useControlled } from "./internal/controlled-state.js";
|
|
49
50
|
|
|
50
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* A button whose state stays applied: pressed or not.
|
|
53
|
+
*
|
|
54
|
+
* `render` is the escape hatch. A caller-rendered element gets `role="button"`
|
|
55
|
+
* because it may be a link or a `div`; the native button branch keeps the role
|
|
56
|
+
* implicit and only adds `type="button"`, which is true of the element rather
|
|
57
|
+
* than of the toggle behaviour.
|
|
58
|
+
*/
|
|
51
59
|
export component Toggle(
|
|
52
60
|
pressed?: boolean,
|
|
53
61
|
defaultPressed?: boolean = false,
|
|
54
62
|
onPressedChange?: (pressed: boolean) => void,
|
|
55
63
|
disabled?: boolean = false,
|
|
56
64
|
children?: React.Node,
|
|
65
|
+
render?: RenderProp,
|
|
57
66
|
...rest: Rest
|
|
58
67
|
) {
|
|
59
68
|
const [on, setOn] = useControlled(pressed, defaultPressed, onPressedChange);
|
|
60
69
|
const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
|
|
70
|
+
const semantics = {
|
|
71
|
+
"aria-pressed": on ? "true" : "false",
|
|
72
|
+
children,
|
|
73
|
+
disabled,
|
|
74
|
+
onClick: composeHandlers(rest.onClick, (_event: PartEvent) => {
|
|
75
|
+
if (!disabled) {
|
|
76
|
+
setOn(!on);
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
80
|
+
if (disabled || (event.key !== " " && event.key !== "Enter")) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Preventing the default stops `Space` scrolling the page, and stops
|
|
84
|
+
// the browser's own click arriving after this handler and pressing the
|
|
85
|
+
// button a second time — back to where it started, which reads as the
|
|
86
|
+
// key having done nothing at all.
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
setOn(!on);
|
|
89
|
+
}),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (render != null) {
|
|
93
|
+
return render(withProps(passed, { ...semantics, role: "button" }));
|
|
94
|
+
}
|
|
61
95
|
|
|
62
96
|
return (
|
|
63
97
|
<button
|
|
64
|
-
{...passed}
|
|
98
|
+
{...withProps(passed, semantics)}
|
|
65
99
|
// No `role`: this *is* a button, and `aria-pressed` is what makes it a
|
|
66
100
|
// toggle one. Adding `role="button"` to a `<button>` would be noise, and
|
|
67
101
|
// adding any other role would be a lie about what pressing it does.
|
|
68
|
-
aria-pressed={on ? "true" : "false"}
|
|
69
|
-
disabled={disabled}
|
|
70
|
-
onClick={composeHandlers(rest.onClick, () => {
|
|
71
|
-
if (!disabled) {
|
|
72
|
-
setOn(!on);
|
|
73
|
-
}
|
|
74
|
-
})}
|
|
75
|
-
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
76
|
-
if (disabled || (event.key !== " " && event.key !== "Enter")) {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
// Preventing the default stops `Space` scrolling the page, and stops
|
|
80
|
-
// the browser's own click arriving after this handler and pressing the
|
|
81
|
-
// button a second time — back to where it started, which reads as the
|
|
82
|
-
// key having done nothing at all.
|
|
83
|
-
event.preventDefault();
|
|
84
|
-
setOn(!on);
|
|
85
|
-
})}
|
|
86
102
|
type="button"
|
|
87
|
-
|
|
88
|
-
{children}
|
|
89
|
-
</button>
|
|
103
|
+
/>
|
|
90
104
|
);
|
|
91
105
|
}
|
package/tooltip.js
CHANGED
|
@@ -73,9 +73,9 @@ import { createContext, useContext, useEffect, useId, useMemo, useRef } from "@u
|
|
|
73
73
|
import { useEventListener } from "@uniflowed/hooks/dom";
|
|
74
74
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
75
75
|
|
|
76
|
-
import type { Align,
|
|
76
|
+
import type { Align, LogicalSide } from "./internal/anchor.js";
|
|
77
77
|
import type { DelayGroup, HoverIntent } from "./internal/hover-intent.js";
|
|
78
|
-
import type { Rest } from "./internal/merge-props.js";
|
|
78
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
79
79
|
import { composeRefs, withProps, withoutComposed } from "./internal/merge-props.js";
|
|
80
80
|
import {
|
|
81
81
|
DEFAULT_CLOSE_DELAY,
|
|
@@ -89,7 +89,7 @@ import {
|
|
|
89
89
|
import { useAnchor } from "./internal/anchor.js";
|
|
90
90
|
import { useControlled } from "./internal/controlled-state.js";
|
|
91
91
|
|
|
92
|
-
export type { Align, Side } from "./internal/anchor.js";
|
|
92
|
+
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
93
93
|
|
|
94
94
|
/** What a `Tooltip.Provider` shares with the tooltips inside it. */
|
|
95
95
|
type TooltipScope = {|
|
|
@@ -126,7 +126,7 @@ type TooltipState = {|
|
|
|
126
126
|
* cannot be closed. It is cleared when the reader leaves the trigger, so
|
|
127
127
|
* coming back opens it again.
|
|
128
128
|
*/
|
|
129
|
-
readonly
|
|
129
|
+
readonly dismissedRef: { current: boolean },
|
|
130
130
|
|};
|
|
131
131
|
|
|
132
132
|
const TooltipContext: React.Context<TooltipState | null> = createContext(null);
|
|
@@ -180,7 +180,7 @@ export component TooltipRoot(
|
|
|
180
180
|
const scope = useContext(TooltipScopeContext);
|
|
181
181
|
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
182
182
|
const triggerRef = useRef<HTMLElement | null>(null);
|
|
183
|
-
const
|
|
183
|
+
const dismissedRef = useRef(false);
|
|
184
184
|
const intent = useHoverIntent(setOpen);
|
|
185
185
|
const own = openDelay ?? scope?.delayDuration ?? DEFAULT_OPEN_DELAY;
|
|
186
186
|
const group = scope?.group;
|
|
@@ -217,7 +217,7 @@ export component TooltipRoot(
|
|
|
217
217
|
() => ({
|
|
218
218
|
base,
|
|
219
219
|
closeDelay,
|
|
220
|
-
|
|
220
|
+
dismissedRef,
|
|
221
221
|
intent,
|
|
222
222
|
open: isOpen,
|
|
223
223
|
openDelay: () => group?.delayFor(own) ?? own,
|
|
@@ -243,25 +243,21 @@ export component TooltipRoot(
|
|
|
243
243
|
* `render` function that forgets to spread something still gets a working
|
|
244
244
|
* tooltip; see the module header.
|
|
245
245
|
*/
|
|
246
|
-
export component TooltipTrigger(
|
|
247
|
-
children?: React.Node,
|
|
248
|
-
render?: (props: Rest) => React.Node,
|
|
249
|
-
...rest: Rest
|
|
250
|
-
) {
|
|
246
|
+
export component TooltipTrigger(children?: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
251
247
|
const tooltip = useTooltip("Tooltip.Trigger");
|
|
252
|
-
const { closeDelay,
|
|
248
|
+
const { closeDelay, dismissedRef, intent, openDelay, setOpen, triggerRef } = tooltip;
|
|
253
249
|
useFocusableTrigger(triggerRef, "Tooltip.Trigger");
|
|
254
250
|
|
|
255
251
|
// Whether the pointer put focus here. A click focuses the button, and
|
|
256
252
|
// reopening the tooltip the click just dismissed would put it back over the
|
|
257
253
|
// thing the reader pressed — so a focus that arrived with a press opens
|
|
258
254
|
// nothing, and the next one, which is the keyboard's, does.
|
|
259
|
-
const
|
|
255
|
+
const pressedRef = useRef(false);
|
|
260
256
|
|
|
261
257
|
useEventListener(triggerRef, "pointerenter", (event: $FlowFixMe) => {
|
|
262
258
|
// A tap is not a hover. See the module header: opening here is what eats
|
|
263
259
|
// the tap the control was there to receive.
|
|
264
|
-
if (event.pointerType === "touch" ||
|
|
260
|
+
if (event.pointerType === "touch" || dismissedRef.current) {
|
|
265
261
|
return;
|
|
266
262
|
}
|
|
267
263
|
intent.openAfter(openDelay());
|
|
@@ -269,57 +265,50 @@ export component TooltipTrigger(
|
|
|
269
265
|
useEventListener(triggerRef, "pointerleave", () => {
|
|
270
266
|
// Leaving is what makes a dismissal stop applying: coming back is a fresh
|
|
271
267
|
// gesture and deserves a fresh answer.
|
|
272
|
-
|
|
268
|
+
dismissedRef.current = false;
|
|
273
269
|
intent.closeAfter(closeDelay);
|
|
274
270
|
});
|
|
275
271
|
useEventListener(triggerRef, "pointerdown", () => {
|
|
276
|
-
|
|
272
|
+
pressedRef.current = true;
|
|
277
273
|
intent.cancel();
|
|
278
274
|
setOpen(false);
|
|
279
275
|
});
|
|
280
276
|
useEventListener(triggerRef, "focusin", () => {
|
|
281
|
-
if (
|
|
282
|
-
|
|
277
|
+
if (pressedRef.current) {
|
|
278
|
+
pressedRef.current = false;
|
|
283
279
|
return;
|
|
284
280
|
}
|
|
285
|
-
if (
|
|
281
|
+
if (dismissedRef.current) {
|
|
286
282
|
return;
|
|
287
283
|
}
|
|
288
284
|
// No delay: the reader has already said what they want by arriving here.
|
|
289
285
|
intent.openAfter(0);
|
|
290
286
|
});
|
|
291
287
|
useEventListener(triggerRef, "focusout", () => {
|
|
292
|
-
|
|
293
|
-
|
|
288
|
+
pressedRef.current = false;
|
|
289
|
+
dismissedRef.current = false;
|
|
294
290
|
intent.closeAfter(0);
|
|
295
291
|
});
|
|
296
292
|
|
|
297
293
|
// Annotated because this one is not written inside a `ref={...}`, and there
|
|
298
294
|
// is nothing else here for Flow to infer the element's type from.
|
|
295
|
+
// React calls callback refs during commit; pointer and focus handlers read it later.
|
|
296
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
299
297
|
const attach = composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
300
298
|
triggerRef.current = element;
|
|
301
299
|
});
|
|
302
300
|
// Only while it is there. `aria-describedby` pointing at an element that has
|
|
303
301
|
// been removed is the dangling reference this package keeps coming back to.
|
|
304
|
-
const
|
|
302
|
+
const props = withProps(withoutComposed(rest, ["ref"]), {
|
|
305
303
|
"aria-describedby": tooltip.open ? `${tooltip.base}-body` : undefined,
|
|
304
|
+
children,
|
|
306
305
|
ref: attach,
|
|
307
|
-
};
|
|
306
|
+
});
|
|
308
307
|
|
|
309
308
|
if (render != null) {
|
|
310
|
-
return render(
|
|
309
|
+
return render(props);
|
|
311
310
|
}
|
|
312
|
-
|
|
313
|
-
return (
|
|
314
|
-
<button
|
|
315
|
-
{...withoutComposed(rest, ["ref"])}
|
|
316
|
-
aria-describedby={ours["aria-describedby"]}
|
|
317
|
-
ref={attach}
|
|
318
|
-
type="button"
|
|
319
|
-
>
|
|
320
|
-
{children}
|
|
321
|
-
</button>
|
|
322
|
-
);
|
|
311
|
+
return <button {...props} type="button" />;
|
|
323
312
|
}
|
|
324
313
|
|
|
325
314
|
/**
|
|
@@ -337,19 +326,20 @@ export component TooltipBody(
|
|
|
337
326
|
alignOffset?: number = 0,
|
|
338
327
|
avoidCollisions?: boolean = true,
|
|
339
328
|
collisionPadding?: number = 0,
|
|
340
|
-
|
|
329
|
+
render?: RenderProp,
|
|
330
|
+
side?: LogicalSide = "top",
|
|
341
331
|
sideOffset?: number = 0,
|
|
342
332
|
...rest: Rest
|
|
343
333
|
) {
|
|
344
334
|
const tooltip = useTooltip("Tooltip.Body");
|
|
345
|
-
const { closeDelay, intent, open, triggerRef } = tooltip;
|
|
335
|
+
const { closeDelay, dismissedRef, intent, open, triggerRef } = tooltip;
|
|
346
336
|
const bodyRef = useRef<HTMLElement | null>(null);
|
|
347
337
|
const close = useStableCallback(() => {
|
|
348
338
|
// `Escape` dismisses it *and* keeps it dismissed while the reader is still
|
|
349
339
|
// on the trigger. Without the flag the pointer that is still resting there
|
|
350
340
|
// — or, for a hover card, the focus it hands back — reopens it at once,
|
|
351
341
|
// and the key does nothing a reader can see.
|
|
352
|
-
|
|
342
|
+
dismissedRef.current = true;
|
|
353
343
|
intent.cancel();
|
|
354
344
|
tooltip.setOpen(false);
|
|
355
345
|
});
|
|
@@ -391,21 +381,24 @@ export component TooltipBody(
|
|
|
391
381
|
return null;
|
|
392
382
|
}
|
|
393
383
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
)
|
|
384
|
+
const props = withProps(withoutComposed(rest, ["ref"]), {
|
|
385
|
+
children,
|
|
386
|
+
"data-align": anchored.align,
|
|
387
|
+
"data-side": anchored.side,
|
|
388
|
+
"data-state": "open",
|
|
389
|
+
id: `${tooltip.base}-body`,
|
|
390
|
+
// React calls callback refs during commit; placement effects read it later.
|
|
391
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
392
|
+
ref: composeRefs(rest.ref, (element) => {
|
|
393
|
+
bodyRef.current = element;
|
|
394
|
+
}),
|
|
395
|
+
role: "tooltip",
|
|
396
|
+
// No `tabIndex`. A tooltip the keyboard can land in is a stop the reader
|
|
397
|
+
// did not ask for and cannot leave the way they expect.
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
if (render != null) {
|
|
401
|
+
return render(props);
|
|
402
|
+
}
|
|
403
|
+
return <div {...props} />;
|
|
411
404
|
}
|
package/tree.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
"use client";
|
|
3
|
+
import * as React from "@uniflowed/react";
|
|
4
|
+
import { CollectionRoot } from "./internal/collection.js";
|
|
5
|
+
import type { CollectionProps } from "./internal/collection.js";
|
|
6
|
+
export component Tree(...props: CollectionProps) {
|
|
7
|
+
return <CollectionRoot options={props} kind="tree" />;
|
|
8
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
"use client";
|
|
3
|
+
//
|
|
4
|
+
// Text for assistive technology and nobody else, and the announcer built on it.
|
|
5
|
+
//
|
|
6
|
+
// # `VisuallyHidden`
|
|
7
|
+
//
|
|
8
|
+
// `display: none` and `visibility: hidden` take an element out of the
|
|
9
|
+
// accessibility tree as well as off the screen, which is the opposite of what a
|
|
10
|
+
// visually hidden label is for. The style in `internal/visually-hidden-style.js`
|
|
11
|
+
// is the one that survives every engine's accessibility mapping: a one-pixel
|
|
12
|
+
// box, clipped twice (`clip` for the engines that predate `clip-path`), and
|
|
13
|
+
// `white-space: nowrap` so a screen reader's virtual cursor does not read a
|
|
14
|
+
// clipped paragraph one word per line.
|
|
15
|
+
//
|
|
16
|
+
// `focusable` is for the skip link: hidden until a keyboard lands on it, then
|
|
17
|
+
// on screen while focus is anywhere inside, because a focus ring around a
|
|
18
|
+
// one-pixel box is a focus ring nobody can see.
|
|
19
|
+
//
|
|
20
|
+
// # `announce`
|
|
21
|
+
//
|
|
22
|
+
// A live region only announces a *change*, and only when the region was in the
|
|
23
|
+
// document before the change — so a region rendered together with its message
|
|
24
|
+
// says nothing, and a component that renders its own region next to itself has
|
|
25
|
+
// to exist, silent, before the thing it wants to say happens. Every component
|
|
26
|
+
// that did that also put an element in the caller's layout, and three of them
|
|
27
|
+
// (the collections, the range calendar and the segmented fields) left theirs
|
|
28
|
+
// visible, so "3 selected" was printed on the page under the list.
|
|
29
|
+
//
|
|
30
|
+
// `announce()` is one pair of regions for the whole document, created on the
|
|
31
|
+
// first call and kept. It follows React Aria's LiveAnnouncer, and not by
|
|
32
|
+
// accident:
|
|
33
|
+
//
|
|
34
|
+
// * one `role="log"` region per politeness, since a region's politeness is
|
|
35
|
+
// read when the region is first seen and cannot be changed on a live one;
|
|
36
|
+
// * each message is a new child node rather than a new text value, so the
|
|
37
|
+
// same message twice is announced twice (`aria-relevant="additions"`);
|
|
38
|
+
// * each message is removed after `timeout`, so a reader who arrives at the
|
|
39
|
+
// end of the document later does not find a transcript there;
|
|
40
|
+
// * the first message waits 100 ms after the regions are created, because a
|
|
41
|
+
// region and its first content inserted together are, again, silent.
|
|
42
|
+
//
|
|
43
|
+
// On the server there is no document and `announce` does nothing; there is
|
|
44
|
+
// nothing to hydrate either, because the regions are never rendered by React.
|
|
45
|
+
|
|
46
|
+
import * as React from "@uniflowed/react";
|
|
47
|
+
import { useState } from "@uniflowed/react";
|
|
48
|
+
import { composeHandlers, withProps, withoutComposed } from "./internal/merge-props.js";
|
|
49
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
50
|
+
import { visuallyHiddenStyle } from "./internal/visually-hidden-style.js";
|
|
51
|
+
|
|
52
|
+
type FocusEvent = {
|
|
53
|
+
readonly currentTarget: mixed,
|
|
54
|
+
readonly relatedTarget: mixed,
|
|
55
|
+
readonly defaultPrevented: boolean,
|
|
56
|
+
...
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Content a screen reader reads and a sighted reader does not see.
|
|
61
|
+
*
|
|
62
|
+
* <button><Icon name="trash" /><VisuallyHidden>Delete draft</VisuallyHidden></button>
|
|
63
|
+
* <VisuallyHidden focusable render={(props) => <a href="#main" {...props} />}>
|
|
64
|
+
* Skip to content
|
|
65
|
+
* </VisuallyHidden>
|
|
66
|
+
*
|
|
67
|
+
* A caller's `style` is kept, underneath the hiding, so it applies again the
|
|
68
|
+
* moment a `focusable` one is shown.
|
|
69
|
+
*/
|
|
70
|
+
export component VisuallyHidden(
|
|
71
|
+
children?: React.Node,
|
|
72
|
+
/** Show the content while focus is inside it: the skip-link pattern. */
|
|
73
|
+
focusable: boolean = false,
|
|
74
|
+
render?: RenderProp,
|
|
75
|
+
...rest: Rest
|
|
76
|
+
) {
|
|
77
|
+
const [focused, setFocused] = useState(false);
|
|
78
|
+
const style = rest.style;
|
|
79
|
+
const own = typeof style === "object" && style != null ? style : {};
|
|
80
|
+
const hidden = !(focusable && focused);
|
|
81
|
+
const props = withProps(withoutComposed(rest, ["onFocus", "onBlur"]), {
|
|
82
|
+
children,
|
|
83
|
+
style: hidden ? { ...own, ...visuallyHiddenStyle } : style,
|
|
84
|
+
onFocus: composeHandlers(rest.onFocus, () => {
|
|
85
|
+
if (focusable) setFocused(true);
|
|
86
|
+
}),
|
|
87
|
+
onBlur: composeHandlers(rest.onBlur, (event: FocusEvent) => {
|
|
88
|
+
// Moving between two links inside one skip block is not leaving it.
|
|
89
|
+
const { currentTarget, relatedTarget } = event;
|
|
90
|
+
if (
|
|
91
|
+
currentTarget instanceof Node &&
|
|
92
|
+
relatedTarget instanceof Node &&
|
|
93
|
+
currentTarget.contains(relatedTarget)
|
|
94
|
+
)
|
|
95
|
+
return;
|
|
96
|
+
setFocused(false);
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
return render != null ? render(props) : <span {...props} />;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type Politeness = "polite" | "assertive";
|
|
103
|
+
|
|
104
|
+
export type AnnounceOptions = {|
|
|
105
|
+
/**
|
|
106
|
+
* `"polite"` (the default) waits for the reader to finish; `"assertive"`
|
|
107
|
+
* interrupts, and is for what cannot wait — an error that stopped a save.
|
|
108
|
+
*/
|
|
109
|
+
readonly politeness?: Politeness,
|
|
110
|
+
/** Milliseconds before the message leaves the document. 7000 by default. */
|
|
111
|
+
readonly timeout?: number,
|
|
112
|
+
|};
|
|
113
|
+
|
|
114
|
+
type Announcer = {|
|
|
115
|
+
readonly root: HTMLElement,
|
|
116
|
+
readonly polite: HTMLElement,
|
|
117
|
+
readonly assertive: HTMLElement,
|
|
118
|
+
/** When the regions went into the document; the first message waits for them. */
|
|
119
|
+
readonly created: number,
|
|
120
|
+
|};
|
|
121
|
+
|
|
122
|
+
let announcer: Announcer | null = null;
|
|
123
|
+
|
|
124
|
+
/** Wait this long after creating the regions, so their first content is a change. */
|
|
125
|
+
const SETTLE_MS = 100;
|
|
126
|
+
|
|
127
|
+
function region(document: Document, politeness: Politeness): HTMLElement {
|
|
128
|
+
const element = document.createElement("div");
|
|
129
|
+
element.setAttribute("role", "log");
|
|
130
|
+
element.setAttribute("aria-live", politeness);
|
|
131
|
+
element.setAttribute("aria-relevant", "additions");
|
|
132
|
+
return element;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function announcerIn(document: Document): Announcer | null {
|
|
136
|
+
const body = document.body;
|
|
137
|
+
if (body == null) return null;
|
|
138
|
+
// A test's cleanup, or an app that replaces `<body>`, can take the regions
|
|
139
|
+
// out; a region that is not in the document announces nothing.
|
|
140
|
+
if (announcer != null && announcer.root.isConnected && announcer.root.ownerDocument === document)
|
|
141
|
+
return announcer;
|
|
142
|
+
const root = document.createElement("div");
|
|
143
|
+
root.setAttribute("data-uf-live-announcer", "");
|
|
144
|
+
root.style.cssText =
|
|
145
|
+
"position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;" +
|
|
146
|
+
"overflow:hidden;clip:rect(0, 0, 0, 0);clip-path:inset(50%);white-space:nowrap";
|
|
147
|
+
const assertive = region(document, "assertive");
|
|
148
|
+
const polite = region(document, "polite");
|
|
149
|
+
root.append(assertive, polite);
|
|
150
|
+
// A direct child of `<body>`, which is the level a modal's conceal walk
|
|
151
|
+
// reaches last; `dialog.js` skips it by the attribute above.
|
|
152
|
+
body.prepend(root);
|
|
153
|
+
announcer = { root, polite, assertive, created: Date.now() };
|
|
154
|
+
return announcer;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The timers `announce` has scheduled and not yet run, per politeness, so
|
|
159
|
+
* `clearAnnouncements` can take back a message that has not arrived yet as
|
|
160
|
+
* well as one that has.
|
|
161
|
+
*/
|
|
162
|
+
const pending: {| readonly polite: Set<TimeoutID>, readonly assertive: Set<TimeoutID> |} = {
|
|
163
|
+
polite: new Set(),
|
|
164
|
+
assertive: new Set(),
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Run `work` after `ms`, unless `clearAnnouncements` cancels it first.
|
|
169
|
+
*
|
|
170
|
+
* A timer outlives whatever scheduled it: a test worker restores its globals
|
|
171
|
+
* between files, and an app can unmount, replace `<body>` or tear its window
|
|
172
|
+
* down, all while a message is still waiting to arrive or to leave. So a timer
|
|
173
|
+
* never reads the `document` global — `work` is handed the document and
|
|
174
|
+
* regions captured when the message was announced, and each timer checks they
|
|
175
|
+
* are still there before touching them.
|
|
176
|
+
*/
|
|
177
|
+
function schedule(politeness: Politeness, ms: number, work: () => void): void {
|
|
178
|
+
const timers = pending[politeness];
|
|
179
|
+
const timer: TimeoutID = setTimeout(() => {
|
|
180
|
+
timers.delete(timer);
|
|
181
|
+
work();
|
|
182
|
+
}, ms);
|
|
183
|
+
timers.add(timer);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether `document` is still the document this realm has, and `region` still
|
|
188
|
+
* in it: the one condition under which a timer may write to either.
|
|
189
|
+
*/
|
|
190
|
+
function stillLive(document: Document, region: HTMLElement): boolean {
|
|
191
|
+
return (
|
|
192
|
+
typeof globalThis.document !== "undefined" &&
|
|
193
|
+
globalThis.document === document &&
|
|
194
|
+
region.isConnected &&
|
|
195
|
+
region.ownerDocument === document
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Say something to a screen reader, from anywhere.
|
|
201
|
+
*
|
|
202
|
+
* announce(`${count} results`);
|
|
203
|
+
* announce("Could not save the draft", { politeness: "assertive" });
|
|
204
|
+
*
|
|
205
|
+
* A function rather than a hook, because what needs announcing comes from
|
|
206
|
+
* event handlers, effects and `catch` blocks — `toast()` makes the same choice.
|
|
207
|
+
* An empty message is ignored rather than announced as silence. On the server
|
|
208
|
+
* it returns before scheduling anything.
|
|
209
|
+
*/
|
|
210
|
+
export function announce(message: string, options?: AnnounceOptions): void {
|
|
211
|
+
if (message.trim() === "" || typeof document === "undefined") return;
|
|
212
|
+
// Captured now, and the only document the timers below ever use.
|
|
213
|
+
const doc = document;
|
|
214
|
+
const current = announcerIn(doc);
|
|
215
|
+
if (current == null) return;
|
|
216
|
+
const politeness = options?.politeness ?? "polite";
|
|
217
|
+
const timeout = options?.timeout ?? 7000;
|
|
218
|
+
const target = politeness === "assertive" ? current.assertive : current.polite;
|
|
219
|
+
const insert = () => {
|
|
220
|
+
// The document went away (a worker's teardown, an unmounted frame) or the
|
|
221
|
+
// regions were taken out of it: there is nobody left to tell.
|
|
222
|
+
if (!stillLive(doc, target)) return;
|
|
223
|
+
const node = doc.createElement("div");
|
|
224
|
+
node.textContent = message;
|
|
225
|
+
target.append(node);
|
|
226
|
+
if (timeout > 0 && Number.isFinite(timeout))
|
|
227
|
+
schedule(politeness, timeout, () => {
|
|
228
|
+
// Removing a detached node is harmless; only a live one needs it.
|
|
229
|
+
if (node.isConnected) node.remove();
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
const wait = current.created + SETTLE_MS - Date.now();
|
|
233
|
+
if (wait > 0) schedule(politeness, wait, insert);
|
|
234
|
+
else insert();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Take every message out, for one politeness or both: the ones in the regions
|
|
239
|
+
* and the ones still waiting to arrive, whose timers are cancelled. After it,
|
|
240
|
+
* nothing `announce` scheduled for that politeness runs.
|
|
241
|
+
*/
|
|
242
|
+
export function clearAnnouncements(politeness?: Politeness): void {
|
|
243
|
+
const both: $ReadOnlyArray<Politeness> = ["assertive", "polite"];
|
|
244
|
+
for (const which of both) {
|
|
245
|
+
if (politeness != null && politeness !== which) continue;
|
|
246
|
+
const timers = pending[which];
|
|
247
|
+
for (const timer of timers) clearTimeout(timer);
|
|
248
|
+
timers.clear();
|
|
249
|
+
}
|
|
250
|
+
const current = announcer;
|
|
251
|
+
if (current == null) return;
|
|
252
|
+
// Regions a teardown took out are not reused, and need not be kept alive.
|
|
253
|
+
if (!current.root.isConnected) {
|
|
254
|
+
announcer = null;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (politeness !== "polite") current.assertive.replaceChildren();
|
|
258
|
+
if (politeness !== "assertive") current.polite.replaceChildren();
|
|
259
|
+
}
|