@uniflowed/ui 0.1.0 → 0.3.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/table.js CHANGED
@@ -75,6 +75,7 @@ import {
75
75
  withoutComposed,
76
76
  } from "./internal/merge-props.js";
77
77
  import { useControlled } from "./internal/controlled-state.js";
78
+ import { visuallyHiddenStyle } from "./internal/visually-hidden-style.js";
78
79
 
79
80
  /** Which column a table is sorted by, and which way. */
80
81
  export type Sort = {|
@@ -104,19 +105,6 @@ const TableContext: React.Context<TableState | null> = createContext(null);
104
105
  /** Whether the rows below are header rows, which decides `th` versus `td`. */
105
106
  const HeaderContext: React.Context<boolean> = createContext(false);
106
107
 
107
- const VISUALLY_HIDDEN_STYLE = Object.freeze({
108
- border: 0,
109
- clip: "rect(0, 0, 0, 0)",
110
- clipPath: "inset(50%)",
111
- height: 1,
112
- margin: -1,
113
- overflow: "hidden",
114
- padding: 0,
115
- position: "absolute",
116
- whiteSpace: "nowrap",
117
- width: 1,
118
- });
119
-
120
108
  hook useTable(part: string): TableState {
121
109
  const state = useContext(TableContext);
122
110
  if (state == null) {
@@ -139,7 +127,7 @@ hook useTable(part: string): TableState {
139
127
  * `announceSort` is the wording of the announcement, for an application with a
140
128
  * translation table. The default is English.
141
129
  */
142
- export component TableRoot(
130
+ component TableRoot(
143
131
  children: React.Node,
144
132
  sort?: Sort | null,
145
133
  defaultSort?: Sort | null = null,
@@ -222,7 +210,7 @@ export component TableRoot(
222
210
  aria-live="polite"
223
211
  data-uf-table-status=""
224
212
  role="status"
225
- style={VISUALLY_HIDDEN_STYLE}
213
+ style={visuallyHiddenStyle}
226
214
  >
227
215
  {message}
228
216
  </div>
@@ -237,7 +225,7 @@ export component TableRoot(
237
225
  * what a screen reader reads when a reader lands on it. A heading above the
238
226
  * table looks the same and is not the table's name.
239
227
  */
240
- export component TableCaption(children: React.Node, render?: RenderProp, ...rest: Rest) {
228
+ component TableCaption(children: React.Node, render?: RenderProp, ...rest: Rest) {
241
229
  const table = useTable("Table.Caption");
242
230
  const register = table.registerCaption;
243
231
 
@@ -259,7 +247,7 @@ export component TableCaption(children: React.Node, render?: RenderProp, ...rest
259
247
  * It tells the root that it exists, because `aria-rowcount` and every row's
260
248
  * `aria-rowindex` count header rows and a table without one counts differently.
261
249
  */
262
- export component TableHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
250
+ component TableHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
263
251
  const table = useTable("Table.Header");
264
252
  const register = table.registerHeader;
265
253
 
@@ -278,7 +266,7 @@ export component TableHeader(children: React.Node, render?: RenderProp, ...rest:
278
266
  }
279
267
 
280
268
  /** The data rows. */
281
- export component TableBody(children: React.Node, render?: RenderProp, ...rest: Rest) {
269
+ component TableBody(children: React.Node, render?: RenderProp, ...rest: Rest) {
282
270
  const props = withProps(rest, { children });
283
271
  return (
284
272
  <HeaderContext.Provider value={false}>
@@ -303,7 +291,7 @@ export component TableBody(children: React.Node, render?: RenderProp, ...rest: R
303
291
  * adding them anyway is a second source of truth that can disagree with the
304
292
  * document.
305
293
  */
306
- export component TableRow(
294
+ component TableRow(
307
295
  children: React.Node,
308
296
  index?: number | null = null,
309
297
  render?: RenderProp,
@@ -344,7 +332,7 @@ export component TableRow(
344
332
  * Not `"none"` on the others: eleven headers each announcing "not sorted" is
345
333
  * eleven announcements of nothing, on every pass through the table.
346
334
  */
347
- export component TableHead(
335
+ component TableHead(
348
336
  children: React.Node,
349
337
  column?: string | null = null,
350
338
  render?: RenderProp,
@@ -407,7 +395,7 @@ export component TableHead(
407
395
  }
408
396
 
409
397
  /** One cell. */
410
- export component TableCell(children: React.Node, render?: RenderProp, ...rest: Rest) {
398
+ component TableCell(children: React.Node, render?: RenderProp, ...rest: Rest) {
411
399
  const props = withProps(rest, { children });
412
400
  if (render != null) {
413
401
  return render(withProps(props, { role: "cell" }));
@@ -423,7 +411,7 @@ export component TableCell(children: React.Node, render?: RenderProp, ...rest: R
423
411
  * moves down the year column. A table of records usually has one and almost
424
412
  * never marks it.
425
413
  */
426
- export component TableRowHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
414
+ component TableRowHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
427
415
  const props = withProps(rest, { children, scope: "row" });
428
416
  if (render != null) {
429
417
  return render(withProps(props, { role: "rowheader" }));
@@ -461,7 +449,7 @@ export component TableRowHeader(children: React.Node, render?: RenderProp, ...re
461
449
  * wants a `Checkbox` of their own, which they should write — this part exists
462
450
  * for the type of `checked`, not for the markup.
463
451
  */
464
- export component TableSelectAll(
452
+ component TableSelectAll(
465
453
  checked: boolean | "mixed",
466
454
  onCheckedChange: (checked: boolean) => void,
467
455
  label?: string = "Select all rows",
@@ -494,7 +482,7 @@ export component TableSelectAll(
494
482
  * Named props rather than `...rest: Rest`, for the reason `Table.SelectAll`
495
483
  * gives above.
496
484
  */
497
- export component TableRowSelect(
485
+ component TableRowSelect(
498
486
  label: string,
499
487
  checked: boolean,
500
488
  onCheckedChange: (checked: boolean) => void,
@@ -518,3 +506,25 @@ export component TableRowSelect(
518
506
  function defaultAnnouncement(column: string, direction: "ascending" | "descending"): string {
519
507
  return `Sorted by ${column}, ${direction}.`;
520
508
  }
509
+
510
+ /**
511
+ * The parts, under the names the `Table` namespace gives them.
512
+ *
513
+ * `index.js` re-exports this module whole — `export * as Table from "./table.js"` —
514
+ * so a caller writes `<Table.Root>`, and the namespace is the prefix. Each
515
+ * part is still *declared* as `TableRoot`, so React DevTools, a component
516
+ * stack and an error name the part a reader can find rather than one of forty
517
+ * `Root`s.
518
+ */
519
+ export {
520
+ TableRoot as Root,
521
+ TableCaption as Caption,
522
+ TableHeader as Header,
523
+ TableBody as Body,
524
+ TableRow as Row,
525
+ TableHead as Head,
526
+ TableRowHeader as RowHeader,
527
+ TableCell as Cell,
528
+ TableSelectAll as SelectAll,
529
+ TableRowSelect as RowSelect,
530
+ };
package/tabs.js CHANGED
@@ -87,7 +87,7 @@ hook useTabs(part: string): TabsState {
87
87
  * distinction every one of these components needs: a form library owns the
88
88
  * value, and a page that just wants tabs does not.
89
89
  */
90
- export component TabsRoot(
90
+ component TabsRoot(
91
91
  children: React.Node,
92
92
  defaultValue: string,
93
93
  value?: string,
@@ -145,7 +145,7 @@ export component TabsRoot(
145
145
  * tabs push themselves into as they mount answers with mount order, which stops
146
146
  * being document order the first time a tab is conditional.
147
147
  */
148
- export component TabsList(children: renders* TabsTab, render?: RenderProp, ...rest: Rest) {
148
+ component TabsList(children: renders* TabsTab, render?: RenderProp, ...rest: Rest) {
149
149
  const tabs = useTabs("Tabs.List");
150
150
  const props = withProps(withoutComposed(rest, ["onKeyDown"]), {
151
151
  // A screen reader announces the axis, and it is also what tells a reader
@@ -186,7 +186,7 @@ export component TabsList(children: renders* TabsTab, render?: RenderProp, ...re
186
186
  * the section exists and is unavailable, where a native `disabled` would leave a
187
187
  * gap they cannot ask about. The keyboard steps over it either way.
188
188
  */
189
- export component TabsTab(
189
+ component TabsTab(
190
190
  value: string,
191
191
  children: React.Node,
192
192
  disabled?: boolean = false,
@@ -249,12 +249,7 @@ export component TabsTab(
249
249
  * subscription rather than "the selected value equals mine", because a caller
250
250
  * may render a subset of panels, or none at all until data arrives.
251
251
  */
252
- export component TabsPanel(
253
- value: string,
254
- children: React.Node,
255
- render?: RenderProp,
256
- ...rest: Rest
257
- ) {
252
+ component TabsPanel(value: string, children: React.Node, render?: RenderProp, ...rest: Rest) {
258
253
  const tabs = useTabs("Tabs.Panel");
259
254
  const register = tabs.registerPanel;
260
255
  const selected = tabs.selected === value;
@@ -287,3 +282,14 @@ export component TabsPanel(
287
282
  }
288
283
  return <div {...props} />;
289
284
  }
285
+
286
+ /**
287
+ * The parts, under the names the `Tabs` namespace gives them.
288
+ *
289
+ * `index.js` re-exports this module whole — `export * as Tabs from "./tabs.js"` —
290
+ * so a caller writes `<Tabs.Root>`, and the namespace is the prefix. Each
291
+ * part is still *declared* as `TabsRoot`, so React DevTools, a component
292
+ * stack and an error name the part a reader can find rather than one of forty
293
+ * `Root`s.
294
+ */
295
+ export { TabsRoot as Root, TabsList as List, TabsTab as Tab, TabsPanel as Panel };
package/toast.js CHANGED
@@ -349,7 +349,7 @@ const ToastPartsContext: React.Context<ToastParts | null> = createContext(null);
349
349
  * so a `<div>` where a notification belongs is a type error rather than a
350
350
  * stack of notifications a reader cannot dismiss.
351
351
  */
352
- export component ToastRegion(
352
+ component ToastRegion(
353
353
  children: (notification: Notification) => renders ToastRoot,
354
354
  label?: string = "Notifications",
355
355
  limit?: number = 3,
@@ -427,7 +427,7 @@ export component ToastRegion(
427
427
  * an id that is not in the document makes a screen reader announce nothing at
428
428
  * all.
429
429
  */
430
- export component ToastRoot(children: React.Node, ...rest: Rest) {
430
+ component ToastRoot(children: React.Node, ...rest: Rest) {
431
431
  const notification = useNotification("Toast.Root");
432
432
  const base = useId();
433
433
  const elementRef = useElementRef<HTMLElement>();
@@ -499,7 +499,7 @@ export component ToastRoot(children: React.Node, ...rest: Rest) {
499
499
  }
500
500
 
501
501
  /** What the notification is about, and the name of its group. */
502
- export component ToastTitle(children: React.Node, ...rest: Rest) {
502
+ component ToastTitle(children: React.Node, ...rest: Rest) {
503
503
  const parts = useToastParts("Toast.Title");
504
504
  useRegistration(parts.registerTitle);
505
505
 
@@ -511,7 +511,7 @@ export component ToastTitle(children: React.Node, ...rest: Rest) {
511
511
  }
512
512
 
513
513
  /** The rest of it, and the group's description. */
514
- export component ToastDescription(children: React.Node, ...rest: Rest) {
514
+ component ToastDescription(children: React.Node, ...rest: Rest) {
515
515
  const parts = useToastParts("Toast.Description");
516
516
  useRegistration(parts.registerDescription);
517
517
 
@@ -532,7 +532,7 @@ export component ToastDescription(children: React.Node, ...rest: Rest) {
532
532
  * countdown stopping while focus is inside is what makes the button reachable
533
533
  * at all; it is not a promise that four seconds was enough time to decide.
534
534
  */
535
- export component ToastAction(children: React.Node, ...rest: Rest) {
535
+ component ToastAction(children: React.Node, ...rest: Rest) {
536
536
  const notification = useNotification("Toast.Action");
537
537
  const passed = withoutComposed(rest, ["onClick"]);
538
538
 
@@ -561,7 +561,7 @@ export component ToastAction(children: React.Node, ...rest: Rest) {
561
561
  * `aria-label` that says "Dismiss" over a button that says "Close" breaks the
562
562
  * speech reader who says "click Close" out loud.
563
563
  */
564
- export component ToastClose(children?: React.Node, label?: string = "Dismiss", ...rest: Rest) {
564
+ component ToastClose(children?: React.Node, label?: string = "Dismiss", ...rest: Rest) {
565
565
  const notification = useNotification("Toast.Close");
566
566
  const passed = withoutComposed(rest, ["onClick"]);
567
567
 
@@ -592,3 +592,21 @@ hook useRegistration(register: (present: boolean) => void): void {
592
592
  return () => register(false);
593
593
  }, [register]);
594
594
  }
595
+
596
+ /**
597
+ * The parts, under the names the `Toast` namespace gives them.
598
+ *
599
+ * `index.js` re-exports this module whole — `export * as Toast from "./toast.js"` —
600
+ * so a caller writes `<Toast.Region>`, and the namespace is the prefix. Each
601
+ * part is still *declared* as `ToastRegion`, so React DevTools, a component
602
+ * stack and an error name the part a reader can find rather than one of forty
603
+ * `Region`s.
604
+ */
605
+ export {
606
+ ToastRegion as Region,
607
+ ToastRoot as Root,
608
+ ToastTitle as Title,
609
+ ToastDescription as Description,
610
+ ToastAction as Action,
611
+ ToastClose as Close,
612
+ };
package/toggle-group.js CHANGED
@@ -70,7 +70,7 @@ import {
70
70
  } from "./internal/merge-props.js";
71
71
  import { moveOnKey, useFirstItem } from "./internal/roving-focus.js";
72
72
  import type { Orientation, RovingSet } from "./internal/roving-focus.js";
73
- import { RadioGroupItem, RadioGroupRoot } from "./radio-group.js";
73
+ import { Item as RadioGroupItem, Root as RadioGroupRoot } from "./radio-group.js";
74
74
  import { useControlled } from "./internal/controlled-state.js";
75
75
 
76
76
  /** Whether the set holds one answer or any number of them. */
@@ -128,7 +128,7 @@ hook useToggleGroup(part: string): ToggleGroupState {
128
128
  * Uncontrolled by default and controlled the moment `value` is passed, like
129
129
  * everything else here.
130
130
  */
131
- export component ToggleGroupRoot(
131
+ component ToggleGroupRoot(
132
132
  children: renders* ToggleGroupItem,
133
133
  type?: ToggleGroupType = "multiple",
134
134
  defaultValue?: $ReadOnlyArray<string> = NOTHING,
@@ -224,7 +224,7 @@ export component ToggleGroupRoot(
224
224
  * a reader is told the set has two members when it has three and cannot ask
225
225
  * where the third went. The arrow keys step over it either way.
226
226
  */
227
- export component ToggleGroupItem(
227
+ component ToggleGroupItem(
228
228
  value: string,
229
229
  children?: React.Node,
230
230
  disabled?: boolean = false,
@@ -282,3 +282,14 @@ export component ToggleGroupItem(
282
282
 
283
283
  return <button {...itemProps} type="button" />;
284
284
  }
285
+
286
+ /**
287
+ * The parts, under the names the `ToggleGroup` namespace gives them.
288
+ *
289
+ * `index.js` re-exports this module whole — `export * as ToggleGroup from "./toggle-group.js"` —
290
+ * so a caller writes `<ToggleGroup.Root>`, and the namespace is the prefix. Each
291
+ * part is still *declared* as `ToggleGroupRoot`, so React DevTools, a component
292
+ * stack and an error name the part a reader can find rather than one of forty
293
+ * `Root`s.
294
+ */
295
+ export { ToggleGroupRoot as Root, ToggleGroupItem as Item };
package/tooltip.js CHANGED
@@ -150,7 +150,7 @@ hook useTooltip(part: string): TooltipState {
150
150
  * Renders no element: it is a context and a clock, and a `<div>` around a
151
151
  * toolbar is the caller's business.
152
152
  */
153
- export component TooltipProvider(
153
+ component TooltipProvider(
154
154
  children: React.Node,
155
155
  delayDuration?: number = DEFAULT_OPEN_DELAY,
156
156
  skipDelayDuration?: number = DEFAULT_SKIP_DELAY,
@@ -168,7 +168,7 @@ export component TooltipProvider(
168
168
  * `delayDuration`, and to 700ms when there is no provider — a tooltip on its
169
169
  * own is a complete tooltip and needs nothing around it.
170
170
  */
171
- export component TooltipRoot(
171
+ component TooltipRoot(
172
172
  children: React.Node,
173
173
  closeDelay?: number = DEFAULT_CLOSE_DELAY,
174
174
  defaultOpen?: boolean = false,
@@ -243,7 +243,7 @@ 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(children?: React.Node, render?: RenderProp, ...rest: Rest) {
246
+ component TooltipTrigger(children?: React.Node, render?: RenderProp, ...rest: Rest) {
247
247
  const tooltip = useTooltip("Tooltip.Trigger");
248
248
  const { closeDelay, dismissedRef, intent, openDelay, setOpen, triggerRef } = tooltip;
249
249
  useFocusableTrigger(triggerRef, "Tooltip.Trigger");
@@ -320,7 +320,7 @@ export component TooltipTrigger(children?: React.Node, render?: RenderProp, ...r
320
320
  * takes focus, holds no tab stop, and answers `Escape` from wherever focus
321
321
  * happens to be.
322
322
  */
323
- export component TooltipBody(
323
+ component TooltipBody(
324
324
  children: React.Node,
325
325
  align?: Align = "center",
326
326
  alignOffset?: number = 0,
@@ -389,7 +389,7 @@ export component TooltipBody(
389
389
  id: `${tooltip.base}-body`,
390
390
  // React calls callback refs during commit; placement effects read it later.
391
391
  // uf-lint-disable-next-line react-compiler/refs
392
- ref: composeRefs(rest.ref, (element) => {
392
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
393
393
  bodyRef.current = element;
394
394
  }),
395
395
  role: "tooltip",
@@ -402,3 +402,19 @@ export component TooltipBody(
402
402
  }
403
403
  return <div {...props} />;
404
404
  }
405
+
406
+ /**
407
+ * The parts, under the names the `Tooltip` namespace gives them.
408
+ *
409
+ * `index.js` re-exports this module whole — `export * as Tooltip from "./tooltip.js"` —
410
+ * so a caller writes `<Tooltip.Provider>`, and the namespace is the prefix. Each
411
+ * part is still *declared* as `TooltipProvider`, so React DevTools, a component
412
+ * stack and an error name the part a reader can find rather than one of forty
413
+ * `Provider`s.
414
+ */
415
+ export {
416
+ TooltipProvider as Provider,
417
+ TooltipRoot as Root,
418
+ TooltipTrigger as Trigger,
419
+ TooltipBody as Body,
420
+ };
@@ -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
+ }