@uniflowed/ui 0.0.0-alpha.9 → 0.2.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 +587 -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 +243 -178
- 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 +52 -52
- package/i18n-provider.js +89 -0
- package/index.js +1177 -31
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +71 -6
- package/internal/collection.js +562 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +85 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +317 -0
- package/internal/selection.js +171 -0
- package/internal/visually-hidden-style.js +41 -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 -28
- package/pagination.js +34 -22
- package/popover.js +116 -75
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +79 -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 +100 -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 +48 -55
- package/tree.js +8 -0
- package/visually-hidden.js +259 -0
package/sidebar.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A sidebar: the one of the four dialog-shaped components that is usually not a
|
|
4
|
+
// dialog at all.
|
|
5
|
+
//
|
|
6
|
+
// `alert-dialog.js`, `sheet.js` and `drawer.js` are modal, and being modal is
|
|
7
|
+
// the point of each. A sidebar is *part of the page*: the reader uses what is
|
|
8
|
+
// beside it while it is open, nothing behind it is inert, nothing is
|
|
9
|
+
// scroll-locked, and there is no focus trap. It is a `<nav>` landmark and a
|
|
10
|
+
// button that says whether it is showing — the disclosure pattern
|
|
11
|
+
// `internal/disclosure.js` describes, applied to a region of the layout.
|
|
12
|
+
//
|
|
13
|
+
// Then the viewport gets narrow, and it *becomes* a dialog. That transition is
|
|
14
|
+
// the component, and it is the reason this is not something a caller assembles
|
|
15
|
+
// out of `Collapsible` and a media query:
|
|
16
|
+
//
|
|
17
|
+
// * **Collapsed is not closed.** On a wide screen the sidebar collapses to
|
|
18
|
+
// its icons and stays in the page. On a narrow one it is a `Sheet`: modal,
|
|
19
|
+
// over the content, gone when it is closed. One `open` describes both, and
|
|
20
|
+
// `aria-expanded` on the trigger is true about both.
|
|
21
|
+
// * **Focus has to move in both directions.** Opening the narrow sidebar
|
|
22
|
+
// moves focus into it, because it is a modal dialog and that is what
|
|
23
|
+
// `dialog.js` promises; closing it gives focus back to the trigger. Opening
|
|
24
|
+
// the wide one moves focus nowhere at all, because nothing was taken away.
|
|
25
|
+
// A component that got this backwards would either strand a reader in a
|
|
26
|
+
// dialog they cannot leave or move their focus for no reason.
|
|
27
|
+
// * **A collapsed button still has a name.** This is where the pattern goes
|
|
28
|
+
// wrong in practice. Collapsed to icons, a button whose accessible name
|
|
29
|
+
// came from its text is announced as "button" — so `Sidebar.Item` takes a
|
|
30
|
+
// `label`, puts it in `aria-label` the moment the sidebar collapses, and
|
|
31
|
+
// shows it in a `Tooltip` for readers who can see the icon and do not know
|
|
32
|
+
// what it means. The name survives whatever the stylesheet does to the
|
|
33
|
+
// text, which is the only way to promise it survives.
|
|
34
|
+
//
|
|
35
|
+
// # The state is the caller's, and that is a server-rendering decision
|
|
36
|
+
//
|
|
37
|
+
// `open` is uncontrolled by default like everything else here, and a real
|
|
38
|
+
// application will control it: shadcn persists the answer in a cookie so the
|
|
39
|
+
// server renders the sidebar in the state the reader left it. That matters more
|
|
40
|
+
// under RSC than it looks — an uncontrolled sidebar renders expanded on the
|
|
41
|
+
// server and collapses on hydration, which is a layout shift on every
|
|
42
|
+
// navigation. `open` and `onOpenChange` are how a caller reads the cookie and
|
|
43
|
+
// hands the answer in.
|
|
44
|
+
//
|
|
45
|
+
// # The narrow viewport is a query, not a guess
|
|
46
|
+
//
|
|
47
|
+
// `useMediaQuery` from `@uniflowed/hooks/browser`, with `false` on the server:
|
|
48
|
+
// there is no viewport during a prerender, and guessing "narrow" would send
|
|
49
|
+
// every reader markup in which the navigation is a closed dialog. The wide
|
|
50
|
+
// layout is the one that is still usable when the guess is wrong.
|
|
51
|
+
|
|
52
|
+
"use client";
|
|
53
|
+
|
|
54
|
+
import * as React from "@uniflowed/react";
|
|
55
|
+
import { createContext, useContext, useId, useMemo } from "@uniflowed/react";
|
|
56
|
+
import { useMediaQuery } from "@uniflowed/hooks/browser";
|
|
57
|
+
|
|
58
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
59
|
+
import { composeHandlers, forwarded, withProps, withoutComposed } from "./internal/merge-props.js";
|
|
60
|
+
import { SheetBody, SheetOverlay, SheetRoot, SheetTrigger } from "./sheet.js";
|
|
61
|
+
import { TooltipBody, TooltipRoot, TooltipTrigger } from "./tooltip.js";
|
|
62
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Which side of the layout the sidebar is on.
|
|
66
|
+
*
|
|
67
|
+
* Two members and not `sheet.js`'s four, because a sidebar is never attached to
|
|
68
|
+
* the top or the bottom: a navigation rail across the top of a page is a header,
|
|
69
|
+
* with different semantics and a different component. `<Sidebar.Root side="top">`
|
|
70
|
+
* is a type error, which is the point of naming the union rather than reusing
|
|
71
|
+
* `Edge`.
|
|
72
|
+
*/
|
|
73
|
+
export type SidebarSide = "left" | "right";
|
|
74
|
+
|
|
75
|
+
/** The breakpoint below which the sidebar is a modal sheet. */
|
|
76
|
+
const NARROW = "(max-width: 48rem)";
|
|
77
|
+
|
|
78
|
+
type SidebarState = {|
|
|
79
|
+
readonly base: string,
|
|
80
|
+
/** Expanded on a wide screen, and showing on a narrow one. */
|
|
81
|
+
readonly open: boolean,
|
|
82
|
+
readonly setOpen: (open: boolean) => void,
|
|
83
|
+
/** In the page, showing icons only. Never true while it is a sheet. */
|
|
84
|
+
readonly collapsed: boolean,
|
|
85
|
+
/** Whether the viewport has made it a modal sheet. */
|
|
86
|
+
readonly modal: boolean,
|
|
87
|
+
readonly side: SidebarSide,
|
|
88
|
+
/** Whether the navigation is in the document, so nothing names it when it is not. */
|
|
89
|
+
readonly present: boolean,
|
|
90
|
+
|};
|
|
91
|
+
|
|
92
|
+
const SidebarContext: React.Context<SidebarState | null> = createContext(null);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The sidebar a part belongs to.
|
|
96
|
+
*
|
|
97
|
+
* Raising rather than returning null, for the reason `useDialog` gives: a
|
|
98
|
+
* `Sidebar.Trigger` outside a root would render a button with an
|
|
99
|
+
* `aria-expanded` that never changes, and it would look correct.
|
|
100
|
+
*/
|
|
101
|
+
hook useSidebar(part: string): SidebarState {
|
|
102
|
+
const state = useContext(SidebarContext);
|
|
103
|
+
if (state == null) {
|
|
104
|
+
throw new Error(`${part} must be rendered inside a Sidebar.Root`);
|
|
105
|
+
}
|
|
106
|
+
return state;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The sidebar, expanded or collapsed — and, on a narrow viewport, a sheet.
|
|
111
|
+
*
|
|
112
|
+
* Renders no element of its own when it is part of the page: the trigger and
|
|
113
|
+
* the navigation are siblings in whatever layout the caller wrote. When the
|
|
114
|
+
* viewport makes it modal it renders a `Sheet.Root` around both, so the trigger
|
|
115
|
+
* is the sheet's trigger and focus goes back to it — which is the half of the
|
|
116
|
+
* transition a wrapper around only the navigation could not do.
|
|
117
|
+
*/
|
|
118
|
+
export component SidebarRoot(
|
|
119
|
+
children: React.Node,
|
|
120
|
+
defaultOpen?: boolean = true,
|
|
121
|
+
narrowQuery?: string = NARROW,
|
|
122
|
+
onOpenChange?: (open: boolean) => void,
|
|
123
|
+
open?: boolean,
|
|
124
|
+
side?: SidebarSide = "left",
|
|
125
|
+
) {
|
|
126
|
+
const base = useId();
|
|
127
|
+
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
128
|
+
// `false` on the server: see the module header. The wide layout is the one
|
|
129
|
+
// that is still usable when there is no viewport to ask.
|
|
130
|
+
const modal = useMediaQuery(narrowQuery, false);
|
|
131
|
+
|
|
132
|
+
const state = useMemo(
|
|
133
|
+
() => ({
|
|
134
|
+
base,
|
|
135
|
+
collapsed: !modal && !isOpen,
|
|
136
|
+
modal,
|
|
137
|
+
open: isOpen,
|
|
138
|
+
// A sheet's navigation is in the document only while the sheet is open;
|
|
139
|
+
// the page's is always there, collapsed or not.
|
|
140
|
+
present: modal ? isOpen : true,
|
|
141
|
+
setOpen,
|
|
142
|
+
side,
|
|
143
|
+
}),
|
|
144
|
+
[base, isOpen, modal, setOpen, side],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<SidebarContext.Provider value={state}>
|
|
149
|
+
{modal ? (
|
|
150
|
+
<SheetRoot onOpenChange={setOpen} open={isOpen} side={side}>
|
|
151
|
+
{children}
|
|
152
|
+
</SheetRoot>
|
|
153
|
+
) : (
|
|
154
|
+
children
|
|
155
|
+
)}
|
|
156
|
+
</SidebarContext.Provider>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The button that expands and collapses it.
|
|
162
|
+
*
|
|
163
|
+
* `aria-expanded` either way, and it is a true sentence about two different
|
|
164
|
+
* things: on a wide screen it says whether the navigation is showing its
|
|
165
|
+
* labels, on a narrow one whether the sheet is open. `aria-controls` names the
|
|
166
|
+
* navigation only while the navigation is in the document — a reference to an
|
|
167
|
+
* id nothing has tells a reader there is somewhere to go and has nowhere to
|
|
168
|
+
* send them.
|
|
169
|
+
*/
|
|
170
|
+
export component SidebarTrigger(children: React.Node, ...rest: Rest) {
|
|
171
|
+
const sidebar = useSidebar("Sidebar.Trigger");
|
|
172
|
+
const passed = withoutComposed(rest, ["onClick"]);
|
|
173
|
+
const named = sidebar.present ? `${sidebar.base}-nav` : undefined;
|
|
174
|
+
|
|
175
|
+
// The sheet's own trigger while it is one: `Dialog.Trigger` is what records
|
|
176
|
+
// where focus came from, and focus going back to this button when the sheet
|
|
177
|
+
// closes is the second half of the transition.
|
|
178
|
+
if (sidebar.modal) {
|
|
179
|
+
// `Dialog.Trigger` names the sheet's own body while it is open, which is a
|
|
180
|
+
// better `aria-controls` than the navigation inside it, so this part adds
|
|
181
|
+
// nothing to it.
|
|
182
|
+
return <SheetTrigger {...forwarded(rest)}>{children}</SheetTrigger>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<button
|
|
187
|
+
{...passed}
|
|
188
|
+
aria-controls={named}
|
|
189
|
+
aria-expanded={sidebar.open ? "true" : "false"}
|
|
190
|
+
onClick={composeHandlers(rest.onClick, () => sidebar.setOpen(!sidebar.open))}
|
|
191
|
+
type="button"
|
|
192
|
+
>
|
|
193
|
+
{children}
|
|
194
|
+
</button>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The navigation itself: a named `<nav>` landmark, and on a narrow viewport a
|
|
200
|
+
* named `<nav>` landmark inside a modal sheet.
|
|
201
|
+
*
|
|
202
|
+
* A `<div>` here is the mistake the component exists to prevent. A `<nav>` is
|
|
203
|
+
* how a screen reader's landmark list offers "skip to the navigation", and a
|
|
204
|
+
* site's main navigation that is not one is navigation a reader has to find by
|
|
205
|
+
* tabbing through it.
|
|
206
|
+
*
|
|
207
|
+
* `label` is required rather than defaulted, because a landmark with no name is
|
|
208
|
+
* announced as "navigation" — and a page with two of those has told the reader
|
|
209
|
+
* there are two and which is which is a guess.
|
|
210
|
+
*/
|
|
211
|
+
export component SidebarBody(
|
|
212
|
+
children: React.Node,
|
|
213
|
+
label: string,
|
|
214
|
+
sheetProps?: Rest,
|
|
215
|
+
...rest: Rest
|
|
216
|
+
) {
|
|
217
|
+
const sidebar = useSidebar("Sidebar.Body");
|
|
218
|
+
const nav = (
|
|
219
|
+
<nav
|
|
220
|
+
{...forwarded(rest)}
|
|
221
|
+
aria-label={label}
|
|
222
|
+
data-collapsed={sidebar.collapsed ? "true" : undefined}
|
|
223
|
+
id={`${sidebar.base}-nav`}
|
|
224
|
+
>
|
|
225
|
+
{children}
|
|
226
|
+
</nav>
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (!sidebar.modal) {
|
|
230
|
+
return nav;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Named rather than titled: a heading nobody asked for would appear in the
|
|
234
|
+
// page's outline, and the sheet's name is the navigation's name.
|
|
235
|
+
return (
|
|
236
|
+
<>
|
|
237
|
+
<SheetOverlay />
|
|
238
|
+
<SheetBody {...forwarded(sheetProps ?? {})} aria-label={label}>
|
|
239
|
+
{nav}
|
|
240
|
+
</SheetBody>
|
|
241
|
+
</>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The top of the sidebar, as a place to put styles. See `Dialog.Header`. */
|
|
246
|
+
export component SidebarHeader(children: React.Node, ...rest: Rest) {
|
|
247
|
+
return <div {...rest}>{children}</div>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The bottom of the sidebar. See `Sidebar.Header`. */
|
|
251
|
+
export component SidebarFooter(children: React.Node, ...rest: Rest) {
|
|
252
|
+
return <div {...rest}>{children}</div>;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* One entry in the navigation, whose name survives the collapse.
|
|
257
|
+
*
|
|
258
|
+
* `label` is what the reader hears. While the sidebar is expanded the entry's
|
|
259
|
+
* own content is its name, so nothing is overridden and a label with an icon,
|
|
260
|
+
* a count and a second line reads as written. The moment it collapses,
|
|
261
|
+
* `aria-label` takes over — because at that point the text is whatever the
|
|
262
|
+
* stylesheet has done to it, and a promise about the accessible name cannot
|
|
263
|
+
* depend on that.
|
|
264
|
+
*
|
|
265
|
+
* `render` for an entry that is a link. Site navigation is links, and a
|
|
266
|
+
* `<button>` that navigates is a button a reader cannot open in a new tab; see
|
|
267
|
+
* `navigation-menu.js`, which is the same argument at the scale of a whole
|
|
268
|
+
* menu.
|
|
269
|
+
*/
|
|
270
|
+
export component SidebarItem(
|
|
271
|
+
children: React.Node,
|
|
272
|
+
label: string,
|
|
273
|
+
render?: RenderProp,
|
|
274
|
+
tooltipProps?: Rest,
|
|
275
|
+
...rest: Rest
|
|
276
|
+
) {
|
|
277
|
+
const sidebar = useSidebar("Sidebar.Item");
|
|
278
|
+
// This removes the caller's ref from rest props; it does not read a ref value.
|
|
279
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
280
|
+
const passed = withoutComposed(rest, ["ref"]);
|
|
281
|
+
// The forwarded ref is passed through to whichever entry wrapper renders.
|
|
282
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
283
|
+
const forwardedRef = rest.ref;
|
|
284
|
+
const mine: Rest = {
|
|
285
|
+
"aria-label": sidebar.collapsed ? label : undefined,
|
|
286
|
+
children,
|
|
287
|
+
"data-collapsed": sidebar.collapsed ? "true" : undefined,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const entry = (extra: Rest) => {
|
|
291
|
+
// `mine` on top, and the order is load-bearing now that a part's props
|
|
292
|
+
// carry its children: the collapsed branch hands this the props
|
|
293
|
+
// `Tooltip.Trigger` built, and those name `children` as `undefined`
|
|
294
|
+
// because that tooltip trigger has none of its own. Applied last, a named
|
|
295
|
+
// `undefined` would blank the entry.
|
|
296
|
+
const props = withProps(withProps(passed, extra), mine);
|
|
297
|
+
return render == null ? <button {...props} type="button" /> : render(props);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
if (!sidebar.collapsed) {
|
|
301
|
+
// The forwarded ref is passed through to the rendered entry.
|
|
302
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
303
|
+
return entry({ ref: forwardedRef });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// The icon's name, shown. A reader who can see the rail and not read minds
|
|
307
|
+
// needs the same sentence `aria-label` gives everybody else, and a tooltip is
|
|
308
|
+
// the mechanism that already satisfies WCAG 1.4.13 in this package.
|
|
309
|
+
return (
|
|
310
|
+
<TooltipRoot>
|
|
311
|
+
<TooltipTrigger ref={forwardedRef} render={(props: Rest) => entry(props)} />
|
|
312
|
+
<TooltipBody
|
|
313
|
+
{...forwarded(tooltipProps ?? {})}
|
|
314
|
+
side={sidebar.side === "left" ? "right" : "left"}
|
|
315
|
+
>
|
|
316
|
+
{label}
|
|
317
|
+
</TooltipBody>
|
|
318
|
+
</TooltipRoot>
|
|
319
|
+
);
|
|
320
|
+
}
|
package/skeleton.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
// The live-region text changes after commit so assistive tech can announce it.
|
|
112
|
+
// uf-lint-disable-next-line react-compiler/set-state-in-effect
|
|
113
|
+
setMessage(label);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// The live-region text changes after commit so assistive tech can announce it.
|
|
117
|
+
// uf-lint-disable-next-line react-compiler/set-state-in-effect
|
|
118
|
+
setMessage(waited.current ? doneLabel : "");
|
|
119
|
+
}, [busy, doneLabel, label]);
|
|
120
|
+
|
|
121
|
+
const props = withProps(rest, {
|
|
122
|
+
"aria-busy": busy ? "true" : undefined,
|
|
123
|
+
children,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<>
|
|
128
|
+
{render == null ? <div {...props} /> : render(props)}
|
|
129
|
+
{/*
|
|
130
|
+
Beside the region rather than inside it, so a reader walking into the
|
|
131
|
+
content does not find a sentence about it sitting among the rows — and
|
|
132
|
+
mounted from the first render holding nothing, because a live region
|
|
133
|
+
that appears together with its text is not announced at all. The module
|
|
134
|
+
header says why that matters more here than anywhere else.
|
|
135
|
+
*/}
|
|
136
|
+
<div aria-atomic="true" aria-live="polite" data-uf-skeleton-status="" role="status">
|
|
137
|
+
{message}
|
|
138
|
+
</div>
|
|
139
|
+
</>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* One grey box.
|
|
145
|
+
*
|
|
146
|
+
* `aria-hidden="true"`, which is the entire component: a placeholder is a
|
|
147
|
+
* picture of content, and content it is not. Everything about its size, its
|
|
148
|
+
* shape and its shimmer is a class name the caller brings.
|
|
149
|
+
*
|
|
150
|
+
* `children` is allowed and is hidden with the rest of it, because sizing a
|
|
151
|
+
* box by putting the text it stands in for inside it is a real technique and
|
|
152
|
+
* there is no reason to make a caller reach for a second element to do it.
|
|
153
|
+
*
|
|
154
|
+
* `render` changes the placeholder element, not the fact that it is hidden
|
|
155
|
+
* from the accessibility tree.
|
|
156
|
+
*/
|
|
157
|
+
export component SkeletonBox(children?: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
158
|
+
const props = withProps(rest, { "aria-hidden": "true", children });
|
|
159
|
+
if (render != null) {
|
|
160
|
+
return render(props);
|
|
161
|
+
}
|
|
162
|
+
return <div {...props} />;
|
|
163
|
+
}
|