@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.
Files changed (65) hide show
  1. package/accordion.js +84 -57
  2. package/alert-dialog.js +284 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +280 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +587 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +215 -31
  9. package/collapsible.js +72 -48
  10. package/color-picker.js +172 -0
  11. package/combobox.js +216 -39
  12. package/context-menu.js +215 -0
  13. package/date-field.js +9 -0
  14. package/date-picker.js +357 -0
  15. package/date-range-picker.js +120 -0
  16. package/dialog.js +243 -178
  17. package/drag-drop.js +125 -0
  18. package/drawer.js +504 -0
  19. package/field.js +260 -43
  20. package/grid-list.js +8 -0
  21. package/hover-card.js +52 -52
  22. package/i18n-provider.js +89 -0
  23. package/index.js +1177 -31
  24. package/input-otp.js +218 -0
  25. package/interactions.js +2327 -0
  26. package/internal/anchor.js +71 -6
  27. package/internal/collection.js +562 -0
  28. package/internal/date-grid.js +260 -0
  29. package/internal/date-range.js +26 -0
  30. package/internal/disclosure.js +201 -0
  31. package/internal/menu-tree.js +228 -0
  32. package/internal/merge-props.js +85 -1
  33. package/internal/roving-focus.js +15 -4
  34. package/internal/segmented-field.js +317 -0
  35. package/internal/selection.js +171 -0
  36. package/internal/visually-hidden-style.js +41 -0
  37. package/list-box.js +13 -0
  38. package/menu.js +553 -361
  39. package/menubar.js +295 -0
  40. package/number-field.js +263 -0
  41. package/package.json +8 -28
  42. package/pagination.js +34 -22
  43. package/popover.js +116 -75
  44. package/progress.js +21 -16
  45. package/radio-group.js +81 -75
  46. package/range-calendar.js +79 -0
  47. package/resizable.js +155 -9
  48. package/scroll-area.js +283 -0
  49. package/select.js +83 -37
  50. package/separator.js +97 -0
  51. package/sheet.js +189 -0
  52. package/sidebar.js +320 -0
  53. package/skeleton.js +163 -0
  54. package/slider.js +95 -89
  55. package/switch.js +42 -34
  56. package/table.js +100 -71
  57. package/tabs.js +100 -91
  58. package/tag-group.js +8 -0
  59. package/time-field.js +8 -0
  60. package/toast.js +36 -66
  61. package/toggle-group.js +53 -49
  62. package/toggle.js +41 -27
  63. package/tooltip.js +48 -55
  64. package/tree.js +8 -0
  65. 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`, which leaves `Enter` alone because a checkbox is something a
32
- // reader answers on their way to submitting a form.
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
- /** A button whose state stays applied: pressed or not. */
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, Side } from "./internal/anchor.js";
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 dismissed: { current: boolean },
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 dismissed = useRef(false);
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
- dismissed,
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, dismissed, intent, openDelay, setOpen, triggerRef } = tooltip;
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 pressed = useRef(false);
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" || dismissed.current) {
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
- dismissed.current = false;
268
+ dismissedRef.current = false;
273
269
  intent.closeAfter(closeDelay);
274
270
  });
275
271
  useEventListener(triggerRef, "pointerdown", () => {
276
- pressed.current = true;
272
+ pressedRef.current = true;
277
273
  intent.cancel();
278
274
  setOpen(false);
279
275
  });
280
276
  useEventListener(triggerRef, "focusin", () => {
281
- if (pressed.current) {
282
- pressed.current = false;
277
+ if (pressedRef.current) {
278
+ pressedRef.current = false;
283
279
  return;
284
280
  }
285
- if (dismissed.current) {
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
- pressed.current = false;
293
- dismissed.current = false;
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 ours = {
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(withProps(withoutComposed(rest, ["ref"]), ours));
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
- side?: Side = "top",
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
- tooltip.dismissed.current = true;
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
- return (
395
- <div
396
- {...withoutComposed(rest, ["ref"])}
397
- data-align={anchored.align}
398
- data-side={anchored.side}
399
- data-state="open"
400
- id={`${tooltip.base}-body`}
401
- ref={composeRefs(rest.ref, (element) => {
402
- bodyRef.current = element;
403
- })}
404
- role="tooltip"
405
- // No `tabIndex`. A tooltip the keyboard can land in is a stop the reader
406
- // did not ask for and cannot leave the way they expect.
407
- >
408
- {children}
409
- </div>
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
+ }