@uniflowed/ui 0.0.0-alpha.9 → 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.
Files changed (62) hide show
  1. package/accordion.js +84 -57
  2. package/alert-dialog.js +284 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +280 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +560 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +215 -31
  9. package/collapsible.js +72 -48
  10. package/color-picker.js +172 -0
  11. package/combobox.js +216 -39
  12. package/context-menu.js +215 -0
  13. package/date-field.js +9 -0
  14. package/date-picker.js +357 -0
  15. package/date-range-picker.js +120 -0
  16. package/dialog.js +235 -177
  17. package/drag-drop.js +125 -0
  18. package/drawer.js +504 -0
  19. package/field.js +260 -43
  20. package/grid-list.js +8 -0
  21. package/hover-card.js +52 -52
  22. package/i18n-provider.js +89 -0
  23. package/index.js +1159 -31
  24. package/input-otp.js +218 -0
  25. package/interactions.js +2327 -0
  26. package/internal/anchor.js +71 -6
  27. package/internal/collection.js +395 -0
  28. package/internal/date-grid.js +260 -0
  29. package/internal/date-range.js +26 -0
  30. package/internal/disclosure.js +201 -0
  31. package/internal/menu-tree.js +228 -0
  32. package/internal/merge-props.js +85 -1
  33. package/internal/roving-focus.js +15 -4
  34. package/internal/segmented-field.js +316 -0
  35. package/list-box.js +13 -0
  36. package/menu.js +553 -361
  37. package/menubar.js +295 -0
  38. package/number-field.js +263 -0
  39. package/package.json +8 -28
  40. package/pagination.js +34 -22
  41. package/popover.js +116 -75
  42. package/progress.js +21 -16
  43. package/radio-group.js +81 -75
  44. package/range-calendar.js +78 -0
  45. package/resizable.js +155 -9
  46. package/scroll-area.js +283 -0
  47. package/select.js +83 -37
  48. package/separator.js +97 -0
  49. package/sheet.js +189 -0
  50. package/sidebar.js +320 -0
  51. package/skeleton.js +163 -0
  52. package/slider.js +95 -89
  53. package/switch.js +42 -34
  54. package/table.js +112 -71
  55. package/tabs.js +100 -91
  56. package/tag-group.js +8 -0
  57. package/time-field.js +8 -0
  58. package/toast.js +36 -66
  59. package/toggle-group.js +53 -49
  60. package/toggle.js +41 -27
  61. package/tooltip.js +48 -55
  62. 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 { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
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 passed = withoutComposed(rest, ["onKeyDown"]);
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 {...rest}>{children}</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
- 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
- );
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
- 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
- );
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
  }
@@ -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
+ }