@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/radio-group.js CHANGED
@@ -76,8 +76,13 @@
76
76
  import * as React from "@uniflowed/react";
77
77
  import { createContext, useCallback, useContext, useId, useMemo, useRef } from "@uniflowed/react";
78
78
 
79
- import type { Rest } from "./internal/merge-props.js";
80
- import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
79
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
80
+ import {
81
+ composeHandlers,
82
+ composeRefs,
83
+ withoutComposed,
84
+ withProps,
85
+ } from "./internal/merge-props.js";
81
86
  import { moveOnKey, useFirstItem } from "./internal/roving-focus.js";
82
87
  import type { Orientation, RovingSet } from "./internal/roving-focus.js";
83
88
  import { useControlled } from "./internal/controlled-state.js";
@@ -146,6 +151,7 @@ export component RadioGroupRoot(
146
151
  onValueChange?: (value: string) => void,
147
152
  orientation?: Orientation = "vertical",
148
153
  name?: string,
154
+ render?: RenderProp,
149
155
  ...rest: Rest
150
156
  ) {
151
157
  // `onValueChange` promises a `string` while the group's *state* is
@@ -171,44 +177,46 @@ export component RadioGroupRoot(
171
177
 
172
178
  const state = useMemo(() => ({ selected, select, firstId }), [selected, select, firstId]);
173
179
  const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
180
+ const content = (
181
+ <>
182
+ {children}
183
+ {/*
184
+ A form submits `<input>` elements, and none of the parts above is one.
185
+ Without this the group is a control a reader can operate and a form
186
+ cannot read, which is the same hole `Combobox` still has.
187
+
188
+ `type="hidden"` rather than a visually hidden real radio, because the
189
+ buttons above already carry the whole of the accessible semantics: a
190
+ second set of native radios would be announced as a second set of
191
+ answers, and hiding them from the accessibility tree to stop that
192
+ leaves elements a form's own validation would then point its
193
+ "please choose one" at.
194
+ */}
195
+ {name == null ? null : <input name={name} type="hidden" value={selected ?? ""} />}
196
+ </>
197
+ );
198
+ const props = withProps(passed, {
199
+ "aria-orientation": orientation,
200
+ children: content,
201
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
202
+ const group: $FlowFixMe = event.currentTarget;
203
+ const next = moveOnKey(event, group, radioSet(orientation));
204
+ if (next != null) {
205
+ // Checking in the same key press is not a shortcut, it is the
206
+ // pattern: a radio group whose arrows moved focus without checking
207
+ // leaves a reader believing they have answered when they have not.
208
+ select(next.getAttribute("data-value") ?? "");
209
+ }
210
+ }),
211
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
212
+ rootRef.current = element;
213
+ }),
214
+ role: "radiogroup",
215
+ });
174
216
 
175
217
  return (
176
218
  <RadioGroupContext.Provider value={state}>
177
- <div
178
- {...passed}
179
- // A reader is told which axis this runs along, and it is also what says
180
- // which pair of arrow keys is live.
181
- aria-orientation={orientation}
182
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
183
- const group: $FlowFixMe = event.currentTarget;
184
- const next = moveOnKey(event, group, radioSet(orientation));
185
- if (next != null) {
186
- // Checking in the same key press is not a shortcut, it is the
187
- // pattern: a radio group whose arrows moved focus without checking
188
- // leaves a reader believing they have answered when they have not.
189
- select(next.getAttribute("data-value") ?? "");
190
- }
191
- })}
192
- ref={composeRefs(rest.ref, (element) => {
193
- rootRef.current = element;
194
- })}
195
- role="radiogroup"
196
- >
197
- {children}
198
- {/*
199
- A form submits `<input>` elements, and none of the parts above is one.
200
- Without this the group is a control a reader can operate and a form
201
- cannot read, which is the same hole `Combobox` still has.
202
-
203
- `type="hidden"` rather than a visually hidden real radio, because the
204
- buttons above already carry the whole of the accessible semantics: a
205
- second set of native radios would be announced as a second set of
206
- answers, and hiding them from the accessibility tree to stop that
207
- leaves elements a form's own validation would then point its
208
- "please choose one" at.
209
- */}
210
- {name == null ? null : <input name={name} type="hidden" value={selected ?? ""} />}
211
- </div>
219
+ {render == null ? <div {...props} /> : render(props)}
212
220
  </RadioGroupContext.Provider>
213
221
  );
214
222
  }
@@ -226,6 +234,7 @@ export component RadioGroupItem(
226
234
  value: string,
227
235
  children?: React.Node,
228
236
  disabled?: boolean = false,
237
+ render?: RenderProp,
229
238
  ...rest: Rest
230
239
  ) {
231
240
  const group = useRadioGroup("RadioGroup.Item");
@@ -233,41 +242,39 @@ export component RadioGroupItem(
233
242
  const checked = group.selected === value;
234
243
  const item = useMemo(() => ({ checked }), [checked]);
235
244
  const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
245
+ const props = withProps(passed, {
246
+ "aria-checked": checked ? "true" : "false",
247
+ "aria-disabled": disabled ? "true" : undefined,
248
+ // Read by the group's key handler, which finds items in the document
249
+ // rather than in a registry and so needs each one to carry its value.
250
+ "data-value": value,
251
+ children,
252
+ id,
253
+ onClick: composeHandlers(rest.onClick, (_event: PartEvent) => {
254
+ if (!disabled) {
255
+ group.select(value);
256
+ }
257
+ }),
258
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
259
+ if (disabled || event.key !== " ") {
260
+ return;
261
+ }
262
+ // Stops `Space` scrolling the page — which is what makes a
263
+ // hand-written radio feel broken even when it works — and stops the
264
+ // browser's own click arriving afterwards to check this again.
265
+ event.preventDefault();
266
+ group.select(value);
267
+ }),
268
+ role: "radio",
269
+ // The roving tab stop: the chosen answer, or the first one while there
270
+ // is no answer, so `Tab` reaches the group in either state and leaves
271
+ // it in one press.
272
+ tabIndex: checked || (group.selected == null && group.firstId === id) ? 0 : -1,
273
+ });
236
274
 
237
275
  return (
238
276
  <RadioItemContext.Provider value={item}>
239
- <button
240
- {...passed}
241
- aria-checked={checked ? "true" : "false"}
242
- aria-disabled={disabled ? "true" : undefined}
243
- // Read by the group's key handler, which finds items in the document
244
- // rather than in a registry and so needs each one to carry its value.
245
- data-value={value}
246
- id={id}
247
- onClick={composeHandlers(rest.onClick, () => {
248
- if (!disabled) {
249
- group.select(value);
250
- }
251
- })}
252
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
253
- if (disabled || event.key !== " ") {
254
- return;
255
- }
256
- // Stops `Space` scrolling the page — which is what makes a
257
- // hand-written radio feel broken even when it works — and stops the
258
- // browser's own click arriving afterwards to check this again.
259
- event.preventDefault();
260
- group.select(value);
261
- })}
262
- role="radio"
263
- // The roving tab stop: the chosen answer, or the first one while there
264
- // is no answer, so `Tab` reaches the group in either state and leaves
265
- // it in one press.
266
- tabIndex={checked || (group.selected == null && group.firstId === id) ? 0 : -1}
267
- type="button"
268
- >
269
- {children}
270
- </button>
277
+ {render == null ? <button {...props} type="button" /> : render(props)}
271
278
  </RadioItemContext.Provider>
272
279
  );
273
280
  }
@@ -282,7 +289,7 @@ export component RadioGroupItem(
282
289
  * `[aria-checked="true"] > *`, and so the "only while chosen" part is not
283
290
  * something each caller reimplements.
284
291
  */
285
- export component RadioGroupIndicator(children?: React.Node, ...rest: Rest) {
292
+ export component RadioGroupIndicator(children?: React.Node, render?: RenderProp, ...rest: Rest) {
286
293
  const item = useContext(RadioItemContext);
287
294
  if (item == null) {
288
295
  throw new Error("RadioGroup.Indicator must be rendered inside a RadioGroup.Item");
@@ -290,9 +297,6 @@ export component RadioGroupIndicator(children?: React.Node, ...rest: Rest) {
290
297
  if (!item.checked) {
291
298
  return null;
292
299
  }
293
- return (
294
- <span {...rest} aria-hidden="true">
295
- {children}
296
- </span>
297
- );
300
+ const props = withProps(rest, { "aria-hidden": "true", children });
301
+ return render == null ? <span {...props} /> : render(props);
298
302
  }
package/separator.js ADDED
@@ -0,0 +1,97 @@
1
+ // @flow
2
+ //
3
+ // A rule, and the one decision in it: whether anybody is told it is there.
4
+ //
5
+ // Two lines of markup, and it belongs in this package rather than in the preset
6
+ // for the same reason `Progress` does — the component *is* a conditional about
7
+ // what a reader hears:
8
+ //
9
+ // * A separator **between groups of content** is `role="separator"` with an
10
+ // `aria-orientation`. A reader moving down the page is told the subject
11
+ // changed, which is the information the line was drawn to give and the only
12
+ // way they get it.
13
+ // * A **decorative** rule — the line under a heading, the hairline between a
14
+ // card's padding and its footer — is `aria-hidden="true"` and announced to
15
+ // nobody. It is a border that happens to be an element.
16
+ //
17
+ // Getting it backwards is silent in both directions: a decorative rule with the
18
+ // role adds a "separator" to every reading of the page, and a real boundary
19
+ // without it takes the boundary away from everyone who is not looking at it.
20
+ //
21
+ // The default is the semantic one, because the two mistakes do not cost the
22
+ // same. A rule wrongly announced is noise a reader can hear and skip; a
23
+ // boundary wrongly silent is information that is simply not there, and nobody
24
+ // finds out. `progress.js` makes the same trade in its own sentence: the safe
25
+ // answer has to be the honest one.
26
+ //
27
+ // # Why this is a `<div>` and not an `<hr>`
28
+ //
29
+ // An `<hr>` already carries `role="separator"`, so for a horizontal rule
30
+ // between two blocks of prose it is the better answer and a caller who can use
31
+ // one should. This exists for what it cannot do.
32
+ //
33
+ // It comes with a border and a margin from the browser's own stylesheet, and a
34
+ // package that ships no styles cannot ship a visible line — every consumer
35
+ // would begin by turning it off. It is horizontal by definition, so a vertical
36
+ // rule between two things in a row is a rotated element rather than a described
37
+ // one. And it is a paragraph-level break in the flow, which is not what a
38
+ // hairline inside a toolbar is.
39
+ //
40
+ // # The two separators that are not this one
41
+ //
42
+ // `Menu.Separator` is the rule between groups of menu items, and belongs to the
43
+ // menu because it has to be skipped by the arrow keys that walk it.
44
+ // `Resizable.Handle` announces itself as a separator too, and is a *control* —
45
+ // the APG window splitter, a separator that behaves like a slider. Neither is
46
+ // this, and reaching for this one in either place loses the behaviour that made
47
+ // them their own components.
48
+ //
49
+ // # No `"use client"`
50
+ //
51
+ // One element, two attributes, nothing to remember. It renders on a server.
52
+
53
+ import type { Orientation } from "./internal/roving-focus.js";
54
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
55
+ import { withProps } from "./internal/merge-props.js";
56
+
57
+ export type { Orientation } from "./internal/roving-focus.js";
58
+
59
+ /**
60
+ * A rule between two things, or a line that is only a line.
61
+ *
62
+ * `decorative` is the whole component. Without it the element is a
63
+ * `role="separator"` a reader is told about; with it the element is hidden from
64
+ * the accessibility tree entirely.
65
+ *
66
+ * <Separator />
67
+ * <Separator orientation="vertical" />
68
+ * <Separator decorative />
69
+ *
70
+ * The decorative one gets `aria-hidden` and no role, rather than
71
+ * `role="presentation"` as well: a `<div>` has nothing to hide behind a
72
+ * presentation role, and one attribute that removes the element from the tree
73
+ * says the whole thing. `Breadcrumb.Separator` carries both because it is an
74
+ * `<li>`, whose `listitem` role would otherwise be counted.
75
+ *
76
+ * `aria-orientation` is written out even for the horizontal case, where ARIA
77
+ * would default to it. It is the attribute a reader of this markup is looking
78
+ * for, and a default that is left implicit is a default somebody has to know.
79
+ *
80
+ * `render` changes the element carrying that decision, not the decision
81
+ * itself: decorative rules stay hidden, semantic rules keep the separator
82
+ * role and orientation.
83
+ */
84
+ export component Separator(
85
+ decorative?: boolean = false,
86
+ orientation?: Orientation = "horizontal",
87
+ render?: RenderProp,
88
+ ...rest: Rest
89
+ ) {
90
+ const props = decorative
91
+ ? withProps(rest, { "aria-hidden": "true" })
92
+ : withProps(rest, { "aria-orientation": orientation, role: "separator" });
93
+ if (render != null) {
94
+ return render(props);
95
+ }
96
+ return <div {...props} />;
97
+ }
package/sheet.js CHANGED
@@ -43,7 +43,7 @@
43
43
  import * as React from "@uniflowed/react";
44
44
  import { createContext, useContext, useMemo } from "@uniflowed/react";
45
45
 
46
- import type { Rest } from "./internal/merge-props.js";
46
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
47
47
  import { forwarded } from "./internal/merge-props.js";
48
48
  import {
49
49
  DialogBody,
@@ -110,17 +110,21 @@ export component SheetRoot(
110
110
  }
111
111
 
112
112
  /** What opens it, and what focus comes back to when it closes. */
113
- export component SheetTrigger(children: React.Node, ...rest: Rest) {
114
- return <DialogTrigger {...forwarded(rest)}>{children}</DialogTrigger>;
113
+ export component SheetTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
114
+ return (
115
+ <DialogTrigger {...forwarded(rest)} render={render}>
116
+ {children}
117
+ </DialogTrigger>
118
+ );
115
119
  }
116
120
 
117
121
  /**
118
122
  * The backdrop, which knows the edge so a stylesheet does not have to be told
119
123
  * twice.
120
124
  */
121
- export component SheetOverlay(...rest: Rest) {
125
+ export component SheetOverlay(render?: RenderProp, ...rest: Rest) {
122
126
  const sheet = useSheet("Sheet.Overlay");
123
- return <DialogOverlay {...forwarded(rest)} data-side={sheet.side} />;
127
+ return <DialogOverlay {...forwarded(rest)} data-side={sheet.side} render={render} />;
124
128
  }
125
129
 
126
130
  /**
@@ -129,37 +133,57 @@ export component SheetOverlay(...rest: Rest) {
129
133
  * Every modal promise `dialog.js` makes is made here, unchanged. This part adds
130
134
  * `data-side` and nothing else, which is the honest size of the difference.
131
135
  */
132
- export component SheetBody(children: React.Node, ...rest: Rest) {
136
+ export component SheetBody(children: React.Node, render?: RenderProp, ...rest: Rest) {
133
137
  const sheet = useSheet("Sheet.Body");
134
138
 
135
139
  return (
136
- <DialogBody {...forwarded(rest)} data-side={sheet.side}>
140
+ <DialogBody {...forwarded(rest)} data-side={sheet.side} render={render}>
137
141
  {children}
138
142
  </DialogBody>
139
143
  );
140
144
  }
141
145
 
142
146
  /** The top of the sheet. See `Dialog.Header` for why it is not a `<header>`. */
143
- export component SheetHeader(children: React.Node, ...rest: Rest) {
144
- return <DialogHeader {...forwarded(rest)}>{children}</DialogHeader>;
147
+ export component SheetHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
148
+ return (
149
+ <DialogHeader {...forwarded(rest)} render={render}>
150
+ {children}
151
+ </DialogHeader>
152
+ );
145
153
  }
146
154
 
147
155
  /** The bottom of the sheet, where the actions go. */
148
- export component SheetFooter(children: React.Node, ...rest: Rest) {
149
- return <DialogFooter {...forwarded(rest)}>{children}</DialogFooter>;
156
+ export component SheetFooter(children: React.Node, render?: RenderProp, ...rest: Rest) {
157
+ return (
158
+ <DialogFooter {...forwarded(rest)} render={render}>
159
+ {children}
160
+ </DialogFooter>
161
+ );
150
162
  }
151
163
 
152
164
  /** The sheet's accessible name. A modal without one is announced as "dialog". */
153
- export component SheetTitle(children: React.Node, ...rest: Rest) {
154
- return <DialogTitle {...forwarded(rest)}>{children}</DialogTitle>;
165
+ export component SheetTitle(children: React.Node, render?: RenderProp, ...rest: Rest) {
166
+ return (
167
+ <DialogTitle {...forwarded(rest)} render={render}>
168
+ {children}
169
+ </DialogTitle>
170
+ );
155
171
  }
156
172
 
157
173
  /** What the sheet is for, announced after its name. */
158
- export component SheetDescription(children: React.Node, ...rest: Rest) {
159
- return <DialogDescription {...forwarded(rest)}>{children}</DialogDescription>;
174
+ export component SheetDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
175
+ return (
176
+ <DialogDescription {...forwarded(rest)} render={render}>
177
+ {children}
178
+ </DialogDescription>
179
+ );
160
180
  }
161
181
 
162
182
  /** A button that closes the sheet. */
163
- export component SheetClose(children: React.Node, ...rest: Rest) {
164
- return <DialogClose {...forwarded(rest)}>{children}</DialogClose>;
183
+ export component SheetClose(children: React.Node, render?: RenderProp, ...rest: Rest) {
184
+ return (
185
+ <DialogClose {...forwarded(rest)} render={render}>
186
+ {children}
187
+ </DialogClose>
188
+ );
165
189
  }
package/sidebar.js CHANGED
@@ -55,7 +55,7 @@ import * as React from "@uniflowed/react";
55
55
  import { createContext, useContext, useId, useMemo } from "@uniflowed/react";
56
56
  import { useMediaQuery } from "@uniflowed/hooks/browser";
57
57
 
58
- import type { Rest } from "./internal/merge-props.js";
58
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
59
59
  import { composeHandlers, forwarded, withProps, withoutComposed } from "./internal/merge-props.js";
60
60
  import { SheetBody, SheetOverlay, SheetRoot, SheetTrigger } from "./sheet.js";
61
61
  import { TooltipBody, TooltipRoot, TooltipTrigger } from "./tooltip.js";
@@ -263,25 +263,25 @@ export component SidebarFooter(children: React.Node, ...rest: Rest) {
263
263
  export component SidebarItem(
264
264
  children: React.Node,
265
265
  label: string,
266
- render?: (props: Rest) => React.Node,
266
+ render?: RenderProp,
267
267
  ...rest: Rest
268
268
  ) {
269
269
  const sidebar = useSidebar("Sidebar.Item");
270
270
  const passed = withoutComposed(rest, ["ref"]);
271
271
  const mine: Rest = {
272
272
  "aria-label": sidebar.collapsed ? label : undefined,
273
+ children,
273
274
  "data-collapsed": sidebar.collapsed ? "true" : undefined,
274
275
  };
275
276
 
276
277
  const entry = (extra: Rest) => {
277
- const props = withProps(withProps(passed, mine), extra);
278
- return render == null ? (
279
- <button {...props} type="button">
280
- {children}
281
- </button>
282
- ) : (
283
- render(props)
284
- );
278
+ // `mine` on top, and the order is load-bearing now that a part's props
279
+ // carry its children: the collapsed branch hands this the props
280
+ // `Tooltip.Trigger` built, and those name `children` as `undefined`
281
+ // because that tooltip trigger has none of its own. Applied last, a named
282
+ // `undefined` would blank the entry.
283
+ const props = withProps(withProps(passed, extra), mine);
284
+ return render == null ? <button {...props} type="button" /> : render(props);
285
285
  };
286
286
 
287
287
  if (!sidebar.collapsed) {
package/skeleton.js ADDED
@@ -0,0 +1,159 @@
1
+ // @flow
2
+ //
3
+ // A loading placeholder, and the reader it is usually invisible to.
4
+ //
5
+ // A skeleton is the one component on the presentational list that silently
6
+ // makes a page *worse*. A screen of grey rounded rectangles tells a sighted
7
+ // reader that content is coming and something is happening. To everybody else
8
+ // it is a screen of empty `<div>`s: nothing is announced, nothing is described,
9
+ // and the honest summary of the page is that it has no content — which is
10
+ // exactly the conclusion somebody reaches before they leave.
11
+ //
12
+ // Three attributes fix it and none of them is on the grey box:
13
+ //
14
+ // * the skeletons themselves are **`aria-hidden="true"`**, because a
15
+ // placeholder is a picture of content and not content;
16
+ // * the region they stand in is **`aria-busy="true"`**, which is the
17
+ // attribute that says "this is being filled in" and stops assistive
18
+ // technology reporting a half-built subtree;
19
+ // * and something has to **say so out loud**, because `aria-busy` is a
20
+ // property a reader can ask about rather than an announcement they are
21
+ // given.
22
+ //
23
+ // # The live region, and why it is empty for one commit
24
+ //
25
+ // This is ubugeeei-prod/uf#289's rule met at the worst possible moment.
26
+ // `combobox.js` states it: a live region added to the page in the same commit
27
+ // as the text it holds is usually not announced, because the technology
28
+ // watching it had nothing to watch until it was already too late.
29
+ //
30
+ // A skeleton screen is busy on its *first* render. So the naive version —
31
+ // render `<div role="status">Loading…</div>` while `pending` — mounts the
32
+ // region with the sentence already in it and is silent, then unmounts the whole
33
+ // thing when the content arrives and is silent again. It announces nothing,
34
+ // ever, which is the same as not having been written.
35
+ //
36
+ // `Skeleton.Root` therefore renders the region empty and fills it in an effect,
37
+ // one commit later. The region existed before the text did, which is the whole
38
+ // of what the rule asks for, and it costs a second commit on mount and nothing
39
+ // afterwards.
40
+ //
41
+ // # Keep the root mounted across the load
42
+ //
43
+ // Which is the one thing this component asks of a caller, and the reason
44
+ // `busy` is a prop rather than the root's presence. A root that is unmounted
45
+ // when the content arrives takes its live region with it, so "loaded" is said
46
+ // to nobody and the reader is left with the last thing they heard, which was
47
+ // "loading". Wrap the thing that loads and toggle `busy`; that is also what
48
+ // lets `aria-busy` go from true to false on one element, which is what it is
49
+ // for.
50
+ //
51
+ // # Not `Progress`
52
+ //
53
+ // A skeleton says *that* something is loading. `Progress` says *how far along*
54
+ // it is, has `aria-valuenow` and lives in `progress.js`. A skeleton with a
55
+ // percentage is a progress bar that has been drawn as boxes, and a progress bar
56
+ // with no number is the indeterminate one that module already ships.
57
+
58
+ "use client";
59
+
60
+ import * as React from "@uniflowed/react";
61
+ import { useEffect, useRef, useState } from "@uniflowed/react";
62
+
63
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
64
+ import { withProps } from "./internal/merge-props.js";
65
+
66
+ /**
67
+ * The region that is being filled in, and the sentence that says so.
68
+ *
69
+ * `children` is the skeletons while `busy`, and the real content once it is
70
+ * not — both go inside, because it is one region either way and `aria-busy`
71
+ * describes it in both states.
72
+ *
73
+ * <Skeleton.Root busy={pending}>
74
+ * {pending ? (
75
+ * <>
76
+ * <Skeleton.Box />
77
+ * <Skeleton.Box />
78
+ * </>
79
+ * ) : (
80
+ * <Invoices rows={invoices} />
81
+ * )}
82
+ * </Skeleton.Root>
83
+ *
84
+ * `label` and `doneLabel` are what is announced. English defaults, because a
85
+ * component that announces nothing by default is the component this one exists
86
+ * to replace; a real application passes its translation.
87
+ *
88
+ * `doneLabel` is announced only after a spell of `busy`, so a region that was
89
+ * never loading never says it has loaded.
90
+ *
91
+ * `render` changes the element that owns the busy state. The live region stays
92
+ * beside it, mounted by this component, because that timing is the accessibility
93
+ * contract rather than markup the caller can safely recreate by sight.
94
+ */
95
+ export component SkeletonRoot(
96
+ children: React.Node,
97
+ busy?: boolean = true,
98
+ label?: string = "Loading…",
99
+ doneLabel?: string = "Loaded",
100
+ render?: RenderProp,
101
+ ...rest: Rest
102
+ ) {
103
+ const [message, setMessage] = useState("");
104
+ // Whether there has been anything to finish. Written and read in effects
105
+ // only, and nothing renders it — the promise `index.js` makes about refs.
106
+ const waited = useRef(false);
107
+
108
+ useEffect(() => {
109
+ if (busy) {
110
+ waited.current = true;
111
+ setMessage(label);
112
+ return;
113
+ }
114
+ setMessage(waited.current ? doneLabel : "");
115
+ }, [busy, doneLabel, label]);
116
+
117
+ const props = withProps(rest, {
118
+ "aria-busy": busy ? "true" : undefined,
119
+ children,
120
+ });
121
+
122
+ return (
123
+ <>
124
+ {render == null ? <div {...props} /> : render(props)}
125
+ {/*
126
+ Beside the region rather than inside it, so a reader walking into the
127
+ content does not find a sentence about it sitting among the rows — and
128
+ mounted from the first render holding nothing, because a live region
129
+ that appears together with its text is not announced at all. The module
130
+ header says why that matters more here than anywhere else.
131
+ */}
132
+ <div aria-atomic="true" aria-live="polite" data-uf-skeleton-status="" role="status">
133
+ {message}
134
+ </div>
135
+ </>
136
+ );
137
+ }
138
+
139
+ /**
140
+ * One grey box.
141
+ *
142
+ * `aria-hidden="true"`, which is the entire component: a placeholder is a
143
+ * picture of content, and content it is not. Everything about its size, its
144
+ * shape and its shimmer is a class name the caller brings.
145
+ *
146
+ * `children` is allowed and is hidden with the rest of it, because sizing a
147
+ * box by putting the text it stands in for inside it is a real technique and
148
+ * there is no reason to make a caller reach for a second element to do it.
149
+ *
150
+ * `render` changes the placeholder element, not the fact that it is hidden
151
+ * from the accessibility tree.
152
+ */
153
+ export component SkeletonBox(children?: React.Node, render?: RenderProp, ...rest: Rest) {
154
+ const props = withProps(rest, { "aria-hidden": "true", children });
155
+ if (render != null) {
156
+ return render(props);
157
+ }
158
+ return <div {...props} />;
159
+ }