@uniflowed/ui 0.0.0-alpha.9 → 0.1.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 (62) 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 +560 -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 +235 -177
  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 +1159 -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 +395 -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 +316 -0
  35. package/list-box.js +13 -0
  36. package/menu.js +553 -361
  37. package/menubar.js +295 -0
  38. package/number-field.js +263 -0
  39. package/package.json +8 -28
  40. package/pagination.js +34 -22
  41. package/popover.js +116 -75
  42. package/progress.js +21 -16
  43. package/radio-group.js +81 -75
  44. package/range-calendar.js +78 -0
  45. package/resizable.js +155 -9
  46. package/scroll-area.js +283 -0
  47. package/select.js +83 -37
  48. package/separator.js +97 -0
  49. package/sheet.js +189 -0
  50. package/sidebar.js +320 -0
  51. package/skeleton.js +163 -0
  52. package/slider.js +95 -89
  53. package/switch.js +42 -34
  54. package/table.js +112 -71
  55. package/tabs.js +100 -91
  56. package/tag-group.js +8 -0
  57. package/time-field.js +8 -0
  58. package/toast.js +36 -66
  59. package/toggle-group.js +53 -49
  60. package/toggle.js +41 -27
  61. package/tooltip.js +48 -55
  62. package/tree.js +8 -0
@@ -108,6 +108,19 @@ import { directionOf } from "./roving-focus.js";
108
108
  */
109
109
  export type Side = "top" | "right" | "bottom" | "left";
110
110
 
111
+ /**
112
+ * A side a caller may ask for, which is the four above plus the two that mean
113
+ * "the way the reader reads".
114
+ *
115
+ * The WAI-ARIA menu pattern puts a submenu on the inline end - to the right of a
116
+ * left-to-right menu and to the left of a right-to-left one - and `menu.js`
117
+ * already spells the *keys* that way, so a physical-only `side` would have left
118
+ * one half of that pattern mirrored and the other half not. `Placement` and
119
+ * `placeOverlay` stay physical: a logical side is resolved once, against the
120
+ * trigger's own direction, before any arithmetic sees it.
121
+ */
122
+ export type LogicalSide = Side | "inline-start" | "inline-end";
123
+
111
124
  /**
112
125
  * Where the overlay sits along the trigger's other axis.
113
126
  *
@@ -170,10 +183,29 @@ export type Anchored = {|
170
183
  /** What `useAnchor` is told, on top of the geometry `placeOverlay` needs. */
171
184
  export type AnchorRequest = {|
172
185
  readonly anchorRef: { current: HTMLElement | null },
186
+ /**
187
+ * A box to place against instead of the anchor element's own.
188
+ *
189
+ * A context menu opens *at a point* rather than against an element: the
190
+ * pointer coordinates the reader right-clicked at, which is a zero-sized
191
+ * rectangle no element in the document has. The element is still needed —
192
+ * it is what the writing direction is read from, what a `ResizeObserver`
193
+ * watches, and what focus returns to — so this replaces the *measurement*
194
+ * and nothing else.
195
+ *
196
+ * `null` (and absent) means "measure the element", which is every other
197
+ * overlay in this package.
198
+ *
199
+ * It has to be stable between renders for the same position, because it is
200
+ * one of the things the placement effect re-runs for; a fresh object each
201
+ * render would re-measure on every render of the page around it.
202
+ */
203
+ readonly anchorRect?: Rect | null,
173
204
  readonly overlayRef: { current: HTMLElement | null },
174
205
  /** Nothing is measured while it is closed: there is nothing to measure. */
175
206
  readonly open: boolean,
176
- readonly side: Side,
207
+ /** Resolved against the trigger's writing direction; see `LogicalSide`. */
208
+ readonly side: LogicalSide,
177
209
  readonly align: Align,
178
210
  readonly sideOffset: number,
179
211
  readonly alignOffset: number,
@@ -189,6 +221,28 @@ const OPPOSITE: { readonly [Side]: Side } = {
189
221
  right: "left",
190
222
  };
191
223
 
224
+ /**
225
+ * The side on the screen that `side` names for a reader reading `direction`.
226
+ *
227
+ * The four physical ones pass through unchanged, because a design that puts a
228
+ * popover to the right of a toolbar means the right of the toolbar in Arabic
229
+ * too; `Side`'s own documentation says why that is the useful default and why
230
+ * alignment is the axis that mirrors.
231
+ */
232
+ export function physicalSide(side: LogicalSide, direction: Direction): Side {
233
+ // Written out rather than left to a wildcard, so that a sixth side added to
234
+ // `LogicalSide` one day is a checker error here instead of a value that falls
235
+ // through unresolved.
236
+ return match (side) {
237
+ "inline-start" => direction === "rtl" ? "right" : "left",
238
+ "inline-end" => direction === "rtl" ? "left" : "right",
239
+ "top" => "top",
240
+ "right" => "right",
241
+ "bottom" => "bottom",
242
+ "left" => "left",
243
+ };
244
+ }
245
+
192
246
  /** Whether a side stacks the overlay above the trigger or beside it. */
193
247
  function isVertical(side: Side): boolean {
194
248
  return side === "top" || side === "bottom";
@@ -388,6 +442,7 @@ export hook useAnchor(request: AnchorRequest): Anchored {
388
442
  const {
389
443
  align,
390
444
  alignOffset,
445
+ anchorRect,
391
446
  anchorRef,
392
447
  avoidCollisions,
393
448
  collisionPadding,
@@ -396,7 +451,15 @@ export hook useAnchor(request: AnchorRequest): Anchored {
396
451
  side,
397
452
  sideOffset,
398
453
  } = request;
399
- const [settled, setSettled] = useState<Anchored>({ align, side });
454
+ // Absent and `null` are one answer here — "measure the element" — so the two
455
+ // spellings become one value before anything depends on it.
456
+ const virtual = anchorRect ?? null;
457
+ // The left-to-right reading of a logical side, which is what `Anchored`
458
+ // reports until something has been measured. In a right-to-left page a
459
+ // submenu's `inline-end` is the *left*, and the first `reflow` says so - one
460
+ // commit later, exactly as a flip does, and for the same reason: the direction
461
+ // is a fact about the document, and a render may not read one.
462
+ const [settled, setSettled] = useState<Anchored>({ align, side: physicalSide(side, "ltr") });
400
463
 
401
464
  const reflow = useStableCallback(() => {
402
465
  const anchor = anchorRef.current;
@@ -405,7 +468,7 @@ export hook useAnchor(request: AnchorRequest): Anchored {
405
468
  if (anchor == null || overlay == null || view == null) {
406
469
  return;
407
470
  }
408
- const box = rectOf(anchor);
471
+ const box = virtual ?? rectOf(anchor);
409
472
  const placement = placeOverlay({
410
473
  align,
411
474
  alignOffset,
@@ -414,7 +477,7 @@ export hook useAnchor(request: AnchorRequest): Anchored {
414
477
  collisionPadding,
415
478
  direction: directionOf(anchor),
416
479
  overlay: rectOf(overlay),
417
- side,
480
+ side: physicalSide(side, directionOf(anchor)),
418
481
  sideOffset,
419
482
  // The viewport of a fixed element, which is the whole of it: a fixed box
420
483
  // is positioned against the viewport rather than against whatever is
@@ -443,8 +506,9 @@ export hook useAnchor(request: AnchorRequest): Anchored {
443
506
  // drawn from `data-side` pointing the wrong way, after a reopen that
444
507
  // follows a flip.
445
508
  if (!open) {
509
+ const asked = physicalSide(side, anchor == null ? "ltr" : directionOf(anchor));
446
510
  setSettled((current) =>
447
- current.side === side && current.align === align ? current : { align, side },
511
+ current.side === asked && current.align === align ? current : { align, side: asked },
448
512
  );
449
513
  }
450
514
  return;
@@ -482,6 +546,7 @@ export hook useAnchor(request: AnchorRequest): Anchored {
482
546
  }, [
483
547
  align,
484
548
  alignOffset,
549
+ anchorRect,
485
550
  anchorRef,
486
551
  avoidCollisions,
487
552
  collisionPadding,
@@ -496,5 +561,5 @@ export hook useAnchor(request: AnchorRequest): Anchored {
496
561
  // first commit — and the server's markup, which measures nothing at all —
497
562
  // says the requested side rather than the last one some other opening
498
563
  // happened to settle on.
499
- return open ? settled : { align, side };
564
+ return open ? settled : { align, side: physicalSide(side, "ltr") };
500
565
  }
@@ -0,0 +1,395 @@
1
+ // @flow
2
+ "use client";
3
+
4
+ import * as React from "@uniflowed/react";
5
+ import { useId, useRef, useState } from "@uniflowed/react";
6
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
7
+ import { useDragAndDrop } from "../drag-drop.js";
8
+ import { useControlled } from "./controlled-state.js";
9
+ import { composeHandlers, composeRefs, withProps } from "./merge-props.js";
10
+ import type { RenderProp, Rest } from "./merge-props.js";
11
+ import { directionOf } from "./roving-focus.js";
12
+ import { startsWithLocale, useLocale } from "../i18n-provider.js";
13
+
14
+ export type CollectionItem = {
15
+ readonly key: string,
16
+ readonly textValue: string,
17
+ readonly disabled?: boolean,
18
+ readonly children?: $ReadOnlyArray<CollectionItem>,
19
+ };
20
+ export type CollectionItemState = {
21
+ readonly selected: boolean,
22
+ readonly active: boolean,
23
+ readonly disabled: boolean,
24
+ readonly level: number,
25
+ };
26
+ type Entry = {
27
+ item: CollectionItem,
28
+ level: number,
29
+ parent: string | null,
30
+ position: number,
31
+ count: number,
32
+ };
33
+ type Kind = "listbox" | "grid" | "tree" | "tags";
34
+
35
+ function entries(
36
+ items: $ReadOnlyArray<CollectionItem>,
37
+ expanded: $ReadOnlyArray<string>,
38
+ tree: boolean,
39
+ ): Array<Entry> {
40
+ const result = [];
41
+ const seen = new Set<string>();
42
+ const visit = (
43
+ siblings: $ReadOnlyArray<CollectionItem>,
44
+ level: number,
45
+ parent: string | null,
46
+ ): void => {
47
+ siblings.forEach((item, index) => {
48
+ if (seen.has(item.key)) throw new Error(`Duplicate collection key: ${item.key}`);
49
+ seen.add(item.key);
50
+ result.push({ item, level, parent, position: index + 1, count: siblings.length });
51
+ const children = item.children;
52
+ if (tree && children != null && expanded.includes(item.key))
53
+ visit(children, level + 1, item.key);
54
+ });
55
+ };
56
+ visit(items, 1, null);
57
+ return result;
58
+ }
59
+
60
+ /** Data owns order and identity. Only visible tree descendants and virtual rows enter the DOM. */
61
+ export component CollectionRoot(kind: Kind, options: CollectionProps) {
62
+ const {
63
+ items,
64
+ children,
65
+ selectionMode: requestedSelectionMode = "single",
66
+ selectedKeys,
67
+ defaultSelectedKeys = [],
68
+ onSelectionChange,
69
+ disabledKeys = [],
70
+ expandedKeys,
71
+ defaultExpandedKeys = [],
72
+ onExpandedChange,
73
+ onRemove,
74
+ onReorder,
75
+ loading = false,
76
+ onLoadMore,
77
+ virtualized = false,
78
+ height = 300,
79
+ rowHeight = 30,
80
+ render,
81
+ ...rest
82
+ } = options;
83
+ const selectionMode = kind === "tags" ? "none" : requestedSelectionMode;
84
+ if (height <= 0 || rowHeight <= 0 || !Number.isFinite(height) || !Number.isFinite(rowHeight))
85
+ throw new RangeError("Collection dimensions must be positive finite numbers");
86
+ const { locale } = useLocale();
87
+ const id = useId();
88
+ const root = useRef<HTMLElement | null>(null);
89
+ const anchor = useRef<string | null>(null);
90
+ const buffer = useRef({ text: "", time: 0 });
91
+ const [selected, setSelected] = useControlled(
92
+ selectedKeys,
93
+ defaultSelectedKeys,
94
+ onSelectionChange,
95
+ );
96
+ const [expanded, setExpanded] = useControlled(
97
+ expandedKeys,
98
+ defaultExpandedKeys,
99
+ onExpandedChange,
100
+ );
101
+ const [active, setActive] = useState<string | null>(null);
102
+ const [scrollTop, setScrollTop] = useState(0);
103
+ const [announcement, announce] = useState("");
104
+ const drag = useDragAndDrop({
105
+ disabled: onReorder == null,
106
+ onDrop: ({ keys, target }) => {
107
+ 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;
120
+ const ordered = items.filter((item) => item.key !== moving).map((item) => item.key);
121
+ ordered.splice(ordered.indexOf(target), 0, moving);
122
+ onReorder?.(ordered);
123
+ },
124
+ });
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
+ const focused =
130
+ enabled.find((row) => row.item.key === active) ??
131
+ enabled.find((row) => selected.includes(row.item.key)) ??
132
+ enabled[0];
133
+ 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`);
155
+ };
156
+ const land = (row: Entry, shift: boolean) => {
157
+ setActive(row.item.key);
158
+ const container = root.current;
159
+ if (virtualized && container != null) {
160
+ const top = rows.indexOf(row) * rowHeight;
161
+ if (top < container.scrollTop || top + rowHeight > container.scrollTop + height) {
162
+ container.scrollTop = top;
163
+ setScrollTop(top);
164
+ }
165
+ }
166
+ if (shift) choose(row.item.key, true, false);
167
+ };
168
+ const remove = (key: string) => {
169
+ const at = enabled.findIndex((row) => row.item.key === key);
170
+ if (at < 0 || onRemove == null) return;
171
+ onRemove(key);
172
+ setActive(enabled[at + 1]?.item.key ?? enabled[at - 1]?.item.key ?? null);
173
+ announce(`Removed ${enabled[at].item.textValue}`);
174
+ };
175
+ const keydown = useStableCallback((event: $FlowFixMe) => {
176
+ if (
177
+ event.target !== event.currentTarget &&
178
+ event.target.closest("button,input,textarea,select")
179
+ )
180
+ return;
181
+ if (drag.dragging && event.key === "Escape") {
182
+ event.preventDefault();
183
+ drag.cancel();
184
+ return;
185
+ }
186
+ if (drag.dragging && event.key === "Enter" && activeKey != null) {
187
+ event.preventDefault();
188
+ drag.drop(activeKey, undefined, focused?.item.textValue);
189
+ return;
190
+ }
191
+ if (
192
+ onReorder != null &&
193
+ event.key === " " &&
194
+ (event.ctrlKey || event.metaKey) &&
195
+ focused != null
196
+ ) {
197
+ event.preventDefault();
198
+ drag.start(focused.item.key, focused.item.textValue);
199
+ return;
200
+ }
201
+ const at = enabled.findIndex((row) => row.item.key === activeKey);
202
+ 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)];
205
+ else if (event.key === "Home") next = enabled[0];
206
+ else if (event.key === "End") {
207
+ next = enabled[enabled.length - 1];
208
+ if (!loading) onLoadMore?.();
209
+ } else if (
210
+ kind === "tree" &&
211
+ focused != null &&
212
+ ["ArrowLeft", "ArrowRight"].includes(event.key)
213
+ ) {
214
+ const open =
215
+ event.key === (directionOf(event.currentTarget) === "rtl" ? "ArrowLeft" : "ArrowRight");
216
+ const key = focused.item.key;
217
+ if (open && focused.item.children?.length) {
218
+ if (!expanded.includes(key)) setExpanded([...expanded, key]);
219
+ else next = enabled[at + 1];
220
+ } else if (!open && expanded.includes(key))
221
+ setExpanded(expanded.filter((each) => each !== key));
222
+ else if (!open) next = enabled.find((row) => row.item.key === focused.parent);
223
+ } 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
+ (event.key === "Delete" || event.key === "Backspace") &&
234
+ kind === "tags" &&
235
+ activeKey != null
236
+ )
237
+ remove(activeKey);
238
+ else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
239
+ const time = Date.now();
240
+ const text = time - buffer.current.time > 500 ? event.key : buffer.current.text + event.key;
241
+ buffer.current = { text, time };
242
+ const repeated = Array.from(text).every((char) => char === event.key);
243
+ const query = repeated ? event.key : text;
244
+ const start = repeated || text.length === 1 ? at + 1 : Math.max(at, 0);
245
+ for (let offset = 0; offset < enabled.length; offset += 1) {
246
+ const candidate = enabled[(start + offset) % enabled.length];
247
+ if (startsWithLocale(candidate.item.textValue, query, locale)) {
248
+ next = candidate;
249
+ break;
250
+ }
251
+ }
252
+ } else return;
253
+ event.preventDefault();
254
+ if (next != null) land(next, event.shiftKey);
255
+ });
256
+ const start = virtualized ? Math.max(0, Math.floor(scrollTop / rowHeight) - 2) : 0;
257
+ const end = virtualized
258
+ ? Math.min(rows.length, start + Math.ceil(height / rowHeight) + 4)
259
+ : rows.length;
260
+ // aria-activedescendant must always name a mounted row, even after a pointer scroll.
261
+ const indices = new Set(rows.slice(start, end).map((_, index) => start + index));
262
+ const activeIndex = rows.findIndex((row) => row.item.key === activeKey);
263
+ if (activeIndex >= 0) indices.add(activeIndex);
264
+ const rowNodes = Array.from(indices)
265
+ .sort((a, b) => a - b)
266
+ .map((index) => {
267
+ const row = rows[index];
268
+ const { item } = row;
269
+ const chosen = selected.includes(item.key);
270
+ const grid = kind === "grid" || kind === "tags";
271
+ const content =
272
+ children?.(item, {
273
+ selected: chosen,
274
+ active: activeKey === item.key,
275
+ disabled: disabled(item),
276
+ level: row.level,
277
+ }) ?? item.textValue;
278
+ const props = {
279
+ ...drag.getDragProps(item.key, item.textValue),
280
+ ...drag.getDropProps(item.key, item.textValue),
281
+ onKeyDown: undefined,
282
+ draggable: onReorder != null && !disabled(item),
283
+ id: `${id}-${index}`,
284
+ role: grid ? "row" : kind === "tree" ? "treeitem" : "option",
285
+ "aria-selected": selectionMode === "none" ? undefined : chosen,
286
+ "aria-disabled": disabled(item) || undefined,
287
+ "aria-expanded":
288
+ kind === "tree" && item.children?.length ? expanded.includes(item.key) : undefined,
289
+ "aria-level": kind === "tree" ? row.level : undefined,
290
+ "aria-posinset": grid ? undefined : kind === "tree" ? row.position : index + 1,
291
+ "aria-setsize": grid ? undefined : kind === "tree" ? row.count : rows.length,
292
+ "aria-rowindex": grid ? index + 1 : undefined,
293
+ "data-key": item.key,
294
+ "data-active": activeKey === item.key || undefined,
295
+ style: virtualized
296
+ ? { position: "absolute", top: index * rowHeight, height: rowHeight, width: "100%" }
297
+ : 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
+ },
304
+ };
305
+ return (
306
+ <div key={item.key} {...props}>
307
+ {grid ? (
308
+ <div role="gridcell">
309
+ {content}
310
+ {kind === "tags" && onRemove != null ? (
311
+ <button
312
+ type="button"
313
+ tabIndex={-1}
314
+ disabled={disabled(item)}
315
+ aria-label={`Remove ${item.textValue}`}
316
+ onClick={(event) => {
317
+ event.stopPropagation();
318
+ root.current?.focus();
319
+ remove(item.key);
320
+ }}
321
+ >
322
+ ×
323
+ </button>
324
+ ) : null}
325
+ </div>
326
+ ) : (
327
+ content
328
+ )}
329
+ </div>
330
+ );
331
+ });
332
+ const setRef = useStableCallback((element: HTMLElement | null) => {
333
+ root.current = element;
334
+ });
335
+ const props = withProps(rest, {
336
+ ref: composeRefs(rest.ref, setRef),
337
+ role: kind === "tags" ? "grid" : kind,
338
+ tabIndex: 0,
339
+ "aria-activedescendant": activeIndex < 0 ? undefined : `${id}-${activeIndex}`,
340
+ "aria-multiselectable": selectionMode === "multiple" || undefined,
341
+ "aria-busy": loading || undefined,
342
+ "aria-rowcount": kind === "grid" || kind === "tags" ? rows.length : undefined,
343
+ 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
+ )
351
+ onLoadMore?.();
352
+ }),
353
+ style: virtualized
354
+ ? { ...(rest.style as $FlowFixMe), height, overflow: "auto", position: "relative" }
355
+ : rest.style,
356
+ children: virtualized ? (
357
+ <div role="presentation" style={{ height: rows.length * rowHeight, position: "relative" }}>
358
+ {rowNodes}
359
+ </div>
360
+ ) : (
361
+ rowNodes
362
+ ),
363
+ });
364
+ return (
365
+ <>
366
+ {render != null ? render(props) : <div {...props} />}
367
+ <span role="status" aria-live="polite">
368
+ {drag.announcement || announcement}
369
+ </span>
370
+ </>
371
+ );
372
+ }
373
+
374
+ export type CollectionProps = {
375
+ items: $ReadOnlyArray<CollectionItem>,
376
+ children?: (item: CollectionItem, state: CollectionItemState) => React.Node,
377
+ selectionMode?: "single" | "multiple" | "none",
378
+ selectedKeys?: $ReadOnlyArray<string>,
379
+ defaultSelectedKeys?: $ReadOnlyArray<string>,
380
+ onSelectionChange?: (keys: $ReadOnlyArray<string>) => void,
381
+ disabledKeys?: $ReadOnlyArray<string>,
382
+ expandedKeys?: $ReadOnlyArray<string>,
383
+ defaultExpandedKeys?: $ReadOnlyArray<string>,
384
+ onExpandedChange?: (keys: $ReadOnlyArray<string>) => void,
385
+ onRemove?: (key: string) => void,
386
+ onReorder?: (keys: $ReadOnlyArray<string>) => void,
387
+ loading?: boolean,
388
+ onLoadMore?: () => void,
389
+ virtualized?: boolean,
390
+ height?: number,
391
+ rowHeight?: number,
392
+ render?: RenderProp,
393
+ readonly key?: empty,
394
+ readonly [string]: mixed,
395
+ };