@uniflowed/ui 0.0.0-alpha.8 → 0.1.0
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 +84 -57
- package/alert-dialog.js +284 -0
- package/alert.js +142 -0
- package/avatar.js +280 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +560 -0
- package/carousel.js +410 -0
- package/checkbox.js +215 -31
- package/collapsible.js +72 -48
- package/color-picker.js +172 -0
- package/combobox.js +216 -39
- package/context-menu.js +215 -0
- package/date-field.js +9 -0
- package/date-picker.js +357 -0
- package/date-range-picker.js +120 -0
- package/dialog.js +235 -198
- package/drag-drop.js +125 -0
- package/drawer.js +504 -0
- package/field.js +260 -43
- package/grid-list.js +8 -0
- package/hover-card.js +334 -0
- package/i18n-provider.js +89 -0
- package/index.js +1254 -32
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +565 -0
- package/internal/collection.js +395 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/focus.js +64 -0
- package/internal/hover-intent.js +259 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +117 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +316 -0
- package/list-box.js +13 -0
- package/menu.js +553 -361
- package/menubar.js +295 -0
- package/number-field.js +263 -0
- package/package.json +8 -25
- package/pagination.js +34 -22
- package/popover.js +367 -0
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +78 -0
- package/resizable.js +155 -9
- package/scroll-area.js +283 -0
- package/select.js +83 -37
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +320 -0
- package/skeleton.js +163 -0
- package/slider.js +95 -89
- package/switch.js +42 -34
- package/table.js +112 -71
- package/tabs.js +100 -91
- package/tag-group.js +8 -0
- package/time-field.js +8 -0
- package/toast.js +36 -66
- package/toggle-group.js +53 -49
- package/toggle.js +41 -27
- package/tooltip.js +404 -0
- package/tree.js +8 -0
package/accordion.js
CHANGED
|
@@ -54,6 +54,19 @@
|
|
|
54
54
|
// are here because a long FAQ is nicer with them — but nothing about them takes
|
|
55
55
|
// a header out of the tab order.
|
|
56
56
|
//
|
|
57
|
+
// # The height a stylesheet animates to
|
|
58
|
+
//
|
|
59
|
+
// `measure` on the root puts each panel's would-be height on it as
|
|
60
|
+
// `--uf-collapsible-height` — one property name across both disclosure
|
|
61
|
+
// components, because it is one measurement and a second name would be a second
|
|
62
|
+
// rule to keep in step. `collapsible.js`'s header shows the stylesheet, and
|
|
63
|
+
// `internal/disclosure.js` holds the measuring pass and the argument for the
|
|
64
|
+
// opt-in.
|
|
65
|
+
//
|
|
66
|
+
// It is on `Accordion.Root` rather than on each `Accordion.Content` because a
|
|
67
|
+
// forty-section FAQ is one decision, made once, rather than forty props that
|
|
68
|
+
// have to agree.
|
|
69
|
+
//
|
|
57
70
|
// # How the sections are found
|
|
58
71
|
//
|
|
59
72
|
// By a `data-*` attribute of this package's own rather than by role, which is
|
|
@@ -77,11 +90,16 @@ import {
|
|
|
77
90
|
useState,
|
|
78
91
|
} from "@uniflowed/react";
|
|
79
92
|
|
|
80
|
-
import type { Rest } from "./internal/merge-props.js";
|
|
81
|
-
import {
|
|
93
|
+
import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
|
|
94
|
+
import {
|
|
95
|
+
composeHandlers,
|
|
96
|
+
composeRefs,
|
|
97
|
+
withProps,
|
|
98
|
+
withoutComposed,
|
|
99
|
+
} from "./internal/merge-props.js";
|
|
82
100
|
import { moveOnKey } from "./internal/roving-focus.js";
|
|
83
101
|
import type { RovingSet } from "./internal/roving-focus.js";
|
|
84
|
-
import { usePresence, useUntilFound } from "./internal/disclosure.js";
|
|
102
|
+
import { useMeasuredHeight, usePresence, useUntilFound } from "./internal/disclosure.js";
|
|
85
103
|
import { useControlled } from "./internal/controlled-state.js";
|
|
86
104
|
|
|
87
105
|
/** Whether one section is open at a time, or any number of them. */
|
|
@@ -110,6 +128,8 @@ type AccordionState = {|
|
|
|
110
128
|
/** Whether closing the last open section is allowed; only meaningful for `single`. */
|
|
111
129
|
readonly closable: boolean,
|
|
112
130
|
readonly type: AccordionType,
|
|
131
|
+
/** Whether each panel carries its measured height; see the module header. */
|
|
132
|
+
readonly measure: boolean,
|
|
113
133
|
|};
|
|
114
134
|
|
|
115
135
|
const AccordionContext: React.Context<AccordionState | null> = createContext(null);
|
|
@@ -160,6 +180,8 @@ export component AccordionRoot(
|
|
|
160
180
|
defaultValue?: $ReadOnlyArray<string> = NOTHING,
|
|
161
181
|
value?: $ReadOnlyArray<string>,
|
|
162
182
|
onValueChange?: (value: $ReadOnlyArray<string>) => void,
|
|
183
|
+
measure?: boolean = false,
|
|
184
|
+
render?: RenderProp,
|
|
163
185
|
...rest: Rest
|
|
164
186
|
) {
|
|
165
187
|
const [open, setOpen] = useControlled<$ReadOnlyArray<string>>(value, defaultValue, onValueChange);
|
|
@@ -184,25 +206,23 @@ export component AccordionRoot(
|
|
|
184
206
|
const state = useMemo(
|
|
185
207
|
// `collapsible` only ever narrows a `single` accordion: in `multiple` mode
|
|
186
208
|
// 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],
|
|
209
|
+
() => ({ open, toggle, closable: type === "multiple" || collapsible, type, measure }),
|
|
210
|
+
[open, toggle, type, collapsible, measure],
|
|
189
211
|
);
|
|
190
|
-
const
|
|
212
|
+
const props = withProps(withoutComposed(rest, ["onKeyDown"]), {
|
|
213
|
+
children,
|
|
214
|
+
// The name the arrow keys use to tell this accordion's headers from those
|
|
215
|
+
// of an accordion nested inside one of its panels.
|
|
216
|
+
"data-accordion": "",
|
|
217
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
218
|
+
const stack: $FlowFixMe = event.currentTarget;
|
|
219
|
+
moveOnKey(event, stack, HEADERS);
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
191
222
|
|
|
192
223
|
return (
|
|
193
224
|
<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>
|
|
225
|
+
{render == null ? <div {...props} /> : render(props)}
|
|
206
226
|
</AccordionContext.Provider>
|
|
207
227
|
);
|
|
208
228
|
}
|
|
@@ -219,6 +239,7 @@ export component AccordionItem(
|
|
|
219
239
|
value: string,
|
|
220
240
|
children: renders* (AccordionHeader | AccordionContent),
|
|
221
241
|
disabled?: boolean = false,
|
|
242
|
+
render?: RenderProp,
|
|
222
243
|
...rest: Rest
|
|
223
244
|
) {
|
|
224
245
|
const accordion = useAccordion("Accordion.Item");
|
|
@@ -241,9 +262,11 @@ export component AccordionItem(
|
|
|
241
262
|
[base, open, toggle, value, accordion.closable, disabled, present],
|
|
242
263
|
);
|
|
243
264
|
|
|
265
|
+
const props = withProps(rest, { children });
|
|
266
|
+
|
|
244
267
|
return (
|
|
245
268
|
<AccordionItemContext.Provider value={state}>
|
|
246
|
-
<div {...
|
|
269
|
+
{render == null ? <div {...props} /> : render(props)}
|
|
247
270
|
</AccordionItemContext.Provider>
|
|
248
271
|
);
|
|
249
272
|
}
|
|
@@ -274,35 +297,34 @@ export component AccordionHeader(
|
|
|
274
297
|
* It carries no `tabIndex` of its own on purpose: every header stays in the
|
|
275
298
|
* page's tab order, which is what makes this an accordion and not a tab list.
|
|
276
299
|
*/
|
|
277
|
-
export component AccordionTrigger(children: React.Node, ...rest: Rest) {
|
|
300
|
+
export component AccordionTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
278
301
|
const item = useAccordionItem("Accordion.Trigger");
|
|
279
|
-
const passed = withoutComposed(rest, ["onClick"]);
|
|
280
302
|
// Locked and disabled are two different sentences a reader hears the same
|
|
281
303
|
// way, and both are `aria-disabled` rather than `disabled` so the header
|
|
282
304
|
// stays where they can find it: "this section will not close" and "this
|
|
283
305
|
// section is unavailable".
|
|
284
306
|
const inert = item.locked || item.disabled;
|
|
285
307
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
308
|
+
const props = withProps(withoutComposed(rest, ["onClick"]), {
|
|
309
|
+
"aria-controls": item.present ? item.contentId : undefined,
|
|
310
|
+
"aria-disabled": inert ? "true" : undefined,
|
|
311
|
+
"aria-expanded": item.open ? "true" : "false",
|
|
312
|
+
children,
|
|
313
|
+
// What the arrow keys look for. Not a role, because the accordion pattern
|
|
314
|
+
// has none to look for; see the module header.
|
|
315
|
+
"data-accordion-trigger": "",
|
|
316
|
+
id: item.triggerId,
|
|
317
|
+
onClick: composeHandlers(rest.onClick, () => {
|
|
318
|
+
if (!inert) {
|
|
319
|
+
item.toggle();
|
|
320
|
+
}
|
|
321
|
+
}),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
if (render != null) {
|
|
325
|
+
return render(props);
|
|
326
|
+
}
|
|
327
|
+
return <button {...props} type="button" />;
|
|
306
328
|
}
|
|
307
329
|
|
|
308
330
|
/**
|
|
@@ -311,25 +333,30 @@ export component AccordionTrigger(children: React.Node, ...rest: Rest) {
|
|
|
311
333
|
* `internal/disclosure.js` explains what "stays in the document" is worth and
|
|
312
334
|
* what `hidden` is upgraded to for it.
|
|
313
335
|
*/
|
|
314
|
-
export component AccordionContent(children: React.Node, ...rest: Rest) {
|
|
336
|
+
export component AccordionContent(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
337
|
+
const accordion = useAccordion("Accordion.Content");
|
|
315
338
|
const item = useAccordionItem("Accordion.Content");
|
|
316
339
|
const contentRef = useRef<HTMLElement | null>(null);
|
|
317
340
|
usePresence(item.registerContent);
|
|
318
341
|
useUntilFound(contentRef, item.open);
|
|
342
|
+
useMeasuredHeight(contentRef, accordion.measure);
|
|
319
343
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
)
|
|
344
|
+
const props = withProps(withoutComposed(rest, ["ref"]), {
|
|
345
|
+
// The name a reader hears for this landmark is the header they pressed.
|
|
346
|
+
"aria-labelledby": item.triggerId,
|
|
347
|
+
children,
|
|
348
|
+
hidden: !item.open,
|
|
349
|
+
id: item.contentId,
|
|
350
|
+
// React calls callback refs during commit; this node is only read by effects.
|
|
351
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
352
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
353
|
+
contentRef.current = element;
|
|
354
|
+
}),
|
|
355
|
+
role: "region",
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (render != null) {
|
|
359
|
+
return render(props);
|
|
360
|
+
}
|
|
361
|
+
return <div {...props} />;
|
|
335
362
|
}
|
package/alert-dialog.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
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 { RenderProp, 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, render?: RenderProp, ...rest: Rest) {
|
|
122
|
+
return (
|
|
123
|
+
<DialogTrigger {...forwarded(rest)} render={render}>
|
|
124
|
+
{children}
|
|
125
|
+
</DialogTrigger>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The backdrop. See `Dialog.Overlay`: it is decoration and says so. */
|
|
130
|
+
export component AlertDialogOverlay(render?: RenderProp, ...rest: Rest) {
|
|
131
|
+
return <DialogOverlay {...forwarded(rest)} render={render} />;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The alert dialog itself: announced as one, described, and not dismissible by
|
|
136
|
+
* a press beside it.
|
|
137
|
+
*
|
|
138
|
+
* The three props it sets on `Dialog.Body` are the three the module header
|
|
139
|
+
* names, and they are set here rather than left to a caller because a caller
|
|
140
|
+
* who set two of them would have an alert dialog that is wrong in the third
|
|
141
|
+
* without anything saying so.
|
|
142
|
+
*/
|
|
143
|
+
export component AlertDialogBody(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
144
|
+
const alert = useAlertDialog("AlertDialog.Body");
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<DialogBody
|
|
148
|
+
{...forwarded(rest)}
|
|
149
|
+
dismissOnOutsidePress={false}
|
|
150
|
+
initialFocus={alert.cancelRef}
|
|
151
|
+
render={render}
|
|
152
|
+
role="alertdialog"
|
|
153
|
+
>
|
|
154
|
+
{children}
|
|
155
|
+
<RequireDescription />
|
|
156
|
+
</DialogBody>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The check that there is something to announce, made where it is answerable.
|
|
162
|
+
*
|
|
163
|
+
* Inside `Dialog.Body` and last, and both halves are load-bearing. Inside,
|
|
164
|
+
* because `Dialog.Body` renders nothing at all while it is closed — an alert
|
|
165
|
+
* dialog that has not been opened has no description in the document, and a
|
|
166
|
+
* check in `AlertDialog.Body` itself therefore fired on every alert dialog ever
|
|
167
|
+
* rendered. Last, because React runs a subtree's effects in document order, so
|
|
168
|
+
* every `AlertDialog.Description` above this has already counted itself by the
|
|
169
|
+
* time this asks.
|
|
170
|
+
*
|
|
171
|
+
* It renders nothing, which is the point: the requirement is about the tree and
|
|
172
|
+
* not about the markup.
|
|
173
|
+
*/
|
|
174
|
+
component RequireDescription() {
|
|
175
|
+
const alert = useAlertDialog("AlertDialog.Body");
|
|
176
|
+
const describedBy = alert.describedBy;
|
|
177
|
+
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (describedBy.current === 0) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
"AlertDialog.Body must contain an AlertDialog.Description: " +
|
|
182
|
+
'role="alertdialog" exists to announce one, and an alert dialog ' +
|
|
183
|
+
"without a description interrupts the reader to say nothing.",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}, [describedBy]);
|
|
187
|
+
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The top of the alert dialog. See `Dialog.Header` for why it is a `div`. */
|
|
192
|
+
export component AlertDialogHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
193
|
+
return (
|
|
194
|
+
<DialogHeader {...forwarded(rest)} render={render}>
|
|
195
|
+
{children}
|
|
196
|
+
</DialogHeader>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The bottom, where `Action` and `Cancel` go. */
|
|
201
|
+
export component AlertDialogFooter(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
202
|
+
return (
|
|
203
|
+
<DialogFooter {...forwarded(rest)} render={render}>
|
|
204
|
+
{children}
|
|
205
|
+
</DialogFooter>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The question, which is the alert dialog's accessible name. */
|
|
210
|
+
export component AlertDialogTitle(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
211
|
+
return (
|
|
212
|
+
<DialogTitle {...forwarded(rest)} render={render}>
|
|
213
|
+
{children}
|
|
214
|
+
</DialogTitle>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* What answering costs, announced the moment focus arrives.
|
|
220
|
+
*
|
|
221
|
+
* This is where "this cannot be undone" belongs. It is the sentence the role
|
|
222
|
+
* exists to deliver, and the only moment the reader has to decide whether they
|
|
223
|
+
* care is before they have pressed anything.
|
|
224
|
+
*/
|
|
225
|
+
export component AlertDialogDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
226
|
+
const alert = useAlertDialog("AlertDialog.Description");
|
|
227
|
+
const describedBy = alert.describedBy;
|
|
228
|
+
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
// This mount counter is a ref because it is read by Dialog.Body after commit.
|
|
231
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
232
|
+
describedBy.current += 1;
|
|
233
|
+
return () => {
|
|
234
|
+
describedBy.current -= 1;
|
|
235
|
+
};
|
|
236
|
+
}, [describedBy]);
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<DialogDescription {...forwarded(rest)} render={render}>
|
|
240
|
+
{children}
|
|
241
|
+
</DialogDescription>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The button that carries out the thing being confirmed.
|
|
247
|
+
*
|
|
248
|
+
* It closes the dialog after the caller's handler has run, and it is not where
|
|
249
|
+
* focus starts; see `AlertDialog.Cancel`. `Dialog.Close` is what both answers
|
|
250
|
+
* are made of, so the composition rule about a caller's `onClick` has one
|
|
251
|
+
* implementation rather than a second copy here.
|
|
252
|
+
*/
|
|
253
|
+
export component AlertDialogAction(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
254
|
+
return (
|
|
255
|
+
<DialogClose {...forwarded(rest)} render={render}>
|
|
256
|
+
{children}
|
|
257
|
+
</DialogClose>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The button that declines, and the one focus lands on.
|
|
263
|
+
*
|
|
264
|
+
* It registers itself so `AlertDialog.Body` can name it as the initial focus
|
|
265
|
+
* without the caller wiring a ref: the least destructive action is a fact about
|
|
266
|
+
* which part this is, not a decision to be repeated at every call.
|
|
267
|
+
*/
|
|
268
|
+
export component AlertDialogCancel(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
269
|
+
const alert = useAlertDialog("AlertDialog.Cancel");
|
|
270
|
+
const cancelRef = alert.cancelRef;
|
|
271
|
+
const passed = withoutComposed(rest, ["ref"]);
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<DialogClose
|
|
275
|
+
{...forwarded(passed)}
|
|
276
|
+
ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
277
|
+
cancelRef.current = element;
|
|
278
|
+
})}
|
|
279
|
+
render={render}
|
|
280
|
+
>
|
|
281
|
+
{children}
|
|
282
|
+
</DialogClose>
|
|
283
|
+
);
|
|
284
|
+
}
|
package/alert.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A callout, and the live region it must not be by default.
|
|
4
|
+
//
|
|
5
|
+
// Of the twenty components in the catalogue that look like a class list, this
|
|
6
|
+
// is the one whose usual shape is arguably wrong to copy rather than merely
|
|
7
|
+
// empty. Every version of it renders `<div role="alert">`, always, and that one
|
|
8
|
+
// attribute is a decision about interrupting the reader that nobody made.
|
|
9
|
+
//
|
|
10
|
+
// # `role="alert"` is a live region, not a colour
|
|
11
|
+
//
|
|
12
|
+
// A live region announces *changes*. An element carrying one that is already in
|
|
13
|
+
// the document when the page loads has no change to report, so it is announced
|
|
14
|
+
// on insertion or it is not announced at all — and which of those you get is a
|
|
15
|
+
// property of the moment the element entered the document, not of the element.
|
|
16
|
+
//
|
|
17
|
+
// So a permanently rendered "your trial ends soon" box with `role="alert"` is
|
|
18
|
+
// one of two things, both bad:
|
|
19
|
+
//
|
|
20
|
+
// * an **interruption on every page load**, on the engines that treat the
|
|
21
|
+
// initial render as an insertion — the reader is pulled out of whatever
|
|
22
|
+
// they were doing to hear a sentence that was equally true yesterday;
|
|
23
|
+
// * or **silence**, on the engines that do not — in which case the role was
|
|
24
|
+
// decoration, and the box is read in its ordinary place in the page like
|
|
25
|
+
// the `<div>` it is.
|
|
26
|
+
//
|
|
27
|
+
// Neither is what the author wanted, and neither is visible in a screenshot.
|
|
28
|
+
// The two cases have to be told apart by the caller, because the caller is the
|
|
29
|
+
// only one who knows which one they have:
|
|
30
|
+
//
|
|
31
|
+
// * a **static callout** — a panel that is part of the page — is a container
|
|
32
|
+
// with a heading and no live semantics at all. It is read where a reader
|
|
33
|
+
// reaches it, and heading navigation finds it, which is what `Alert.Title`
|
|
34
|
+
// being a real heading is for.
|
|
35
|
+
// * an **alert** — something that appeared because something happened — is
|
|
36
|
+
// `live`, and is `role="alert"`.
|
|
37
|
+
//
|
|
38
|
+
// `field.js` already makes exactly this call for `Field.Error`, which is
|
|
39
|
+
// rendered only once the field is wrong and is `role="alert"` for that reason.
|
|
40
|
+
//
|
|
41
|
+
// # Why `live` is a boolean and there is no polite option
|
|
42
|
+
//
|
|
43
|
+
// Because a polite one cannot be built this way, and offering it would be
|
|
44
|
+
// offering silence. `combobox.js` states the rule: a live region added to the
|
|
45
|
+
// page in the same commit as the text it holds is usually not announced,
|
|
46
|
+
// because the technology watching it had nothing to watch until it was already
|
|
47
|
+
// too late. `role="status"` is polite, so it is subject to that rule in full —
|
|
48
|
+
// a polite region has to have been in the document *first*, empty, and a
|
|
49
|
+
// component you render at the moment the thing happens never was.
|
|
50
|
+
//
|
|
51
|
+
// `role="alert"` is assertive, and assertive regions are announced on insertion
|
|
52
|
+
// by every engine that implements them; that is what the role is for. So the
|
|
53
|
+
// one live shape this component can honestly offer is the assertive one.
|
|
54
|
+
//
|
|
55
|
+
// The polite, page-level shape is `Toast`, which is the component that exists
|
|
56
|
+
// to have been watching already — `toast.js` and ubugeeei-prod/uf#289. An
|
|
57
|
+
// application that wants "saved" said politely wants a toast, not an alert, and
|
|
58
|
+
// pointing at it is a better answer than a `live="polite"` that does nothing.
|
|
59
|
+
//
|
|
60
|
+
// # No `"use client"`
|
|
61
|
+
//
|
|
62
|
+
// It holds no state, listens to nothing and manages no focus. Which of the two
|
|
63
|
+
// alerts this is arrived as a prop, and the heading level did too. It renders
|
|
64
|
+
// on a server.
|
|
65
|
+
|
|
66
|
+
import * as React from "@uniflowed/react";
|
|
67
|
+
|
|
68
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
69
|
+
import { withProps } from "./internal/merge-props.js";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A callout: a panel that is part of the page, or one that just appeared.
|
|
73
|
+
*
|
|
74
|
+
* `live` is the whole component. Without it there is no role, deliberately —
|
|
75
|
+
* a box a reader reaches in reading order needs no announcement, and giving it
|
|
76
|
+
* one costs an interruption on every page load or nothing at all. With it the
|
|
77
|
+
* container is `role="alert"`, which is assertive and therefore the one live
|
|
78
|
+
* shape that is announced when it is inserted with its text in it.
|
|
79
|
+
*
|
|
80
|
+
* {error != null && (
|
|
81
|
+
* <Alert.Root live>
|
|
82
|
+
* <Alert.Title>Could not save</Alert.Title>
|
|
83
|
+
* <Alert.Description>{error}</Alert.Description>
|
|
84
|
+
* </Alert.Root>
|
|
85
|
+
* )}
|
|
86
|
+
*
|
|
87
|
+
* Rendered unconditionally with `live` on it, this is the mistake the module
|
|
88
|
+
* header is about: the role is a promise about a change, and a box that was
|
|
89
|
+
* always there has no change to report.
|
|
90
|
+
*/
|
|
91
|
+
export component AlertRoot(
|
|
92
|
+
children: React.Node,
|
|
93
|
+
live?: boolean = false,
|
|
94
|
+
render?: RenderProp,
|
|
95
|
+
...rest: Rest
|
|
96
|
+
) {
|
|
97
|
+
const props = withProps(rest, { children, role: live ? "alert" : undefined });
|
|
98
|
+
if (render != null) {
|
|
99
|
+
return render(props);
|
|
100
|
+
}
|
|
101
|
+
return <div {...props} />;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The callout's heading.
|
|
106
|
+
*
|
|
107
|
+
* A real heading rather than a bold `<div>`, because a heading is how a screen
|
|
108
|
+
* reader user finds a region of a page without reading it — and a callout
|
|
109
|
+
* nobody can jump to is a callout that has to be walked into.
|
|
110
|
+
*
|
|
111
|
+
* `level` is the caller's for the reason `accordion.js` gives for the same
|
|
112
|
+
* prop: the level that keeps a document outline true depends on what the
|
|
113
|
+
* callout is inside, and a hard-coded one produces an outline nobody can
|
|
114
|
+
* navigate. The guess is stated rather than hidden — `3`, which is right for a
|
|
115
|
+
* callout inside a section that has a title of its own — and a level outside
|
|
116
|
+
* the six HTML has is clamped, because `<h7>` is not an element and is
|
|
117
|
+
* announced as nothing at all.
|
|
118
|
+
*/
|
|
119
|
+
export component AlertTitle(
|
|
120
|
+
children: React.Node,
|
|
121
|
+
level?: number = 3,
|
|
122
|
+
render?: RenderProp,
|
|
123
|
+
...rest: Rest
|
|
124
|
+
) {
|
|
125
|
+
const clamped = Math.min(6, Math.max(1, Math.trunc(level)));
|
|
126
|
+
const Heading = `h${String(clamped)}`;
|
|
127
|
+
const props = withProps(rest, { children });
|
|
128
|
+
|
|
129
|
+
if (render != null) {
|
|
130
|
+
return render(withProps(props, { "aria-level": clamped, role: "heading" }));
|
|
131
|
+
}
|
|
132
|
+
return <Heading {...props} />;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** What the callout says, under its heading. */
|
|
136
|
+
export component AlertDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
137
|
+
const props = withProps(rest, { children });
|
|
138
|
+
if (render != null) {
|
|
139
|
+
return render(props);
|
|
140
|
+
}
|
|
141
|
+
return <p {...props} />;
|
|
142
|
+
}
|