@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.
@@ -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
- }