@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/accordion.js +335 -0
- package/alert-dialog.js +256 -0
- package/carousel.js +410 -0
- package/checkbox.js +80 -0
- package/collapsible.js +147 -0
- package/combobox.js +557 -0
- package/dialog.js +499 -0
- package/drawer.js +458 -0
- package/field.js +170 -0
- package/hover-card.js +334 -0
- package/index.js +1012 -0
- package/input-otp.js +218 -0
- package/internal/anchor.js +500 -0
- package/internal/controlled-state.js +65 -0
- package/internal/disclosure.js +97 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +201 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +654 -0
- package/navigation-menu.js +251 -0
- package/package.json +57 -0
- package/pagination.js +197 -0
- package/popover.js +326 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/scroll-area.js +283 -0
- package/select.js +855 -0
- package/sheet.js +165 -0
- package/sidebar.js +300 -0
- package/slider.js +405 -0
- package/switch.js +73 -0
- package/table.js +479 -0
- package/tabs.js +280 -0
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
- package/tooltip.js +411 -0
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
|
+
}
|