@uniflowed/ui 0.0.0-alpha.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/accordion.js +84 -57
  2. package/alert-dialog.js +284 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +280 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +587 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +215 -31
  9. package/collapsible.js +72 -48
  10. package/color-picker.js +172 -0
  11. package/combobox.js +216 -39
  12. package/context-menu.js +215 -0
  13. package/date-field.js +9 -0
  14. package/date-picker.js +357 -0
  15. package/date-range-picker.js +120 -0
  16. package/dialog.js +243 -178
  17. package/drag-drop.js +125 -0
  18. package/drawer.js +504 -0
  19. package/field.js +260 -43
  20. package/grid-list.js +8 -0
  21. package/hover-card.js +52 -52
  22. package/i18n-provider.js +89 -0
  23. package/index.js +1177 -31
  24. package/input-otp.js +218 -0
  25. package/interactions.js +2327 -0
  26. package/internal/anchor.js +71 -6
  27. package/internal/collection.js +562 -0
  28. package/internal/date-grid.js +260 -0
  29. package/internal/date-range.js +26 -0
  30. package/internal/disclosure.js +201 -0
  31. package/internal/menu-tree.js +228 -0
  32. package/internal/merge-props.js +85 -1
  33. package/internal/roving-focus.js +15 -4
  34. package/internal/segmented-field.js +317 -0
  35. package/internal/selection.js +171 -0
  36. package/internal/visually-hidden-style.js +41 -0
  37. package/list-box.js +13 -0
  38. package/menu.js +553 -361
  39. package/menubar.js +295 -0
  40. package/number-field.js +263 -0
  41. package/package.json +8 -28
  42. package/pagination.js +34 -22
  43. package/popover.js +116 -75
  44. package/progress.js +21 -16
  45. package/radio-group.js +81 -75
  46. package/range-calendar.js +79 -0
  47. package/resizable.js +155 -9
  48. package/scroll-area.js +283 -0
  49. package/select.js +83 -37
  50. package/separator.js +97 -0
  51. package/sheet.js +189 -0
  52. package/sidebar.js +320 -0
  53. package/skeleton.js +163 -0
  54. package/slider.js +95 -89
  55. package/switch.js +42 -34
  56. package/table.js +100 -71
  57. package/tabs.js +100 -91
  58. package/tag-group.js +8 -0
  59. package/time-field.js +8 -0
  60. package/toast.js +36 -66
  61. package/toggle-group.js +53 -49
  62. package/toggle.js +41 -27
  63. package/tooltip.js +48 -55
  64. package/tree.js +8 -0
  65. package/visually-hidden.js +259 -0
@@ -0,0 +1,562 @@
1
+ // @flow
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.
32
+
33
+ import * as React from "@uniflowed/react";
34
+ import { useId, useRef, useState } from "@uniflowed/react";
35
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
36
+ import { useDragAndDrop } from "../drag-drop.js";
37
+ import { useControlled } from "./controlled-state.js";
38
+ import { composeHandlers, composeRefs, withProps } from "./merge-props.js";
39
+ import type { PartEvent, RenderProp } from "./merge-props.js";
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";
43
+ import { startsWithLocale, useLocale } from "../i18n-provider.js";
44
+ import { visuallyHiddenStyle } from "./visually-hidden-style.js";
45
+
46
+ export type CollectionItem = {
47
+ readonly key: string,
48
+ readonly textValue: string,
49
+ readonly disabled?: boolean,
50
+ readonly children?: $ReadOnlyArray<CollectionItem>,
51
+ };
52
+ export type CollectionItemState = {
53
+ readonly selected: boolean,
54
+ readonly active: boolean,
55
+ readonly disabled: boolean,
56
+ readonly level: number,
57
+ };
58
+ type Entry = {
59
+ item: CollectionItem,
60
+ level: number,
61
+ parent: string | null,
62
+ position: number,
63
+ count: number,
64
+ };
65
+ type Kind = "listbox" | "grid" | "tree" | "tags";
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
+
90
+ function entries(
91
+ items: $ReadOnlyArray<CollectionItem>,
92
+ expanded: $ReadOnlySet<string>,
93
+ tree: boolean,
94
+ ): Array<Entry> {
95
+ const result = [];
96
+ const seen = new Set<string>();
97
+ const visit = (
98
+ siblings: $ReadOnlyArray<CollectionItem>,
99
+ level: number,
100
+ parent: string | null,
101
+ ): void => {
102
+ siblings.forEach((item, index) => {
103
+ if (seen.has(item.key)) throw new Error(`Duplicate collection key: ${item.key}`);
104
+ seen.add(item.key);
105
+ result.push({ item, level, parent, position: index + 1, count: siblings.length });
106
+ const children = item.children;
107
+ if (tree && children != null && expanded.has(item.key)) visit(children, level + 1, item.key);
108
+ });
109
+ };
110
+ visit(items, 1, null);
111
+ return result;
112
+ }
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
+
127
+ /** Data owns order and identity. Only visible tree descendants and virtual rows enter the DOM. */
128
+ export component CollectionRoot(kind: Kind, options: CollectionProps) {
129
+ const {
130
+ items,
131
+ children,
132
+ selectionMode: requestedSelectionMode = "single",
133
+ selectionBehavior = "toggle",
134
+ disallowEmptySelection = false,
135
+ escapeKeyBehavior = "clearSelection",
136
+ selectedKeys,
137
+ defaultSelectedKeys = [],
138
+ onSelectionChange,
139
+ onAction,
140
+ disabledKeys = [],
141
+ expandedKeys,
142
+ defaultExpandedKeys = [],
143
+ onExpandedChange,
144
+ onRemove,
145
+ onReorder,
146
+ loading = false,
147
+ onLoadMore,
148
+ virtualized = false,
149
+ height = 300,
150
+ rowHeight = 30,
151
+ render,
152
+ ...rest
153
+ } = options;
154
+ const mode: SelectionMode = kind === "tags" ? "none" : requestedSelectionMode;
155
+ const policy: SelectionPolicy = {
156
+ mode,
157
+ behavior: selectionBehavior,
158
+ disallowEmpty: disallowEmptySelection,
159
+ };
160
+ if (height <= 0 || rowHeight <= 0 || !Number.isFinite(height) || !Number.isFinite(rowHeight))
161
+ throw new RangeError("Collection dimensions must be positive finite numbers");
162
+ const { locale } = useLocale();
163
+ const id = useId();
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.
167
+ const anchor = useRef<string | null>(null);
168
+ const lead = useRef<string | null>(null);
169
+ const buffer = useRef({ text: "", time: 0 });
170
+ const [selected, setSelected] = useControlled(
171
+ selectedKeys,
172
+ defaultSelectedKeys,
173
+ onSelectionChange,
174
+ );
175
+ const [expanded, setExpanded] = useControlled(
176
+ expandedKeys,
177
+ defaultExpandedKeys,
178
+ onExpandedChange,
179
+ );
180
+ const [active, setActive] = useState<string | null>(null);
181
+ const [scrollTop, setScrollTop] = useState(0);
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);
194
+ const drag = useDragAndDrop({
195
+ disabled: onReorder == null,
196
+ onDrop: ({ keys, target }) => {
197
+ const moving = keys[0];
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;
202
+ const ordered = items.filter((item) => item.key !== moving).map((item) => item.key);
203
+ ordered.splice(ordered.indexOf(target), 0, moving);
204
+ onReorder?.(ordered);
205
+ },
206
+ });
207
+ const focused =
208
+ enabled.find((row) => row.item.key === active) ??
209
+ enabled.find((row) => selectedSet.has(row.item.key)) ??
210
+ enabled[0];
211
+ const activeKey = focused?.item.key;
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`);
219
+ };
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) => {
238
+ const container = root.current;
239
+ if (virtualized && container != null) {
240
+ const top = rows.indexOf(row) * rowHeight;
241
+ if (top < container.scrollTop || top + rowHeight > container.scrollTop + height) {
242
+ container.scrollTop = top;
243
+ setScrollTop(top);
244
+ }
245
+ }
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;
273
+ };
274
+ const remove = (key: string) => {
275
+ const at = enabled.findIndex((row) => row.item.key === key);
276
+ if (at < 0 || onRemove == null) return;
277
+ onRemove(key);
278
+ setActive(enabled[at + 1]?.item.key ?? enabled[at - 1]?.item.key ?? null);
279
+ announce(`Removed ${enabled[at].item.textValue}`);
280
+ };
281
+ const keydown = useStableCallback((event: KeyEvent) => {
282
+ const { target, currentTarget } = event;
283
+ if (!(currentTarget instanceof HTMLElement)) return;
284
+ if (
285
+ target !== currentTarget &&
286
+ target instanceof Element &&
287
+ target.closest("button,input,textarea,select") != null
288
+ )
289
+ return;
290
+ const modifier = event.ctrlKey || event.metaKey;
291
+ if (drag.dragging && event.key === "Escape") {
292
+ event.preventDefault();
293
+ drag.cancel();
294
+ return;
295
+ }
296
+ if (drag.dragging && event.key === "Enter" && activeKey != null) {
297
+ event.preventDefault();
298
+ drag.drop(activeKey, undefined, focused?.item.textValue);
299
+ return;
300
+ }
301
+ if (onReorder != null && event.key === " " && modifier && focused != null) {
302
+ event.preventDefault();
303
+ drag.start(focused.item.key, focused.item.textValue);
304
+ return;
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"));
312
+ const at = enabled.findIndex((row) => row.item.key === activeKey);
313
+ let next = null;
314
+ if (forward) next = enabled[Math.min(at + 1, enabled.length - 1)];
315
+ else if (backward) next = enabled[Math.max(at - 1, 0)];
316
+ else if (event.key === "Home") next = enabled[0];
317
+ else if (event.key === "End") {
318
+ next = enabled[enabled.length - 1];
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]);
329
+ } else if (
330
+ kind === "tree" &&
331
+ focused != null &&
332
+ (event.key === "ArrowLeft" || event.key === "ArrowRight")
333
+ ) {
334
+ const open = event.key === (rtl ? "ArrowLeft" : "ArrowRight");
335
+ const key = focused.item.key;
336
+ if (open && (focused.item.children?.length ?? 0) > 0) {
337
+ if (!expandedSet.has(key)) setExpanded([...expanded, key]);
338
+ else next = enabled[at + 1];
339
+ } else if (!open && expandedSet.has(key))
340
+ setExpanded(expanded.filter((each) => each !== key));
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);
356
+ } else if (
357
+ (event.key === "Delete" || event.key === "Backspace") &&
358
+ kind === "tags" &&
359
+ activeKey != null
360
+ )
361
+ remove(activeKey);
362
+ else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
363
+ const time = Date.now();
364
+ const text = time - buffer.current.time > 500 ? event.key : buffer.current.text + event.key;
365
+ buffer.current = { text, time };
366
+ const repeated = Array.from(text).every((char) => char === event.key);
367
+ const query = repeated ? event.key : text;
368
+ const start = repeated || text.length === 1 ? at + 1 : Math.max(at, 0);
369
+ for (let offset = 0; offset < enabled.length; offset += 1) {
370
+ const candidate = enabled[(start + offset) % enabled.length];
371
+ if (startsWithLocale(candidate.item.textValue, query, locale)) {
372
+ next = candidate;
373
+ break;
374
+ }
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
+ }
382
+ } else return;
383
+ event.preventDefault();
384
+ if (next != null) land(next, event);
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
+ };
398
+ const start = virtualized ? Math.max(0, Math.floor(scrollTop / rowHeight) - 2) : 0;
399
+ const end = virtualized
400
+ ? Math.min(rows.length, start + Math.ceil(height / rowHeight) + 4)
401
+ : rows.length;
402
+ // aria-activedescendant must always name a mounted row, even after a pointer scroll.
403
+ const indices = new Set(rows.slice(start, end).map((_, index) => start + index));
404
+ const activeIndex = rows.findIndex((row) => row.item.key === activeKey);
405
+ if (activeIndex >= 0) indices.add(activeIndex);
406
+ const rowNodes = Array.from(indices)
407
+ .sort((a, b) => a - b)
408
+ .map((index) => {
409
+ const row = rows[index];
410
+ const { item } = row;
411
+ const chosen = selectedSet.has(item.key);
412
+ const grid = kind === "grid" || kind === "tags";
413
+ const content =
414
+ children?.(item, {
415
+ selected: chosen,
416
+ active: activeKey === item.key,
417
+ disabled: disabled(item),
418
+ level: row.level,
419
+ }) ?? item.textValue;
420
+ const props = {
421
+ ...drag.getDragProps(item.key, item.textValue),
422
+ ...drag.getDropProps(item.key, item.textValue),
423
+ onKeyDown: undefined,
424
+ draggable: onReorder != null && !disabled(item),
425
+ id: `${id}-${index}`,
426
+ role: grid ? "row" : kind === "tree" ? "treeitem" : "option",
427
+ "aria-selected": mode === "none" ? undefined : chosen,
428
+ "aria-disabled": disabled(item) || undefined,
429
+ "aria-expanded":
430
+ kind === "tree" && (item.children?.length ?? 0) > 0
431
+ ? expandedSet.has(item.key)
432
+ : undefined,
433
+ "aria-level": kind === "tree" ? row.level : undefined,
434
+ "aria-posinset": grid ? undefined : kind === "tree" ? row.position : index + 1,
435
+ "aria-setsize": grid ? undefined : kind === "tree" ? row.count : rows.length,
436
+ "aria-rowindex": grid ? index + 1 : undefined,
437
+ "data-key": item.key,
438
+ "data-active": activeKey === item.key || undefined,
439
+ style: virtualized
440
+ ? { position: "absolute", top: index * rowHeight, height: rowHeight, width: "100%" }
441
+ : undefined,
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,
450
+ };
451
+ return (
452
+ <div key={item.key} {...props}>
453
+ {grid ? (
454
+ <div role="gridcell">
455
+ {content}
456
+ {kind === "tags" && onRemove != null ? (
457
+ <button
458
+ type="button"
459
+ tabIndex={-1}
460
+ disabled={disabled(item)}
461
+ aria-label={`Remove ${item.textValue}`}
462
+ onClick={(event: RowClick) => {
463
+ event.stopPropagation();
464
+ root.current?.focus();
465
+ remove(item.key);
466
+ }}
467
+ >
468
+ ×
469
+ </button>
470
+ ) : null}
471
+ </div>
472
+ ) : (
473
+ content
474
+ )}
475
+ </div>
476
+ );
477
+ });
478
+ const setRef = useStableCallback((element: HTMLElement | null) => {
479
+ root.current = element;
480
+ });
481
+ const style = rest.style;
482
+ const props = withProps(rest, {
483
+ ref: composeRefs(rest.ref, setRef),
484
+ role: kind === "tags" ? "grid" : kind,
485
+ tabIndex: 0,
486
+ "aria-activedescendant": activeIndex < 0 ? undefined : `${id}-${activeIndex}`,
487
+ "aria-multiselectable": mode === "multiple" || undefined,
488
+ "aria-busy": loading || undefined,
489
+ "aria-rowcount": kind === "grid" || kind === "tags" ? rows.length : undefined,
490
+ onKeyDown: composeHandlers(rest.onKeyDown, keydown),
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)
496
+ onLoadMore?.();
497
+ }),
498
+ style: virtualized
499
+ ? {
500
+ ...(typeof style === "object" && style != null ? style : {}),
501
+ height,
502
+ overflow: "auto",
503
+ position: "relative",
504
+ }
505
+ : style,
506
+ children: virtualized ? (
507
+ <div role="presentation" style={{ height: rows.length * rowHeight, position: "relative" }}>
508
+ {rowNodes}
509
+ </div>
510
+ ) : (
511
+ rowNodes
512
+ ),
513
+ });
514
+ return (
515
+ <>
516
+ {render != null ? render(props) : <div {...props} />}
517
+ {/* For a screen reader; a sighted reader sees the selection itself. */}
518
+ <span role="status" aria-live="polite" style={visuallyHiddenStyle}>
519
+ {drag.announcement || announcement}
520
+ </span>
521
+ </>
522
+ );
523
+ }
524
+
525
+ export type CollectionProps = {
526
+ items: $ReadOnlyArray<CollectionItem>,
527
+ children?: (item: CollectionItem, state: CollectionItemState) => React.Node,
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",
539
+ selectedKeys?: $ReadOnlyArray<string>,
540
+ defaultSelectedKeys?: $ReadOnlyArray<string>,
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,
548
+ disabledKeys?: $ReadOnlyArray<string>,
549
+ expandedKeys?: $ReadOnlyArray<string>,
550
+ defaultExpandedKeys?: $ReadOnlyArray<string>,
551
+ onExpandedChange?: (keys: $ReadOnlyArray<string>) => void,
552
+ onRemove?: (key: string) => void,
553
+ onReorder?: (keys: $ReadOnlyArray<string>) => void,
554
+ loading?: boolean,
555
+ onLoadMore?: () => void,
556
+ virtualized?: boolean,
557
+ height?: number,
558
+ rowHeight?: number,
559
+ render?: RenderProp,
560
+ readonly key?: empty,
561
+ readonly [string]: mixed,
562
+ };