@uniflowed/ui 0.0.0-alpha.2 → 0.0.0-alpha.4
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 +79 -0
- package/combobox.js +546 -0
- package/dialog.js +482 -0
- package/{internal/field.js → field.js} +24 -13
- package/index.js +182 -26
- package/internal/controlled-state.js +65 -0
- package/internal/{props.js → merge-props.js} +19 -11
- package/internal/roving-focus.js +236 -0
- package/menu.js +628 -0
- package/package.json +11 -7
- package/switch.js +72 -0
- package/tabs.js +286 -0
- package/internal/dialog.js +0 -236
- package/internal/switch.js +0 -122
- package/internal/tabs.js +0 -270
package/internal/switch.js
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
// @flow
|
|
2
|
-
//
|
|
3
|
-
// A switch, and a checkbox that is not an `<input>`.
|
|
4
|
-
//
|
|
5
|
-
// Both exist because the styled version of a checkbox is almost always a `div`
|
|
6
|
-
// with a tick drawn in it, and the moment it stops being an `<input>` it stops
|
|
7
|
-
// being announced, stops toggling on Space, and stops being reachable by Tab.
|
|
8
|
-
// These keep all three: the role, the `aria-checked` state, and the keys.
|
|
9
|
-
//
|
|
10
|
-
// A switch is not a checkbox. A checkbox has three states — on, off and
|
|
11
|
-
// indeterminate — and a switch has two; a screen reader says "on"/"off" for one
|
|
12
|
-
// and "checked"/"unchecked" for the other. Using the wrong one is the kind of
|
|
13
|
-
// mistake that is invisible until somebody uses the thing.
|
|
14
|
-
|
|
15
|
-
import * as React from "@uniflowed/react";
|
|
16
|
-
import { useCallback, useState } from "@uniflowed/react";
|
|
17
|
-
|
|
18
|
-
import { composeHandlers, withoutComposed } from "./props.js";
|
|
19
|
-
|
|
20
|
-
/** Space toggles, and so does Enter, because both do on a native control. */
|
|
21
|
-
function toggleKeys(event: SyntheticKeyboardEvent<HTMLElement>, toggle: () => void): void {
|
|
22
|
-
if (event.key !== " " && event.key !== "Enter") {
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
// Space scrolls the page otherwise, which is what makes a hand-written
|
|
26
|
-
// toggle feel broken even when it works.
|
|
27
|
-
event.preventDefault();
|
|
28
|
-
toggle();
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** State for a control that may be controlled or not. */
|
|
32
|
-
function useToggle(
|
|
33
|
-
checked: boolean | void,
|
|
34
|
-
defaultChecked: boolean,
|
|
35
|
-
onCheckedChange: ((checked: boolean) => void) | void,
|
|
36
|
-
): [boolean, () => void] {
|
|
37
|
-
const [internal, setInternal] = useState(defaultChecked);
|
|
38
|
-
const current = checked ?? internal;
|
|
39
|
-
|
|
40
|
-
const toggle = useCallback(() => {
|
|
41
|
-
const next = !current;
|
|
42
|
-
if (checked == null) {
|
|
43
|
-
setInternal(next);
|
|
44
|
-
}
|
|
45
|
-
onCheckedChange?.(next);
|
|
46
|
-
}, [checked, current, onCheckedChange]);
|
|
47
|
-
|
|
48
|
-
return [current, toggle];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** A two-state switch: on or off. */
|
|
52
|
-
export component Switch(
|
|
53
|
-
checked?: boolean,
|
|
54
|
-
defaultChecked?: boolean = false,
|
|
55
|
-
onCheckedChange?: (checked: boolean) => void,
|
|
56
|
-
disabled?: boolean = false,
|
|
57
|
-
children?: React.Node,
|
|
58
|
-
...rest: { readonly [string]: mixed }
|
|
59
|
-
) {
|
|
60
|
-
const [on, toggle] = useToggle(checked, defaultChecked, onCheckedChange);
|
|
61
|
-
const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
|
|
62
|
-
|
|
63
|
-
return (
|
|
64
|
-
<button
|
|
65
|
-
{...passed}
|
|
66
|
-
aria-checked={on ? "true" : "false"}
|
|
67
|
-
disabled={disabled}
|
|
68
|
-
onClick={composeHandlers(rest.onClick, () => {
|
|
69
|
-
if (!disabled) {
|
|
70
|
-
toggle();
|
|
71
|
-
}
|
|
72
|
-
})}
|
|
73
|
-
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
74
|
-
if (!disabled) {
|
|
75
|
-
toggleKeys(event, toggle);
|
|
76
|
-
}
|
|
77
|
-
})}
|
|
78
|
-
role="switch"
|
|
79
|
-
type="button"
|
|
80
|
-
>
|
|
81
|
-
{children}
|
|
82
|
-
</button>
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** A checkbox, which may also be indeterminate. */
|
|
87
|
-
export component Checkbox(
|
|
88
|
-
checked?: boolean,
|
|
89
|
-
defaultChecked?: boolean = false,
|
|
90
|
-
indeterminate?: boolean = false,
|
|
91
|
-
onCheckedChange?: (checked: boolean) => void,
|
|
92
|
-
disabled?: boolean = false,
|
|
93
|
-
children?: React.Node,
|
|
94
|
-
...rest: { readonly [string]: mixed }
|
|
95
|
-
) {
|
|
96
|
-
const [on, toggle] = useToggle(checked, defaultChecked, onCheckedChange);
|
|
97
|
-
const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
|
|
98
|
-
|
|
99
|
-
return (
|
|
100
|
-
<button
|
|
101
|
-
{...passed}
|
|
102
|
-
// "mixed" is the third state, and it is why a checkbox cannot simply be
|
|
103
|
-
// a switch with a different label.
|
|
104
|
-
aria-checked={indeterminate ? "mixed" : on ? "true" : "false"}
|
|
105
|
-
disabled={disabled}
|
|
106
|
-
onClick={composeHandlers(rest.onClick, () => {
|
|
107
|
-
if (!disabled) {
|
|
108
|
-
toggle();
|
|
109
|
-
}
|
|
110
|
-
})}
|
|
111
|
-
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
112
|
-
if (!disabled) {
|
|
113
|
-
toggleKeys(event, toggle);
|
|
114
|
-
}
|
|
115
|
-
})}
|
|
116
|
-
role="checkbox"
|
|
117
|
-
type="button"
|
|
118
|
-
>
|
|
119
|
-
{children}
|
|
120
|
-
</button>
|
|
121
|
-
);
|
|
122
|
-
}
|
package/internal/tabs.js
DELETED
|
@@ -1,270 +0,0 @@
|
|
|
1
|
-
// @flow
|
|
2
|
-
//
|
|
3
|
-
// Tabs, with the keyboard behaviour the pattern requires.
|
|
4
|
-
//
|
|
5
|
-
// A tab list is not a row of buttons. Only one tab is in the page's tab order —
|
|
6
|
-
// Tab moves *into* and *out of* the list, and the arrow keys move between the
|
|
7
|
-
// tabs inside it — because a list of twelve tabs that each take a Tab press
|
|
8
|
-
// makes the rest of the page unreachable for anyone not using a mouse. That is
|
|
9
|
-
// a roving `tabindex`, and it is the thing hand-written tabs almost always
|
|
10
|
-
// leave out.
|
|
11
|
-
//
|
|
12
|
-
// This is also where Flow says something no other type system can. `Tabs.List`
|
|
13
|
-
// takes `renders* TabsTab`, so putting a `<button>` in the list is a type
|
|
14
|
-
// error rather than a screen reader announcing "button" where the user expects
|
|
15
|
-
// "tab, 2 of 5".
|
|
16
|
-
|
|
17
|
-
import * as React from "@uniflowed/react";
|
|
18
|
-
|
|
19
|
-
import { composeHandlers, composeRefs, withoutComposed } from "./props.js";
|
|
20
|
-
import {
|
|
21
|
-
createContext,
|
|
22
|
-
useCallback,
|
|
23
|
-
useContext,
|
|
24
|
-
useId,
|
|
25
|
-
useMemo,
|
|
26
|
-
useRef,
|
|
27
|
-
useState,
|
|
28
|
-
} from "@uniflowed/react";
|
|
29
|
-
|
|
30
|
-
type TabsState = {|
|
|
31
|
-
readonly base: string,
|
|
32
|
-
readonly selected: string,
|
|
33
|
-
readonly select: (value: string) => void,
|
|
34
|
-
readonly register: (value: string, element: HTMLElement | null, disabled: boolean) => void,
|
|
35
|
-
/**
|
|
36
|
-
* Focus the tab `pick` chooses, given where we are and how many there are.
|
|
37
|
-
*
|
|
38
|
-
* `pick` returns the index to aim for and the direction to keep searching in
|
|
39
|
-
* when that tab is disabled. The direction cannot be inferred from the
|
|
40
|
-
* index: `End` aims at the last tab and, if it is disabled, has to walk
|
|
41
|
-
* *backwards* to the last enabled one — inferring "forwards" from the target
|
|
42
|
-
* being ahead of us wrapped around to the first tab instead.
|
|
43
|
-
*/
|
|
44
|
-
readonly focusBy: (from: string, pick: (at: number, count: number) => [number, 1 | -1]) => void,
|
|
45
|
-
|};
|
|
46
|
-
|
|
47
|
-
const TabsContext: React.Context<TabsState | null> = createContext(null);
|
|
48
|
-
|
|
49
|
-
function useTabs(part: string): TabsState {
|
|
50
|
-
const state = useContext(TabsContext);
|
|
51
|
-
if (state == null) {
|
|
52
|
-
throw new Error(`${part} must be rendered inside a Tabs.Root`);
|
|
53
|
-
}
|
|
54
|
-
return state;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* The tab set.
|
|
59
|
-
*
|
|
60
|
-
* Uncontrolled by default and controlled when `value` is given, which is the
|
|
61
|
-
* distinction every one of these components needs: a form library owns the
|
|
62
|
-
* value, and a page that just wants tabs does not.
|
|
63
|
-
*/
|
|
64
|
-
export component TabsRoot(
|
|
65
|
-
children: React.Node,
|
|
66
|
-
defaultValue: string,
|
|
67
|
-
value?: string,
|
|
68
|
-
onValueChange?: (value: string) => void,
|
|
69
|
-
...rest: { readonly [string]: mixed }
|
|
70
|
-
) {
|
|
71
|
-
const base = useId();
|
|
72
|
-
const [internal, setInternal] = useState(defaultValue);
|
|
73
|
-
const selected = value ?? internal;
|
|
74
|
-
// The order tabs were mounted in, which is document order, and is what the
|
|
75
|
-
// arrow keys move through.
|
|
76
|
-
const order = useRef<Array<string>>([]);
|
|
77
|
-
const elements = useRef<{ [string]: HTMLElement }>({});
|
|
78
|
-
|
|
79
|
-
const select = useCallback(
|
|
80
|
-
(next: string) => {
|
|
81
|
-
if (value == null) {
|
|
82
|
-
setInternal(next);
|
|
83
|
-
}
|
|
84
|
-
onValueChange?.(next);
|
|
85
|
-
},
|
|
86
|
-
[value, onValueChange],
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
const disabledTabs = useRef<{ [string]: boolean }>({});
|
|
90
|
-
|
|
91
|
-
const register = useCallback((tab: string, element: HTMLElement | null, disabled: boolean) => {
|
|
92
|
-
if (element == null) {
|
|
93
|
-
order.current = order.current.filter((entry) => entry !== tab);
|
|
94
|
-
delete elements.current[tab];
|
|
95
|
-
delete disabledTabs.current[tab];
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
if (!order.current.includes(tab)) {
|
|
99
|
-
order.current.push(tab);
|
|
100
|
-
}
|
|
101
|
-
elements.current[tab] = element;
|
|
102
|
-
disabledTabs.current[tab] = disabled;
|
|
103
|
-
}, []);
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Focus and select the first enabled tab at or after `index`.
|
|
107
|
-
*
|
|
108
|
-
* Disabled tabs are stepped over rather than landed on. Selecting one meant
|
|
109
|
-
* the panel changed to a tab that cannot take focus, so focus stayed where
|
|
110
|
-
* it was and the next arrow press started from the wrong place — after which
|
|
111
|
-
* the tabs beyond the disabled one were unreachable by keyboard.
|
|
112
|
-
*/
|
|
113
|
-
const focusAt = useCallback(
|
|
114
|
-
(index: number, step: number = 1) => {
|
|
115
|
-
const tabs = order.current;
|
|
116
|
-
if (tabs.length === 0) {
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
const wrap = (at: number) => ((at % tabs.length) + tabs.length) % tabs.length;
|
|
120
|
-
const direction = step === 0 ? 1 : step;
|
|
121
|
-
|
|
122
|
-
for (let tried = 0; tried < tabs.length; tried += 1) {
|
|
123
|
-
const tab = tabs[wrap(index + tried * direction)];
|
|
124
|
-
if (disabledTabs.current[tab] === true) {
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
select(tab);
|
|
128
|
-
// Selection follows focus, which is the pattern for tabs whose panels
|
|
129
|
-
// are already in the document: one key press per tab rather than an
|
|
130
|
-
// arrow and then a space.
|
|
131
|
-
elements.current[tab]?.focus();
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
// Every tab is disabled, so there is nowhere to go.
|
|
135
|
-
},
|
|
136
|
-
[select],
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
const state = useMemo(
|
|
140
|
-
() => ({
|
|
141
|
-
base,
|
|
142
|
-
selected,
|
|
143
|
-
select,
|
|
144
|
-
register,
|
|
145
|
-
focusBy: (from: string, pick: (at: number, count: number) => [number, 1 | -1]) => {
|
|
146
|
-
const [target, direction] = pick(order.current.indexOf(from), order.current.length);
|
|
147
|
-
focusAt(target, direction);
|
|
148
|
-
},
|
|
149
|
-
}),
|
|
150
|
-
[base, selected, select, register, focusAt],
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
return (
|
|
154
|
-
<TabsContext.Provider value={state}>
|
|
155
|
-
<div {...rest}>{children}</div>
|
|
156
|
-
</TabsContext.Provider>
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* The row of tabs.
|
|
162
|
-
*
|
|
163
|
-
* `renders* TabsTab` is the constraint: the children have to be tabs. A
|
|
164
|
-
* `<button>` here would be announced as a button inside a tablist, which is
|
|
165
|
-
* how a keyboard user ends up unable to tell where they are.
|
|
166
|
-
*/
|
|
167
|
-
export component TabsList(children: renders* TabsTab, ...rest: { readonly [string]: mixed }) {
|
|
168
|
-
return (
|
|
169
|
-
<div {...rest} role="tablist">
|
|
170
|
-
{children}
|
|
171
|
-
</div>
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** One tab. Exactly one of them is in the page's tab order. */
|
|
176
|
-
export component TabsTab(
|
|
177
|
-
value: string,
|
|
178
|
-
children: React.Node,
|
|
179
|
-
disabled?: boolean = false,
|
|
180
|
-
...rest: { readonly [string]: mixed }
|
|
181
|
-
) {
|
|
182
|
-
const tabs = useTabs("Tabs.Tab");
|
|
183
|
-
const active = tabs.selected === value;
|
|
184
|
-
|
|
185
|
-
const passed = withoutComposed(rest, ["onClick", "onKeyDown", "ref"]);
|
|
186
|
-
|
|
187
|
-
return (
|
|
188
|
-
<button
|
|
189
|
-
// `passed` first, and everything this component owns after it. A caller
|
|
190
|
-
// `ref` used to replace the registration ref, which took the tab out of
|
|
191
|
-
// the keyboard order without any sign that it had.
|
|
192
|
-
{...passed}
|
|
193
|
-
aria-controls={`${tabs.base}-panel-${value}`}
|
|
194
|
-
aria-selected={active ? "true" : "false"}
|
|
195
|
-
disabled={disabled}
|
|
196
|
-
id={`${tabs.base}-tab-${value}`}
|
|
197
|
-
onClick={composeHandlers(rest.onClick, () => {
|
|
198
|
-
if (!disabled) {
|
|
199
|
-
tabs.select(value);
|
|
200
|
-
}
|
|
201
|
-
})}
|
|
202
|
-
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
203
|
-
const intent = arrowKey(event.key);
|
|
204
|
-
if (intent == null) {
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
// Prevent the default before moving, or the arrow also scrolls the
|
|
208
|
-
// page under the tab that just took focus.
|
|
209
|
-
event.preventDefault();
|
|
210
|
-
// `match` is an expression, so it computes the index to move to rather
|
|
211
|
-
// than performing the four movements — which also means adding a key
|
|
212
|
-
// to `arrowKey` stops compiling here until it is handled.
|
|
213
|
-
tabs.focusBy(
|
|
214
|
-
value,
|
|
215
|
-
(at, count) =>
|
|
216
|
-
match (intent) {
|
|
217
|
-
"previous" => [at - 1, -1],
|
|
218
|
-
"next" => [at + 1, 1],
|
|
219
|
-
"first" => [0, 1],
|
|
220
|
-
"last" => [count - 1, -1],
|
|
221
|
-
},
|
|
222
|
-
);
|
|
223
|
-
})}
|
|
224
|
-
ref={composeRefs(rest.ref, (element) => tabs.register(value, element, disabled))}
|
|
225
|
-
role="tab"
|
|
226
|
-
// The roving tabindex: Tab reaches the selected tab and nothing else in
|
|
227
|
-
// the list, so it moves past the whole set in one press.
|
|
228
|
-
tabIndex={active ? 0 : -1}
|
|
229
|
-
type="button"
|
|
230
|
-
>
|
|
231
|
-
{children}
|
|
232
|
-
</button>
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** The panel a tab controls, rendered only while its tab is selected. */
|
|
237
|
-
export component TabsPanel(
|
|
238
|
-
value: string,
|
|
239
|
-
children: React.Node,
|
|
240
|
-
...rest: { readonly [string]: mixed }
|
|
241
|
-
) {
|
|
242
|
-
const tabs = useTabs("Tabs.Panel");
|
|
243
|
-
if (tabs.selected !== value) {
|
|
244
|
-
return null;
|
|
245
|
-
}
|
|
246
|
-
return (
|
|
247
|
-
<div
|
|
248
|
-
{...rest}
|
|
249
|
-
aria-labelledby={`${tabs.base}-tab-${value}`}
|
|
250
|
-
id={`${tabs.base}-panel-${value}`}
|
|
251
|
-
role="tabpanel"
|
|
252
|
-
// The panel itself is focusable so that Tab out of the tab list lands on
|
|
253
|
-
// the content the tab describes, which is where the reader expects to go.
|
|
254
|
-
tabIndex={0}
|
|
255
|
-
>
|
|
256
|
-
{children}
|
|
257
|
-
</div>
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** Which movement a key asks for, or nothing if the key is not ours. */
|
|
262
|
-
function arrowKey(key: string): "previous" | "next" | "first" | "last" | null {
|
|
263
|
-
return match (key) {
|
|
264
|
-
"ArrowLeft" | "ArrowUp" => "previous",
|
|
265
|
-
"ArrowRight" | "ArrowDown" => "next",
|
|
266
|
-
"Home" => "first",
|
|
267
|
-
"End" => "last",
|
|
268
|
-
_ => null,
|
|
269
|
-
};
|
|
270
|
-
}
|