@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.
- 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 +560 -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 +235 -177
- 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 +1159 -31
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +71 -6
- package/internal/collection.js +395 -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 +316 -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 +78 -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 +112 -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/internal/anchor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
+
};
|