@uniflowed/ui 0.1.0 → 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/calendar.js CHANGED
@@ -77,6 +77,7 @@ import {
77
77
  withoutComposed,
78
78
  } from "./internal/merge-props.js";
79
79
  import { directionOf } from "./internal/roving-focus.js";
80
+ import { visuallyHiddenStyle } from "./internal/visually-hidden-style.js";
80
81
  import {
81
82
  firstDayOfWeekFor,
82
83
  moveDate,
@@ -321,7 +322,16 @@ export component CalendarRoot(
321
322
  <CalendarContext.Provider value={state}>
322
323
  <div {...rest}>
323
324
  {children}
324
- <div aria-atomic="true" aria-live="polite" id={`${base}-status`} role="status">
325
+ {/* Heard and not drawn: the caption above the grid already shows the
326
+ month, and a second copy under it is what a sighted reader would
327
+ otherwise see after the first page. */}
328
+ <div
329
+ aria-atomic="true"
330
+ aria-live="polite"
331
+ id={`${base}-status`}
332
+ role="status"
333
+ style={visuallyHiddenStyle}
334
+ >
325
335
  {announcement}
326
336
  </div>
327
337
  </div>
@@ -343,7 +353,14 @@ export component CalendarRoot(
343
353
  * with a dot under it for an appointment is a `Calendar.Day` with a child, not a
344
354
  * fork of this component. Omitted, every day renders its own number.
345
355
  */
346
- export component CalendarMonth(children?: (date: PlainDate) => renders CalendarDay, ...rest: Rest) {
356
+ export component CalendarMonth(
357
+ /** A class for the `<caption>`, which this part renders itself. */
358
+ captionClassName?: string,
359
+ children?: (date: PlainDate) => renders CalendarDay,
360
+ /** A class for each weekday heading, which this part renders itself. */
361
+ columnHeaderClassName?: string,
362
+ ...rest: Rest
363
+ ) {
347
364
  const calendar = useCalendar("Calendar.Month");
348
365
  const gridRef = useRef<HTMLElement | null>(null);
349
366
  const { focused, focusedDayRef, moveFocus, pendingFocusRef, weekStartsOn } = calendar;
@@ -406,7 +423,9 @@ export component CalendarMonth(children?: (date: PlainDate) => renders CalendarD
406
423
  })}
407
424
  role="grid"
408
425
  >
409
- <caption id={`${calendar.base}-caption`}>{calendar.caption}</caption>
426
+ <caption className={captionClassName} id={`${calendar.base}-caption`}>
427
+ {calendar.caption}
428
+ </caption>
410
429
  <thead>
411
430
  <tr>
412
431
  {columns.map((day) => (
@@ -416,6 +435,7 @@ export component CalendarMonth(children?: (date: PlainDate) => renders CalendarD
416
435
  // header. `aria-label` wins over the contents for the announced
417
436
  // name, so a reader still hears "Wednesday" rather than "Wed".
418
437
  aria-label={day.toLocaleString(calendar.locale, COLUMN_FORMAT)}
438
+ className={columnHeaderClassName}
419
439
  key={day.dayOfWeek}
420
440
  scope="col"
421
441
  >
@@ -463,9 +483,14 @@ export component CalendarMonth(children?: (date: PlainDate) => renders CalendarD
463
483
  export component CalendarDay(date: PlainDate, children?: React.Node, ...rest: Rest) {
464
484
  const calendar = useCalendar("Calendar.Day");
465
485
  const disabled = calendar.isDisabled(date);
466
- const chosen =
467
- calendar.isDateSelected?.(date) ??
468
- (calendar.selected != null && calendar.selected.equals(date));
486
+ const isChosen = (day: PlainDate) =>
487
+ calendar.isDateSelected?.(day) ?? (calendar.selected != null && calendar.selected.equals(day));
488
+ const chosen = isChosen(date);
489
+ // Where a run of chosen days opens and closes, for a stylesheet to round the
490
+ // ends of a range and join its middle. Markup only: nothing reads it, and a
491
+ // single chosen day is a run that opens and closes on itself.
492
+ const opensRun = chosen && !isChosen(date.subtract({ days: 1 }));
493
+ const closesRun = chosen && !isChosen(date.add({ days: 1 }));
469
494
  const passed = withoutComposed(rest, ["onClick", "onFocus", "onKeyDown"]);
470
495
 
471
496
  return (
@@ -481,6 +506,8 @@ export component CalendarDay(date: PlainDate, children?: React.Node, ...rest: Re
481
506
  // makes a screen reader announce "not selected" on all thirty-one.
482
507
  aria-selected={chosen ? "true" : undefined}
483
508
  data-date={date.toString()}
509
+ data-selection-end={closesRun ? "true" : undefined}
510
+ data-selection-start={opensRun ? "true" : undefined}
484
511
  onClick={composeHandlers(rest.onClick, () => calendar.select(date))}
485
512
  // The tab stop follows real focus, which is the half of a roving set that
486
513
  // is easy to leave out and impossible to see. A press on an *unavailable*
package/dialog.js CHANGED
@@ -471,6 +471,11 @@ export component DialogClose(children: React.Node, render?: RenderProp, ...rest:
471
471
  * hiding body's children would hide nothing new and the outer dialog's own
472
472
  * content would stay readable behind the inner one.
473
473
  *
474
+ * The one sibling left alone is the live announcer `visually-hidden.js`
475
+ * creates, which lives in `<body>` and would otherwise go silent for as long as
476
+ * any dialog is open. React Aria's `ariaHideOutside` keeps its announcer for the
477
+ * same reason.
478
+ *
474
479
  * Both attributes, because they address different audiences. `aria-hidden`
475
480
  * removes the subtree from the accessibility tree; `inert` also stops clicks
476
481
  * and takes it out of the tab order, which is the browser's own enforcement of
@@ -488,7 +493,9 @@ function concealOutside(element: HTMLElement): () => void {
488
493
  break;
489
494
  }
490
495
  for (const sibling of Array.from(parent.children)) {
491
- if (sibling === node) {
496
+ // `announce()`'s regions stay readable: a message about what the dialog
497
+ // just did ("Saved", "3 results") is exactly what a modal needs to say.
498
+ if (sibling === node || sibling.hasAttribute("data-uf-live-announcer")) {
492
499
  continue;
493
500
  }
494
501
  restore.push({
package/index.js CHANGED
@@ -1872,3 +1872,21 @@ export {
1872
1872
  } from "./date-range-picker.js";
1873
1873
  export type { Locale } from "./i18n-provider.js";
1874
1874
  export { startsWithLocale } from "./i18n-provider.js";
1875
+
1876
+ /**
1877
+ * For a screen reader and nobody else.
1878
+ *
1879
+ * `VisuallyHidden` is text that stays in the accessibility tree and off the
1880
+ * screen — an icon button's name, a skip link (`focusable`) that appears when a
1881
+ * keyboard reaches it. `announce` says something through one pair of live
1882
+ * regions shared by the whole document, from an event handler, an effect or a
1883
+ * `catch`, without the caller rendering a region of their own:
1884
+ *
1885
+ * announce(`${results.length} results`);
1886
+ * announce("Could not save", { politeness: "assertive" });
1887
+ *
1888
+ * `visually-hidden.js` says why a region rendered with its message is silent,
1889
+ * and why these regions stay readable while a dialog hides the rest of the page.
1890
+ */
1891
+ export { VisuallyHidden, announce, clearAnnouncements } from "./visually-hidden.js";
1892
+ export type { AnnounceOptions, Politeness } from "./visually-hidden.js";
@@ -1,5 +1,34 @@
1
1
  // @flow
2
2
  "use client";
3
+ //
4
+ // The one collection behind `ListBox`, `GridList`, `Tree` and `TagGroup`.
5
+ //
6
+ // Focus stays on the collection element and `aria-activedescendant` names the
7
+ // active row, so a virtualised list of 10,000 rows needs only the rows in view
8
+ // plus the active one in the document. What a gesture does to the selection is
9
+ // `selection.js`'s answer; this file maps keys and pointers onto those answers
10
+ // and keeps the anchor a Shift range grows from.
11
+ //
12
+ // The keyboard map (WAI-ARIA APG listbox, grid and tree patterns, with React
13
+ // Aria's selection manager for the parts the APG leaves open):
14
+ //
15
+ // ArrowUp/ArrowDown previous/next row (TagGroup: also Left/Right,
16
+ // mirrored in right-to-left text)
17
+ // Home/End first/last row; End also asks for more rows
18
+ // PageUp/PageDown a viewport's worth of rows
19
+ // Shift + any of those extend the selection from the anchor
20
+ // Ctrl/Cmd + any move without selecting (`selectionBehavior="replace"`)
21
+ // Space select (toggle or replace, per `selectionBehavior`)
22
+ // Ctrl/Cmd+Space toggle one row — or lift it, when `onReorder` is set
23
+ // Enter `onAction`, or select when there is none
24
+ // Ctrl/Cmd+A select every enabled row
25
+ // Escape clear the selection (`escapeKeyBehavior`)
26
+ // Tree: Right/Left expand, enter; collapse, go to parent (mirrored)
27
+ // Tree: * expand every sibling of the active row
28
+ // printable characters locale-collated typeahead
29
+ //
30
+ // The Escape key is only claimed when it cleared something, so a collection
31
+ // inside a popover still lets the popover close on the Escape that follows.
3
32
 
4
33
  import * as React from "@uniflowed/react";
5
34
  import { useId, useRef, useState } from "@uniflowed/react";
@@ -7,9 +36,12 @@ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
7
36
  import { useDragAndDrop } from "../drag-drop.js";
8
37
  import { useControlled } from "./controlled-state.js";
9
38
  import { composeHandlers, composeRefs, withProps } from "./merge-props.js";
10
- import type { RenderProp, Rest } from "./merge-props.js";
39
+ import type { PartEvent, RenderProp } from "./merge-props.js";
11
40
  import { directionOf } from "./roving-focus.js";
41
+ import { clearAll, extendTo, orderedKeys, replaceWith, selectAll, toggleKey } from "./selection.js";
42
+ import type { SelectionBehavior, SelectionMode, SelectionPolicy } from "./selection.js";
12
43
  import { startsWithLocale, useLocale } from "../i18n-provider.js";
44
+ import { visuallyHiddenStyle } from "./visually-hidden-style.js";
13
45
 
14
46
  export type CollectionItem = {
15
47
  readonly key: string,
@@ -32,9 +64,32 @@ type Entry = {
32
64
  };
33
65
  type Kind = "listbox" | "grid" | "tree" | "tags";
34
66
 
67
+ /**
68
+ * What a row's click handler reads.
69
+ *
70
+ * `detail` is the click count: `0` for a click no pointer made — a screen
71
+ * reader's activation, or `element.click()` — which React Aria calls a virtual
72
+ * click. `nativeEvent` is read for `pointerType`, which a browser puts on the
73
+ * `PointerEvent` a click is and happy-dom and older engines leave off.
74
+ */
75
+ type RowClick = {
76
+ readonly detail: number,
77
+ readonly ctrlKey: boolean,
78
+ readonly metaKey: boolean,
79
+ readonly shiftKey: boolean,
80
+ readonly nativeEvent: mixed,
81
+ readonly stopPropagation: () => mixed,
82
+ ...
83
+ };
84
+
85
+ type Scroll = { readonly currentTarget: mixed, readonly defaultPrevented: boolean, ... };
86
+
87
+ /** What the collection's key handler reads: `PartEvent` plus the element the key came from. */
88
+ type KeyEvent = { ...PartEvent, readonly target: mixed, ... };
89
+
35
90
  function entries(
36
91
  items: $ReadOnlyArray<CollectionItem>,
37
- expanded: $ReadOnlyArray<string>,
92
+ expanded: $ReadOnlySet<string>,
38
93
  tree: boolean,
39
94
  ): Array<Entry> {
40
95
  const result = [];
@@ -49,23 +104,39 @@ function entries(
49
104
  seen.add(item.key);
50
105
  result.push({ item, level, parent, position: index + 1, count: siblings.length });
51
106
  const children = item.children;
52
- if (tree && children != null && expanded.includes(item.key))
53
- visit(children, level + 1, item.key);
107
+ if (tree && children != null && expanded.has(item.key)) visit(children, level + 1, item.key);
54
108
  });
55
109
  };
56
110
  visit(items, 1, null);
57
111
  return result;
58
112
  }
59
113
 
114
+ /**
115
+ * A touch or a virtual click toggles even under `selectionBehavior="replace"`.
116
+ *
117
+ * There is no Ctrl key on a phone and no modifier on a screen reader's
118
+ * activation, so a replace-only rule would leave both able to choose one row
119
+ * and never two. React Aria draws the same line.
120
+ */
121
+ function togglesByPointer(event: RowClick): boolean {
122
+ if (event.detail === 0) return true;
123
+ const native = event.nativeEvent;
124
+ return typeof native === "object" && native != null && native.pointerType === "touch";
125
+ }
126
+
60
127
  /** Data owns order and identity. Only visible tree descendants and virtual rows enter the DOM. */
61
128
  export component CollectionRoot(kind: Kind, options: CollectionProps) {
62
129
  const {
63
130
  items,
64
131
  children,
65
132
  selectionMode: requestedSelectionMode = "single",
133
+ selectionBehavior = "toggle",
134
+ disallowEmptySelection = false,
135
+ escapeKeyBehavior = "clearSelection",
66
136
  selectedKeys,
67
137
  defaultSelectedKeys = [],
68
138
  onSelectionChange,
139
+ onAction,
69
140
  disabledKeys = [],
70
141
  expandedKeys,
71
142
  defaultExpandedKeys = [],
@@ -80,13 +151,21 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
80
151
  render,
81
152
  ...rest
82
153
  } = options;
83
- const selectionMode = kind === "tags" ? "none" : requestedSelectionMode;
154
+ const mode: SelectionMode = kind === "tags" ? "none" : requestedSelectionMode;
155
+ const policy: SelectionPolicy = {
156
+ mode,
157
+ behavior: selectionBehavior,
158
+ disallowEmpty: disallowEmptySelection,
159
+ };
84
160
  if (height <= 0 || rowHeight <= 0 || !Number.isFinite(height) || !Number.isFinite(rowHeight))
85
161
  throw new RangeError("Collection dimensions must be positive finite numbers");
86
162
  const { locale } = useLocale();
87
163
  const id = useId();
88
164
  const root = useRef<HTMLElement | null>(null);
165
+ // Where a Shift range starts (`anchor`) and where the last one ended (`lead`).
166
+ // Written only by event handlers, never read during render.
89
167
  const anchor = useRef<string | null>(null);
168
+ const lead = useRef<string | null>(null);
90
169
  const buffer = useRef({ text: "", time: 0 });
91
170
  const [selected, setSelected] = useControlled(
92
171
  selectedKeys,
@@ -101,60 +180,61 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
101
180
  const [active, setActive] = useState<string | null>(null);
102
181
  const [scrollTop, setScrollTop] = useState(0);
103
182
  const [announcement, announce] = useState("");
183
+ // Sets, because every row asks "am I selected, am I disabled" on every render
184
+ // and a 10,000-row collection with everything selected made that 10⁸
185
+ // comparisons as arrays.
186
+ // The React Compiler keeps each of these until its input changes.
187
+ const selectedSet = new Set(selected);
188
+ const disabledSet = new Set(disabledKeys);
189
+ const expandedSet = new Set(expanded);
190
+ const rows = entries(items, expandedSet, kind === "tree");
191
+ const disabled = (item: CollectionItem) => item.disabled === true || disabledSet.has(item.key);
192
+ const enabled = rows.filter((row) => !disabled(row.item));
193
+ const order = enabled.map((row) => row.item.key);
104
194
  const drag = useDragAndDrop({
105
195
  disabled: onReorder == null,
106
196
  onDrop: ({ keys, target }) => {
107
197
  const moving = keys[0];
108
- if (
109
- [moving, target].some(
110
- (key) => disabledKeys.includes(key) || items.find((item) => item.key === key)?.disabled,
111
- )
112
- )
113
- return;
114
- if (
115
- moving === target ||
116
- !items.some((item) => item.key === moving) ||
117
- !items.some((item) => item.key === target)
118
- )
119
- return;
198
+ const byKey = (key: string) => items.find((item) => item.key === key);
199
+ const from = byKey(moving);
200
+ const to = byKey(target);
201
+ if (from == null || to == null || moving === target || disabled(from) || disabled(to)) return;
120
202
  const ordered = items.filter((item) => item.key !== moving).map((item) => item.key);
121
203
  ordered.splice(ordered.indexOf(target), 0, moving);
122
204
  onReorder?.(ordered);
123
205
  },
124
206
  });
125
- const rows = entries(items, expanded, kind === "tree");
126
- const disabled = (item: CollectionItem) =>
127
- item.disabled === true || disabledKeys.includes(item.key);
128
- const enabled = rows.filter((row) => !disabled(row.item));
129
207
  const focused =
130
208
  enabled.find((row) => row.item.key === active) ??
131
- enabled.find((row) => selected.includes(row.item.key)) ??
209
+ enabled.find((row) => selectedSet.has(row.item.key)) ??
132
210
  enabled[0];
133
211
  const activeKey = focused?.item.key;
134
- const choose = (key: string, range: boolean, toggle: boolean) => {
135
- const item = rows.find((row) => row.item.key === key)?.item;
136
- if (item == null || disabled(item) || selectionMode === "none") return;
137
- let next;
138
- if (selectionMode === "single") next = [key];
139
- else if (range && anchor.current != null) {
140
- const from = enabled.findIndex((row) => row.item.key === anchor.current);
141
- const to = enabled.findIndex((row) => row.item.key === key);
142
- next = enabled
143
- .slice(Math.min(Math.max(from, 0), to), Math.max(from, to) + 1)
144
- .map((row) => row.item.key);
145
- } else {
146
- next = toggle
147
- ? selected.includes(key)
148
- ? selected.filter((each) => each !== key)
149
- : [...selected, key]
150
- : [key];
151
- anchor.current = key;
152
- }
153
- setSelected(next);
154
- announce(`${next.length} selected`);
212
+
213
+ /** Report a selection, unless the gesture changed nothing. */
214
+ const commit = (next: $ReadOnlySet<string>) => {
215
+ if (next === selectedSet) return;
216
+ const keys = orderedKeys(next, order);
217
+ setSelected(keys);
218
+ announce(`${keys.length} selected`);
155
219
  };
156
- const land = (row: Entry, shift: boolean) => {
157
- setActive(row.item.key);
220
+ /** A plain or Ctrl/Cmd gesture on one row: toggle or replace, and move the anchor. */
221
+ const selectOne = (key: string, toggle: boolean) => {
222
+ if (mode === "none") return;
223
+ commit(
224
+ toggle || selectionBehavior === "toggle"
225
+ ? toggleKey(policy, selectedSet, key)
226
+ : replaceWith(policy, selectedSet, key),
227
+ );
228
+ anchor.current = key;
229
+ lead.current = key;
230
+ };
231
+ /** A Shift gesture: grow from the anchor, which is the active row if nothing set one. */
232
+ const extend = (key: string, from: string | void) => {
233
+ if (anchor.current == null || !order.includes(anchor.current)) anchor.current = from ?? key;
234
+ commit(extendTo(policy, selectedSet, order, anchor.current, lead.current, key));
235
+ lead.current = key;
236
+ };
237
+ const scrollTo = (row: Entry) => {
158
238
  const container = root.current;
159
239
  if (virtualized && container != null) {
160
240
  const top = rows.indexOf(row) * rowHeight;
@@ -163,7 +243,33 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
163
243
  setScrollTop(top);
164
244
  }
165
245
  }
166
- if (shift) choose(row.item.key, true, false);
246
+ };
247
+ /** Move the active row, and select the way the modifiers ask. */
248
+ const land = (
249
+ row: Entry,
250
+ event: {
251
+ readonly shiftKey: boolean,
252
+ readonly ctrlKey: boolean,
253
+ readonly metaKey: boolean,
254
+ ...
255
+ },
256
+ ) => {
257
+ const previous = activeKey;
258
+ setActive(row.item.key);
259
+ scrollTo(row);
260
+ if (mode === "none") return;
261
+ if (event.shiftKey && mode === "multiple") extend(row.item.key, previous);
262
+ else if (selectionBehavior === "replace" && !event.ctrlKey && !event.metaKey)
263
+ selectOne(row.item.key, false);
264
+ };
265
+ /** Rows per PageUp/PageDown: the viewport over one row, or ten when nothing is laid out. */
266
+ const pageSize = (): number => {
267
+ if (virtualized) return Math.max(1, Math.floor(height / rowHeight));
268
+ const container = root.current;
269
+ const row = container?.querySelector("[data-key]");
270
+ if (container != null && row instanceof HTMLElement && row.offsetHeight > 0)
271
+ return Math.max(1, Math.floor(container.clientHeight / row.offsetHeight));
272
+ return 10;
167
273
  };
168
274
  const remove = (key: string) => {
169
275
  const at = enabled.findIndex((row) => row.item.key === key);
@@ -172,12 +278,16 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
172
278
  setActive(enabled[at + 1]?.item.key ?? enabled[at - 1]?.item.key ?? null);
173
279
  announce(`Removed ${enabled[at].item.textValue}`);
174
280
  };
175
- const keydown = useStableCallback((event: $FlowFixMe) => {
281
+ const keydown = useStableCallback((event: KeyEvent) => {
282
+ const { target, currentTarget } = event;
283
+ if (!(currentTarget instanceof HTMLElement)) return;
176
284
  if (
177
- event.target !== event.currentTarget &&
178
- event.target.closest("button,input,textarea,select")
285
+ target !== currentTarget &&
286
+ target instanceof Element &&
287
+ target.closest("button,input,textarea,select") != null
179
288
  )
180
289
  return;
290
+ const modifier = event.ctrlKey || event.metaKey;
181
291
  if (drag.dragging && event.key === "Escape") {
182
292
  event.preventDefault();
183
293
  drag.cancel();
@@ -188,48 +298,62 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
188
298
  drag.drop(activeKey, undefined, focused?.item.textValue);
189
299
  return;
190
300
  }
191
- if (
192
- onReorder != null &&
193
- event.key === " " &&
194
- (event.ctrlKey || event.metaKey) &&
195
- focused != null
196
- ) {
301
+ if (onReorder != null && event.key === " " && modifier && focused != null) {
197
302
  event.preventDefault();
198
303
  drag.start(focused.item.key, focused.item.textValue);
199
304
  return;
200
305
  }
306
+ const rtl = directionOf(currentTarget) === "rtl";
307
+ const inline = kind === "tags";
308
+ const forward =
309
+ event.key === "ArrowDown" || (inline && event.key === (rtl ? "ArrowLeft" : "ArrowRight"));
310
+ const backward =
311
+ event.key === "ArrowUp" || (inline && event.key === (rtl ? "ArrowRight" : "ArrowLeft"));
201
312
  const at = enabled.findIndex((row) => row.item.key === activeKey);
202
313
  let next = null;
203
- if (event.key === "ArrowDown") next = enabled[Math.min(at + 1, enabled.length - 1)];
204
- else if (event.key === "ArrowUp") next = enabled[Math.max(at - 1, 0)];
314
+ if (forward) next = enabled[Math.min(at + 1, enabled.length - 1)];
315
+ else if (backward) next = enabled[Math.max(at - 1, 0)];
205
316
  else if (event.key === "Home") next = enabled[0];
206
317
  else if (event.key === "End") {
207
318
  next = enabled[enabled.length - 1];
208
319
  if (!loading) onLoadMore?.();
320
+ } else if (event.key === "PageDown")
321
+ next = enabled[Math.min(Math.max(at, 0) + pageSize(), enabled.length - 1)];
322
+ else if (event.key === "PageUp") next = enabled[Math.max(at - pageSize(), 0)];
323
+ else if (kind === "tree" && focused != null && event.key === "*") {
324
+ const siblings = rows.filter(
325
+ (row) => row.parent === focused.parent && (row.item.children?.length ?? 0) > 0,
326
+ );
327
+ const missing = siblings.map((row) => row.item.key).filter((key) => !expandedSet.has(key));
328
+ if (missing.length > 0) setExpanded([...expanded, ...missing]);
209
329
  } else if (
210
330
  kind === "tree" &&
211
331
  focused != null &&
212
- ["ArrowLeft", "ArrowRight"].includes(event.key)
332
+ (event.key === "ArrowLeft" || event.key === "ArrowRight")
213
333
  ) {
214
- const open =
215
- event.key === (directionOf(event.currentTarget) === "rtl" ? "ArrowLeft" : "ArrowRight");
334
+ const open = event.key === (rtl ? "ArrowLeft" : "ArrowRight");
216
335
  const key = focused.item.key;
217
- if (open && focused.item.children?.length) {
218
- if (!expanded.includes(key)) setExpanded([...expanded, key]);
336
+ if (open && (focused.item.children?.length ?? 0) > 0) {
337
+ if (!expandedSet.has(key)) setExpanded([...expanded, key]);
219
338
  else next = enabled[at + 1];
220
- } else if (!open && expanded.includes(key))
339
+ } else if (!open && expandedSet.has(key))
221
340
  setExpanded(expanded.filter((each) => each !== key));
222
341
  else if (!open) next = enabled.find((row) => row.item.key === focused.parent);
342
+ } else if (modifier && event.key.toLowerCase() === "a" && !event.altKey) {
343
+ if (mode !== "multiple") return;
344
+ commit(selectAll(policy, selectedSet, order));
345
+ } else if (event.key === "Escape") {
346
+ if (escapeKeyBehavior !== "clearSelection") return;
347
+ const cleared = clearAll(policy, selectedSet);
348
+ // Unclaimed when nothing changed, so an enclosing popover still closes.
349
+ if (cleared === selectedSet) return;
350
+ commit(cleared);
351
+ } else if (event.key === "Enter" && activeKey != null && onAction != null) {
352
+ onAction(activeKey);
353
+ } else if ((event.key === " " || event.key === "Enter") && activeKey != null) {
354
+ if (event.shiftKey && mode === "multiple") extend(activeKey, activeKey);
355
+ else selectOne(activeKey, modifier);
223
356
  } else if (
224
- (event.ctrlKey || event.metaKey) &&
225
- event.key === "a" &&
226
- selectionMode === "multiple"
227
- ) {
228
- setSelected(enabled.map((row) => row.item.key));
229
- announce(`${enabled.length} selected`);
230
- } else if ((event.key === " " || event.key === "Enter") && activeKey != null)
231
- choose(activeKey, event.shiftKey, true);
232
- else if (
233
357
  (event.key === "Delete" || event.key === "Backspace") &&
234
358
  kind === "tags" &&
235
359
  activeKey != null
@@ -249,10 +373,28 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
249
373
  break;
250
374
  }
251
375
  }
376
+ // Typeahead moves; it never extends. Under `replace` the selection follows.
377
+ if (next != null) {
378
+ event.preventDefault();
379
+ land(next, { shiftKey: false, ctrlKey: false, metaKey: false });
380
+ return;
381
+ }
252
382
  } else return;
253
383
  event.preventDefault();
254
- if (next != null) land(next, event.shiftKey);
384
+ if (next != null) land(next, event);
255
385
  });
386
+ const rowClick = (item: CollectionItem, event: RowClick) => {
387
+ if (disabled(item)) return;
388
+ root.current?.focus();
389
+ setActive(item.key);
390
+ // With nothing to select, a click is the row's action.
391
+ if (mode === "none") {
392
+ onAction?.(item.key);
393
+ return;
394
+ }
395
+ if (event.shiftKey && mode === "multiple") extend(item.key, activeKey);
396
+ else selectOne(item.key, event.ctrlKey || event.metaKey || togglesByPointer(event));
397
+ };
256
398
  const start = virtualized ? Math.max(0, Math.floor(scrollTop / rowHeight) - 2) : 0;
257
399
  const end = virtualized
258
400
  ? Math.min(rows.length, start + Math.ceil(height / rowHeight) + 4)
@@ -266,7 +408,7 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
266
408
  .map((index) => {
267
409
  const row = rows[index];
268
410
  const { item } = row;
269
- const chosen = selected.includes(item.key);
411
+ const chosen = selectedSet.has(item.key);
270
412
  const grid = kind === "grid" || kind === "tags";
271
413
  const content =
272
414
  children?.(item, {
@@ -282,10 +424,12 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
282
424
  draggable: onReorder != null && !disabled(item),
283
425
  id: `${id}-${index}`,
284
426
  role: grid ? "row" : kind === "tree" ? "treeitem" : "option",
285
- "aria-selected": selectionMode === "none" ? undefined : chosen,
427
+ "aria-selected": mode === "none" ? undefined : chosen,
286
428
  "aria-disabled": disabled(item) || undefined,
287
429
  "aria-expanded":
288
- kind === "tree" && item.children?.length ? expanded.includes(item.key) : undefined,
430
+ kind === "tree" && (item.children?.length ?? 0) > 0
431
+ ? expandedSet.has(item.key)
432
+ : undefined,
289
433
  "aria-level": kind === "tree" ? row.level : undefined,
290
434
  "aria-posinset": grid ? undefined : kind === "tree" ? row.position : index + 1,
291
435
  "aria-setsize": grid ? undefined : kind === "tree" ? row.count : rows.length,
@@ -295,12 +439,14 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
295
439
  style: virtualized
296
440
  ? { position: "absolute", top: index * rowHeight, height: rowHeight, width: "100%" }
297
441
  : undefined,
298
- onClick: (event: $FlowFixMe) => {
299
- if (disabled(item)) return;
300
- root.current?.focus();
301
- setActive(item.key);
302
- choose(item.key, event.shiftKey, selectionMode === "multiple");
303
- },
442
+ onClick: (event: RowClick) => rowClick(item, event),
443
+ // Under `replace` a click selects, so the action needs a second gesture.
444
+ onDoubleClick:
445
+ onAction != null && selectionBehavior === "replace" && mode !== "none"
446
+ ? () => {
447
+ if (!disabled(item)) onAction(item.key);
448
+ }
449
+ : undefined,
304
450
  };
305
451
  return (
306
452
  <div key={item.key} {...props}>
@@ -313,7 +459,7 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
313
459
  tabIndex={-1}
314
460
  disabled={disabled(item)}
315
461
  aria-label={`Remove ${item.textValue}`}
316
- onClick={(event) => {
462
+ onClick={(event: RowClick) => {
317
463
  event.stopPropagation();
318
464
  root.current?.focus();
319
465
  remove(item.key);
@@ -332,27 +478,31 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
332
478
  const setRef = useStableCallback((element: HTMLElement | null) => {
333
479
  root.current = element;
334
480
  });
481
+ const style = rest.style;
335
482
  const props = withProps(rest, {
336
483
  ref: composeRefs(rest.ref, setRef),
337
484
  role: kind === "tags" ? "grid" : kind,
338
485
  tabIndex: 0,
339
486
  "aria-activedescendant": activeIndex < 0 ? undefined : `${id}-${activeIndex}`,
340
- "aria-multiselectable": selectionMode === "multiple" || undefined,
487
+ "aria-multiselectable": mode === "multiple" || undefined,
341
488
  "aria-busy": loading || undefined,
342
489
  "aria-rowcount": kind === "grid" || kind === "tags" ? rows.length : undefined,
343
490
  onKeyDown: composeHandlers(rest.onKeyDown, keydown),
344
- onScroll: composeHandlers(rest.onScroll, (event: $FlowFixMe) => {
345
- if (virtualized) setScrollTop(event.currentTarget.scrollTop);
346
- if (
347
- !loading &&
348
- event.currentTarget.scrollTop + event.currentTarget.clientHeight >=
349
- event.currentTarget.scrollHeight
350
- )
491
+ onScroll: composeHandlers(rest.onScroll, (event: Scroll) => {
492
+ const element = event.currentTarget;
493
+ if (!(element instanceof HTMLElement)) return;
494
+ if (virtualized) setScrollTop(element.scrollTop);
495
+ if (!loading && element.scrollTop + element.clientHeight >= element.scrollHeight)
351
496
  onLoadMore?.();
352
497
  }),
353
498
  style: virtualized
354
- ? { ...(rest.style as $FlowFixMe), height, overflow: "auto", position: "relative" }
355
- : rest.style,
499
+ ? {
500
+ ...(typeof style === "object" && style != null ? style : {}),
501
+ height,
502
+ overflow: "auto",
503
+ position: "relative",
504
+ }
505
+ : style,
356
506
  children: virtualized ? (
357
507
  <div role="presentation" style={{ height: rows.length * rowHeight, position: "relative" }}>
358
508
  {rowNodes}
@@ -364,7 +514,8 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
364
514
  return (
365
515
  <>
366
516
  {render != null ? render(props) : <div {...props} />}
367
- <span role="status" aria-live="polite">
517
+ {/* For a screen reader; a sighted reader sees the selection itself. */}
518
+ <span role="status" aria-live="polite" style={visuallyHiddenStyle}>
368
519
  {drag.announcement || announcement}
369
520
  </span>
370
521
  </>
@@ -374,10 +525,26 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
374
525
  export type CollectionProps = {
375
526
  items: $ReadOnlyArray<CollectionItem>,
376
527
  children?: (item: CollectionItem, state: CollectionItemState) => React.Node,
377
- selectionMode?: "single" | "multiple" | "none",
528
+ selectionMode?: SelectionMode,
529
+ /**
530
+ * What a plain click or Space does: `"toggle"` flips the row (the default),
531
+ * `"replace"` makes it the whole selection and lets the arrow keys carry the
532
+ * selection with them. Ctrl/Cmd toggles and Shift extends under either.
533
+ */
534
+ selectionBehavior?: SelectionBehavior,
535
+ /** Refuse the gesture that would leave nothing selected. */
536
+ disallowEmptySelection?: boolean,
537
+ /** Whether Escape clears the selection. */
538
+ escapeKeyBehavior?: "clearSelection" | "none",
378
539
  selectedKeys?: $ReadOnlyArray<string>,
379
540
  defaultSelectedKeys?: $ReadOnlyArray<string>,
380
541
  onSelectionChange?: (keys: $ReadOnlyArray<string>) => void,
542
+ /**
543
+ * Open a row rather than select it. Enter runs it; so does a click when
544
+ * `selectionMode` is `"none"`, and a double click under
545
+ * `selectionBehavior="replace"`.
546
+ */
547
+ onAction?: (key: string) => void,
381
548
  disabledKeys?: $ReadOnlyArray<string>,
382
549
  expandedKeys?: $ReadOnlyArray<string>,
383
550
  defaultExpandedKeys?: $ReadOnlyArray<string>,
@@ -9,6 +9,7 @@ import { composeHandlers, withProps } from "./merge-props.js";
9
9
  import type { RenderProp, Rest } from "./merge-props.js";
10
10
  import { directionOf } from "./roving-focus.js";
11
11
  import { useLocale } from "../i18n-provider.js";
12
+ import { visuallyHiddenStyle } from "./visually-hidden-style.js";
12
13
 
13
14
  type Segment = "year" | "month" | "day" | "hour" | "minute" | "second" | "dayPeriod";
14
15
  type Fields = { [string]: string };
@@ -306,7 +307,7 @@ export component SegmentedField(time: boolean, options: DateFieldProps) {
306
307
  children: (
307
308
  <>
308
309
  {segments}
309
- <span role="status" aria-live="polite">
310
+ <span role="status" aria-live="polite" style={visuallyHiddenStyle}>
310
311
  {announcement}
311
312
  </span>
312
313
  </>
@@ -0,0 +1,171 @@
1
+ // @flow
2
+ //
3
+ // What a collection's selection becomes after one gesture, as pure functions.
4
+ //
5
+ // `ListBox`, `GridList`, `Tree` and `TagGroup` all ask the same four questions
6
+ // — toggle this key, make it the only one, extend to it, take everything — and
7
+ // the answers depend on three settings rather than on which collection is
8
+ // asking. Keeping them here, apart from React, is what lets every rule be
9
+ // tested without rendering and lets all four collections agree by construction.
10
+ //
11
+ // The rules are React Aria's selection manager (`@react-stately/selection`),
12
+ // which is itself the WAI-ARIA APG's listbox and grid selection models made
13
+ // precise:
14
+ //
15
+ // * `mode` is what may be chosen: nothing, one key, or any number.
16
+ // * `behavior` is what a plain gesture does. `"toggle"` flips the key — the
17
+ // checkbox model, right for touch and for lists whose rows carry a
18
+ // checkbox. `"replace"` makes the key the whole selection — the file
19
+ // manager model, where Ctrl/Cmd adds one and Shift adds a range.
20
+ // * `disallowEmpty` refuses the gesture that would leave nothing selected,
21
+ // rather than accepting it and then reselecting.
22
+ //
23
+ // Every function returns the *same* set it was given when nothing changes, so a
24
+ // caller can compare by identity and skip `onSelectionChange` for a gesture
25
+ // that did nothing — a controlled parent is not told about a change that is
26
+ // not one.
27
+ //
28
+ // # Why this is `internal/`
29
+ //
30
+ // A public selection manager would be a second way to build a collection, and
31
+ // the point of this package is that there is one. The collections are the
32
+ // public surface; `packages/ui/index.js` says why the internals stay internal.
33
+
34
+ export type SelectionMode = "none" | "single" | "multiple";
35
+ export type SelectionBehavior = "toggle" | "replace";
36
+
37
+ /** The three settings every answer below depends on. */
38
+ export type SelectionPolicy = {|
39
+ readonly mode: SelectionMode,
40
+ readonly behavior: SelectionBehavior,
41
+ readonly disallowEmpty: boolean,
42
+ |};
43
+
44
+ /**
45
+ * The keys a range covers, in collection order, inclusive at both ends.
46
+ *
47
+ * `order` is the selectable keys only — disabled rows are not in it — so a range
48
+ * dragged across a disabled row does not pick it up, which is what React Aria
49
+ * and every native list do. An end that is not in `order` (a key filtered out
50
+ * since it was chosen) contributes nothing rather than stretching the range to
51
+ * the start of the list.
52
+ */
53
+ export function keyRange(
54
+ order: $ReadOnlyArray<string>,
55
+ from: string,
56
+ to: string,
57
+ ): $ReadOnlyArray<string> {
58
+ const start = order.indexOf(from);
59
+ const end = order.indexOf(to);
60
+ if (start < 0 || end < 0) return end < 0 ? [] : [to];
61
+ return order.slice(Math.min(start, end), Math.max(start, end) + 1);
62
+ }
63
+
64
+ /** Flip one key, unless that would empty a selection that must not be empty. */
65
+ export function toggleKey(
66
+ policy: SelectionPolicy,
67
+ selected: $ReadOnlySet<string>,
68
+ key: string,
69
+ ): $ReadOnlySet<string> {
70
+ if (policy.mode === "none") return selected;
71
+ if (selected.has(key)) {
72
+ if (policy.disallowEmpty && selected.size === 1) return selected;
73
+ if (policy.mode === "single") return new Set();
74
+ const next = new Set(selected);
75
+ next.delete(key);
76
+ return next;
77
+ }
78
+ if (policy.mode === "single") return new Set([key]);
79
+ const next = new Set(selected);
80
+ next.add(key);
81
+ return next;
82
+ }
83
+
84
+ /** Make one key the whole selection. */
85
+ export function replaceWith(
86
+ policy: SelectionPolicy,
87
+ selected: $ReadOnlySet<string>,
88
+ key: string,
89
+ ): $ReadOnlySet<string> {
90
+ if (policy.mode === "none") return selected;
91
+ if (selected.size === 1 && selected.has(key)) return selected;
92
+ return new Set([key]);
93
+ }
94
+
95
+ /**
96
+ * Extend from `anchor` to `key`, replacing the range the previous extension made.
97
+ *
98
+ * `lead` is where the last extension ended. Shift+Down, Shift+Down, Shift+Up
99
+ * must end with two rows selected rather than three, so the old range
100
+ * (`anchor`…`lead`) comes out before the new one (`anchor`…`key`) goes in —
101
+ * while anything selected outside both ranges, by an earlier Ctrl/Cmd+click,
102
+ * stays. With no anchor the extension starts at `key`.
103
+ */
104
+ export function extendTo(
105
+ policy: SelectionPolicy,
106
+ selected: $ReadOnlySet<string>,
107
+ order: $ReadOnlyArray<string>,
108
+ anchor: string | null,
109
+ lead: string | null,
110
+ key: string,
111
+ ): $ReadOnlySet<string> {
112
+ if (policy.mode === "none") return selected;
113
+ if (policy.mode === "single") return replaceWith(policy, selected, key);
114
+ const from = anchor ?? key;
115
+ const next = new Set(selected);
116
+ if (lead != null) for (const each of keyRange(order, from, lead)) next.delete(each);
117
+ for (const each of keyRange(order, from, key)) next.add(each);
118
+ if (next.size === 0 && policy.disallowEmpty) return selected;
119
+ return sameKeys(next, selected) ? selected : next;
120
+ }
121
+
122
+ /** Every selectable key, when more than one may be chosen. */
123
+ export function selectAll(
124
+ policy: SelectionPolicy,
125
+ selected: $ReadOnlySet<string>,
126
+ order: $ReadOnlyArray<string>,
127
+ ): $ReadOnlySet<string> {
128
+ if (policy.mode !== "multiple" || order.length === 0) return selected;
129
+ const next = new Set(selected);
130
+ for (const key of order) next.add(key);
131
+ return sameKeys(next, selected) ? selected : next;
132
+ }
133
+
134
+ /** Nothing, unless nothing is not allowed. */
135
+ export function clearAll(
136
+ policy: SelectionPolicy,
137
+ selected: $ReadOnlySet<string>,
138
+ ): $ReadOnlySet<string> {
139
+ if (policy.mode === "none" || policy.disallowEmpty || selected.size === 0) return selected;
140
+ return new Set();
141
+ }
142
+
143
+ /**
144
+ * A selection as the array `onSelectionChange` reports: collection order first,
145
+ * then any key the collection is not showing, in the order it was chosen.
146
+ *
147
+ * Keys the collection is not showing are real — a child of a collapsed tree
148
+ * row, a row a filter hid — and dropping them because they are out of sight
149
+ * would deselect something the reader chose and cannot see go.
150
+ */
151
+ export function orderedKeys(
152
+ selected: $ReadOnlySet<string>,
153
+ order: $ReadOnlyArray<string>,
154
+ ): $ReadOnlyArray<string> {
155
+ const result = [];
156
+ const placed = new Set<string>();
157
+ for (const key of order) {
158
+ if (selected.has(key)) {
159
+ result.push(key);
160
+ placed.add(key);
161
+ }
162
+ }
163
+ for (const key of selected) if (!placed.has(key)) result.push(key);
164
+ return result;
165
+ }
166
+
167
+ function sameKeys(a: $ReadOnlySet<string>, b: $ReadOnlySet<string>): boolean {
168
+ if (a.size !== b.size) return false;
169
+ for (const key of a) if (!b.has(key)) return false;
170
+ return true;
171
+ }
@@ -0,0 +1,41 @@
1
+ // @flow
2
+ //
3
+ // The one inline style for "hidden from sight, present for a screen reader".
4
+ //
5
+ // Its own module, with no `"use client"`, so a part that renders on the server
6
+ // can import it without importing `visually-hidden.js` — a client module whose
7
+ // every export is a client reference to a Server Component. Internal for the
8
+ // reason `packages/ui/index.js` gives about every module here: the public
9
+ // answer is `VisuallyHidden`.
10
+
11
+ /**
12
+ * The style that hides an element from sight and from nothing else.
13
+ *
14
+ * Shared by `VisuallyHidden` and by the parts that keep a live region of their
15
+ * own beside what they render — the table's sort announcement, the
16
+ * collections', the calendar's and the range calendar's, and the segmented
17
+ * fields'. Frozen, because every one of them holds the same object.
18
+ */
19
+ export const visuallyHiddenStyle: {|
20
+ readonly border: number,
21
+ readonly clip: string,
22
+ readonly clipPath: string,
23
+ readonly height: number,
24
+ readonly margin: number,
25
+ readonly overflow: string,
26
+ readonly padding: number,
27
+ readonly position: string,
28
+ readonly whiteSpace: string,
29
+ readonly width: number,
30
+ |} = Object.freeze({
31
+ border: 0,
32
+ clip: "rect(0, 0, 0, 0)",
33
+ clipPath: "inset(50%)",
34
+ height: 1,
35
+ margin: -1,
36
+ overflow: "hidden",
37
+ padding: 0,
38
+ position: "absolute",
39
+ whiteSpace: "nowrap",
40
+ width: 1,
41
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Headless, accessible React components whose composition Flow checks, part of the Unified Toolchain for Flow.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,10 +19,10 @@
19
19
  "!*.test.js"
20
20
  ],
21
21
  "dependencies": {
22
- "@uniflowed/core": "0.1.0",
23
- "@uniflowed/hooks": "0.1.0",
24
- "@uniflowed/react": "0.1.0",
25
- "@uniflowed/state": "0.1.0"
22
+ "@uniflowed/core": "0.2.0",
23
+ "@uniflowed/hooks": "0.2.0",
24
+ "@uniflowed/react": "0.2.0",
25
+ "@uniflowed/state": "0.2.0"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "react": ">=19"
package/range-calendar.js CHANGED
@@ -8,6 +8,7 @@ import type { Rest } from "./internal/merge-props.js";
8
8
  import type { DateRange } from "./internal/date-range.js";
9
9
  import { validateRange, unavailableInRange } from "./internal/date-range.js";
10
10
  import { CalendarRoot, CalendarMonth } from "./calendar.js";
11
+ import { visuallyHiddenStyle } from "./internal/visually-hidden-style.js";
11
12
  export type { DateRange } from "./internal/date-range.js";
12
13
 
13
14
  export component RangeCalendarRoot(
@@ -70,7 +71,7 @@ export component RangeCalendarRoot(
70
71
  >
71
72
  {children}
72
73
  </CalendarRoot>
73
- <span role="status" aria-live="polite">
74
+ <span role="status" aria-live="polite" style={visuallyHiddenStyle}>
74
75
  {announcement}
75
76
  </span>
76
77
  </div>
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) {
@@ -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>
@@ -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
+ }