@uniflowed/ui 0.0.0-alpha.10

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/tabs.js ADDED
@@ -0,0 +1,280 @@
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 everything after it unreachable for anyone not using a mouse. That is a
9
+ // roving `tabindex`, and it is the thing hand-written tabs almost always leave
10
+ // out.
11
+ //
12
+ // # Automatic and manual activation
13
+ //
14
+ // The second thing they leave out is the choice between them, and it is not a
15
+ // preference: it is about what a panel costs to show.
16
+ //
17
+ // * **Automatic** — the default. Moving to a tab selects it, so reaching a
18
+ // panel is one key press. This is what the pattern prescribes when the
19
+ // panels are already in the document and showing one is free.
20
+ // * **Manual** — arrow keys move focus and select nothing until `Enter` or
21
+ // `Space`. This is what a panel that fetches, or that takes real work to
22
+ // render, needs: with automatic activation a reader arrowing from the first
23
+ // tab to the fourth starts three loads they did not ask for, and a screen
24
+ // reader announces three panels they never wanted to hear about.
25
+ //
26
+ // # Which arrow keys
27
+ //
28
+ // `orientation` decides, and the keys it does *not* claim matter as much as the
29
+ // ones it does: `ArrowDown` in a horizontal tab list belongs to the page, and a
30
+ // component that swallows it has taken scrolling away from every reader who
31
+ // uses the keyboard to read.
32
+ //
33
+ // # Composition is type-checked
34
+ //
35
+ // `Tabs.List` takes `renders* TabsTab`, so putting a `<button>` in the list is
36
+ // a *type error* rather than a screen reader announcing "button" where the
37
+ // reader expected "tab, 2 of 5". A library written in TypeScript can document
38
+ // that constraint; Flow can state it.
39
+
40
+ "use client";
41
+
42
+ import * as React from "@uniflowed/react";
43
+ import {
44
+ createContext,
45
+ useCallback,
46
+ useContext,
47
+ useEffect,
48
+ useId,
49
+ useMemo,
50
+ useState,
51
+ } from "@uniflowed/react";
52
+
53
+ import type { Rest } from "./internal/merge-props.js";
54
+ import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
55
+ import { moveOnKey } from "./internal/roving-focus.js";
56
+ import { useControlled } from "./internal/controlled-state.js";
57
+ import type { Orientation } from "./internal/roving-focus.js";
58
+
59
+ /** When a tab becomes the selected one. */
60
+ export type ActivationMode = "automatic" | "manual";
61
+
62
+ type TabsState = {|
63
+ readonly base: string,
64
+ readonly selected: string,
65
+ readonly select: (value: string) => void,
66
+ readonly orientation: Orientation,
67
+ readonly activation: ActivationMode,
68
+ /** The panel values currently mounted, so a tab only claims one that exists. */
69
+ readonly mounted: $ReadOnlyArray<string>,
70
+ readonly registerPanel: (value: string, present: boolean) => void,
71
+ |};
72
+
73
+ const TabsContext: React.Context<TabsState | null> = createContext(null);
74
+
75
+ hook useTabs(part: string): TabsState {
76
+ const state = useContext(TabsContext);
77
+ if (state == null) {
78
+ throw new Error(`${part} must be rendered inside a Tabs.Root`);
79
+ }
80
+ return state;
81
+ }
82
+
83
+ /**
84
+ * The tab set.
85
+ *
86
+ * Uncontrolled by default and controlled when `value` is given, which is the
87
+ * distinction every one of these components needs: a form library owns the
88
+ * value, and a page that just wants tabs does not.
89
+ */
90
+ export component TabsRoot(
91
+ children: React.Node,
92
+ defaultValue: string,
93
+ value?: string,
94
+ onValueChange?: (value: string) => void,
95
+ activationMode?: ActivationMode = "automatic",
96
+ orientation?: Orientation = "horizontal",
97
+ ...rest: Rest
98
+ ) {
99
+ const base = useId();
100
+ const [selected, select] = useControlled(value, defaultValue, onValueChange);
101
+ const [mounted, setMounted] = useState<$ReadOnlyArray<string>>([]);
102
+
103
+ // Functional updates, so two panels mounting in the same commit do not each
104
+ // overwrite the other's registration with a list computed before it existed.
105
+ const registerPanel = useCallback((panel: string, present: boolean) => {
106
+ setMounted((current) => {
107
+ const has = current.includes(panel);
108
+ if (present === has) {
109
+ return current;
110
+ }
111
+ return present ? [...current, panel] : current.filter((each) => each !== panel);
112
+ });
113
+ }, []);
114
+
115
+ const state = useMemo(
116
+ () => ({
117
+ base,
118
+ selected,
119
+ select,
120
+ orientation,
121
+ activation: activationMode,
122
+ mounted,
123
+ registerPanel,
124
+ }),
125
+ [base, selected, select, orientation, activationMode, mounted, registerPanel],
126
+ );
127
+
128
+ return (
129
+ <TabsContext.Provider value={state}>
130
+ <div {...rest}>{children}</div>
131
+ </TabsContext.Provider>
132
+ );
133
+ }
134
+
135
+ /**
136
+ * The row of tabs, and the one place the arrow keys are handled.
137
+ *
138
+ * The handler is here rather than on each tab because the keys are about the
139
+ * *set*: "the next tab" is a question only the list can answer, and answering it
140
+ * from the DOM at the moment of the press means a tab added, removed or
141
+ * reordered since the last render is still in the right place. A registry the
142
+ * tabs push themselves into as they mount answers with mount order, which stops
143
+ * being document order the first time a tab is conditional.
144
+ */
145
+ export component TabsList(children: renders* TabsTab, ...rest: Rest) {
146
+ const tabs = useTabs("Tabs.List");
147
+ const passed = withoutComposed(rest, ["onKeyDown"]);
148
+
149
+ return (
150
+ <div
151
+ {...passed}
152
+ // A screen reader announces the axis, and it is also what tells a reader
153
+ // which arrow keys to try.
154
+ aria-orientation={tabs.orientation}
155
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
156
+ const list: $FlowFixMe = event.currentTarget;
157
+ // The list the key arrived on carries the answer to both halves of
158
+ // this: which items there are, and which way the page reads — so a tab
159
+ // set inside somebody else's `dir="rtl"` walks the right way without
160
+ // the caller having had to know it needed to say so.
161
+ const next = moveOnKey(event, list, {
162
+ item: '[role="tab"]',
163
+ owner: '[role="tablist"]',
164
+ orientation: tabs.orientation,
165
+ wrap: true,
166
+ skipDisabled: true,
167
+ });
168
+ if (next != null && tabs.activation === "automatic") {
169
+ tabs.select(next.getAttribute("data-value") ?? "");
170
+ }
171
+ })}
172
+ role="tablist"
173
+ >
174
+ {children}
175
+ </div>
176
+ );
177
+ }
178
+
179
+ /**
180
+ * One tab. Exactly one of them is in the page's tab order.
181
+ *
182
+ * A disabled tab is `aria-disabled` rather than `disabled`, so it stays in the
183
+ * accessibility tree: a reader is told "Billing, tab, dimmed, 3 of 5" and knows
184
+ * the section exists and is unavailable, where a native `disabled` would leave a
185
+ * gap they cannot ask about. The keyboard steps over it either way.
186
+ */
187
+ export component TabsTab(
188
+ value: string,
189
+ children: React.Node,
190
+ disabled?: boolean = false,
191
+ ...rest: Rest
192
+ ) {
193
+ const tabs = useTabs("Tabs.Tab");
194
+ const active = tabs.selected === value;
195
+ const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
196
+
197
+ return (
198
+ <button
199
+ // `passed` first, and everything this component owns after it. A caller
200
+ // `onClick` used to replace the selection handler, so clicking a tab did
201
+ // nothing at all.
202
+ {...passed}
203
+ aria-disabled={disabled ? "true" : undefined}
204
+ // Only when the panel is actually mounted. Panels are rendered on demand,
205
+ // and a tab pointing `aria-controls` at an id that is not in the document
206
+ // tells a reader there is somewhere to go and then has nowhere to send
207
+ // them.
208
+ aria-controls={tabs.mounted.includes(value) ? `${tabs.base}-panel-${value}` : undefined}
209
+ aria-selected={active ? "true" : "false"}
210
+ // Read by the list's key handler, which finds tabs in the document rather
211
+ // than in a registry and so needs each one to carry its own value.
212
+ data-value={value}
213
+ id={`${tabs.base}-tab-${value}`}
214
+ onClick={composeHandlers(rest.onClick, () => {
215
+ if (!disabled) {
216
+ tabs.select(value);
217
+ }
218
+ })}
219
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
220
+ // Manual activation's other half: the arrows moved focus here without
221
+ // selecting, and this is how the reader says they meant it.
222
+ if (event.key !== "Enter" && event.key !== " ") {
223
+ return;
224
+ }
225
+ event.preventDefault();
226
+ if (!disabled) {
227
+ tabs.select(value);
228
+ }
229
+ })}
230
+ role="tab"
231
+ // The roving tabindex: Tab reaches the selected tab and nothing else in
232
+ // the list, so it moves past the whole set in one press.
233
+ tabIndex={active ? 0 : -1}
234
+ type="button"
235
+ >
236
+ {children}
237
+ </button>
238
+ );
239
+ }
240
+
241
+ /**
242
+ * The panel a tab controls, rendered only while its tab is selected.
243
+ *
244
+ * It registers itself with the root while it is mounted, which is what lets
245
+ * `Tabs.Tab` decide whether it has a panel to name. That has to be a real
246
+ * subscription rather than "the selected value equals mine", because a caller
247
+ * may render a subset of panels, or none at all until data arrives.
248
+ */
249
+ export component TabsPanel(value: string, children: React.Node, ...rest: Rest) {
250
+ const tabs = useTabs("Tabs.Panel");
251
+ const register = tabs.registerPanel;
252
+ const selected = tabs.selected === value;
253
+
254
+ useEffect(() => {
255
+ if (!selected) {
256
+ return;
257
+ }
258
+ register(value, true);
259
+ return () => register(value, false);
260
+ }, [register, value, selected]);
261
+
262
+ if (!selected) {
263
+ return null;
264
+ }
265
+
266
+ return (
267
+ <div
268
+ {...rest}
269
+ aria-labelledby={`${tabs.base}-tab-${value}`}
270
+ id={`${tabs.base}-panel-${value}`}
271
+ role="tabpanel"
272
+ // The panel itself is focusable so that Tab out of the tab list lands on
273
+ // the content the tab describes, which is where the reader expects to go
274
+ // and where a panel of plain prose has nothing else to offer.
275
+ tabIndex={0}
276
+ >
277
+ {children}
278
+ </div>
279
+ );
280
+ }