@uniflowed/ui 0.0.0-alpha.4 → 0.0.0-alpha.40
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 +360 -0
- package/alert-dialog.js +282 -0
- package/alert.js +142 -0
- package/avatar.js +276 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +547 -0
- package/carousel.js +410 -0
- package/checkbox.js +216 -31
- package/collapsible.js +169 -0
- package/combobox.js +209 -40
- package/context-menu.js +206 -0
- package/date-picker.js +346 -0
- package/dialog.js +229 -197
- package/drawer.js +490 -0
- package/field.js +257 -42
- package/hover-card.js +330 -0
- package/index.js +1548 -24
- package/input-otp.js +218 -0
- package/interactions.js +2323 -0
- package/internal/anchor.js +565 -0
- package/internal/date-grid.js +260 -0
- package/internal/disclosure.js +298 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +206 -7
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +205 -11
- package/menu.js +521 -336
- package/menubar.js +288 -0
- package/navigation-menu.js +251 -0
- package/package.json +8 -12
- package/pagination.js +209 -0
- package/popover.js +344 -0
- package/progress.js +91 -0
- package/radio-group.js +302 -0
- package/resizable.js +447 -0
- package/scroll-area.js +283 -0
- package/select.js +888 -0
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +313 -0
- package/skeleton.js +159 -0
- package/slider.js +405 -0
- package/switch.js +43 -34
- package/table.js +520 -0
- package/tabs.js +99 -96
- package/toast.js +592 -0
- package/toggle-group.js +282 -0
- package/toggle.js +105 -0
- package/tooltip.js +400 -0
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
|
+
}
|
package/avatar.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// An avatar: three states, and an `alt` that is empty on purpose.
|
|
4
|
+
//
|
|
5
|
+
// This one looks like a rounded `<img>` with a `<span>` behind it, and it is a
|
|
6
|
+
// component because of two things that are not visible in a screenshot: what
|
|
7
|
+
// happens between the states, and what a screen reader says.
|
|
8
|
+
//
|
|
9
|
+
// # The three states, and the flash between two of them
|
|
10
|
+
//
|
|
11
|
+
// An image is *loading*, *loaded* or *failed*, and the naive version has two —
|
|
12
|
+
// there is an image or there is not — so it renders the fallback whenever the
|
|
13
|
+
// image has not painted yet. On a cached image that is a flash of somebody's
|
|
14
|
+
// initials for one frame, on every navigation, for ever. The fix is to hold the
|
|
15
|
+
// fallback back for a moment: an image that is going to appear immediately does
|
|
16
|
+
// so before the delay is up, and only an image that is genuinely slow or
|
|
17
|
+
// genuinely broken ever shows initials.
|
|
18
|
+
//
|
|
19
|
+
// # Asking the DOM, and the one direction it may be asked in
|
|
20
|
+
//
|
|
21
|
+
// The event is not enough on its own. A cached image can finish loading before
|
|
22
|
+
// React has attached `onLoad` — most obviously when the markup came from a
|
|
23
|
+
// server and the browser started the request while the JavaScript was still
|
|
24
|
+
// downloading — and a component that waits for an event that already happened
|
|
25
|
+
// waits for ever, which is the same flash held permanently.
|
|
26
|
+
//
|
|
27
|
+
// So the element is asked directly, in an effect, which is where this package
|
|
28
|
+
// is allowed to read the document (see `index.js`). And it is asked in *one*
|
|
29
|
+
// direction:
|
|
30
|
+
//
|
|
31
|
+
// * `complete` **and** `naturalWidth > 0` means it loaded. Pixels exist;
|
|
32
|
+
// nothing else produces them.
|
|
33
|
+
// * anything else means nothing. In particular `complete` with no pixels is
|
|
34
|
+
// *not* read as a failure, because that is also what a DOM that does not
|
|
35
|
+
// fetch images says about a perfectly good `src` — a test environment, a
|
|
36
|
+
// server-side render, a browser with images turned off — and concluding
|
|
37
|
+
// "failed" from it would put a fallback over an image that was never asked
|
|
38
|
+
// for. The `error` event is what says a load failed, and an image that
|
|
39
|
+
// failed before anything was listening falls through to the delay and shows
|
|
40
|
+
// the fallback a moment later, which is the same answer arriving late
|
|
41
|
+
// rather than the wrong answer arriving early.
|
|
42
|
+
//
|
|
43
|
+
// # `alt=""` is the default, and it is the accessible answer
|
|
44
|
+
//
|
|
45
|
+
// An avatar almost always sits beside the name of the person it is a picture
|
|
46
|
+
// of. Putting that name in `alt` makes every screen reader say it twice —
|
|
47
|
+
// "Ada Lovelace, image, Ada Lovelace" — which is the most common avatar bug
|
|
48
|
+
// there is, and it is caused by a component being helpful. An empty `alt` takes
|
|
49
|
+
// the image out of the accessibility tree, which is what "decorative" means and
|
|
50
|
+
// what this one is.
|
|
51
|
+
//
|
|
52
|
+
// A caller whose avatar is the *only* thing identifying the person — a bare
|
|
53
|
+
// grid of faces, a comment with no byline — passes `alt` and gets it. The
|
|
54
|
+
// default is the common case; the prop is the honest one.
|
|
55
|
+
//
|
|
56
|
+
// The fallback's content is the caller's and is announced, because this
|
|
57
|
+
// component cannot know whether "AL" is a decoration beside a name or the only
|
|
58
|
+
// thing on the row. A decorative avatar wants `aria-hidden` on its fallback for
|
|
59
|
+
// the same reason its image wants `alt=""`, and that is one prop the caller
|
|
60
|
+
// spreads.
|
|
61
|
+
|
|
62
|
+
"use client";
|
|
63
|
+
|
|
64
|
+
import * as React from "@uniflowed/react";
|
|
65
|
+
import {
|
|
66
|
+
createContext,
|
|
67
|
+
useCallback,
|
|
68
|
+
useContext,
|
|
69
|
+
useEffect,
|
|
70
|
+
useMemo,
|
|
71
|
+
useRef,
|
|
72
|
+
useState,
|
|
73
|
+
} from "@uniflowed/react";
|
|
74
|
+
import { useTimeout } from "@uniflowed/hooks/timing";
|
|
75
|
+
|
|
76
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
77
|
+
import {
|
|
78
|
+
composeHandlers,
|
|
79
|
+
composeRefs,
|
|
80
|
+
withProps,
|
|
81
|
+
withoutComposed,
|
|
82
|
+
} from "./internal/merge-props.js";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Where an avatar's image is between having been asked for and being there.
|
|
86
|
+
*
|
|
87
|
+
* Three members rather than a `loaded` boolean, because the fallback's whole
|
|
88
|
+
* job is to tell the middle one from the last one: an image that has not
|
|
89
|
+
* arrived *yet* must not be replaced, and one that is never arriving must.
|
|
90
|
+
*/
|
|
91
|
+
export type AvatarStatus = "loading" | "loaded" | "error";
|
|
92
|
+
|
|
93
|
+
/** What has been decided, and about which source. */
|
|
94
|
+
type Seen = {| readonly source: string | null, readonly status: AvatarStatus |};
|
|
95
|
+
|
|
96
|
+
/** Nothing asked yet. A source of `null` with no verdict cannot collide. */
|
|
97
|
+
const START: Seen = Object.freeze({ source: null, status: "loading" });
|
|
98
|
+
|
|
99
|
+
type AvatarState = {|
|
|
100
|
+
readonly status: AvatarStatus,
|
|
101
|
+
readonly hasImage: boolean,
|
|
102
|
+
readonly report: (source: string | null, status: AvatarStatus) => void,
|
|
103
|
+
readonly registerImage: (present: boolean) => void,
|
|
104
|
+
|};
|
|
105
|
+
|
|
106
|
+
const AvatarContext: React.Context<AvatarState | null> = createContext(null);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The avatar a part belongs to.
|
|
110
|
+
*
|
|
111
|
+
* Raising rather than returning null, for `field.js`'s reason: an
|
|
112
|
+
* `Avatar.Fallback` outside a root would render initials that never go away and
|
|
113
|
+
* would look exactly like one that works.
|
|
114
|
+
*/
|
|
115
|
+
hook useAvatar(part: string): AvatarState {
|
|
116
|
+
const state = useContext(AvatarContext);
|
|
117
|
+
if (state == null) {
|
|
118
|
+
throw new Error(`${part} must be rendered inside an Avatar.Root`);
|
|
119
|
+
}
|
|
120
|
+
return state;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The avatar, and the state machine its two parts read.
|
|
125
|
+
*
|
|
126
|
+
* A `<span>` rather than a `<div>`, because an avatar belongs beside a name —
|
|
127
|
+
* in a table cell, in a paragraph, inside a button's label — and a block
|
|
128
|
+
* element is invalid in half of those.
|
|
129
|
+
*/
|
|
130
|
+
export component AvatarRoot(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
131
|
+
const [seen, setSeen] = useState<Seen>(START);
|
|
132
|
+
const [hasImage, setHasImage] = useState(false);
|
|
133
|
+
|
|
134
|
+
const report = useCallback((source: string | null, status: AvatarStatus) => {
|
|
135
|
+
setSeen((current) => {
|
|
136
|
+
if (current.source !== source) {
|
|
137
|
+
return { source, status };
|
|
138
|
+
}
|
|
139
|
+
// A verdict already reached about this source is not revisited. Without
|
|
140
|
+
// this the failed image would be put back to ask again, fail again, and
|
|
141
|
+
// be put back again: `Avatar.Image` stops rendering the element once it
|
|
142
|
+
// has failed, so "loading" for the same source is a loop and not a retry.
|
|
143
|
+
if (status === "loading" || current.status === status) {
|
|
144
|
+
return current;
|
|
145
|
+
}
|
|
146
|
+
return { source, status };
|
|
147
|
+
});
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
const state = useMemo(
|
|
151
|
+
() => ({
|
|
152
|
+
status: seen.status,
|
|
153
|
+
hasImage,
|
|
154
|
+
report,
|
|
155
|
+
registerImage: setHasImage,
|
|
156
|
+
}),
|
|
157
|
+
[seen, hasImage, report],
|
|
158
|
+
);
|
|
159
|
+
const props = withProps(rest, { children });
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<AvatarContext.Provider value={state}>
|
|
163
|
+
{render != null ? render(props) : <span {...props} />}
|
|
164
|
+
</AvatarContext.Provider>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The picture.
|
|
170
|
+
*
|
|
171
|
+
* `src` is named rather than left in `rest` because the state machine is about
|
|
172
|
+
* it: a new source is a new question, and the verdict reached about the last
|
|
173
|
+
* one has to stop applying the moment it changes.
|
|
174
|
+
*
|
|
175
|
+
* `alt` defaults to `""`, which is the decision this component exists for as
|
|
176
|
+
* much as the three states are — see the module header.
|
|
177
|
+
*
|
|
178
|
+
* The element stops being rendered once it has failed, rather than being left
|
|
179
|
+
* to show the browser's broken-image glyph beside the fallback that replaced
|
|
180
|
+
* it. That is a thing a package which ships no styles cannot leave to a
|
|
181
|
+
* stylesheet: `hidden` loses to any `display` the caller sets, and a caller who
|
|
182
|
+
* has not written that rule yet would see both.
|
|
183
|
+
*/
|
|
184
|
+
export component AvatarImage(
|
|
185
|
+
alt?: string = "",
|
|
186
|
+
src?: string | null,
|
|
187
|
+
render?: RenderProp,
|
|
188
|
+
...rest: Rest
|
|
189
|
+
) {
|
|
190
|
+
const avatar = useAvatar("Avatar.Image");
|
|
191
|
+
const element = useRef<HTMLImageElement | null>(null);
|
|
192
|
+
const report = avatar.report;
|
|
193
|
+
const registerImage = avatar.registerImage;
|
|
194
|
+
const source = src ?? null;
|
|
195
|
+
const passed = withoutComposed(rest, ["onError", "onLoad", "ref"]);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
registerImage(true);
|
|
199
|
+
return () => registerImage(false);
|
|
200
|
+
}, [registerImage]);
|
|
201
|
+
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (source == null || source === "") {
|
|
204
|
+
// No source is not a slow source. There is nothing coming, so the
|
|
205
|
+
// fallback is the answer now rather than after the delay.
|
|
206
|
+
report(source, "error");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const image = element.current;
|
|
210
|
+
// Null only when this source has already failed and the element went with
|
|
211
|
+
// it, in which case the verdict on record is the right one.
|
|
212
|
+
if (image != null && image.complete && image.naturalWidth > 0) {
|
|
213
|
+
report(source, "loaded");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
report(source, "loading");
|
|
217
|
+
}, [report, source]);
|
|
218
|
+
|
|
219
|
+
if (avatar.status === "error") {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
const props = withProps(passed, {
|
|
223
|
+
alt,
|
|
224
|
+
onError: composeHandlers(rest.onError, () => report(source, "error")),
|
|
225
|
+
onLoad: composeHandlers(rest.onLoad, () => report(source, "loaded")),
|
|
226
|
+
ref: composeRefs(rest.ref, (node: HTMLImageElement | null) => {
|
|
227
|
+
element.current = node;
|
|
228
|
+
}),
|
|
229
|
+
src: source ?? undefined,
|
|
230
|
+
});
|
|
231
|
+
if (render != null) {
|
|
232
|
+
return render(props);
|
|
233
|
+
}
|
|
234
|
+
return <img {...props} />;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* What is shown instead: initials, a silhouette, a coloured disc.
|
|
239
|
+
*
|
|
240
|
+
* Absent while the image is still loading, which is the point. `delay` is how
|
|
241
|
+
* long "still loading" is allowed to last before the fallback appears anyway —
|
|
242
|
+
* long enough that a cached image never flashes initials, short enough that a
|
|
243
|
+
* genuinely slow one does not leave a hole. Pass `0` to show it the moment
|
|
244
|
+
* there is nothing to show instead.
|
|
245
|
+
*
|
|
246
|
+
* The delay applies to *loading* and to nothing else. A failed image and an
|
|
247
|
+
* avatar with no `Avatar.Image` at all are both answers rather than waits, and
|
|
248
|
+
* the fallback for either is immediate.
|
|
249
|
+
*/
|
|
250
|
+
export component AvatarFallback(
|
|
251
|
+
children: React.Node,
|
|
252
|
+
delay?: number = 300,
|
|
253
|
+
render?: RenderProp,
|
|
254
|
+
...rest: Rest
|
|
255
|
+
) {
|
|
256
|
+
const avatar = useAvatar("Avatar.Fallback");
|
|
257
|
+
const [elapsed, setElapsed] = useState(false);
|
|
258
|
+
const waiting = avatar.hasImage && avatar.status === "loading";
|
|
259
|
+
|
|
260
|
+
useTimeout(() => setElapsed(true), waiting && delay > 0 ? delay : null);
|
|
261
|
+
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
if (!waiting) {
|
|
264
|
+
setElapsed(false);
|
|
265
|
+
}
|
|
266
|
+
}, [waiting]);
|
|
267
|
+
|
|
268
|
+
if (avatar.status === "loaded" || (waiting && delay > 0 && !elapsed)) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
const props = withProps(rest, { children });
|
|
272
|
+
if (render != null) {
|
|
273
|
+
return render(props);
|
|
274
|
+
}
|
|
275
|
+
return <span {...props} />;
|
|
276
|
+
}
|
package/breadcrumb.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A breadcrumb trail, read as a trail rather than as punctuation.
|
|
4
|
+
//
|
|
5
|
+
// It is `pagination.js`'s shape — a named `<nav>` around a list, with one item
|
|
6
|
+
// marked `aria-current="page"` — and it is a separate module for the same
|
|
7
|
+
// reason those two are separate entries: a paginated table and a trail through
|
|
8
|
+
// a hierarchy are different things to a reader, and the parts are named after
|
|
9
|
+
// what they mean rather than after what they render.
|
|
10
|
+
//
|
|
11
|
+
// Three decisions, each of which is invisible when it is missing:
|
|
12
|
+
//
|
|
13
|
+
// * **It is navigation, so it is a `<nav>` with a name.** A page has more
|
|
14
|
+
// than one `nav` and an unnamed one is announced as "navigation", with
|
|
15
|
+
// nothing to tell it from the site's menu. `aria-label="Breadcrumb"` is
|
|
16
|
+
// what puts it in a screen reader's landmark list under a useful name, and
|
|
17
|
+
// it is the name assistive technology's own documentation tells readers to
|
|
18
|
+
// look for.
|
|
19
|
+
// * **The last item is `aria-current="page"`, and it is not a link.** It is
|
|
20
|
+
// where the reader already is. A trail whose last entry is a link that
|
|
21
|
+
// leads to the page it is on is a link that does nothing, and announcing
|
|
22
|
+
// "link" for it is a promise the page does not keep — so `Breadcrumb.Page`
|
|
23
|
+
// is a `<span>`. The `role="link"` with `aria-disabled` that this component
|
|
24
|
+
// is usually copied with says "link, dimmed", which is a *control the
|
|
25
|
+
// reader cannot use* rather than a place they have arrived at.
|
|
26
|
+
// * **The separators are `aria-hidden`.** Otherwise the trail is read as
|
|
27
|
+
// "Home slash Settings slash Billing", and the slashes are the loudest
|
|
28
|
+
// thing in it. They are `<li>` elements because an `<ol>` may only contain
|
|
29
|
+
// `<li>`, and they carry `role="presentation"` as well so that the count a
|
|
30
|
+
// reader is given — "list, three items" — is the number of places and not
|
|
31
|
+
// the number of places plus the punctuation between them.
|
|
32
|
+
//
|
|
33
|
+
// # What the type says that the markup cannot
|
|
34
|
+
//
|
|
35
|
+
// `Breadcrumb.List` declares `renders* (Breadcrumb.Item | Breadcrumb.Separator)`,
|
|
36
|
+
// so a `<div>` between two crumbs is a type error rather than an `<ol>` a
|
|
37
|
+
// validator would reject and a screen reader would count wrong.
|
|
38
|
+
// `Pagination.Content` states the same constraint for the same element and the
|
|
39
|
+
// same reason.
|
|
40
|
+
//
|
|
41
|
+
// # No `"use client"`
|
|
42
|
+
//
|
|
43
|
+
// Nothing here holds state, listens to anything or moves focus. Which crumb is
|
|
44
|
+
// current is the caller's, and the links are links. It renders on a server.
|
|
45
|
+
|
|
46
|
+
import * as React from "@uniflowed/react";
|
|
47
|
+
|
|
48
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
49
|
+
import { withProps } from "./internal/merge-props.js";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The trail, as a named landmark.
|
|
53
|
+
*
|
|
54
|
+
* `label` is the accessible name and has a default because there is one right
|
|
55
|
+
* answer in English and it is the one readers are taught to look for. Pass it
|
|
56
|
+
* to translate; there is no case for leaving it off, which is why it is not
|
|
57
|
+
* optional in the sense of being absent.
|
|
58
|
+
*/
|
|
59
|
+
export component BreadcrumbRoot(
|
|
60
|
+
children: React.Node,
|
|
61
|
+
label?: string = "Breadcrumb",
|
|
62
|
+
render?: RenderProp,
|
|
63
|
+
...rest: Rest
|
|
64
|
+
) {
|
|
65
|
+
const props = withProps(rest, { "aria-label": label, children });
|
|
66
|
+
if (render != null) {
|
|
67
|
+
return render(withProps(props, { role: "navigation" }));
|
|
68
|
+
}
|
|
69
|
+
return <nav {...props} />;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The crumbs, in order.
|
|
74
|
+
*
|
|
75
|
+
* An ordered list rather than a row of links, because the order is the whole
|
|
76
|
+
* information: a reader is told how many levels there are before walking them,
|
|
77
|
+
* and can skip the lot in one keystroke.
|
|
78
|
+
*/
|
|
79
|
+
export component BreadcrumbList(
|
|
80
|
+
children: renders* (BreadcrumbItem | BreadcrumbSeparator),
|
|
81
|
+
render?: RenderProp,
|
|
82
|
+
...rest: Rest
|
|
83
|
+
) {
|
|
84
|
+
const props = withProps(rest, { children });
|
|
85
|
+
if (render != null) {
|
|
86
|
+
return render(withProps(props, { role: "list" }));
|
|
87
|
+
}
|
|
88
|
+
return <ol {...props} />;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One level of the trail. Holds a `Breadcrumb.Link` or a `Breadcrumb.Page`. */
|
|
92
|
+
export component BreadcrumbItem(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
93
|
+
const props = withProps(rest, { children });
|
|
94
|
+
if (render != null) {
|
|
95
|
+
return render(withProps(props, { role: "listitem" }));
|
|
96
|
+
}
|
|
97
|
+
return <li {...props} />;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A level you can go back to. */
|
|
101
|
+
export component BreadcrumbLink(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
102
|
+
const props = withProps(rest, { children });
|
|
103
|
+
if (render != null) {
|
|
104
|
+
return render(withProps(props, { role: "link" }));
|
|
105
|
+
}
|
|
106
|
+
return <a {...props} />;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The level you are on.
|
|
111
|
+
*
|
|
112
|
+
* `aria-current="page"` is the whole of it, and it is on this part rather than
|
|
113
|
+
* being a `current` prop on `Breadcrumb.Link` so that the last crumb cannot be
|
|
114
|
+
* a link by accident. See the module header for why announcing it as a disabled
|
|
115
|
+
* link is worse than announcing it as text.
|
|
116
|
+
*/
|
|
117
|
+
export component BreadcrumbPage(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
118
|
+
const props = withProps(rest, { "aria-current": "page", children });
|
|
119
|
+
if (render != null) {
|
|
120
|
+
return render(props);
|
|
121
|
+
}
|
|
122
|
+
return <span {...props} />;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The mark between two crumbs.
|
|
127
|
+
*
|
|
128
|
+
* The glyph is the caller's — a slash, a chevron, an icon — because it is a
|
|
129
|
+
* design decision and this package makes none. What is not the caller's is that
|
|
130
|
+
* it is announced to nobody.
|
|
131
|
+
*/
|
|
132
|
+
export component BreadcrumbSeparator(children?: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
133
|
+
const props = withProps(rest, { "aria-hidden": "true", children, role: "presentation" });
|
|
134
|
+
if (render != null) {
|
|
135
|
+
return render(props);
|
|
136
|
+
}
|
|
137
|
+
return <li {...props} />;
|
|
138
|
+
}
|