@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 +61 -54
- package/alert-dialog.js +43 -17
- package/alert.js +142 -0
- package/avatar.js +276 -0
- package/breadcrumb.js +138 -0
- package/checkbox.js +45 -39
- package/collapsible.js +41 -36
- package/context-menu.js +32 -24
- package/dialog.js +143 -119
- package/drawer.js +121 -89
- package/field.js +71 -37
- package/hover-card.js +23 -27
- package/index.js +567 -31
- package/input-otp.js +8 -8
- package/interactions.js +2163 -0
- package/internal/merge-props.js +85 -1
- package/menu.js +236 -213
- package/menubar.js +91 -89
- package/package.json +8 -40
- package/pagination.js +29 -17
- package/popover.js +59 -55
- package/progress.js +21 -16
- package/radio-group.js +79 -75
- package/separator.js +97 -0
- package/sheet.js +41 -17
- package/sidebar.js +10 -10
- package/skeleton.js +159 -0
- package/slider.js +89 -89
- package/switch.js +37 -31
- package/table.js +90 -67
- package/tabs.js +100 -91
- package/toast.js +23 -55
- package/toggle-group.js +52 -49
- package/toggle.js +38 -25
- package/tooltip.js +26 -37
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 {
|
|
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
|
-
|
|
294
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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?:
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
+
}
|