@uniflowed/ui 0.0.0-alpha.2 → 0.0.0-alpha.5
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/checkbox.js +80 -0
- package/combobox.js +544 -0
- package/dialog.js +483 -0
- package/{internal/field.js → field.js} +31 -22
- package/index.js +182 -26
- package/internal/controlled-state.js +65 -0
- package/internal/merge-props.js +136 -0
- package/internal/roving-focus.js +236 -0
- package/menu.js +629 -0
- package/package.json +11 -7
- package/switch.js +73 -0
- package/tabs.js +283 -0
- package/internal/dialog.js +0 -236
- package/internal/props.js +0 -78
- package/internal/switch.js +0 -122
- package/internal/tabs.js +0 -270
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// The keyboard pattern shared by every list of things in this package.
|
|
4
|
+
//
|
|
5
|
+
// A tab list, a menu and a listbox look nothing alike and behave identically at
|
|
6
|
+
// the keyboard, because WAI-ARIA says they must: the *set* takes one stop in the
|
|
7
|
+
// page's tab order, and the arrow keys move within it. That is what makes a
|
|
8
|
+
// twelve-item menu something a keyboard user passes in one Tab press instead of
|
|
9
|
+
// twelve, and it is the part hand-written components leave out.
|
|
10
|
+
//
|
|
11
|
+
// Four rules make it up, and each one has a way of being got wrong that no
|
|
12
|
+
// screenshot shows:
|
|
13
|
+
//
|
|
14
|
+
// * **Document order, read from the document.** Items are found by querying
|
|
15
|
+
// the container at the moment a key is pressed, not from a registry the
|
|
16
|
+
// items push themselves into as they mount. Mount order is not document
|
|
17
|
+
// order the moment a list is filtered, reordered, or has a conditional item
|
|
18
|
+
// in the middle of it — and a registry that disagrees with the page sends
|
|
19
|
+
// the arrow keys somewhere the reader is not.
|
|
20
|
+
// * **Nesting.** A submenu's items are inside its parent menu's element, so
|
|
21
|
+
// "the items of this menu" cannot be `querySelectorAll` alone. An item
|
|
22
|
+
// belongs to the nearest container of its own kind.
|
|
23
|
+
// * **Disabled is skipped, not landed on.** And the direction to keep
|
|
24
|
+
// searching in cannot be inferred from the target index: `End` aims at the
|
|
25
|
+
// last item and, if that one is disabled, has to walk *backwards*. Guessing
|
|
26
|
+
// "forwards, because the target is ahead of us" wrapped `End` around to the
|
|
27
|
+
// first item.
|
|
28
|
+
// * **Typeahead.** Pressing `r` in a menu goes to Refresh. Without it a menu
|
|
29
|
+
// of thirty items is thirty arrow presses, and every native menu on every
|
|
30
|
+
// platform has had this since before the web.
|
|
31
|
+
//
|
|
32
|
+
// # Why this is `internal/` and not a subpath
|
|
33
|
+
//
|
|
34
|
+
// It is a description of DOM structure this package owns — that a tab lives
|
|
35
|
+
// under `[role="tablist"]`, that a menu item's owner is `[role="menu"]` — and
|
|
36
|
+
// those relationships are only guaranteed because the components in this
|
|
37
|
+
// package build them. Handed to a consumer it would be a set of selectors that
|
|
38
|
+
// happen to work today, which is a different and much weaker promise than the
|
|
39
|
+
// one the components make.
|
|
40
|
+
|
|
41
|
+
import { useCallback, useRef } from "@uniflowed/react";
|
|
42
|
+
|
|
43
|
+
/** Which way a key asks the focus to move within a set. */
|
|
44
|
+
export type Movement = "previous" | "next" | "first" | "last";
|
|
45
|
+
|
|
46
|
+
/** The axis a set's arrow keys run along. */
|
|
47
|
+
export type Orientation = "horizontal" | "vertical";
|
|
48
|
+
|
|
49
|
+
/** How long a typeahead buffer survives without another key, in milliseconds. */
|
|
50
|
+
const TYPEAHEAD_WINDOW = 500;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The items directly belonging to `container`, in document order.
|
|
54
|
+
*
|
|
55
|
+
* `owner` names the container's own kind — `[role="menu"]` for a menu — so an
|
|
56
|
+
* item inside a *nested* container of that kind is left to the nested one. A
|
|
57
|
+
* plain `querySelectorAll` returns a submenu's items as if they were the parent
|
|
58
|
+
* menu's, which makes `ArrowDown` in the parent step into a menu the reader
|
|
59
|
+
* cannot see.
|
|
60
|
+
*/
|
|
61
|
+
export function itemsOf(container: HTMLElement, item: string, owner: string): Array<HTMLElement> {
|
|
62
|
+
return Array.from(container.querySelectorAll(item)).filter(
|
|
63
|
+
(element: $FlowFixMe) => element.closest(owner) === container,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether the keyboard may land on this item.
|
|
69
|
+
*
|
|
70
|
+
* Both spellings, because the two mean different things and this package uses
|
|
71
|
+
* both: a native `disabled` takes an element out of the accessibility tree's
|
|
72
|
+
* reach, while `aria-disabled` leaves it announced — which is what a menu item
|
|
73
|
+
* or a tab wants, so a reader can tell the option exists and is unavailable
|
|
74
|
+
* rather than finding a gap where it used to be.
|
|
75
|
+
*/
|
|
76
|
+
export function isEnabled(element: HTMLElement): boolean {
|
|
77
|
+
return (
|
|
78
|
+
(element as $FlowFixMe).disabled !== true && element.getAttribute("aria-disabled") !== "true"
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The movement a key asks for along `orientation`, or nothing if it is not ours.
|
|
84
|
+
*
|
|
85
|
+
* The unhandled keys matter as much as the handled ones. `ArrowDown` inside a
|
|
86
|
+
* *horizontal* tab list belongs to the page — it scrolls — and a component that
|
|
87
|
+
* swallows it has taken a key away from every reader who uses it to read.
|
|
88
|
+
*/
|
|
89
|
+
export function movementFor(key: string, orientation: Orientation): Movement | null {
|
|
90
|
+
return match (key) {
|
|
91
|
+
"Home" => "first",
|
|
92
|
+
"End" => "last",
|
|
93
|
+
"ArrowUp" => orientation === "vertical" ? "previous" : null,
|
|
94
|
+
"ArrowDown" => orientation === "vertical" ? "next" : null,
|
|
95
|
+
"ArrowLeft" => orientation === "horizontal" ? "previous" : null,
|
|
96
|
+
"ArrowRight" => orientation === "horizontal" ? "next" : null,
|
|
97
|
+
_ => null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The item `movement` reaches from `from`, skipping disabled ones.
|
|
103
|
+
*
|
|
104
|
+
* `from` may be `-1` for "nothing is focused yet", which is what makes
|
|
105
|
+
* `ArrowDown` on a freshly opened menu land on the first item. `wrap` is false
|
|
106
|
+
* for a set where running off the end should stop rather than cycle.
|
|
107
|
+
*
|
|
108
|
+
* Returns null when every item is disabled, or when the ends are closed and
|
|
109
|
+
* there is nothing further in that direction — in both cases the caller should
|
|
110
|
+
* leave focus where it is rather than move it somewhere arbitrary.
|
|
111
|
+
*/
|
|
112
|
+
export function moveTo(
|
|
113
|
+
items: $ReadOnlyArray<HTMLElement>,
|
|
114
|
+
from: number,
|
|
115
|
+
movement: Movement,
|
|
116
|
+
wrap: boolean,
|
|
117
|
+
): HTMLElement | null {
|
|
118
|
+
const count = items.length;
|
|
119
|
+
if (count === 0) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
// Two things this expression is careful about, each of which was a bug.
|
|
123
|
+
//
|
|
124
|
+
// The direction is part of the answer rather than derived from it: `last`
|
|
125
|
+
// aims at the end and searches *backwards* from there, and deriving
|
|
126
|
+
// "forwards" from the target being ahead of `from` sent `End` past the end
|
|
127
|
+
// and around to the first item whenever the last one was disabled.
|
|
128
|
+
//
|
|
129
|
+
// And `from` is -1 when nothing is focused yet, which the two directions read
|
|
130
|
+
// differently: "next" from nowhere is the first item, and "previous" from
|
|
131
|
+
// nowhere is the *last* one. Letting -1 fall through the arithmetic aimed
|
|
132
|
+
// `previous` at -2, which wraps to `count - 2` — so `ArrowUp` on a freshly
|
|
133
|
+
// opened list landed one short of the end, and on a two-item list landed on
|
|
134
|
+
// the first item.
|
|
135
|
+
const aim = match (movement) {
|
|
136
|
+
"previous" => [from < 0 ? count - 1 : from - 1, -1],
|
|
137
|
+
"next" => [from + 1, 1],
|
|
138
|
+
"first" => [0, 1],
|
|
139
|
+
"last" => [count - 1, -1],
|
|
140
|
+
};
|
|
141
|
+
const [target, direction] = aim;
|
|
142
|
+
|
|
143
|
+
for (let tried = 0; tried < count; tried += 1) {
|
|
144
|
+
const at = target + tried * direction;
|
|
145
|
+
if (!wrap && (at < 0 || at >= count)) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const candidate = items[((at % count) + count) % count];
|
|
149
|
+
if (isEnabled(candidate)) {
|
|
150
|
+
return candidate;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The index of the focused item, or `-1` when focus is elsewhere. */
|
|
157
|
+
export function indexOfActive(items: $ReadOnlyArray<HTMLElement>, active: mixed): number {
|
|
158
|
+
return items.findIndex((item) => item === active);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Match items by the characters a reader types, the way every native menu does.
|
|
163
|
+
*
|
|
164
|
+
* The returned function is stable, so a component may pass it straight to a key
|
|
165
|
+
* handler without re-subscribing anything. The buffer lives in a ref and is only
|
|
166
|
+
* ever touched from an event handler — never during a render, where a value that
|
|
167
|
+
* depends on how many times React chose to render is a bug waiting for
|
|
168
|
+
* Strict Mode to find it.
|
|
169
|
+
*
|
|
170
|
+
* Two behaviours people notice when they are missing:
|
|
171
|
+
*
|
|
172
|
+
* * Typing `s`, `a`, `v` within half a second looks for "sav", not for three
|
|
173
|
+
* separate items starting with `s`, `a` and `v`.
|
|
174
|
+
* * Pressing the *same* letter repeatedly cycles through the items starting
|
|
175
|
+
* with it, which is how a reader reaches the second "Save as…".
|
|
176
|
+
*/
|
|
177
|
+
export hook useTypeahead(): (
|
|
178
|
+
items: $ReadOnlyArray<HTMLElement>,
|
|
179
|
+
from: number,
|
|
180
|
+
key: string,
|
|
181
|
+
) => HTMLElement | null {
|
|
182
|
+
const buffer = useRef<{| text: string, at: number |}>({ text: "", at: 0 });
|
|
183
|
+
|
|
184
|
+
return useCallback(
|
|
185
|
+
(items: $ReadOnlyArray<HTMLElement>, from: number, key: string): HTMLElement | null => {
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
const text = now - buffer.current.at > TYPEAHEAD_WINDOW ? key : buffer.current.text + key;
|
|
188
|
+
buffer.current = { text, at: now };
|
|
189
|
+
|
|
190
|
+
const repeated = text.length > 1 && text.split("").every((each) => each === text[0]);
|
|
191
|
+
const needle = (repeated ? text[0] : text).toLowerCase();
|
|
192
|
+
// A single character — or the same one again — moves on from where we
|
|
193
|
+
// are. A longer buffer starts *at* the current item, so typing "sa" after
|
|
194
|
+
// "s" can keep the item "s" already found.
|
|
195
|
+
const start = repeated || text.length === 1 ? from + 1 : Math.max(from, 0);
|
|
196
|
+
|
|
197
|
+
for (let tried = 0; tried < items.length; tried += 1) {
|
|
198
|
+
const candidate = items[(((start + tried) % items.length) + items.length) % items.length];
|
|
199
|
+
if (isEnabled(candidate) && labelOf(candidate).startsWith(needle)) {
|
|
200
|
+
return candidate;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
},
|
|
205
|
+
[],
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Whether a key press is a character a reader meant to type.
|
|
211
|
+
*
|
|
212
|
+
* Modifier combinations are excluded because `Ctrl+P` is the browser's, and a
|
|
213
|
+
* component that treats it as "the letter p" both steals the shortcut and jumps
|
|
214
|
+
* the selection somewhere the reader did not ask for.
|
|
215
|
+
*/
|
|
216
|
+
export function isTypeaheadKey(event: {
|
|
217
|
+
readonly key: string,
|
|
218
|
+
readonly altKey?: boolean,
|
|
219
|
+
readonly ctrlKey?: boolean,
|
|
220
|
+
readonly metaKey?: boolean,
|
|
221
|
+
...
|
|
222
|
+
}): boolean {
|
|
223
|
+
return (
|
|
224
|
+
event.key.length === 1 &&
|
|
225
|
+
event.key !== " " &&
|
|
226
|
+
event.altKey !== true &&
|
|
227
|
+
event.ctrlKey !== true &&
|
|
228
|
+
event.metaKey !== true
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** What a reader hears for this item, lower-cased for matching. */
|
|
233
|
+
function labelOf(element: HTMLElement): string {
|
|
234
|
+
const spoken = element.getAttribute("aria-label") ?? element.textContent ?? "";
|
|
235
|
+
return spoken.replace(/\s+/g, " ").trim().toLowerCase();
|
|
236
|
+
}
|