@uniflowed/ui 0.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/accordion.js +335 -0
- package/alert-dialog.js +256 -0
- package/carousel.js +410 -0
- package/checkbox.js +80 -0
- package/collapsible.js +147 -0
- package/combobox.js +557 -0
- package/dialog.js +499 -0
- package/drawer.js +458 -0
- package/field.js +170 -0
- package/hover-card.js +334 -0
- package/index.js +1012 -0
- package/input-otp.js +218 -0
- package/internal/anchor.js +500 -0
- package/internal/controlled-state.js +65 -0
- package/internal/disclosure.js +97 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +201 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +654 -0
- package/navigation-menu.js +251 -0
- package/package.json +57 -0
- package/pagination.js +197 -0
- package/popover.js +326 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/scroll-area.js +283 -0
- package/select.js +855 -0
- package/sheet.js +165 -0
- package/sidebar.js +300 -0
- package/slider.js +405 -0
- package/switch.js +73 -0
- package/table.js +479 -0
- package/tabs.js +280 -0
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
- package/tooltip.js +411 -0
package/accordion.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// An accordion: a stack of disclosures that know about each other.
|
|
4
|
+
//
|
|
5
|
+
// The disclosure itself is `collapsible.js` and the argument for the closed
|
|
6
|
+
// panel staying in the document is `internal/disclosure.js`. What this adds is
|
|
7
|
+
// everything that follows from the sections being a *stack*, and every one of
|
|
8
|
+
// them is a thing a hand-written accordion leaves out.
|
|
9
|
+
//
|
|
10
|
+
// # The heading level is the caller's
|
|
11
|
+
//
|
|
12
|
+
// The trigger is a button inside a heading, and which heading depends entirely
|
|
13
|
+
// on where the accordion sits: inside a section titled by an `<h2>` it must be
|
|
14
|
+
// an `<h3>`, and at the top of a page it might be an `<h2>`. A component that
|
|
15
|
+
// hard-codes one produces a document outline nobody can navigate — and skimming
|
|
16
|
+
// by heading is how a screen-reader user reads a long page, so the outline is
|
|
17
|
+
// not decoration. `Dialog.Title` hard-codes `<h2>`, which is defensible for a
|
|
18
|
+
// dialog, because a dialog is a document of its own with one title in it. It is
|
|
19
|
+
// not defensible here.
|
|
20
|
+
//
|
|
21
|
+
// # The panel is a named region
|
|
22
|
+
//
|
|
23
|
+
// `role="region"` with `aria-labelledby` naming the trigger that opens it, so
|
|
24
|
+
// the panel turns up in a screen reader's list of landmarks under the name the
|
|
25
|
+
// reader just pressed. An unnamed region is a landmark that says "region" and
|
|
26
|
+
// nothing else, which is why the naming is wired here rather than left as
|
|
27
|
+
// advice — and why the trigger's id and the panel's are made in one place.
|
|
28
|
+
//
|
|
29
|
+
// # `single`, `multiple`, and the section that cannot be closed
|
|
30
|
+
//
|
|
31
|
+
// A `single` accordion keeps one section open; `multiple` lets any number be.
|
|
32
|
+
// `collapsible` asks whether the open section of a `single` accordion may be
|
|
33
|
+
// closed again, leaving nothing open. When it may not, the open section's
|
|
34
|
+
// trigger says `aria-disabled="true"` rather than `disabled`, so a reader is
|
|
35
|
+
// told "pressing this does nothing" instead of finding that a header they can
|
|
36
|
+
// see has vanished from the accessibility tree — the same distinction
|
|
37
|
+
// `menu.js` and `tabs.js` make, for the same reason.
|
|
38
|
+
//
|
|
39
|
+
// It has one consequence worth stating, because it is the opposite of every
|
|
40
|
+
// other set in this package: the arrow keys **land on** a disabled header here
|
|
41
|
+
// rather than stepping over it. `moveTo`'s `skipDisabled` is where that lives.
|
|
42
|
+
// An accordion's headers are ordinary buttons in the page's tab order — `Tab`
|
|
43
|
+
// reaches every one of them — so arrows that skipped one would disagree with
|
|
44
|
+
// `Tab` about which headers exist.
|
|
45
|
+
//
|
|
46
|
+
// # The headers are not a roving tab stop
|
|
47
|
+
//
|
|
48
|
+
// This is the difference between an accordion and a tab list, and it is easy to
|
|
49
|
+
// get backwards. A tab list is *one* control, so it takes one stop in the tab
|
|
50
|
+
// order and the arrows move inside it. An accordion is a stack of ordinary
|
|
51
|
+
// buttons that happen to be near each other: `Tab` reaches every header,
|
|
52
|
+
// because each one is a real control a reader might want to press. The arrow
|
|
53
|
+
// keys between headers are a convenience the practices call optional, and they
|
|
54
|
+
// are here because a long FAQ is nicer with them — but nothing about them takes
|
|
55
|
+
// a header out of the tab order.
|
|
56
|
+
//
|
|
57
|
+
// # How the sections are found
|
|
58
|
+
//
|
|
59
|
+
// By a `data-*` attribute of this package's own rather than by role, which is
|
|
60
|
+
// the exception to what `roving-focus.js`'s other sets do. There is no ARIA
|
|
61
|
+
// role for an accordion, nor for one of its headers — the pattern is built out
|
|
62
|
+
// of headings and buttons — so there is no role to ask for, and an accordion
|
|
63
|
+
// nested inside another accordion's panel still has to keep its own arrow keys
|
|
64
|
+
// to itself. That needs a name for the container and a name for the header, and
|
|
65
|
+
// this package has to be the one to give them.
|
|
66
|
+
|
|
67
|
+
"use client";
|
|
68
|
+
|
|
69
|
+
import * as React from "@uniflowed/react";
|
|
70
|
+
import {
|
|
71
|
+
createContext,
|
|
72
|
+
useCallback,
|
|
73
|
+
useContext,
|
|
74
|
+
useId,
|
|
75
|
+
useMemo,
|
|
76
|
+
useRef,
|
|
77
|
+
useState,
|
|
78
|
+
} from "@uniflowed/react";
|
|
79
|
+
|
|
80
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
81
|
+
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
82
|
+
import { moveOnKey } from "./internal/roving-focus.js";
|
|
83
|
+
import type { RovingSet } from "./internal/roving-focus.js";
|
|
84
|
+
import { usePresence, useUntilFound } from "./internal/disclosure.js";
|
|
85
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
86
|
+
|
|
87
|
+
/** Whether one section is open at a time, or any number of them. */
|
|
88
|
+
export type AccordionType = "single" | "multiple";
|
|
89
|
+
|
|
90
|
+
/** Nothing open, as one array rather than a new one per render. */
|
|
91
|
+
const NOTHING: $ReadOnlyArray<string> = [];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The headers the arrow keys run across, and what owns them.
|
|
95
|
+
*
|
|
96
|
+
* `skipDisabled` is false, which is this set's one departure from every other
|
|
97
|
+
* one here; the module header says why.
|
|
98
|
+
*/
|
|
99
|
+
const HEADERS: RovingSet = {
|
|
100
|
+
item: "[data-accordion-trigger]",
|
|
101
|
+
owner: "[data-accordion]",
|
|
102
|
+
orientation: "vertical",
|
|
103
|
+
wrap: true,
|
|
104
|
+
skipDisabled: false,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
type AccordionState = {|
|
|
108
|
+
readonly open: $ReadOnlyArray<string>,
|
|
109
|
+
readonly toggle: (value: string) => void,
|
|
110
|
+
/** Whether closing the last open section is allowed; only meaningful for `single`. */
|
|
111
|
+
readonly closable: boolean,
|
|
112
|
+
readonly type: AccordionType,
|
|
113
|
+
|};
|
|
114
|
+
|
|
115
|
+
const AccordionContext: React.Context<AccordionState | null> = createContext(null);
|
|
116
|
+
|
|
117
|
+
type AccordionItemState = {|
|
|
118
|
+
readonly triggerId: string,
|
|
119
|
+
readonly contentId: string,
|
|
120
|
+
readonly open: boolean,
|
|
121
|
+
readonly toggle: () => void,
|
|
122
|
+
/** True when this section is open and the accordion will not let it close. */
|
|
123
|
+
readonly locked: boolean,
|
|
124
|
+
readonly disabled: boolean,
|
|
125
|
+
/** Whether an `Accordion.Content` is rendered, so the trigger names one that exists. */
|
|
126
|
+
readonly present: boolean,
|
|
127
|
+
readonly registerContent: (present: boolean) => void,
|
|
128
|
+
|};
|
|
129
|
+
|
|
130
|
+
const AccordionItemContext: React.Context<AccordionItemState | null> = createContext(null);
|
|
131
|
+
|
|
132
|
+
hook useAccordion(part: string): AccordionState {
|
|
133
|
+
const state = useContext(AccordionContext);
|
|
134
|
+
if (state == null) {
|
|
135
|
+
throw new Error(`${part} must be rendered inside an Accordion.Root`);
|
|
136
|
+
}
|
|
137
|
+
return state;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
hook useAccordionItem(part: string): AccordionItemState {
|
|
141
|
+
const state = useContext(AccordionItemContext);
|
|
142
|
+
if (state == null) {
|
|
143
|
+
throw new Error(`${part} must be rendered inside an Accordion.Item`);
|
|
144
|
+
}
|
|
145
|
+
return state;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The stack, and the one place the arrow keys are handled.
|
|
150
|
+
*
|
|
151
|
+
* `value` is the list of open sections in both modes, for the reason
|
|
152
|
+
* `toggle-group.js` gives at greater length: in `single` mode it holds at most
|
|
153
|
+
* one, and keeping that invariant is the component's job rather than the
|
|
154
|
+
* caller's to remember.
|
|
155
|
+
*/
|
|
156
|
+
export component AccordionRoot(
|
|
157
|
+
children: renders* AccordionItem,
|
|
158
|
+
type?: AccordionType = "single",
|
|
159
|
+
collapsible?: boolean = true,
|
|
160
|
+
defaultValue?: $ReadOnlyArray<string> = NOTHING,
|
|
161
|
+
value?: $ReadOnlyArray<string>,
|
|
162
|
+
onValueChange?: (value: $ReadOnlyArray<string>) => void,
|
|
163
|
+
...rest: Rest
|
|
164
|
+
) {
|
|
165
|
+
const [open, setOpen] = useControlled<$ReadOnlyArray<string>>(value, defaultValue, onValueChange);
|
|
166
|
+
|
|
167
|
+
const toggle = useCallback(
|
|
168
|
+
(item: string) => {
|
|
169
|
+
const isOpen = open.includes(item);
|
|
170
|
+
if (type === "multiple") {
|
|
171
|
+
setOpen(isOpen ? open.filter((each) => each !== item) : [...open, item]);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// Opening one closes the other, which is the whole of `single`. Closing
|
|
175
|
+
// the open one is a separate question, and `collapsible` answers it.
|
|
176
|
+
if (isOpen && !collapsible) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
setOpen(isOpen ? NOTHING : [item]);
|
|
180
|
+
},
|
|
181
|
+
[open, setOpen, type, collapsible],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const state = useMemo(
|
|
185
|
+
// `collapsible` only ever narrows a `single` accordion: in `multiple` mode
|
|
186
|
+
// every section closes on its own, and there is no last one to protect.
|
|
187
|
+
() => ({ open, toggle, closable: type === "multiple" || collapsible, type }),
|
|
188
|
+
[open, toggle, type, collapsible],
|
|
189
|
+
);
|
|
190
|
+
const passed = withoutComposed(rest, ["onKeyDown"]);
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<AccordionContext.Provider value={state}>
|
|
194
|
+
<div
|
|
195
|
+
{...passed}
|
|
196
|
+
// The name the arrow keys use to tell this accordion's headers from
|
|
197
|
+
// those of an accordion nested inside one of its panels.
|
|
198
|
+
data-accordion=""
|
|
199
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
200
|
+
const stack: $FlowFixMe = event.currentTarget;
|
|
201
|
+
moveOnKey(event, stack, HEADERS);
|
|
202
|
+
})}
|
|
203
|
+
>
|
|
204
|
+
{children}
|
|
205
|
+
</div>
|
|
206
|
+
</AccordionContext.Provider>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* One section: a header and the panel it shows.
|
|
212
|
+
*
|
|
213
|
+
* Renders a `<div>` because a section needs an element to be styled as one, and
|
|
214
|
+
* because the heading and the panel have to be siblings rather than nested —
|
|
215
|
+
* a panel inside its own heading would be part of the heading's accessible
|
|
216
|
+
* name.
|
|
217
|
+
*/
|
|
218
|
+
export component AccordionItem(
|
|
219
|
+
value: string,
|
|
220
|
+
children: renders* (AccordionHeader | AccordionContent),
|
|
221
|
+
disabled?: boolean = false,
|
|
222
|
+
...rest: Rest
|
|
223
|
+
) {
|
|
224
|
+
const accordion = useAccordion("Accordion.Item");
|
|
225
|
+
const base = useId();
|
|
226
|
+
const [present, setPresent] = useState(false);
|
|
227
|
+
const open = accordion.open.includes(value);
|
|
228
|
+
const toggle = accordion.toggle;
|
|
229
|
+
|
|
230
|
+
const state = useMemo(
|
|
231
|
+
() => ({
|
|
232
|
+
triggerId: `${base}-trigger`,
|
|
233
|
+
contentId: `${base}-content`,
|
|
234
|
+
open,
|
|
235
|
+
toggle: () => toggle(value),
|
|
236
|
+
locked: open && !accordion.closable,
|
|
237
|
+
disabled,
|
|
238
|
+
present,
|
|
239
|
+
registerContent: setPresent,
|
|
240
|
+
}),
|
|
241
|
+
[base, open, toggle, value, accordion.closable, disabled, present],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<AccordionItemContext.Provider value={state}>
|
|
246
|
+
<div {...rest}>{children}</div>
|
|
247
|
+
</AccordionItemContext.Provider>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The heading the trigger lives in.
|
|
253
|
+
*
|
|
254
|
+
* `level` is the caller's and has no sensible default beyond a guess, so the
|
|
255
|
+
* guess is stated: `3`, which is right for an accordion inside a section that
|
|
256
|
+
* has a title of its own. HTML has six levels and `<h7>` is not an element, so
|
|
257
|
+
* a level outside that range is clamped rather than rendered — a tag nobody
|
|
258
|
+
* recognises is announced as nothing at all, which loses the heading entirely.
|
|
259
|
+
*/
|
|
260
|
+
export component AccordionHeader(
|
|
261
|
+
children: renders AccordionTrigger,
|
|
262
|
+
level?: number = 3,
|
|
263
|
+
...rest: Rest
|
|
264
|
+
) {
|
|
265
|
+
const clamped = Math.min(6, Math.max(1, Math.trunc(level)));
|
|
266
|
+
const Heading = `h${String(clamped)}`;
|
|
267
|
+
|
|
268
|
+
return <Heading {...rest}>{children}</Heading>;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The button that opens and closes the section.
|
|
273
|
+
*
|
|
274
|
+
* It carries no `tabIndex` of its own on purpose: every header stays in the
|
|
275
|
+
* page's tab order, which is what makes this an accordion and not a tab list.
|
|
276
|
+
*/
|
|
277
|
+
export component AccordionTrigger(children: React.Node, ...rest: Rest) {
|
|
278
|
+
const item = useAccordionItem("Accordion.Trigger");
|
|
279
|
+
const passed = withoutComposed(rest, ["onClick"]);
|
|
280
|
+
// Locked and disabled are two different sentences a reader hears the same
|
|
281
|
+
// way, and both are `aria-disabled` rather than `disabled` so the header
|
|
282
|
+
// stays where they can find it: "this section will not close" and "this
|
|
283
|
+
// section is unavailable".
|
|
284
|
+
const inert = item.locked || item.disabled;
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<button
|
|
288
|
+
{...passed}
|
|
289
|
+
aria-controls={item.present ? item.contentId : undefined}
|
|
290
|
+
aria-disabled={inert ? "true" : undefined}
|
|
291
|
+
aria-expanded={item.open ? "true" : "false"}
|
|
292
|
+
// What the arrow keys look for. Not a role, because the accordion pattern
|
|
293
|
+
// has none to look for; see the module header.
|
|
294
|
+
data-accordion-trigger=""
|
|
295
|
+
id={item.triggerId}
|
|
296
|
+
onClick={composeHandlers(rest.onClick, () => {
|
|
297
|
+
if (!inert) {
|
|
298
|
+
item.toggle();
|
|
299
|
+
}
|
|
300
|
+
})}
|
|
301
|
+
type="button"
|
|
302
|
+
>
|
|
303
|
+
{children}
|
|
304
|
+
</button>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The panel, which is a named region and stays in the document while closed.
|
|
310
|
+
*
|
|
311
|
+
* `internal/disclosure.js` explains what "stays in the document" is worth and
|
|
312
|
+
* what `hidden` is upgraded to for it.
|
|
313
|
+
*/
|
|
314
|
+
export component AccordionContent(children: React.Node, ...rest: Rest) {
|
|
315
|
+
const item = useAccordionItem("Accordion.Content");
|
|
316
|
+
const contentRef = useRef<HTMLElement | null>(null);
|
|
317
|
+
usePresence(item.registerContent);
|
|
318
|
+
useUntilFound(contentRef, item.open);
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<div
|
|
322
|
+
{...withoutComposed(rest, ["ref"])}
|
|
323
|
+
// The name a reader hears for this landmark is the header they pressed.
|
|
324
|
+
aria-labelledby={item.triggerId}
|
|
325
|
+
hidden={!item.open}
|
|
326
|
+
id={item.contentId}
|
|
327
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
328
|
+
contentRef.current = element;
|
|
329
|
+
})}
|
|
330
|
+
role="region"
|
|
331
|
+
>
|
|
332
|
+
{children}
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
package/alert-dialog.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// An alert dialog: the modal a reader has to answer.
|
|
4
|
+
//
|
|
5
|
+
// It is `dialog.js` with the three decisions that module's header names taken
|
|
6
|
+
// the other way, and it is a component rather than a page of advice because
|
|
7
|
+
// each of the three is silent when it is wrong:
|
|
8
|
+
//
|
|
9
|
+
// * **`role="alertdialog"`.** A screen reader announces an `alertdialog`'s
|
|
10
|
+
// description as soon as focus arrives, without waiting to be asked. That
|
|
11
|
+
// is the whole of what the role buys, and it is why the description below
|
|
12
|
+
// is not optional.
|
|
13
|
+
// * **A press outside does not close it.** There is no way to decline by
|
|
14
|
+
// accident. `Escape` still closes it, because a modal a reader cannot leave
|
|
15
|
+
// from the keyboard is a trap and declining is what `Escape` means — so the
|
|
16
|
+
// two dismissals differ deliberately: the deliberate one works, the
|
|
17
|
+
// accidental one does not.
|
|
18
|
+
// * **Focus lands on `AlertDialog.Cancel`.** The APG puts it on the least
|
|
19
|
+
// destructive action, and `Cancel` is that action by construction here
|
|
20
|
+
// rather than by a caller remembering to pass a ref. A confirmation whose
|
|
21
|
+
// `Enter` deletes the project is a confirmation that asked nothing.
|
|
22
|
+
//
|
|
23
|
+
// # Why the description is required
|
|
24
|
+
//
|
|
25
|
+
// `aria-describedby` is what makes `alertdialog` worth using. An alert dialog
|
|
26
|
+
// with nothing to announce is a `dialog` that has told the reader's software to
|
|
27
|
+
// expect something urgent and then said only its title — which is worse than
|
|
28
|
+
// the plain `Dialog`, because the reader has been interrupted for nothing.
|
|
29
|
+
//
|
|
30
|
+
// So `AlertDialog.Body` raises when no `AlertDialog.Description` is inside it.
|
|
31
|
+
// Raising rather than warning, for the reason `useDialog` gives: the failure is
|
|
32
|
+
// invisible in the markup, invisible in a screenshot, and audible only to
|
|
33
|
+
// somebody who is not in the room. A component that lets it through ships it.
|
|
34
|
+
//
|
|
35
|
+
// # Action and Cancel are two parts, not one `Close` with a variant
|
|
36
|
+
//
|
|
37
|
+
// `Dialog.Close` closes the dialog and says nothing about what closing meant.
|
|
38
|
+
// The two buttons of a confirmation mean opposite things — one carries out the
|
|
39
|
+
// thing being confirmed, the other declines it — and a reader who has been
|
|
40
|
+
// asked a question is entitled to have the answer be a named button rather
|
|
41
|
+
// than a `variant="destructive"` on a shared one. `Cancel` is also the part
|
|
42
|
+
// focus goes to, which is a behaviour a variant cannot carry.
|
|
43
|
+
|
|
44
|
+
"use client";
|
|
45
|
+
|
|
46
|
+
import * as React from "@uniflowed/react";
|
|
47
|
+
import { createContext, useContext, useEffect, useMemo, useRef } from "@uniflowed/react";
|
|
48
|
+
|
|
49
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
50
|
+
import { composeRefs, forwarded, withoutComposed } from "./internal/merge-props.js";
|
|
51
|
+
import {
|
|
52
|
+
DialogBody,
|
|
53
|
+
DialogClose,
|
|
54
|
+
DialogDescription,
|
|
55
|
+
DialogFooter,
|
|
56
|
+
DialogHeader,
|
|
57
|
+
DialogOverlay,
|
|
58
|
+
DialogRoot,
|
|
59
|
+
DialogTitle,
|
|
60
|
+
DialogTrigger,
|
|
61
|
+
} from "./dialog.js";
|
|
62
|
+
|
|
63
|
+
type AlertDialogState = {|
|
|
64
|
+
/**
|
|
65
|
+
* The least destructive action, and where focus goes.
|
|
66
|
+
*
|
|
67
|
+
* A ref rather than state, because nothing renders it: it is read once, by
|
|
68
|
+
* `Dialog.Body`'s focus effect, after the commit that attached it.
|
|
69
|
+
*/
|
|
70
|
+
readonly cancelRef: { current: HTMLElement | null },
|
|
71
|
+
/**
|
|
72
|
+
* How many `AlertDialog.Description`s are in the document.
|
|
73
|
+
*
|
|
74
|
+
* A counted ref rather than the `described` boolean `Dialog.Root` already
|
|
75
|
+
* keeps, because that one is state: it is `false` on the commit that mounts
|
|
76
|
+
* the description, so a check against it would raise on every alert dialog
|
|
77
|
+
* ever rendered. A child's effect runs before its parent's, so by the time
|
|
78
|
+
* `AlertDialog.Body` asks, every description below it has answered.
|
|
79
|
+
*/
|
|
80
|
+
readonly describedBy: { current: number },
|
|
81
|
+
|};
|
|
82
|
+
|
|
83
|
+
const AlertDialogContext: React.Context<AlertDialogState | null> = createContext(null);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The alert dialog a part belongs to.
|
|
87
|
+
*
|
|
88
|
+
* Raising rather than returning null, for the reason `useDialog` gives: an
|
|
89
|
+
* `AlertDialog.Cancel` outside a root would render a button that closes nothing
|
|
90
|
+
* and takes no focus, and it would look correct.
|
|
91
|
+
*/
|
|
92
|
+
hook useAlertDialog(part: string): AlertDialogState {
|
|
93
|
+
const state = useContext(AlertDialogContext);
|
|
94
|
+
if (state == null) {
|
|
95
|
+
throw new Error(`${part} must be rendered inside an AlertDialog.Root`);
|
|
96
|
+
}
|
|
97
|
+
return state;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The alert dialog, open or closed. Uncontrolled unless `open` is given. */
|
|
101
|
+
export component AlertDialogRoot(
|
|
102
|
+
children: React.Node,
|
|
103
|
+
defaultOpen?: boolean = false,
|
|
104
|
+
open?: boolean,
|
|
105
|
+
onOpenChange?: (open: boolean) => void,
|
|
106
|
+
) {
|
|
107
|
+
const cancelRef = useRef<HTMLElement | null>(null);
|
|
108
|
+
const describedBy = useRef(0);
|
|
109
|
+
const state = useMemo(() => ({ cancelRef, describedBy }), []);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<AlertDialogContext.Provider value={state}>
|
|
113
|
+
<DialogRoot defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open}>
|
|
114
|
+
{children}
|
|
115
|
+
</DialogRoot>
|
|
116
|
+
</AlertDialogContext.Provider>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What opens it, and what focus comes back to when it closes. */
|
|
121
|
+
export component AlertDialogTrigger(children: React.Node, ...rest: Rest) {
|
|
122
|
+
return <DialogTrigger {...forwarded(rest)}>{children}</DialogTrigger>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The backdrop. See `Dialog.Overlay`: it is decoration and says so. */
|
|
126
|
+
export component AlertDialogOverlay(...rest: Rest) {
|
|
127
|
+
return <DialogOverlay {...forwarded(rest)} />;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The alert dialog itself: announced as one, described, and not dismissible by
|
|
132
|
+
* a press beside it.
|
|
133
|
+
*
|
|
134
|
+
* The three props it sets on `Dialog.Body` are the three the module header
|
|
135
|
+
* names, and they are set here rather than left to a caller because a caller
|
|
136
|
+
* who set two of them would have an alert dialog that is wrong in the third
|
|
137
|
+
* without anything saying so.
|
|
138
|
+
*/
|
|
139
|
+
export component AlertDialogBody(children: React.Node, ...rest: Rest) {
|
|
140
|
+
const alert = useAlertDialog("AlertDialog.Body");
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<DialogBody
|
|
144
|
+
{...forwarded(rest)}
|
|
145
|
+
dismissOnOutsidePress={false}
|
|
146
|
+
initialFocus={alert.cancelRef}
|
|
147
|
+
role="alertdialog"
|
|
148
|
+
>
|
|
149
|
+
{children}
|
|
150
|
+
<RequireDescription />
|
|
151
|
+
</DialogBody>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The check that there is something to announce, made where it is answerable.
|
|
157
|
+
*
|
|
158
|
+
* Inside `Dialog.Body` and last, and both halves are load-bearing. Inside,
|
|
159
|
+
* because `Dialog.Body` renders nothing at all while it is closed — an alert
|
|
160
|
+
* dialog that has not been opened has no description in the document, and a
|
|
161
|
+
* check in `AlertDialog.Body` itself therefore fired on every alert dialog ever
|
|
162
|
+
* rendered. Last, because React runs a subtree's effects in document order, so
|
|
163
|
+
* every `AlertDialog.Description` above this has already counted itself by the
|
|
164
|
+
* time this asks.
|
|
165
|
+
*
|
|
166
|
+
* It renders nothing, which is the point: the requirement is about the tree and
|
|
167
|
+
* not about the markup.
|
|
168
|
+
*/
|
|
169
|
+
component RequireDescription() {
|
|
170
|
+
const alert = useAlertDialog("AlertDialog.Body");
|
|
171
|
+
const describedBy = alert.describedBy;
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (describedBy.current === 0) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
"AlertDialog.Body must contain an AlertDialog.Description: " +
|
|
177
|
+
'role="alertdialog" exists to announce one, and an alert dialog ' +
|
|
178
|
+
"without a description interrupts the reader to say nothing.",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}, [describedBy]);
|
|
182
|
+
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The top of the alert dialog. See `Dialog.Header` for why it is a `div`. */
|
|
187
|
+
export component AlertDialogHeader(children: React.Node, ...rest: Rest) {
|
|
188
|
+
return <DialogHeader {...forwarded(rest)}>{children}</DialogHeader>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The bottom, where `Action` and `Cancel` go. */
|
|
192
|
+
export component AlertDialogFooter(children: React.Node, ...rest: Rest) {
|
|
193
|
+
return <DialogFooter {...forwarded(rest)}>{children}</DialogFooter>;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The question, which is the alert dialog's accessible name. */
|
|
197
|
+
export component AlertDialogTitle(children: React.Node, ...rest: Rest) {
|
|
198
|
+
return <DialogTitle {...forwarded(rest)}>{children}</DialogTitle>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* What answering costs, announced the moment focus arrives.
|
|
203
|
+
*
|
|
204
|
+
* This is where "this cannot be undone" belongs. It is the sentence the role
|
|
205
|
+
* exists to deliver, and the only moment the reader has to decide whether they
|
|
206
|
+
* care is before they have pressed anything.
|
|
207
|
+
*/
|
|
208
|
+
export component AlertDialogDescription(children: React.Node, ...rest: Rest) {
|
|
209
|
+
const alert = useAlertDialog("AlertDialog.Description");
|
|
210
|
+
const describedBy = alert.describedBy;
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
describedBy.current += 1;
|
|
214
|
+
return () => {
|
|
215
|
+
describedBy.current -= 1;
|
|
216
|
+
};
|
|
217
|
+
}, [describedBy]);
|
|
218
|
+
|
|
219
|
+
return <DialogDescription {...forwarded(rest)}>{children}</DialogDescription>;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The button that carries out the thing being confirmed.
|
|
224
|
+
*
|
|
225
|
+
* It closes the dialog after the caller's handler has run, and it is not where
|
|
226
|
+
* focus starts; see `AlertDialog.Cancel`. `Dialog.Close` is what both answers
|
|
227
|
+
* are made of, so the composition rule about a caller's `onClick` has one
|
|
228
|
+
* implementation rather than a second copy here.
|
|
229
|
+
*/
|
|
230
|
+
export component AlertDialogAction(children: React.Node, ...rest: Rest) {
|
|
231
|
+
return <DialogClose {...forwarded(rest)}>{children}</DialogClose>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The button that declines, and the one focus lands on.
|
|
236
|
+
*
|
|
237
|
+
* It registers itself so `AlertDialog.Body` can name it as the initial focus
|
|
238
|
+
* without the caller wiring a ref: the least destructive action is a fact about
|
|
239
|
+
* which part this is, not a decision to be repeated at every call.
|
|
240
|
+
*/
|
|
241
|
+
export component AlertDialogCancel(children: React.Node, ...rest: Rest) {
|
|
242
|
+
const alert = useAlertDialog("AlertDialog.Cancel");
|
|
243
|
+
const cancelRef = alert.cancelRef;
|
|
244
|
+
const passed = withoutComposed(rest, ["ref"]);
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<DialogClose
|
|
248
|
+
{...forwarded(passed)}
|
|
249
|
+
ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
250
|
+
cancelRef.current = element;
|
|
251
|
+
})}
|
|
252
|
+
>
|
|
253
|
+
{children}
|
|
254
|
+
</DialogClose>
|
|
255
|
+
);
|
|
256
|
+
}
|