@uniflowed/ui 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/input-otp.js CHANGED
@@ -112,7 +112,7 @@ function clean(raw: string, kind: InputOtpKind, length: number): string {
112
112
  * Renders no element of its own beyond the input — see the module header. The
113
113
  * caller's props go on the input, because the input is the field.
114
114
  */
115
- export component InputOtpRoot(
115
+ component InputOtpRoot(
116
116
  children: React.Node,
117
117
  defaultValue?: string = "",
118
118
  disabled?: boolean = false,
@@ -173,7 +173,7 @@ export component InputOtpRoot(
173
173
  * already been announced, and a reader told "group, 3, group, 4" is being read
174
174
  * a picture.
175
175
  */
176
- export component InputOtpGroup(children: React.Node, ...rest: Rest) {
176
+ component InputOtpGroup(children: React.Node, ...rest: Rest) {
177
177
  return (
178
178
  <div {...rest} aria-hidden="true">
179
179
  {children}
@@ -188,7 +188,7 @@ export component InputOtpGroup(children: React.Node, ...rest: Rest) {
188
188
  * whether there is one in it already, so a stylesheet can draw a cursor and a
189
189
  * border without this module having an opinion about either.
190
190
  */
191
- export component InputOtpSlot(children?: React.Node, index: number, ...rest: Rest) {
191
+ component InputOtpSlot(children?: React.Node, index: number, ...rest: Rest) {
192
192
  const otp = useInputOtp("InputOtp.Slot");
193
193
  const character = otp.value[index] ?? "";
194
194
  // The box the next character goes in, clamped so a full code lights its last
@@ -209,10 +209,26 @@ export component InputOtpSlot(children?: React.Node, index: number, ...rest: Res
209
209
  }
210
210
 
211
211
  /** The dash between two groups. Decoration, and it says so. */
212
- export component InputOtpSeparator(children?: React.Node, render?: RenderProp, ...rest: Rest) {
212
+ component InputOtpSeparator(children?: React.Node, render?: RenderProp, ...rest: Rest) {
213
213
  const props = withProps(rest, { "aria-hidden": "true", children });
214
214
  if (render != null) {
215
215
  return render(props);
216
216
  }
217
217
  return <div {...props} />;
218
218
  }
219
+
220
+ /**
221
+ * The parts, under the names the `InputOtp` namespace gives them.
222
+ *
223
+ * `index.js` re-exports this module whole — `export * as InputOtp from "./input-otp.js"` —
224
+ * so a caller writes `<InputOtp.Root>`, and the namespace is the prefix. Each
225
+ * part is still *declared* as `InputOtpRoot`, so React DevTools, a component
226
+ * stack and an error name the part a reader can find rather than one of forty
227
+ * `Root`s.
228
+ */
229
+ export {
230
+ InputOtpRoot as Root,
231
+ InputOtpGroup as Group,
232
+ InputOtpSlot as Slot,
233
+ InputOtpSeparator as Separator,
234
+ };
package/interactions.js CHANGED
@@ -1798,7 +1798,6 @@ hook useDescription(text: string | void): string | void {
1798
1798
  const entry: SharedDescription = shared;
1799
1799
  entry.users += 1;
1800
1800
  // The id is state because it must render only after the shared DOM node exists.
1801
- // uf-lint-disable-next-line react-compiler/set-state-in-effect
1802
1801
  setId(entry.node.id);
1803
1802
  return () => {
1804
1803
  entry.users -= 1;
@@ -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>,
package/internal/focus.js CHANGED
@@ -52,13 +52,22 @@ export const FOCUS_STOPS: string =
52
52
  * document.
53
53
  */
54
54
  export function focusable(root: HTMLElement): Array<HTMLElement> {
55
- return Array.from(root.querySelectorAll(FOCUS_STOPS)).filter(
56
- (element: $FlowFixMe) =>
55
+ const found: Array<HTMLElement> = [];
56
+ for (const element of root.querySelectorAll(FOCUS_STOPS)) {
57
+ if (
58
+ // Asked rather than annotated: `querySelectorAll` answers `Element`, and
59
+ // what focus lands on is an HTML or SVG element, both `HTMLElement` to
60
+ // Flow.
61
+ (element instanceof HTMLElement || element instanceof SVGElement) &&
57
62
  // All three hide a whole subtree, so all three are asked of the
58
63
  // ancestors; see the module header for what reading them off the element
59
64
  // alone let through.
60
65
  element.closest("[hidden]") == null &&
61
66
  element.closest("[inert]") == null &&
62
- element.closest('[aria-hidden="true"]') == null,
63
- );
67
+ element.closest('[aria-hidden="true"]') == null
68
+ ) {
69
+ found.push(element);
70
+ }
71
+ }
72
+ return found;
64
73
  }
@@ -69,9 +69,18 @@ const TYPEAHEAD_WINDOW = 500;
69
69
  * cannot see.
70
70
  */
71
71
  export function itemsOf(container: HTMLElement, item: string, owner: string): Array<HTMLElement> {
72
- return Array.from(container.querySelectorAll(item)).filter(
73
- (element: $FlowFixMe) => element.closest(owner) === container,
74
- );
72
+ const found: Array<HTMLElement> = [];
73
+ for (const element of container.querySelectorAll(item)) {
74
+ // An item is an HTML or SVG element, both `HTMLElement` to Flow; asked
75
+ // rather than annotated, because `querySelectorAll` answers `Element`.
76
+ if (
77
+ (element instanceof HTMLElement || element instanceof SVGElement) &&
78
+ element.closest(owner) === container
79
+ ) {
80
+ found.push(element);
81
+ }
82
+ }
83
+ return found;
75
84
  }
76
85
 
77
86
  /**