@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 +33 -6
- package/dialog.js +8 -1
- package/index.js +18 -0
- package/internal/collection.js +262 -95
- package/internal/segmented-field.js +2 -1
- package/internal/selection.js +171 -0
- package/internal/visually-hidden-style.js +41 -0
- package/package.json +5 -5
- package/range-calendar.js +2 -1
- package/table.js +2 -14
- package/visually-hidden.js +259 -0
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
|
-
|
|
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(
|
|
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`}>
|
|
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
|
|
467
|
-
calendar.isDateSelected?.(
|
|
468
|
-
|
|
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
|
-
|
|
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";
|
package/internal/collection.js
CHANGED
|
@@ -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 {
|
|
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: $
|
|
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.
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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) =>
|
|
209
|
+
enabled.find((row) => selectedSet.has(row.item.key)) ??
|
|
132
210
|
enabled[0];
|
|
133
211
|
const activeKey = focused?.item.key;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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:
|
|
281
|
+
const keydown = useStableCallback((event: KeyEvent) => {
|
|
282
|
+
const { target, currentTarget } = event;
|
|
283
|
+
if (!(currentTarget instanceof HTMLElement)) return;
|
|
176
284
|
if (
|
|
177
|
-
|
|
178
|
-
|
|
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 (
|
|
204
|
-
else if (
|
|
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
|
-
|
|
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 (!
|
|
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 &&
|
|
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
|
|
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 =
|
|
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":
|
|
427
|
+
"aria-selected": mode === "none" ? undefined : chosen,
|
|
286
428
|
"aria-disabled": disabled(item) || undefined,
|
|
287
429
|
"aria-expanded":
|
|
288
|
-
kind === "tree" && item.children?.length
|
|
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:
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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":
|
|
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:
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
? {
|
|
355
|
-
|
|
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
|
-
|
|
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?:
|
|
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.
|
|
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.
|
|
23
|
-
"@uniflowed/hooks": "0.
|
|
24
|
-
"@uniflowed/react": "0.
|
|
25
|
-
"@uniflowed/state": "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={
|
|
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
|
+
}
|