@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.
- package/accordion.js +84 -57
- package/alert-dialog.js +284 -0
- package/alert.js +142 -0
- package/avatar.js +280 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +587 -0
- package/carousel.js +410 -0
- package/checkbox.js +215 -31
- package/collapsible.js +72 -48
- package/color-picker.js +172 -0
- package/combobox.js +216 -39
- package/context-menu.js +215 -0
- package/date-field.js +9 -0
- package/date-picker.js +357 -0
- package/date-range-picker.js +120 -0
- package/dialog.js +243 -178
- package/drag-drop.js +125 -0
- package/drawer.js +504 -0
- package/field.js +260 -43
- package/grid-list.js +8 -0
- package/hover-card.js +52 -52
- package/i18n-provider.js +89 -0
- package/index.js +1177 -31
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +71 -6
- package/internal/collection.js +562 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +85 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +317 -0
- package/internal/selection.js +171 -0
- package/internal/visually-hidden-style.js +41 -0
- package/list-box.js +13 -0
- package/menu.js +553 -361
- package/menubar.js +295 -0
- package/number-field.js +263 -0
- package/package.json +8 -28
- package/pagination.js +34 -22
- package/popover.js +116 -75
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +79 -0
- package/resizable.js +155 -9
- package/scroll-area.js +283 -0
- package/select.js +83 -37
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +320 -0
- package/skeleton.js +163 -0
- package/slider.js +95 -89
- package/switch.js +42 -34
- package/table.js +100 -71
- package/tabs.js +100 -91
- package/tag-group.js +8 -0
- package/time-field.js +8 -0
- package/toast.js +36 -66
- package/toggle-group.js +53 -49
- package/toggle.js +41 -27
- package/tooltip.js +48 -55
- package/tree.js +8 -0
- 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
|
+
};
|