@uniflowed/ui 0.0.0-alpha.2

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/index.js ADDED
@@ -0,0 +1,103 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/ui`: components you own, with the behaviour you would get wrong.
4
+ //
5
+ // The premise is the one shadcn established and it is the right one: a
6
+ // component library that ships styles is a library you fight, so these ship
7
+ // none. Every part takes `className` and every other DOM prop and passes it
8
+ // through; what they contribute is the part that is genuinely hard and
9
+ // genuinely invisible when it is missing.
10
+ //
11
+ // That part is behaviour, and specifically keyboard and screen-reader
12
+ // behaviour: a roving `tabindex` so a twelve-tab list does not take twelve Tab
13
+ // presses to get past, a focus trap that actually cannot be escaped, focus
14
+ // restored to whatever opened a dialog, `aria-describedby` pointing only at
15
+ // elements that are in the document. None of it is visible in a screenshot and
16
+ // all of it is what separates a component from a div that looks like one.
17
+ //
18
+ // # Composition is type-checked
19
+ //
20
+ // This is where Flow says something no other type system can. `Tabs.List`
21
+ // declares `renders* Tabs.Tab`, so a `<button>` in a tab list is a *type
22
+ // error* — not a review comment, not a runtime warning, not a screen reader
23
+ // announcing "button" where the reader expected "tab, 2 of 5". A library
24
+ // written in TypeScript can document that constraint; it cannot state it.
25
+
26
+ import {
27
+ FieldControl,
28
+ FieldDescription,
29
+ FieldError,
30
+ FieldLabel,
31
+ FieldRoot,
32
+ } from "./internal/field.js";
33
+ import {
34
+ DialogClose,
35
+ DialogContent,
36
+ DialogRoot,
37
+ DialogTitle,
38
+ DialogTrigger,
39
+ } from "./internal/dialog.js";
40
+ import { TabsList, TabsPanel, TabsRoot, TabsTab } from "./internal/tabs.js";
41
+ import { Checkbox, Switch } from "./internal/switch.js";
42
+
43
+ export { Checkbox, Switch };
44
+
45
+ /**
46
+ * An accessible form field.
47
+ *
48
+ * `Field.Control` takes a render function rather than rendering an `<input>`,
49
+ * because a field wraps a select, a textarea or somebody else's component just
50
+ * as often, and each needs the same attributes.
51
+ *
52
+ * <Field.Root invalid={error != null}>
53
+ * <Field.Label>Email</Field.Label>
54
+ * <Field.Control render={(props) => <input type="email" {...props} />} />
55
+ * <Field.Description>We will not share it.</Field.Description>
56
+ * <Field.Error>{error}</Field.Error>
57
+ * </Field.Root>
58
+ */
59
+ export const Field = {
60
+ Root: FieldRoot,
61
+ Label: FieldLabel,
62
+ Control: FieldControl,
63
+ Description: FieldDescription,
64
+ Error: FieldError,
65
+ };
66
+
67
+ /**
68
+ * Tabs, with the arrow-key behaviour the pattern requires.
69
+ *
70
+ * <Tabs.Root defaultValue="one">
71
+ * <Tabs.List>
72
+ * <Tabs.Tab value="one">One</Tabs.Tab>
73
+ * <Tabs.Tab value="two">Two</Tabs.Tab>
74
+ * </Tabs.List>
75
+ * <Tabs.Panel value="one">…</Tabs.Panel>
76
+ * <Tabs.Panel value="two">…</Tabs.Panel>
77
+ * </Tabs.Root>
78
+ */
79
+ export const Tabs = {
80
+ Root: TabsRoot,
81
+ List: TabsList,
82
+ Tab: TabsTab,
83
+ Panel: TabsPanel,
84
+ };
85
+
86
+ /**
87
+ * A modal dialog: focus moved in, kept in, and given back.
88
+ *
89
+ * <Dialog.Root>
90
+ * <Dialog.Trigger>Open</Dialog.Trigger>
91
+ * <Dialog.Content>
92
+ * <Dialog.Title>Are you sure?</Dialog.Title>
93
+ * <Dialog.Close>Cancel</Dialog.Close>
94
+ * </Dialog.Content>
95
+ * </Dialog.Root>
96
+ */
97
+ export const Dialog = {
98
+ Root: DialogRoot,
99
+ Trigger: DialogTrigger,
100
+ Content: DialogContent,
101
+ Title: DialogTitle,
102
+ Close: DialogClose,
103
+ };
@@ -0,0 +1,236 @@
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
+ }
@@ -0,0 +1,161 @@
1
+ // @flow
2
+ //
3
+ // An accessible form field, wired up for you.
4
+ //
5
+ // The hard part of a form field is not the markup, it is the wiring: the label
6
+ // has to point at the control, the description and the error message have to be
7
+ // named by `aria-describedby`, the control has to say `aria-invalid` when it is
8
+ // wrong, and every id has to be unique on the page and stable across renders.
9
+ // Doing that by hand is four attributes and two `useId` calls per field, and
10
+ // getting one wrong is silent — the field looks right and a screen reader
11
+ // announces nothing.
12
+ //
13
+ // So the parts read the ids off a context the root creates. `Field.Label` knows
14
+ // which control it labels because there is exactly one in its root, and
15
+ // `Field.Error` registers itself so the control can point at it only when it is
16
+ // actually rendered — pointing `aria-describedby` at an id that is not in the
17
+ // document makes a screen reader announce nothing at all, which is worse than
18
+ // omitting the attribute.
19
+
20
+ import * as React from "@uniflowed/react";
21
+ import { createContext, useContext, useId, useMemo, useState } from "@uniflowed/react";
22
+
23
+ type FieldState = {|
24
+ readonly controlId: string,
25
+ readonly labelId: string,
26
+ readonly descriptionId: string,
27
+ readonly errorId: string,
28
+ readonly invalid: boolean,
29
+ readonly describedBy: string | void,
30
+ readonly registerDescription: (present: boolean) => void,
31
+ readonly registerError: (present: boolean) => void,
32
+ |};
33
+
34
+ const FieldContext: React.Context<FieldState | null> = createContext(null);
35
+
36
+ /**
37
+ * The field a part belongs to.
38
+ *
39
+ * Raising rather than returning null: a `Field.Label` outside a `Field.Root`
40
+ * would render a label pointing at nothing, and would look correct.
41
+ */
42
+ function useField(part: string): FieldState {
43
+ const state = useContext(FieldContext);
44
+ if (state == null) {
45
+ throw new Error(`${part} must be rendered inside a Field.Root`);
46
+ }
47
+ return state;
48
+ }
49
+
50
+ /**
51
+ * The field's container, and the only place ids are made.
52
+ *
53
+ * `invalid` is the root's business rather than the control's because three
54
+ * parts have to agree about it: the control says `aria-invalid`, the error
55
+ * message is rendered or not, and the control's `aria-describedby` includes the
56
+ * error's id or not.
57
+ */
58
+ export component FieldRoot(
59
+ children: React.Node,
60
+ invalid?: boolean = false,
61
+ ...rest: { readonly [string]: mixed }
62
+ ) {
63
+ const base = useId();
64
+ const [hasDescription, setHasDescription] = useState(false);
65
+ const [hasError, setHasError] = useState(false);
66
+
67
+ const state = useMemo(() => {
68
+ const descriptionId = `${base}-description`;
69
+ const errorId = `${base}-error`;
70
+ // Only ids that are in the document. `aria-describedby` naming a missing
71
+ // element makes a screen reader announce nothing rather than skipping it.
72
+ const described = [
73
+ hasDescription ? descriptionId : null,
74
+ invalid && hasError ? errorId : null,
75
+ ].filter(Boolean);
76
+
77
+ return {
78
+ controlId: `${base}-control`,
79
+ labelId: `${base}-label`,
80
+ descriptionId,
81
+ errorId,
82
+ invalid,
83
+ describedBy: described.length === 0 ? undefined : described.join(" "),
84
+ registerDescription: setHasDescription,
85
+ registerError: setHasError,
86
+ };
87
+ }, [base, invalid, hasDescription, hasError]);
88
+
89
+ return (
90
+ <FieldContext.Provider value={state}>
91
+ <div {...rest}>{children}</div>
92
+ </FieldContext.Provider>
93
+ );
94
+ }
95
+
96
+ /** The label, pointing at the control by id rather than by nesting. */
97
+ export component FieldLabel(children: React.Node, ...rest: { readonly [string]: mixed }) {
98
+ const field = useField("Field.Label");
99
+ // `rest` first: a caller `id` here would break the relationship the control
100
+ // points at, and it would break it silently.
101
+ return (
102
+ <label {...rest} htmlFor={field.controlId} id={field.labelId}>
103
+ {children}
104
+ </label>
105
+ );
106
+ }
107
+
108
+ /**
109
+ * The control, given every attribute the rest of the field implies.
110
+ *
111
+ * `render` takes the element rather than this rendering an `<input>`, because a
112
+ * field wraps a select, a textarea, a combobox or somebody else's component
113
+ * just as often, and each of those needs the same six attributes.
114
+ */
115
+ export component FieldControl(render: (props: { readonly [string]: mixed }) => React.Node) {
116
+ const field = useField("Field.Control");
117
+ return render({
118
+ id: field.controlId,
119
+ "aria-labelledby": field.labelId,
120
+ "aria-describedby": field.describedBy,
121
+ "aria-invalid": field.invalid ? "true" : undefined,
122
+ });
123
+ }
124
+
125
+ /** Help text, which the control points at while it is rendered. */
126
+ export component FieldDescription(children: React.Node, ...rest: { readonly [string]: mixed }) {
127
+ const field = useField("Field.Description");
128
+ React.useEffect(() => {
129
+ field.registerDescription(true);
130
+ return () => field.registerDescription(false);
131
+ }, [field]);
132
+
133
+ return (
134
+ <p {...rest} id={field.descriptionId}>
135
+ {children}
136
+ </p>
137
+ );
138
+ }
139
+
140
+ /**
141
+ * The error message, rendered only when the field is invalid.
142
+ *
143
+ * `role="alert"` so it is announced when it appears, which is the point of an
144
+ * error that arrives after a blur or a submit.
145
+ */
146
+ export component FieldError(children: React.Node, ...rest: { readonly [string]: mixed }) {
147
+ const field = useField("Field.Error");
148
+ React.useEffect(() => {
149
+ field.registerError(true);
150
+ return () => field.registerError(false);
151
+ }, [field]);
152
+
153
+ if (!field.invalid) {
154
+ return null;
155
+ }
156
+ return (
157
+ <p {...rest} id={field.errorId} role="alert">
158
+ {children}
159
+ </p>
160
+ );
161
+ }
@@ -0,0 +1,78 @@
1
+ // @flow
2
+ //
3
+ // Merging a caller's props with the ones a component owns.
4
+ //
5
+ // `<div {...rest} role="dialog">` and `<div role="dialog" {...rest}>` are
6
+ // different components. The second lets a caller pass `role="button"` and get
7
+ // it; the first does not. That sounds like a preference until you notice what
8
+ // else is in `rest`:
9
+ //
10
+ // * A caller `ref` replaced the ref the dialog uses to find its focus stops,
11
+ // so `contentRef.current` stayed null, the Tab handler returned early, and
12
+ // the focus trap was *silently off* while the dialog still announced
13
+ // `aria-modal="true"`.
14
+ // * A caller `onClick` replaced a tab's selection handler, so clicking a tab
15
+ // did nothing.
16
+ // * A caller `onKeyDown` replaced the dialog's, so Escape stopped closing it.
17
+ //
18
+ // None of those fail loudly. So the rule here is: the caller's props go on
19
+ // first and the component's own semantics go on last, and for the two kinds of
20
+ // prop where a caller legitimately wants *both* — event handlers and refs —
21
+ // they are composed rather than one replacing the other.
22
+
23
+ /** Anything a caller can spread onto an element. */
24
+ export type Rest = { readonly [string]: mixed };
25
+
26
+ /**
27
+ * Call the caller's handler and then the component's.
28
+ *
29
+ * The caller's runs first so it can inspect the event before the component
30
+ * acts on it, and the component's runs unless the caller stopped the event —
31
+ * `defaultPrevented` is the caller's way of saying "I handled this", which is
32
+ * the same contract the DOM uses.
33
+ */
34
+ export function composeHandlers<TEvent extends { readonly defaultPrevented?: boolean }>(
35
+ theirs: mixed,
36
+ ours: (event: TEvent) => mixed,
37
+ ): (event: TEvent) => mixed {
38
+ if (typeof theirs !== "function") {
39
+ return ours;
40
+ }
41
+ return (event: TEvent) => {
42
+ (theirs as $FlowFixMe)(event);
43
+ if (event.defaultPrevented !== true) {
44
+ ours(event);
45
+ }
46
+ };
47
+ }
48
+
49
+ /** Set both refs, whichever kinds they are. */
50
+ export function composeRefs<T>(
51
+ theirs: mixed,
52
+ ours: (value: T | null) => mixed,
53
+ ): (value: T | null) => void {
54
+ return (value: T | null) => {
55
+ ours(value);
56
+ if (typeof theirs === "function") {
57
+ (theirs as $FlowFixMe)(value);
58
+ } else if (theirs != null && typeof theirs === "object") {
59
+ (theirs as $FlowFixMe).current = value;
60
+ }
61
+ };
62
+ }
63
+
64
+ /**
65
+ * A caller's props with the handlers and ref removed.
66
+ *
67
+ * They are pulled out because they have to be composed rather than spread, and
68
+ * leaving them in would put the caller's copy back on top of the composed one.
69
+ */
70
+ export function withoutComposed(rest: Rest, names: $ReadOnlyArray<string>): Rest {
71
+ const kept: { [string]: mixed } = {};
72
+ for (const key of Object.keys(rest)) {
73
+ if (!names.includes(key)) {
74
+ kept[key] = rest[key];
75
+ }
76
+ }
77
+ return kept;
78
+ }
@@ -0,0 +1,122 @@
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
+ }
@@ -0,0 +1,270 @@
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
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@uniflowed/ui",
3
+ "version": "0.0.0-alpha.2",
4
+ "description": "Headless, accessible React components whose composition Flow checks, part of the Unified Toolchain for Flow.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
+ "directory": "packages/ui"
12
+ },
13
+ "exports": {
14
+ "./dialog/types": "./dialog/types.js",
15
+ "./form/types": "./form/types.js",
16
+ ".": "./index.js",
17
+ "./types/renders": "./types/renders.js"
18
+ },
19
+ "files": [
20
+ "index.js",
21
+ "internal"
22
+ ],
23
+ "dependencies": {
24
+ "@uniflowed/react": "0.0.0-alpha.2",
25
+ "@uniflowed/validator": "0.0.0-alpha.2"
26
+ },
27
+ "peerDependencies": {
28
+ "react": ">=19"
29
+ }
30
+ }