@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/switch.js ADDED
@@ -0,0 +1,72 @@
1
+ // @flow
2
+ //
3
+ // A switch: two states, and a screen reader that says which.
4
+ //
5
+ // It exists because the styled version of an on/off control is almost always a
6
+ // `div` with a knob drawn in it, and the moment it stops being a real control it
7
+ // stops being announced, stops toggling on `Space`, and stops being reachable by
8
+ // `Tab`. This keeps all three — the role, the `aria-checked` state, and the
9
+ // keys — while shipping no styles at all.
10
+ //
11
+ // # A switch is not a checkbox
12
+ //
13
+ // A screen reader says "on" and "off" for a switch and "checked" and
14
+ // "unchecked" for a checkbox, and the two are not interchangeable: a checkbox
15
+ // answers a question ("include me in the mailing list") and a switch operates a
16
+ // thing ("notifications, on"). A checkbox also has a third state that a switch
17
+ // does not, which is why `checkbox.js` is a separate component rather than this
18
+ // one with a different `role`.
19
+ //
20
+ // The keyboard follows from the same distinction. `Space` toggles both. `Enter`
21
+ // toggles a *switch*, because a switch is an operation and pressing Enter on
22
+ // something that operates is what a reader expects — while `checkbox.js`
23
+ // deliberately leaves `Enter` alone so that a checkbox inside a form still
24
+ // submits it. That is the whole reason these are not one file with a flag.
25
+
26
+ "use client";
27
+
28
+ import * as React from "@uniflowed/react";
29
+
30
+ import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
31
+ import { useControlled } from "./internal/controlled-state.js";
32
+
33
+ /** A two-state switch: on or off. */
34
+ export component Switch(
35
+ checked?: boolean,
36
+ defaultChecked?: boolean = false,
37
+ onCheckedChange?: (checked: boolean) => void,
38
+ disabled?: boolean = false,
39
+ children?: React.Node,
40
+ ...rest: { readonly [string]: mixed }
41
+ ) {
42
+ const [on, setOn] = useControlled(checked, defaultChecked, onCheckedChange);
43
+ const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
44
+
45
+ return (
46
+ <button
47
+ {...passed}
48
+ aria-checked={on ? "true" : "false"}
49
+ disabled={disabled}
50
+ onClick={composeHandlers(rest.onClick, () => {
51
+ if (!disabled) {
52
+ setOn(!on);
53
+ }
54
+ })}
55
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
56
+ if (disabled || (event.key !== " " && event.key !== "Enter")) {
57
+ return;
58
+ }
59
+ // Preventing the default is not decoration. It stops `Space` scrolling
60
+ // the page — which is what makes a hand-written toggle feel broken even
61
+ // when it works — and it stops the browser's own click from arriving
62
+ // after this handler and toggling the switch a second time.
63
+ event.preventDefault();
64
+ setOn(!on);
65
+ })}
66
+ role="switch"
67
+ type="button"
68
+ >
69
+ {children}
70
+ </button>
71
+ );
72
+ }
package/tabs.js ADDED
@@ -0,0 +1,286 @@
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 { composeHandlers, withoutComposed } from "./internal/merge-props.js";
54
+ import { indexOfActive, itemsOf, movementFor, moveTo } from "./internal/roving-focus.js";
55
+ import { useControlled } from "./internal/controlled-state.js";
56
+ import type { Orientation } from "./internal/roving-focus.js";
57
+
58
+ /** When a tab becomes the selected one. */
59
+ export type ActivationMode = "automatic" | "manual";
60
+
61
+ type TabsState = {|
62
+ readonly base: string,
63
+ readonly selected: string,
64
+ readonly select: (value: string) => void,
65
+ readonly orientation: Orientation,
66
+ readonly activation: ActivationMode,
67
+ /** The panel values currently mounted, so a tab only claims one that exists. */
68
+ readonly mounted: $ReadOnlyArray<string>,
69
+ readonly registerPanel: (value: string, present: boolean) => void,
70
+ |};
71
+
72
+ const TabsContext: React.Context<TabsState | null> = createContext(null);
73
+
74
+ hook useTabs(part: string): TabsState {
75
+ const state = useContext(TabsContext);
76
+ if (state == null) {
77
+ throw new Error(`${part} must be rendered inside a Tabs.Root`);
78
+ }
79
+ return state;
80
+ }
81
+
82
+ /**
83
+ * The tab set.
84
+ *
85
+ * Uncontrolled by default and controlled when `value` is given, which is the
86
+ * distinction every one of these components needs: a form library owns the
87
+ * value, and a page that just wants tabs does not.
88
+ */
89
+ export component TabsRoot(
90
+ children: React.Node,
91
+ defaultValue: string,
92
+ value?: string,
93
+ onValueChange?: (value: string) => void,
94
+ activationMode?: ActivationMode = "automatic",
95
+ orientation?: Orientation = "horizontal",
96
+ ...rest: { readonly [string]: mixed }
97
+ ) {
98
+ const base = useId();
99
+ const [selected, select] = useControlled(value, defaultValue, onValueChange);
100
+ const [mounted, setMounted] = useState<$ReadOnlyArray<string>>([]);
101
+
102
+ // Functional updates, so two panels mounting in the same commit do not each
103
+ // overwrite the other's registration with a list computed before it existed.
104
+ const registerPanel = useCallback((panel: string, present: boolean) => {
105
+ setMounted((current) => {
106
+ const has = current.includes(panel);
107
+ if (present === has) {
108
+ return current;
109
+ }
110
+ return present ? [...current, panel] : current.filter((each) => each !== panel);
111
+ });
112
+ }, []);
113
+
114
+ const state = useMemo(
115
+ () => ({
116
+ base,
117
+ selected,
118
+ select,
119
+ orientation,
120
+ activation: activationMode,
121
+ mounted,
122
+ registerPanel,
123
+ }),
124
+ [base, selected, select, orientation, activationMode, mounted, registerPanel],
125
+ );
126
+
127
+ return (
128
+ <TabsContext.Provider value={state}>
129
+ <div {...rest}>{children}</div>
130
+ </TabsContext.Provider>
131
+ );
132
+ }
133
+
134
+ /**
135
+ * The row of tabs, and the one place the arrow keys are handled.
136
+ *
137
+ * The handler is here rather than on each tab because the keys are about the
138
+ * *set*: "the next tab" is a question only the list can answer, and answering it
139
+ * from the DOM at the moment of the press means a tab added, removed or
140
+ * reordered since the last render is still in the right place. A registry the
141
+ * tabs push themselves into as they mount answers with mount order, which stops
142
+ * being document order the first time a tab is conditional.
143
+ */
144
+ export component TabsList(children: renders* TabsTab, ...rest: { readonly [string]: mixed }) {
145
+ const tabs = useTabs("Tabs.List");
146
+ const passed = withoutComposed(rest, ["onKeyDown"]);
147
+
148
+ return (
149
+ <div
150
+ {...passed}
151
+ // A screen reader announces the axis, and it is also what tells a reader
152
+ // which arrow keys to try.
153
+ aria-orientation={tabs.orientation}
154
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
155
+ const list: $FlowFixMe = event.currentTarget;
156
+ const movement = movementFor(event.key, tabs.orientation);
157
+ if (movement == null) {
158
+ return;
159
+ }
160
+ const items = itemsOf(list, '[role="tab"]', '[role="tablist"]');
161
+ const active = list.ownerDocument?.activeElement;
162
+ const next = moveTo(items, indexOfActive(items, active), movement, true);
163
+ if (next == null) {
164
+ return;
165
+ }
166
+ // Before moving, or the arrow also scrolls the page under the tab that
167
+ // just took focus.
168
+ event.preventDefault();
169
+ next.focus();
170
+ if (tabs.activation === "automatic") {
171
+ tabs.select(next.getAttribute("data-value") ?? "");
172
+ }
173
+ })}
174
+ role="tablist"
175
+ >
176
+ {children}
177
+ </div>
178
+ );
179
+ }
180
+
181
+ /**
182
+ * One tab. Exactly one of them is in the page's tab order.
183
+ *
184
+ * A disabled tab is `aria-disabled` rather than `disabled`, so it stays in the
185
+ * accessibility tree: a reader is told "Billing, tab, dimmed, 3 of 5" and knows
186
+ * the section exists and is unavailable, where a native `disabled` would leave a
187
+ * gap they cannot ask about. The keyboard steps over it either way.
188
+ */
189
+ export component TabsTab(
190
+ value: string,
191
+ children: React.Node,
192
+ disabled?: boolean = false,
193
+ ...rest: { readonly [string]: mixed }
194
+ ) {
195
+ const tabs = useTabs("Tabs.Tab");
196
+ const active = tabs.selected === value;
197
+ const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
198
+
199
+ return (
200
+ <button
201
+ // `passed` first, and everything this component owns after it. A caller
202
+ // `onClick` used to replace the selection handler, so clicking a tab did
203
+ // nothing at all.
204
+ {...passed}
205
+ aria-disabled={disabled ? "true" : undefined}
206
+ // Only when the panel is actually mounted. Panels are rendered on demand,
207
+ // and a tab pointing `aria-controls` at an id that is not in the document
208
+ // tells a reader there is somewhere to go and then has nowhere to send
209
+ // them.
210
+ aria-controls={tabs.mounted.includes(value) ? `${tabs.base}-panel-${value}` : undefined}
211
+ aria-selected={active ? "true" : "false"}
212
+ // Read by the list's key handler, which finds tabs in the document rather
213
+ // than in a registry and so needs each one to carry its own value.
214
+ data-value={value}
215
+ id={`${tabs.base}-tab-${value}`}
216
+ onClick={composeHandlers(rest.onClick, () => {
217
+ if (!disabled) {
218
+ tabs.select(value);
219
+ }
220
+ })}
221
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
222
+ // Manual activation's other half: the arrows moved focus here without
223
+ // selecting, and this is how the reader says they meant it.
224
+ if (event.key !== "Enter" && event.key !== " ") {
225
+ return;
226
+ }
227
+ event.preventDefault();
228
+ if (!disabled) {
229
+ tabs.select(value);
230
+ }
231
+ })}
232
+ role="tab"
233
+ // The roving tabindex: Tab reaches the selected tab and nothing else in
234
+ // the list, so it moves past the whole set in one press.
235
+ tabIndex={active ? 0 : -1}
236
+ type="button"
237
+ >
238
+ {children}
239
+ </button>
240
+ );
241
+ }
242
+
243
+ /**
244
+ * The panel a tab controls, rendered only while its tab is selected.
245
+ *
246
+ * It registers itself with the root while it is mounted, which is what lets
247
+ * `Tabs.Tab` decide whether it has a panel to name. That has to be a real
248
+ * subscription rather than "the selected value equals mine", because a caller
249
+ * may render a subset of panels, or none at all until data arrives.
250
+ */
251
+ export component TabsPanel(
252
+ value: string,
253
+ children: React.Node,
254
+ ...rest: { readonly [string]: mixed }
255
+ ) {
256
+ const tabs = useTabs("Tabs.Panel");
257
+ const register = tabs.registerPanel;
258
+ const selected = tabs.selected === value;
259
+
260
+ useEffect(() => {
261
+ if (!selected) {
262
+ return;
263
+ }
264
+ register(value, true);
265
+ return () => register(value, false);
266
+ }, [register, value, selected]);
267
+
268
+ if (!selected) {
269
+ return null;
270
+ }
271
+
272
+ return (
273
+ <div
274
+ {...rest}
275
+ aria-labelledby={`${tabs.base}-tab-${value}`}
276
+ id={`${tabs.base}-panel-${value}`}
277
+ role="tabpanel"
278
+ // The panel itself is focusable so that Tab out of the tab list lands on
279
+ // the content the tab describes, which is where the reader expects to go
280
+ // and where a panel of plain prose has nothing else to offer.
281
+ tabIndex={0}
282
+ >
283
+ {children}
284
+ </div>
285
+ );
286
+ }
@@ -1,236 +0,0 @@
1
- // @flow
2
- //
3
- // A modal dialog, which is the component people most often get wrong.
4
- //
5
- // Four things have to be true for a dialog to be usable by someone who is not
6
- // using a mouse, and a hand-written one usually has one or two of them:
7
- //
8
- // * Focus moves into the dialog when it opens, and to the first thing worth
9
- // acting on rather than to whatever happens to be first in the document.
10
- // * Tab cannot leave. A dialog you can Tab out of leaves the reader
11
- // somewhere in a page they cannot see, with no way back.
12
- // * Escape closes it.
13
- // * Focus returns to whatever opened it. Otherwise it restarts at the top of
14
- // the document, and the reader has to find their place again.
15
- //
16
- // The dialog is rendered where it is declared rather than through a portal.
17
- // A portal solves a stacking-context problem that belongs to CSS, and it costs
18
- // the thing this component is for: rendered in place, the dialog is next to its
19
- // trigger in the accessibility tree, which is where a screen reader looks.
20
-
21
- import * as React from "@uniflowed/react";
22
-
23
- import { composeHandlers, composeRefs, withoutComposed } from "./props.js";
24
- import {
25
- createContext,
26
- useCallback,
27
- useContext,
28
- useEffect,
29
- useId,
30
- useMemo,
31
- useRef,
32
- useState,
33
- } from "@uniflowed/react";
34
-
35
- type DialogState = {|
36
- readonly base: string,
37
- readonly open: boolean,
38
- readonly setOpen: (open: boolean) => void,
39
- readonly triggerRef: { current: HTMLElement | null },
40
- |};
41
-
42
- const DialogContext: React.Context<DialogState | null> = createContext(null);
43
-
44
- function useDialog(part: string): DialogState {
45
- const state = useContext(DialogContext);
46
- if (state == null) {
47
- throw new Error(`${part} must be rendered inside a Dialog.Root`);
48
- }
49
- return state;
50
- }
51
-
52
- /** The dialog, open or closed. Uncontrolled unless `open` is given. */
53
- export component DialogRoot(
54
- children: React.Node,
55
- defaultOpen?: boolean = false,
56
- open?: boolean,
57
- onOpenChange?: (open: boolean) => void,
58
- ) {
59
- const base = useId();
60
- const [internal, setInternal] = useState(defaultOpen);
61
- const triggerRef = useRef<HTMLElement | null>(null);
62
- const isOpen = open ?? internal;
63
-
64
- const setOpen = useCallback(
65
- (next: boolean) => {
66
- if (open == null) {
67
- setInternal(next);
68
- }
69
- onOpenChange?.(next);
70
- },
71
- [open, onOpenChange],
72
- );
73
-
74
- const state = useMemo(
75
- () => ({ base, open: isOpen, setOpen, triggerRef }),
76
- [base, isOpen, setOpen],
77
- );
78
-
79
- return <DialogContext.Provider value={state}>{children}</DialogContext.Provider>;
80
- }
81
-
82
- /** What opens the dialog, and what focus comes back to when it closes. */
83
- export component DialogTrigger(children: React.Node, ...rest: { readonly [string]: mixed }) {
84
- const dialog = useDialog("Dialog.Trigger");
85
- const passed = withoutComposed(rest, ["onClick", "ref"]);
86
-
87
- return (
88
- <button
89
- {...passed}
90
- aria-expanded={dialog.open ? "true" : "false"}
91
- aria-haspopup="dialog"
92
- onClick={composeHandlers(rest.onClick, () => dialog.setOpen(true))}
93
- ref={composeRefs(rest.ref, (element) => {
94
- dialog.triggerRef.current = element;
95
- })}
96
- type="button"
97
- >
98
- {children}
99
- </button>
100
- );
101
- }
102
-
103
- /**
104
- * The dialog itself: focus moved in, Tab kept inside, Escape closing it.
105
- *
106
- * `aria-modal` tells a screen reader that the rest of the page is not
107
- * available, which is the half of "modal" that CSS cannot express.
108
- */
109
- export component DialogContent(children: React.Node, ...rest: { readonly [string]: mixed }) {
110
- const dialog = useDialog("Dialog.Content");
111
- const contentRef = useRef<HTMLElement | null>(null);
112
-
113
- useEffect(() => {
114
- if (!dialog.open) {
115
- return;
116
- }
117
- const opener = dialog.triggerRef.current;
118
- const content = contentRef.current;
119
- // The first thing worth acting on, not the first thing in the document —
120
- // and the dialog itself if it contains nothing focusable, so focus is
121
- // inside it either way.
122
- const target = content == null ? null : (focusable(content)[0] ?? content);
123
- target?.focus();
124
-
125
- return () => {
126
- // Back to the trigger. Leaving focus on a removed node sends it to the
127
- // top of the document, and the reader has to find their place again.
128
- opener?.focus();
129
- };
130
- }, [dialog.open, dialog.triggerRef]);
131
-
132
- if (!dialog.open) {
133
- return null;
134
- }
135
-
136
- const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
137
-
138
- return (
139
- <div
140
- // `passed` first. A caller `ref` used to replace `contentRef`, which
141
- // left it null, made the Tab branch below return early, and turned the
142
- // focus trap off while the dialog still announced `aria-modal="true"`.
143
- // A caller `onKeyDown` used to replace this one, and Escape stopped
144
- // closing the dialog.
145
- {...passed}
146
- aria-labelledby={`${dialog.base}-title`}
147
- aria-modal="true"
148
- id={`${dialog.base}-content`}
149
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
150
- if (event.key === "Escape") {
151
- event.preventDefault();
152
- dialog.setOpen(false);
153
- return;
154
- }
155
- if (event.key !== "Tab") {
156
- return;
157
- }
158
- const content = contentRef.current;
159
- if (content == null) {
160
- return;
161
- }
162
- const stops = focusable(content);
163
- if (stops.length === 0) {
164
- // Nothing to move to, so Tab must not leave either.
165
- event.preventDefault();
166
- return;
167
- }
168
- const first = stops[0];
169
- const last = stops[stops.length - 1];
170
- const active = content.ownerDocument?.activeElement;
171
- // Wrap at the ends. This is the whole of "focus cannot leave": every
172
- // other Tab press is the browser's own business.
173
- if (event.shiftKey && (active === first || active === content)) {
174
- event.preventDefault();
175
- last.focus();
176
- } else if (!event.shiftKey && active === last) {
177
- event.preventDefault();
178
- first.focus();
179
- }
180
- })}
181
- ref={composeRefs(rest.ref, (element) => {
182
- contentRef.current = element;
183
- })}
184
- role="dialog"
185
- tabIndex={-1}
186
- >
187
- {children}
188
- </div>
189
- );
190
- }
191
-
192
- /** The dialog's accessible name, which `aria-labelledby` points at. */
193
- export component DialogTitle(children: React.Node, ...rest: { readonly [string]: mixed }) {
194
- const dialog = useDialog("Dialog.Title");
195
- return (
196
- <h2 {...rest} id={`${dialog.base}-title`}>
197
- {children}
198
- </h2>
199
- );
200
- }
201
-
202
- /** A button that closes the dialog. */
203
- export component DialogClose(children: React.Node, ...rest: { readonly [string]: mixed }) {
204
- const dialog = useDialog("Dialog.Close");
205
- const passed = withoutComposed(rest, ["onClick"]);
206
-
207
- return (
208
- <button
209
- {...passed}
210
- onClick={composeHandlers(rest.onClick, () => dialog.setOpen(false))}
211
- type="button"
212
- >
213
- {children}
214
- </button>
215
- );
216
- }
217
-
218
- /**
219
- * The focus stops inside an element, in document order.
220
- *
221
- * Disabled controls and `tabindex="-1"` are excluded because the browser
222
- * excludes them, and anything inside `[hidden]` or `aria-hidden` is excluded
223
- * because a reader cannot see it.
224
- */
225
- function focusable(root: HTMLElement): Array<HTMLElement> {
226
- const selector =
227
- 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
228
- return Array.from(root.querySelectorAll(selector)).filter(
229
- (element: any) =>
230
- // Both attributes hide a whole subtree, so both are checked on the
231
- // ancestors. Reading `aria-hidden` off the element alone returned a
232
- // button inside `<div aria-hidden="true">` as a focus stop, and the trap
233
- // then moved focus to a control no screen reader exposes.
234
- element.closest("[hidden]") == null && element.closest('[aria-hidden="true"]') == null,
235
- );
236
- }