@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/checkbox.js +79 -0
- package/combobox.js +546 -0
- package/dialog.js +482 -0
- package/{internal/field.js → field.js} +24 -13
- package/index.js +182 -26
- package/internal/controlled-state.js +65 -0
- package/internal/{props.js → merge-props.js} +19 -11
- package/internal/roving-focus.js +236 -0
- package/menu.js +628 -0
- package/package.json +11 -7
- package/switch.js +72 -0
- package/tabs.js +286 -0
- package/internal/dialog.js +0 -236
- package/internal/switch.js +0 -122
- package/internal/tabs.js +0 -270
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
|
+
}
|
package/internal/dialog.js
DELETED
|
@@ -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
|
-
}
|