@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 +103 -0
- package/internal/dialog.js +236 -0
- package/internal/field.js +161 -0
- package/internal/props.js +78 -0
- package/internal/switch.js +122 -0
- package/internal/tabs.js +270 -0
- package/package.json +30 -0
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
|
+
}
|
package/internal/tabs.js
ADDED
|
@@ -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
|
+
}
|