@uniflowed/ui 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/accordion.js +22 -9
- package/alert-dialog.js +41 -19
- package/alert.js +14 -8
- package/avatar.js +14 -8
- package/breadcrumb.js +24 -6
- package/calendar.js +54 -10
- package/carousel.js +24 -6
- package/collapsible.js +14 -3
- package/color-picker.js +22 -5
- package/combobox.js +30 -21
- package/context-menu.js +31 -2
- package/date-picker.js +37 -11
- package/date-range-picker.js +25 -8
- package/dialog.js +39 -11
- package/drawer.js +47 -34
- package/field.js +24 -6
- package/hover-card.js +14 -3
- package/index.js +107 -740
- package/input-otp.js +20 -4
- package/interactions.js +0 -1
- package/internal/collection.js +262 -95
- package/internal/focus.js +13 -4
- package/internal/roving-focus.js +12 -3
- package/internal/segmented-field.js +2 -1
- package/internal/selection.js +171 -0
- package/internal/visually-hidden-style.js +41 -0
- package/menu.js +36 -13
- package/menubar.js +32 -5
- package/navigation-menu.js +24 -6
- package/number-field.js +20 -12
- package/package.json +5 -5
- package/pagination.js +22 -5
- package/popover.js +14 -4
- package/radio-group.js +15 -4
- package/range-calendar.js +22 -3
- package/resizable.js +14 -3
- package/scroll-area.js +14 -3
- package/select.js +30 -13
- package/sheet.js +39 -18
- package/sidebar.js +31 -16
- package/skeleton.js +13 -3
- package/slider.js +15 -4
- package/table.js +34 -24
- package/tabs.js +15 -9
- package/toast.js +24 -6
- package/toggle-group.js +14 -3
- package/tooltip.js +21 -5
- package/visually-hidden.js +259 -0
package/input-otp.js
CHANGED
|
@@ -112,7 +112,7 @@ function clean(raw: string, kind: InputOtpKind, length: number): string {
|
|
|
112
112
|
* Renders no element of its own beyond the input — see the module header. The
|
|
113
113
|
* caller's props go on the input, because the input is the field.
|
|
114
114
|
*/
|
|
115
|
-
|
|
115
|
+
component InputOtpRoot(
|
|
116
116
|
children: React.Node,
|
|
117
117
|
defaultValue?: string = "",
|
|
118
118
|
disabled?: boolean = false,
|
|
@@ -173,7 +173,7 @@ export component InputOtpRoot(
|
|
|
173
173
|
* already been announced, and a reader told "group, 3, group, 4" is being read
|
|
174
174
|
* a picture.
|
|
175
175
|
*/
|
|
176
|
-
|
|
176
|
+
component InputOtpGroup(children: React.Node, ...rest: Rest) {
|
|
177
177
|
return (
|
|
178
178
|
<div {...rest} aria-hidden="true">
|
|
179
179
|
{children}
|
|
@@ -188,7 +188,7 @@ export component InputOtpGroup(children: React.Node, ...rest: Rest) {
|
|
|
188
188
|
* whether there is one in it already, so a stylesheet can draw a cursor and a
|
|
189
189
|
* border without this module having an opinion about either.
|
|
190
190
|
*/
|
|
191
|
-
|
|
191
|
+
component InputOtpSlot(children?: React.Node, index: number, ...rest: Rest) {
|
|
192
192
|
const otp = useInputOtp("InputOtp.Slot");
|
|
193
193
|
const character = otp.value[index] ?? "";
|
|
194
194
|
// The box the next character goes in, clamped so a full code lights its last
|
|
@@ -209,10 +209,26 @@ export component InputOtpSlot(children?: React.Node, index: number, ...rest: Res
|
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
/** The dash between two groups. Decoration, and it says so. */
|
|
212
|
-
|
|
212
|
+
component InputOtpSeparator(children?: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
213
213
|
const props = withProps(rest, { "aria-hidden": "true", children });
|
|
214
214
|
if (render != null) {
|
|
215
215
|
return render(props);
|
|
216
216
|
}
|
|
217
217
|
return <div {...props} />;
|
|
218
218
|
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The parts, under the names the `InputOtp` namespace gives them.
|
|
222
|
+
*
|
|
223
|
+
* `index.js` re-exports this module whole — `export * as InputOtp from "./input-otp.js"` —
|
|
224
|
+
* so a caller writes `<InputOtp.Root>`, and the namespace is the prefix. Each
|
|
225
|
+
* part is still *declared* as `InputOtpRoot`, so React DevTools, a component
|
|
226
|
+
* stack and an error name the part a reader can find rather than one of forty
|
|
227
|
+
* `Root`s.
|
|
228
|
+
*/
|
|
229
|
+
export {
|
|
230
|
+
InputOtpRoot as Root,
|
|
231
|
+
InputOtpGroup as Group,
|
|
232
|
+
InputOtpSlot as Slot,
|
|
233
|
+
InputOtpSeparator as Separator,
|
|
234
|
+
};
|
package/interactions.js
CHANGED
|
@@ -1798,7 +1798,6 @@ hook useDescription(text: string | void): string | void {
|
|
|
1798
1798
|
const entry: SharedDescription = shared;
|
|
1799
1799
|
entry.users += 1;
|
|
1800
1800
|
// The id is state because it must render only after the shared DOM node exists.
|
|
1801
|
-
// uf-lint-disable-next-line react-compiler/set-state-in-effect
|
|
1802
1801
|
setId(entry.node.id);
|
|
1803
1802
|
return () => {
|
|
1804
1803
|
entry.users -= 1;
|
package/internal/collection.js
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
// @flow
|
|
2
2
|
"use client";
|
|
3
|
+
//
|
|
4
|
+
// The one collection behind `ListBox`, `GridList`, `Tree` and `TagGroup`.
|
|
5
|
+
//
|
|
6
|
+
// Focus stays on the collection element and `aria-activedescendant` names the
|
|
7
|
+
// active row, so a virtualised list of 10,000 rows needs only the rows in view
|
|
8
|
+
// plus the active one in the document. What a gesture does to the selection is
|
|
9
|
+
// `selection.js`'s answer; this file maps keys and pointers onto those answers
|
|
10
|
+
// and keeps the anchor a Shift range grows from.
|
|
11
|
+
//
|
|
12
|
+
// The keyboard map (WAI-ARIA APG listbox, grid and tree patterns, with React
|
|
13
|
+
// Aria's selection manager for the parts the APG leaves open):
|
|
14
|
+
//
|
|
15
|
+
// ArrowUp/ArrowDown previous/next row (TagGroup: also Left/Right,
|
|
16
|
+
// mirrored in right-to-left text)
|
|
17
|
+
// Home/End first/last row; End also asks for more rows
|
|
18
|
+
// PageUp/PageDown a viewport's worth of rows
|
|
19
|
+
// Shift + any of those extend the selection from the anchor
|
|
20
|
+
// Ctrl/Cmd + any move without selecting (`selectionBehavior="replace"`)
|
|
21
|
+
// Space select (toggle or replace, per `selectionBehavior`)
|
|
22
|
+
// Ctrl/Cmd+Space toggle one row — or lift it, when `onReorder` is set
|
|
23
|
+
// Enter `onAction`, or select when there is none
|
|
24
|
+
// Ctrl/Cmd+A select every enabled row
|
|
25
|
+
// Escape clear the selection (`escapeKeyBehavior`)
|
|
26
|
+
// Tree: Right/Left expand, enter; collapse, go to parent (mirrored)
|
|
27
|
+
// Tree: * expand every sibling of the active row
|
|
28
|
+
// printable characters locale-collated typeahead
|
|
29
|
+
//
|
|
30
|
+
// The Escape key is only claimed when it cleared something, so a collection
|
|
31
|
+
// inside a popover still lets the popover close on the Escape that follows.
|
|
3
32
|
|
|
4
33
|
import * as React from "@uniflowed/react";
|
|
5
34
|
import { useId, useRef, useState } from "@uniflowed/react";
|
|
@@ -7,9 +36,12 @@ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
|
7
36
|
import { useDragAndDrop } from "../drag-drop.js";
|
|
8
37
|
import { useControlled } from "./controlled-state.js";
|
|
9
38
|
import { composeHandlers, composeRefs, withProps } from "./merge-props.js";
|
|
10
|
-
import type {
|
|
39
|
+
import type { PartEvent, RenderProp } from "./merge-props.js";
|
|
11
40
|
import { directionOf } from "./roving-focus.js";
|
|
41
|
+
import { clearAll, extendTo, orderedKeys, replaceWith, selectAll, toggleKey } from "./selection.js";
|
|
42
|
+
import type { SelectionBehavior, SelectionMode, SelectionPolicy } from "./selection.js";
|
|
12
43
|
import { startsWithLocale, useLocale } from "../i18n-provider.js";
|
|
44
|
+
import { visuallyHiddenStyle } from "./visually-hidden-style.js";
|
|
13
45
|
|
|
14
46
|
export type CollectionItem = {
|
|
15
47
|
readonly key: string,
|
|
@@ -32,9 +64,32 @@ type Entry = {
|
|
|
32
64
|
};
|
|
33
65
|
type Kind = "listbox" | "grid" | "tree" | "tags";
|
|
34
66
|
|
|
67
|
+
/**
|
|
68
|
+
* What a row's click handler reads.
|
|
69
|
+
*
|
|
70
|
+
* `detail` is the click count: `0` for a click no pointer made — a screen
|
|
71
|
+
* reader's activation, or `element.click()` — which React Aria calls a virtual
|
|
72
|
+
* click. `nativeEvent` is read for `pointerType`, which a browser puts on the
|
|
73
|
+
* `PointerEvent` a click is and happy-dom and older engines leave off.
|
|
74
|
+
*/
|
|
75
|
+
type RowClick = {
|
|
76
|
+
readonly detail: number,
|
|
77
|
+
readonly ctrlKey: boolean,
|
|
78
|
+
readonly metaKey: boolean,
|
|
79
|
+
readonly shiftKey: boolean,
|
|
80
|
+
readonly nativeEvent: mixed,
|
|
81
|
+
readonly stopPropagation: () => mixed,
|
|
82
|
+
...
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
type Scroll = { readonly currentTarget: mixed, readonly defaultPrevented: boolean, ... };
|
|
86
|
+
|
|
87
|
+
/** What the collection's key handler reads: `PartEvent` plus the element the key came from. */
|
|
88
|
+
type KeyEvent = { ...PartEvent, readonly target: mixed, ... };
|
|
89
|
+
|
|
35
90
|
function entries(
|
|
36
91
|
items: $ReadOnlyArray<CollectionItem>,
|
|
37
|
-
expanded: $
|
|
92
|
+
expanded: $ReadOnlySet<string>,
|
|
38
93
|
tree: boolean,
|
|
39
94
|
): Array<Entry> {
|
|
40
95
|
const result = [];
|
|
@@ -49,23 +104,39 @@ function entries(
|
|
|
49
104
|
seen.add(item.key);
|
|
50
105
|
result.push({ item, level, parent, position: index + 1, count: siblings.length });
|
|
51
106
|
const children = item.children;
|
|
52
|
-
if (tree && children != null && expanded.
|
|
53
|
-
visit(children, level + 1, item.key);
|
|
107
|
+
if (tree && children != null && expanded.has(item.key)) visit(children, level + 1, item.key);
|
|
54
108
|
});
|
|
55
109
|
};
|
|
56
110
|
visit(items, 1, null);
|
|
57
111
|
return result;
|
|
58
112
|
}
|
|
59
113
|
|
|
114
|
+
/**
|
|
115
|
+
* A touch or a virtual click toggles even under `selectionBehavior="replace"`.
|
|
116
|
+
*
|
|
117
|
+
* There is no Ctrl key on a phone and no modifier on a screen reader's
|
|
118
|
+
* activation, so a replace-only rule would leave both able to choose one row
|
|
119
|
+
* and never two. React Aria draws the same line.
|
|
120
|
+
*/
|
|
121
|
+
function togglesByPointer(event: RowClick): boolean {
|
|
122
|
+
if (event.detail === 0) return true;
|
|
123
|
+
const native = event.nativeEvent;
|
|
124
|
+
return typeof native === "object" && native != null && native.pointerType === "touch";
|
|
125
|
+
}
|
|
126
|
+
|
|
60
127
|
/** Data owns order and identity. Only visible tree descendants and virtual rows enter the DOM. */
|
|
61
128
|
export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
62
129
|
const {
|
|
63
130
|
items,
|
|
64
131
|
children,
|
|
65
132
|
selectionMode: requestedSelectionMode = "single",
|
|
133
|
+
selectionBehavior = "toggle",
|
|
134
|
+
disallowEmptySelection = false,
|
|
135
|
+
escapeKeyBehavior = "clearSelection",
|
|
66
136
|
selectedKeys,
|
|
67
137
|
defaultSelectedKeys = [],
|
|
68
138
|
onSelectionChange,
|
|
139
|
+
onAction,
|
|
69
140
|
disabledKeys = [],
|
|
70
141
|
expandedKeys,
|
|
71
142
|
defaultExpandedKeys = [],
|
|
@@ -80,13 +151,21 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
80
151
|
render,
|
|
81
152
|
...rest
|
|
82
153
|
} = options;
|
|
83
|
-
const
|
|
154
|
+
const mode: SelectionMode = kind === "tags" ? "none" : requestedSelectionMode;
|
|
155
|
+
const policy: SelectionPolicy = {
|
|
156
|
+
mode,
|
|
157
|
+
behavior: selectionBehavior,
|
|
158
|
+
disallowEmpty: disallowEmptySelection,
|
|
159
|
+
};
|
|
84
160
|
if (height <= 0 || rowHeight <= 0 || !Number.isFinite(height) || !Number.isFinite(rowHeight))
|
|
85
161
|
throw new RangeError("Collection dimensions must be positive finite numbers");
|
|
86
162
|
const { locale } = useLocale();
|
|
87
163
|
const id = useId();
|
|
88
164
|
const root = useRef<HTMLElement | null>(null);
|
|
165
|
+
// Where a Shift range starts (`anchor`) and where the last one ended (`lead`).
|
|
166
|
+
// Written only by event handlers, never read during render.
|
|
89
167
|
const anchor = useRef<string | null>(null);
|
|
168
|
+
const lead = useRef<string | null>(null);
|
|
90
169
|
const buffer = useRef({ text: "", time: 0 });
|
|
91
170
|
const [selected, setSelected] = useControlled(
|
|
92
171
|
selectedKeys,
|
|
@@ -101,60 +180,61 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
101
180
|
const [active, setActive] = useState<string | null>(null);
|
|
102
181
|
const [scrollTop, setScrollTop] = useState(0);
|
|
103
182
|
const [announcement, announce] = useState("");
|
|
183
|
+
// Sets, because every row asks "am I selected, am I disabled" on every render
|
|
184
|
+
// and a 10,000-row collection with everything selected made that 10⁸
|
|
185
|
+
// comparisons as arrays.
|
|
186
|
+
// The React Compiler keeps each of these until its input changes.
|
|
187
|
+
const selectedSet = new Set(selected);
|
|
188
|
+
const disabledSet = new Set(disabledKeys);
|
|
189
|
+
const expandedSet = new Set(expanded);
|
|
190
|
+
const rows = entries(items, expandedSet, kind === "tree");
|
|
191
|
+
const disabled = (item: CollectionItem) => item.disabled === true || disabledSet.has(item.key);
|
|
192
|
+
const enabled = rows.filter((row) => !disabled(row.item));
|
|
193
|
+
const order = enabled.map((row) => row.item.key);
|
|
104
194
|
const drag = useDragAndDrop({
|
|
105
195
|
disabled: onReorder == null,
|
|
106
196
|
onDrop: ({ keys, target }) => {
|
|
107
197
|
const moving = keys[0];
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
)
|
|
113
|
-
return;
|
|
114
|
-
if (
|
|
115
|
-
moving === target ||
|
|
116
|
-
!items.some((item) => item.key === moving) ||
|
|
117
|
-
!items.some((item) => item.key === target)
|
|
118
|
-
)
|
|
119
|
-
return;
|
|
198
|
+
const byKey = (key: string) => items.find((item) => item.key === key);
|
|
199
|
+
const from = byKey(moving);
|
|
200
|
+
const to = byKey(target);
|
|
201
|
+
if (from == null || to == null || moving === target || disabled(from) || disabled(to)) return;
|
|
120
202
|
const ordered = items.filter((item) => item.key !== moving).map((item) => item.key);
|
|
121
203
|
ordered.splice(ordered.indexOf(target), 0, moving);
|
|
122
204
|
onReorder?.(ordered);
|
|
123
205
|
},
|
|
124
206
|
});
|
|
125
|
-
const rows = entries(items, expanded, kind === "tree");
|
|
126
|
-
const disabled = (item: CollectionItem) =>
|
|
127
|
-
item.disabled === true || disabledKeys.includes(item.key);
|
|
128
|
-
const enabled = rows.filter((row) => !disabled(row.item));
|
|
129
207
|
const focused =
|
|
130
208
|
enabled.find((row) => row.item.key === active) ??
|
|
131
|
-
enabled.find((row) =>
|
|
209
|
+
enabled.find((row) => selectedSet.has(row.item.key)) ??
|
|
132
210
|
enabled[0];
|
|
133
211
|
const activeKey = focused?.item.key;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const to = enabled.findIndex((row) => row.item.key === key);
|
|
142
|
-
next = enabled
|
|
143
|
-
.slice(Math.min(Math.max(from, 0), to), Math.max(from, to) + 1)
|
|
144
|
-
.map((row) => row.item.key);
|
|
145
|
-
} else {
|
|
146
|
-
next = toggle
|
|
147
|
-
? selected.includes(key)
|
|
148
|
-
? selected.filter((each) => each !== key)
|
|
149
|
-
: [...selected, key]
|
|
150
|
-
: [key];
|
|
151
|
-
anchor.current = key;
|
|
152
|
-
}
|
|
153
|
-
setSelected(next);
|
|
154
|
-
announce(`${next.length} selected`);
|
|
212
|
+
|
|
213
|
+
/** Report a selection, unless the gesture changed nothing. */
|
|
214
|
+
const commit = (next: $ReadOnlySet<string>) => {
|
|
215
|
+
if (next === selectedSet) return;
|
|
216
|
+
const keys = orderedKeys(next, order);
|
|
217
|
+
setSelected(keys);
|
|
218
|
+
announce(`${keys.length} selected`);
|
|
155
219
|
};
|
|
156
|
-
|
|
157
|
-
|
|
220
|
+
/** A plain or Ctrl/Cmd gesture on one row: toggle or replace, and move the anchor. */
|
|
221
|
+
const selectOne = (key: string, toggle: boolean) => {
|
|
222
|
+
if (mode === "none") return;
|
|
223
|
+
commit(
|
|
224
|
+
toggle || selectionBehavior === "toggle"
|
|
225
|
+
? toggleKey(policy, selectedSet, key)
|
|
226
|
+
: replaceWith(policy, selectedSet, key),
|
|
227
|
+
);
|
|
228
|
+
anchor.current = key;
|
|
229
|
+
lead.current = key;
|
|
230
|
+
};
|
|
231
|
+
/** A Shift gesture: grow from the anchor, which is the active row if nothing set one. */
|
|
232
|
+
const extend = (key: string, from: string | void) => {
|
|
233
|
+
if (anchor.current == null || !order.includes(anchor.current)) anchor.current = from ?? key;
|
|
234
|
+
commit(extendTo(policy, selectedSet, order, anchor.current, lead.current, key));
|
|
235
|
+
lead.current = key;
|
|
236
|
+
};
|
|
237
|
+
const scrollTo = (row: Entry) => {
|
|
158
238
|
const container = root.current;
|
|
159
239
|
if (virtualized && container != null) {
|
|
160
240
|
const top = rows.indexOf(row) * rowHeight;
|
|
@@ -163,7 +243,33 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
163
243
|
setScrollTop(top);
|
|
164
244
|
}
|
|
165
245
|
}
|
|
166
|
-
|
|
246
|
+
};
|
|
247
|
+
/** Move the active row, and select the way the modifiers ask. */
|
|
248
|
+
const land = (
|
|
249
|
+
row: Entry,
|
|
250
|
+
event: {
|
|
251
|
+
readonly shiftKey: boolean,
|
|
252
|
+
readonly ctrlKey: boolean,
|
|
253
|
+
readonly metaKey: boolean,
|
|
254
|
+
...
|
|
255
|
+
},
|
|
256
|
+
) => {
|
|
257
|
+
const previous = activeKey;
|
|
258
|
+
setActive(row.item.key);
|
|
259
|
+
scrollTo(row);
|
|
260
|
+
if (mode === "none") return;
|
|
261
|
+
if (event.shiftKey && mode === "multiple") extend(row.item.key, previous);
|
|
262
|
+
else if (selectionBehavior === "replace" && !event.ctrlKey && !event.metaKey)
|
|
263
|
+
selectOne(row.item.key, false);
|
|
264
|
+
};
|
|
265
|
+
/** Rows per PageUp/PageDown: the viewport over one row, or ten when nothing is laid out. */
|
|
266
|
+
const pageSize = (): number => {
|
|
267
|
+
if (virtualized) return Math.max(1, Math.floor(height / rowHeight));
|
|
268
|
+
const container = root.current;
|
|
269
|
+
const row = container?.querySelector("[data-key]");
|
|
270
|
+
if (container != null && row instanceof HTMLElement && row.offsetHeight > 0)
|
|
271
|
+
return Math.max(1, Math.floor(container.clientHeight / row.offsetHeight));
|
|
272
|
+
return 10;
|
|
167
273
|
};
|
|
168
274
|
const remove = (key: string) => {
|
|
169
275
|
const at = enabled.findIndex((row) => row.item.key === key);
|
|
@@ -172,12 +278,16 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
172
278
|
setActive(enabled[at + 1]?.item.key ?? enabled[at - 1]?.item.key ?? null);
|
|
173
279
|
announce(`Removed ${enabled[at].item.textValue}`);
|
|
174
280
|
};
|
|
175
|
-
const keydown = useStableCallback((event:
|
|
281
|
+
const keydown = useStableCallback((event: KeyEvent) => {
|
|
282
|
+
const { target, currentTarget } = event;
|
|
283
|
+
if (!(currentTarget instanceof HTMLElement)) return;
|
|
176
284
|
if (
|
|
177
|
-
|
|
178
|
-
|
|
285
|
+
target !== currentTarget &&
|
|
286
|
+
target instanceof Element &&
|
|
287
|
+
target.closest("button,input,textarea,select") != null
|
|
179
288
|
)
|
|
180
289
|
return;
|
|
290
|
+
const modifier = event.ctrlKey || event.metaKey;
|
|
181
291
|
if (drag.dragging && event.key === "Escape") {
|
|
182
292
|
event.preventDefault();
|
|
183
293
|
drag.cancel();
|
|
@@ -188,48 +298,62 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
188
298
|
drag.drop(activeKey, undefined, focused?.item.textValue);
|
|
189
299
|
return;
|
|
190
300
|
}
|
|
191
|
-
if (
|
|
192
|
-
onReorder != null &&
|
|
193
|
-
event.key === " " &&
|
|
194
|
-
(event.ctrlKey || event.metaKey) &&
|
|
195
|
-
focused != null
|
|
196
|
-
) {
|
|
301
|
+
if (onReorder != null && event.key === " " && modifier && focused != null) {
|
|
197
302
|
event.preventDefault();
|
|
198
303
|
drag.start(focused.item.key, focused.item.textValue);
|
|
199
304
|
return;
|
|
200
305
|
}
|
|
306
|
+
const rtl = directionOf(currentTarget) === "rtl";
|
|
307
|
+
const inline = kind === "tags";
|
|
308
|
+
const forward =
|
|
309
|
+
event.key === "ArrowDown" || (inline && event.key === (rtl ? "ArrowLeft" : "ArrowRight"));
|
|
310
|
+
const backward =
|
|
311
|
+
event.key === "ArrowUp" || (inline && event.key === (rtl ? "ArrowRight" : "ArrowLeft"));
|
|
201
312
|
const at = enabled.findIndex((row) => row.item.key === activeKey);
|
|
202
313
|
let next = null;
|
|
203
|
-
if (
|
|
204
|
-
else if (
|
|
314
|
+
if (forward) next = enabled[Math.min(at + 1, enabled.length - 1)];
|
|
315
|
+
else if (backward) next = enabled[Math.max(at - 1, 0)];
|
|
205
316
|
else if (event.key === "Home") next = enabled[0];
|
|
206
317
|
else if (event.key === "End") {
|
|
207
318
|
next = enabled[enabled.length - 1];
|
|
208
319
|
if (!loading) onLoadMore?.();
|
|
320
|
+
} else if (event.key === "PageDown")
|
|
321
|
+
next = enabled[Math.min(Math.max(at, 0) + pageSize(), enabled.length - 1)];
|
|
322
|
+
else if (event.key === "PageUp") next = enabled[Math.max(at - pageSize(), 0)];
|
|
323
|
+
else if (kind === "tree" && focused != null && event.key === "*") {
|
|
324
|
+
const siblings = rows.filter(
|
|
325
|
+
(row) => row.parent === focused.parent && (row.item.children?.length ?? 0) > 0,
|
|
326
|
+
);
|
|
327
|
+
const missing = siblings.map((row) => row.item.key).filter((key) => !expandedSet.has(key));
|
|
328
|
+
if (missing.length > 0) setExpanded([...expanded, ...missing]);
|
|
209
329
|
} else if (
|
|
210
330
|
kind === "tree" &&
|
|
211
331
|
focused != null &&
|
|
212
|
-
|
|
332
|
+
(event.key === "ArrowLeft" || event.key === "ArrowRight")
|
|
213
333
|
) {
|
|
214
|
-
const open =
|
|
215
|
-
event.key === (directionOf(event.currentTarget) === "rtl" ? "ArrowLeft" : "ArrowRight");
|
|
334
|
+
const open = event.key === (rtl ? "ArrowLeft" : "ArrowRight");
|
|
216
335
|
const key = focused.item.key;
|
|
217
|
-
if (open && focused.item.children?.length) {
|
|
218
|
-
if (!
|
|
336
|
+
if (open && (focused.item.children?.length ?? 0) > 0) {
|
|
337
|
+
if (!expandedSet.has(key)) setExpanded([...expanded, key]);
|
|
219
338
|
else next = enabled[at + 1];
|
|
220
|
-
} else if (!open &&
|
|
339
|
+
} else if (!open && expandedSet.has(key))
|
|
221
340
|
setExpanded(expanded.filter((each) => each !== key));
|
|
222
341
|
else if (!open) next = enabled.find((row) => row.item.key === focused.parent);
|
|
342
|
+
} else if (modifier && event.key.toLowerCase() === "a" && !event.altKey) {
|
|
343
|
+
if (mode !== "multiple") return;
|
|
344
|
+
commit(selectAll(policy, selectedSet, order));
|
|
345
|
+
} else if (event.key === "Escape") {
|
|
346
|
+
if (escapeKeyBehavior !== "clearSelection") return;
|
|
347
|
+
const cleared = clearAll(policy, selectedSet);
|
|
348
|
+
// Unclaimed when nothing changed, so an enclosing popover still closes.
|
|
349
|
+
if (cleared === selectedSet) return;
|
|
350
|
+
commit(cleared);
|
|
351
|
+
} else if (event.key === "Enter" && activeKey != null && onAction != null) {
|
|
352
|
+
onAction(activeKey);
|
|
353
|
+
} else if ((event.key === " " || event.key === "Enter") && activeKey != null) {
|
|
354
|
+
if (event.shiftKey && mode === "multiple") extend(activeKey, activeKey);
|
|
355
|
+
else selectOne(activeKey, modifier);
|
|
223
356
|
} else if (
|
|
224
|
-
(event.ctrlKey || event.metaKey) &&
|
|
225
|
-
event.key === "a" &&
|
|
226
|
-
selectionMode === "multiple"
|
|
227
|
-
) {
|
|
228
|
-
setSelected(enabled.map((row) => row.item.key));
|
|
229
|
-
announce(`${enabled.length} selected`);
|
|
230
|
-
} else if ((event.key === " " || event.key === "Enter") && activeKey != null)
|
|
231
|
-
choose(activeKey, event.shiftKey, true);
|
|
232
|
-
else if (
|
|
233
357
|
(event.key === "Delete" || event.key === "Backspace") &&
|
|
234
358
|
kind === "tags" &&
|
|
235
359
|
activeKey != null
|
|
@@ -249,10 +373,28 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
249
373
|
break;
|
|
250
374
|
}
|
|
251
375
|
}
|
|
376
|
+
// Typeahead moves; it never extends. Under `replace` the selection follows.
|
|
377
|
+
if (next != null) {
|
|
378
|
+
event.preventDefault();
|
|
379
|
+
land(next, { shiftKey: false, ctrlKey: false, metaKey: false });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
252
382
|
} else return;
|
|
253
383
|
event.preventDefault();
|
|
254
|
-
if (next != null) land(next, event
|
|
384
|
+
if (next != null) land(next, event);
|
|
255
385
|
});
|
|
386
|
+
const rowClick = (item: CollectionItem, event: RowClick) => {
|
|
387
|
+
if (disabled(item)) return;
|
|
388
|
+
root.current?.focus();
|
|
389
|
+
setActive(item.key);
|
|
390
|
+
// With nothing to select, a click is the row's action.
|
|
391
|
+
if (mode === "none") {
|
|
392
|
+
onAction?.(item.key);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (event.shiftKey && mode === "multiple") extend(item.key, activeKey);
|
|
396
|
+
else selectOne(item.key, event.ctrlKey || event.metaKey || togglesByPointer(event));
|
|
397
|
+
};
|
|
256
398
|
const start = virtualized ? Math.max(0, Math.floor(scrollTop / rowHeight) - 2) : 0;
|
|
257
399
|
const end = virtualized
|
|
258
400
|
? Math.min(rows.length, start + Math.ceil(height / rowHeight) + 4)
|
|
@@ -266,7 +408,7 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
266
408
|
.map((index) => {
|
|
267
409
|
const row = rows[index];
|
|
268
410
|
const { item } = row;
|
|
269
|
-
const chosen =
|
|
411
|
+
const chosen = selectedSet.has(item.key);
|
|
270
412
|
const grid = kind === "grid" || kind === "tags";
|
|
271
413
|
const content =
|
|
272
414
|
children?.(item, {
|
|
@@ -282,10 +424,12 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
282
424
|
draggable: onReorder != null && !disabled(item),
|
|
283
425
|
id: `${id}-${index}`,
|
|
284
426
|
role: grid ? "row" : kind === "tree" ? "treeitem" : "option",
|
|
285
|
-
"aria-selected":
|
|
427
|
+
"aria-selected": mode === "none" ? undefined : chosen,
|
|
286
428
|
"aria-disabled": disabled(item) || undefined,
|
|
287
429
|
"aria-expanded":
|
|
288
|
-
kind === "tree" && item.children?.length
|
|
430
|
+
kind === "tree" && (item.children?.length ?? 0) > 0
|
|
431
|
+
? expandedSet.has(item.key)
|
|
432
|
+
: undefined,
|
|
289
433
|
"aria-level": kind === "tree" ? row.level : undefined,
|
|
290
434
|
"aria-posinset": grid ? undefined : kind === "tree" ? row.position : index + 1,
|
|
291
435
|
"aria-setsize": grid ? undefined : kind === "tree" ? row.count : rows.length,
|
|
@@ -295,12 +439,14 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
295
439
|
style: virtualized
|
|
296
440
|
? { position: "absolute", top: index * rowHeight, height: rowHeight, width: "100%" }
|
|
297
441
|
: undefined,
|
|
298
|
-
onClick: (event:
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
442
|
+
onClick: (event: RowClick) => rowClick(item, event),
|
|
443
|
+
// Under `replace` a click selects, so the action needs a second gesture.
|
|
444
|
+
onDoubleClick:
|
|
445
|
+
onAction != null && selectionBehavior === "replace" && mode !== "none"
|
|
446
|
+
? () => {
|
|
447
|
+
if (!disabled(item)) onAction(item.key);
|
|
448
|
+
}
|
|
449
|
+
: undefined,
|
|
304
450
|
};
|
|
305
451
|
return (
|
|
306
452
|
<div key={item.key} {...props}>
|
|
@@ -313,7 +459,7 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
313
459
|
tabIndex={-1}
|
|
314
460
|
disabled={disabled(item)}
|
|
315
461
|
aria-label={`Remove ${item.textValue}`}
|
|
316
|
-
onClick={(event) => {
|
|
462
|
+
onClick={(event: RowClick) => {
|
|
317
463
|
event.stopPropagation();
|
|
318
464
|
root.current?.focus();
|
|
319
465
|
remove(item.key);
|
|
@@ -332,27 +478,31 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
332
478
|
const setRef = useStableCallback((element: HTMLElement | null) => {
|
|
333
479
|
root.current = element;
|
|
334
480
|
});
|
|
481
|
+
const style = rest.style;
|
|
335
482
|
const props = withProps(rest, {
|
|
336
483
|
ref: composeRefs(rest.ref, setRef),
|
|
337
484
|
role: kind === "tags" ? "grid" : kind,
|
|
338
485
|
tabIndex: 0,
|
|
339
486
|
"aria-activedescendant": activeIndex < 0 ? undefined : `${id}-${activeIndex}`,
|
|
340
|
-
"aria-multiselectable":
|
|
487
|
+
"aria-multiselectable": mode === "multiple" || undefined,
|
|
341
488
|
"aria-busy": loading || undefined,
|
|
342
489
|
"aria-rowcount": kind === "grid" || kind === "tags" ? rows.length : undefined,
|
|
343
490
|
onKeyDown: composeHandlers(rest.onKeyDown, keydown),
|
|
344
|
-
onScroll: composeHandlers(rest.onScroll, (event:
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
event.currentTarget.scrollHeight
|
|
350
|
-
)
|
|
491
|
+
onScroll: composeHandlers(rest.onScroll, (event: Scroll) => {
|
|
492
|
+
const element = event.currentTarget;
|
|
493
|
+
if (!(element instanceof HTMLElement)) return;
|
|
494
|
+
if (virtualized) setScrollTop(element.scrollTop);
|
|
495
|
+
if (!loading && element.scrollTop + element.clientHeight >= element.scrollHeight)
|
|
351
496
|
onLoadMore?.();
|
|
352
497
|
}),
|
|
353
498
|
style: virtualized
|
|
354
|
-
? {
|
|
355
|
-
|
|
499
|
+
? {
|
|
500
|
+
...(typeof style === "object" && style != null ? style : {}),
|
|
501
|
+
height,
|
|
502
|
+
overflow: "auto",
|
|
503
|
+
position: "relative",
|
|
504
|
+
}
|
|
505
|
+
: style,
|
|
356
506
|
children: virtualized ? (
|
|
357
507
|
<div role="presentation" style={{ height: rows.length * rowHeight, position: "relative" }}>
|
|
358
508
|
{rowNodes}
|
|
@@ -364,7 +514,8 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
364
514
|
return (
|
|
365
515
|
<>
|
|
366
516
|
{render != null ? render(props) : <div {...props} />}
|
|
367
|
-
|
|
517
|
+
{/* For a screen reader; a sighted reader sees the selection itself. */}
|
|
518
|
+
<span role="status" aria-live="polite" style={visuallyHiddenStyle}>
|
|
368
519
|
{drag.announcement || announcement}
|
|
369
520
|
</span>
|
|
370
521
|
</>
|
|
@@ -374,10 +525,26 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
374
525
|
export type CollectionProps = {
|
|
375
526
|
items: $ReadOnlyArray<CollectionItem>,
|
|
376
527
|
children?: (item: CollectionItem, state: CollectionItemState) => React.Node,
|
|
377
|
-
selectionMode?:
|
|
528
|
+
selectionMode?: SelectionMode,
|
|
529
|
+
/**
|
|
530
|
+
* What a plain click or Space does: `"toggle"` flips the row (the default),
|
|
531
|
+
* `"replace"` makes it the whole selection and lets the arrow keys carry the
|
|
532
|
+
* selection with them. Ctrl/Cmd toggles and Shift extends under either.
|
|
533
|
+
*/
|
|
534
|
+
selectionBehavior?: SelectionBehavior,
|
|
535
|
+
/** Refuse the gesture that would leave nothing selected. */
|
|
536
|
+
disallowEmptySelection?: boolean,
|
|
537
|
+
/** Whether Escape clears the selection. */
|
|
538
|
+
escapeKeyBehavior?: "clearSelection" | "none",
|
|
378
539
|
selectedKeys?: $ReadOnlyArray<string>,
|
|
379
540
|
defaultSelectedKeys?: $ReadOnlyArray<string>,
|
|
380
541
|
onSelectionChange?: (keys: $ReadOnlyArray<string>) => void,
|
|
542
|
+
/**
|
|
543
|
+
* Open a row rather than select it. Enter runs it; so does a click when
|
|
544
|
+
* `selectionMode` is `"none"`, and a double click under
|
|
545
|
+
* `selectionBehavior="replace"`.
|
|
546
|
+
*/
|
|
547
|
+
onAction?: (key: string) => void,
|
|
381
548
|
disabledKeys?: $ReadOnlyArray<string>,
|
|
382
549
|
expandedKeys?: $ReadOnlyArray<string>,
|
|
383
550
|
defaultExpandedKeys?: $ReadOnlyArray<string>,
|
package/internal/focus.js
CHANGED
|
@@ -52,13 +52,22 @@ export const FOCUS_STOPS: string =
|
|
|
52
52
|
* document.
|
|
53
53
|
*/
|
|
54
54
|
export function focusable(root: HTMLElement): Array<HTMLElement> {
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
const found: Array<HTMLElement> = [];
|
|
56
|
+
for (const element of root.querySelectorAll(FOCUS_STOPS)) {
|
|
57
|
+
if (
|
|
58
|
+
// Asked rather than annotated: `querySelectorAll` answers `Element`, and
|
|
59
|
+
// what focus lands on is an HTML or SVG element, both `HTMLElement` to
|
|
60
|
+
// Flow.
|
|
61
|
+
(element instanceof HTMLElement || element instanceof SVGElement) &&
|
|
57
62
|
// All three hide a whole subtree, so all three are asked of the
|
|
58
63
|
// ancestors; see the module header for what reading them off the element
|
|
59
64
|
// alone let through.
|
|
60
65
|
element.closest("[hidden]") == null &&
|
|
61
66
|
element.closest("[inert]") == null &&
|
|
62
|
-
element.closest('[aria-hidden="true"]') == null
|
|
63
|
-
|
|
67
|
+
element.closest('[aria-hidden="true"]') == null
|
|
68
|
+
) {
|
|
69
|
+
found.push(element);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return found;
|
|
64
73
|
}
|
package/internal/roving-focus.js
CHANGED
|
@@ -69,9 +69,18 @@ const TYPEAHEAD_WINDOW = 500;
|
|
|
69
69
|
* cannot see.
|
|
70
70
|
*/
|
|
71
71
|
export function itemsOf(container: HTMLElement, item: string, owner: string): Array<HTMLElement> {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
const found: Array<HTMLElement> = [];
|
|
73
|
+
for (const element of container.querySelectorAll(item)) {
|
|
74
|
+
// An item is an HTML or SVG element, both `HTMLElement` to Flow; asked
|
|
75
|
+
// rather than annotated, because `querySelectorAll` answers `Element`.
|
|
76
|
+
if (
|
|
77
|
+
(element instanceof HTMLElement || element instanceof SVGElement) &&
|
|
78
|
+
element.closest(owner) === container
|
|
79
|
+
) {
|
|
80
|
+
found.push(element);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return found;
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
/**
|