@uniflowed/ui 0.0.0-alpha.18 → 0.0.0-alpha.37

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 CHANGED
@@ -90,8 +90,13 @@ import {
90
90
  useState,
91
91
  } from "@uniflowed/react";
92
92
 
93
- import type { Rest } from "./internal/merge-props.js";
94
- 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";
95
100
  import { moveOnKey } from "./internal/roving-focus.js";
96
101
  import type { RovingSet } from "./internal/roving-focus.js";
97
102
  import { useMeasuredHeight, usePresence, useUntilFound } from "./internal/disclosure.js";
@@ -176,6 +181,7 @@ export component AccordionRoot(
176
181
  value?: $ReadOnlyArray<string>,
177
182
  onValueChange?: (value: $ReadOnlyArray<string>) => void,
178
183
  measure?: boolean = false,
184
+ render?: RenderProp,
179
185
  ...rest: Rest
180
186
  ) {
181
187
  const [open, setOpen] = useControlled<$ReadOnlyArray<string>>(value, defaultValue, onValueChange);
@@ -203,22 +209,20 @@ export component AccordionRoot(
203
209
  () => ({ open, toggle, closable: type === "multiple" || collapsible, type, measure }),
204
210
  [open, toggle, type, collapsible, measure],
205
211
  );
206
- 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
+ });
207
222
 
208
223
  return (
209
224
  <AccordionContext.Provider value={state}>
210
- <div
211
- {...passed}
212
- // The name the arrow keys use to tell this accordion's headers from
213
- // those of an accordion nested inside one of its panels.
214
- data-accordion=""
215
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
216
- const stack: $FlowFixMe = event.currentTarget;
217
- moveOnKey(event, stack, HEADERS);
218
- })}
219
- >
220
- {children}
221
- </div>
225
+ {render == null ? <div {...props} /> : render(props)}
222
226
  </AccordionContext.Provider>
223
227
  );
224
228
  }
@@ -235,6 +239,7 @@ export component AccordionItem(
235
239
  value: string,
236
240
  children: renders* (AccordionHeader | AccordionContent),
237
241
  disabled?: boolean = false,
242
+ render?: RenderProp,
238
243
  ...rest: Rest
239
244
  ) {
240
245
  const accordion = useAccordion("Accordion.Item");
@@ -257,9 +262,11 @@ export component AccordionItem(
257
262
  [base, open, toggle, value, accordion.closable, disabled, present],
258
263
  );
259
264
 
265
+ const props = withProps(rest, { children });
266
+
260
267
  return (
261
268
  <AccordionItemContext.Provider value={state}>
262
- <div {...rest}>{children}</div>
269
+ {render == null ? <div {...props} /> : render(props)}
263
270
  </AccordionItemContext.Provider>
264
271
  );
265
272
  }
@@ -290,35 +297,34 @@ export component AccordionHeader(
290
297
  * It carries no `tabIndex` of its own on purpose: every header stays in the
291
298
  * page's tab order, which is what makes this an accordion and not a tab list.
292
299
  */
293
- export component AccordionTrigger(children: React.Node, ...rest: Rest) {
300
+ export component AccordionTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
294
301
  const item = useAccordionItem("Accordion.Trigger");
295
- const passed = withoutComposed(rest, ["onClick"]);
296
302
  // Locked and disabled are two different sentences a reader hears the same
297
303
  // way, and both are `aria-disabled` rather than `disabled` so the header
298
304
  // stays where they can find it: "this section will not close" and "this
299
305
  // section is unavailable".
300
306
  const inert = item.locked || item.disabled;
301
307
 
302
- return (
303
- <button
304
- {...passed}
305
- aria-controls={item.present ? item.contentId : undefined}
306
- aria-disabled={inert ? "true" : undefined}
307
- aria-expanded={item.open ? "true" : "false"}
308
- // What the arrow keys look for. Not a role, because the accordion pattern
309
- // has none to look for; see the module header.
310
- data-accordion-trigger=""
311
- id={item.triggerId}
312
- onClick={composeHandlers(rest.onClick, () => {
313
- if (!inert) {
314
- item.toggle();
315
- }
316
- })}
317
- type="button"
318
- >
319
- {children}
320
- </button>
321
- );
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" />;
322
328
  }
323
329
 
324
330
  /**
@@ -327,7 +333,7 @@ export component AccordionTrigger(children: React.Node, ...rest: Rest) {
327
333
  * `internal/disclosure.js` explains what "stays in the document" is worth and
328
334
  * what `hidden` is upgraded to for it.
329
335
  */
330
- export component AccordionContent(children: React.Node, ...rest: Rest) {
336
+ export component AccordionContent(children: React.Node, render?: RenderProp, ...rest: Rest) {
331
337
  const accordion = useAccordion("Accordion.Content");
332
338
  const item = useAccordionItem("Accordion.Content");
333
339
  const contentRef = useRef<HTMLElement | null>(null);
@@ -335,19 +341,20 @@ export component AccordionContent(children: React.Node, ...rest: Rest) {
335
341
  useUntilFound(contentRef, item.open);
336
342
  useMeasuredHeight(contentRef, accordion.measure);
337
343
 
338
- return (
339
- <div
340
- {...withoutComposed(rest, ["ref"])}
341
- // The name a reader hears for this landmark is the header they pressed.
342
- aria-labelledby={item.triggerId}
343
- hidden={!item.open}
344
- id={item.contentId}
345
- ref={composeRefs(rest.ref, (element) => {
346
- contentRef.current = element;
347
- })}
348
- role="region"
349
- >
350
- {children}
351
- </div>
352
- );
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
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
351
+ contentRef.current = element;
352
+ }),
353
+ role: "region",
354
+ });
355
+
356
+ if (render != null) {
357
+ return render(props);
358
+ }
359
+ return <div {...props} />;
353
360
  }
package/alert-dialog.js CHANGED
@@ -46,7 +46,7 @@
46
46
  import * as React from "@uniflowed/react";
47
47
  import { createContext, useContext, useEffect, useMemo, useRef } from "@uniflowed/react";
48
48
 
49
- import type { Rest } from "./internal/merge-props.js";
49
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
50
50
  import { composeRefs, forwarded, withoutComposed } from "./internal/merge-props.js";
51
51
  import {
52
52
  DialogBody,
@@ -118,13 +118,17 @@ export component AlertDialogRoot(
118
118
  }
119
119
 
120
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>;
121
+ export component AlertDialogTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
122
+ return (
123
+ <DialogTrigger {...forwarded(rest)} render={render}>
124
+ {children}
125
+ </DialogTrigger>
126
+ );
123
127
  }
124
128
 
125
129
  /** The backdrop. See `Dialog.Overlay`: it is decoration and says so. */
126
- export component AlertDialogOverlay(...rest: Rest) {
127
- return <DialogOverlay {...forwarded(rest)} />;
130
+ export component AlertDialogOverlay(render?: RenderProp, ...rest: Rest) {
131
+ return <DialogOverlay {...forwarded(rest)} render={render} />;
128
132
  }
129
133
 
130
134
  /**
@@ -136,7 +140,7 @@ export component AlertDialogOverlay(...rest: Rest) {
136
140
  * who set two of them would have an alert dialog that is wrong in the third
137
141
  * without anything saying so.
138
142
  */
139
- export component AlertDialogBody(children: React.Node, ...rest: Rest) {
143
+ export component AlertDialogBody(children: React.Node, render?: RenderProp, ...rest: Rest) {
140
144
  const alert = useAlertDialog("AlertDialog.Body");
141
145
 
142
146
  return (
@@ -144,6 +148,7 @@ export component AlertDialogBody(children: React.Node, ...rest: Rest) {
144
148
  {...forwarded(rest)}
145
149
  dismissOnOutsidePress={false}
146
150
  initialFocus={alert.cancelRef}
151
+ render={render}
147
152
  role="alertdialog"
148
153
  >
149
154
  {children}
@@ -184,18 +189,30 @@ component RequireDescription() {
184
189
  }
185
190
 
186
191
  /** 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>;
192
+ export component AlertDialogHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
193
+ return (
194
+ <DialogHeader {...forwarded(rest)} render={render}>
195
+ {children}
196
+ </DialogHeader>
197
+ );
189
198
  }
190
199
 
191
200
  /** The bottom, where `Action` and `Cancel` go. */
192
- export component AlertDialogFooter(children: React.Node, ...rest: Rest) {
193
- return <DialogFooter {...forwarded(rest)}>{children}</DialogFooter>;
201
+ export component AlertDialogFooter(children: React.Node, render?: RenderProp, ...rest: Rest) {
202
+ return (
203
+ <DialogFooter {...forwarded(rest)} render={render}>
204
+ {children}
205
+ </DialogFooter>
206
+ );
194
207
  }
195
208
 
196
209
  /** 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>;
210
+ export component AlertDialogTitle(children: React.Node, render?: RenderProp, ...rest: Rest) {
211
+ return (
212
+ <DialogTitle {...forwarded(rest)} render={render}>
213
+ {children}
214
+ </DialogTitle>
215
+ );
199
216
  }
200
217
 
201
218
  /**
@@ -205,7 +222,7 @@ export component AlertDialogTitle(children: React.Node, ...rest: Rest) {
205
222
  * exists to deliver, and the only moment the reader has to decide whether they
206
223
  * care is before they have pressed anything.
207
224
  */
208
- export component AlertDialogDescription(children: React.Node, ...rest: Rest) {
225
+ export component AlertDialogDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
209
226
  const alert = useAlertDialog("AlertDialog.Description");
210
227
  const describedBy = alert.describedBy;
211
228
 
@@ -216,7 +233,11 @@ export component AlertDialogDescription(children: React.Node, ...rest: Rest) {
216
233
  };
217
234
  }, [describedBy]);
218
235
 
219
- return <DialogDescription {...forwarded(rest)}>{children}</DialogDescription>;
236
+ return (
237
+ <DialogDescription {...forwarded(rest)} render={render}>
238
+ {children}
239
+ </DialogDescription>
240
+ );
220
241
  }
221
242
 
222
243
  /**
@@ -227,8 +248,12 @@ export component AlertDialogDescription(children: React.Node, ...rest: Rest) {
227
248
  * are made of, so the composition rule about a caller's `onClick` has one
228
249
  * implementation rather than a second copy here.
229
250
  */
230
- export component AlertDialogAction(children: React.Node, ...rest: Rest) {
231
- return <DialogClose {...forwarded(rest)}>{children}</DialogClose>;
251
+ export component AlertDialogAction(children: React.Node, render?: RenderProp, ...rest: Rest) {
252
+ return (
253
+ <DialogClose {...forwarded(rest)} render={render}>
254
+ {children}
255
+ </DialogClose>
256
+ );
232
257
  }
233
258
 
234
259
  /**
@@ -238,7 +263,7 @@ export component AlertDialogAction(children: React.Node, ...rest: Rest) {
238
263
  * without the caller wiring a ref: the least destructive action is a fact about
239
264
  * which part this is, not a decision to be repeated at every call.
240
265
  */
241
- export component AlertDialogCancel(children: React.Node, ...rest: Rest) {
266
+ export component AlertDialogCancel(children: React.Node, render?: RenderProp, ...rest: Rest) {
242
267
  const alert = useAlertDialog("AlertDialog.Cancel");
243
268
  const cancelRef = alert.cancelRef;
244
269
  const passed = withoutComposed(rest, ["ref"]);
@@ -249,6 +274,7 @@ export component AlertDialogCancel(children: React.Node, ...rest: Rest) {
249
274
  ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
250
275
  cancelRef.current = element;
251
276
  })}
277
+ render={render}
252
278
  >
253
279
  {children}
254
280
  </DialogClose>
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
+ }