@uniflowed/ui 0.0.0-alpha.10
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 +335 -0
- package/alert-dialog.js +256 -0
- package/carousel.js +410 -0
- package/checkbox.js +80 -0
- package/collapsible.js +147 -0
- package/combobox.js +557 -0
- package/dialog.js +499 -0
- package/drawer.js +458 -0
- package/field.js +170 -0
- package/hover-card.js +334 -0
- package/index.js +1012 -0
- package/input-otp.js +218 -0
- package/internal/anchor.js +500 -0
- package/internal/controlled-state.js +65 -0
- package/internal/disclosure.js +97 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +201 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +654 -0
- package/navigation-menu.js +251 -0
- package/package.json +57 -0
- package/pagination.js +197 -0
- package/popover.js +326 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/scroll-area.js +283 -0
- package/select.js +855 -0
- package/sheet.js +165 -0
- package/sidebar.js +300 -0
- package/slider.js +405 -0
- package/switch.js +73 -0
- package/table.js +479 -0
- package/tabs.js +280 -0
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
- package/tooltip.js +411 -0
package/toast.js
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Notifications, and the live region that was already watching.
|
|
4
|
+
//
|
|
5
|
+
// `combobox.js` states the rule this whole component exists for:
|
|
6
|
+
//
|
|
7
|
+
// > A live region added to the page in the same commit as the text it holds is
|
|
8
|
+
// > usually not announced, because the technology watching it had nothing to
|
|
9
|
+
// > watch until it was already too late; leaving it mounted and empty is what
|
|
10
|
+
// > makes the *next* change speak.
|
|
11
|
+
//
|
|
12
|
+
// Every hand-written toast mounts a `<div role="alert">` at the moment the
|
|
13
|
+
// message arrives, which is exactly the commit that makes it silent. It looks
|
|
14
|
+
// perfect on screen, it passes every review, and the reader who most needs to
|
|
15
|
+
// be told that the save failed is told nothing. That is the bug, it is
|
|
16
|
+
// invisible, and one component in the layout is the only place it can be fixed
|
|
17
|
+
// once for a whole application.
|
|
18
|
+
//
|
|
19
|
+
// So `Toast.Region` renders on mount and stays, holding nothing. Notifications
|
|
20
|
+
// are appended into it.
|
|
21
|
+
//
|
|
22
|
+
// # Two regions, not one
|
|
23
|
+
//
|
|
24
|
+
// "Saved" and "could not save" interrupt a reader differently and should, and
|
|
25
|
+
// which of the two a notification is belongs to the notification rather than
|
|
26
|
+
// to the application. That cannot be a role that changes: swapping a live
|
|
27
|
+
// region's `role` or `aria-live` while it is being watched is the same bug as
|
|
28
|
+
// mounting it late, because the technology is watching the region it saw. So
|
|
29
|
+
// there are two regions, both mounted from the start and both empty —
|
|
30
|
+
// `role="status"` and `role="alert"` — and a notification is appended into the
|
|
31
|
+
// one that matches it.
|
|
32
|
+
//
|
|
33
|
+
// What that costs is chronological order between the two piles: a failure and
|
|
34
|
+
// a success on screen together are in separate containers, and no CSS
|
|
35
|
+
// interleaves them. It is written down rather than discovered, and it buys the
|
|
36
|
+
// thing that matters more, which is that what a reader hears is exactly what a
|
|
37
|
+
// reader sees. The alternative — one visible stack plus hidden announcers
|
|
38
|
+
// mirroring its text — puts every notification in the document twice, so a
|
|
39
|
+
// reader browsing the page finds each one again with no way to tell it is the
|
|
40
|
+
// same one.
|
|
41
|
+
//
|
|
42
|
+
// # `aria-atomic`, and why `limit` is an accessibility setting
|
|
43
|
+
//
|
|
44
|
+
// Both regions are `aria-atomic="true"`, so a change presents the whole
|
|
45
|
+
// region. With one notification showing — the ordinary case — that is exactly
|
|
46
|
+
// right, and it is what makes a two-line notification read as one sentence
|
|
47
|
+
// rather than as a fragment. With three showing, adding a fourth reads all
|
|
48
|
+
// four. `limit` is therefore not only how tall the stack is allowed to get; it
|
|
49
|
+
// is how much a reader is made to listen to. Three is the default because it
|
|
50
|
+
// is about as much as anyone will hear out.
|
|
51
|
+
//
|
|
52
|
+
// # Focus is never taken, and there is a key that gives it
|
|
53
|
+
//
|
|
54
|
+
// Nothing here calls `focus()` when a notification appears. Moving focus to a
|
|
55
|
+
// toast interrupts whatever the reader was typing, and it is the single
|
|
56
|
+
// failure that makes people turn notifications off.
|
|
57
|
+
//
|
|
58
|
+
// But a notification carrying an Undo button that vanishes after four seconds
|
|
59
|
+
// is a control no keyboard reader can operate, so the region is a named
|
|
60
|
+
// landmark — `role="region"` with an `aria-label` — and `F6`, the key the APG
|
|
61
|
+
// suggests for moving between panes, moves focus into it. It moves focus to
|
|
62
|
+
// the region itself rather than to the first button in it, which is
|
|
63
|
+
// deliberate: landing on the region is what makes a screen reader read the
|
|
64
|
+
// region's name and its contents, and landing on a button skips straight past
|
|
65
|
+
// both. `Tab` from there reaches the buttons in order. Pressing `F6` again
|
|
66
|
+
// while focus is inside gives it back to wherever it came from, because a key
|
|
67
|
+
// that only goes one way strands the reader it was meant to help.
|
|
68
|
+
//
|
|
69
|
+
// `F6` is bound while the reader is typing, which is the one case that matters:
|
|
70
|
+
// a notification that arrives during a form fill is the notification most
|
|
71
|
+
// likely to be about the form.
|
|
72
|
+
//
|
|
73
|
+
// # Timers that stop
|
|
74
|
+
//
|
|
75
|
+
// WCAG 2.2.1, *Timing Adjustable*, applies to anything that disappears on its
|
|
76
|
+
// own. The countdown stops while the pointer is over a notification, while
|
|
77
|
+
// focus is inside it, and while the document is hidden — the third because a
|
|
78
|
+
// reader who switches tabs for a minute should not come back to an empty
|
|
79
|
+
// region and no idea what they missed.
|
|
80
|
+
//
|
|
81
|
+
// It *stops*; it does not restart. The one-line version of this is
|
|
82
|
+
// `useTimeout(dismiss, paused ? null : duration)`, which reads correctly and
|
|
83
|
+
// is wrong: `useTimeout` re-arms whenever its delay changes, so every unpause
|
|
84
|
+
// gives the notification its whole life again, and a reader who brushes the
|
|
85
|
+
// stack with the pointer keeps it on screen indefinitely. What is left is
|
|
86
|
+
// banked instead, which costs a clock and two refs and is what "pause" means.
|
|
87
|
+
//
|
|
88
|
+
// A notification given `duration: null` never expires at all, which is what
|
|
89
|
+
// anything carrying an action should be.
|
|
90
|
+
//
|
|
91
|
+
// # The queue lives outside React
|
|
92
|
+
//
|
|
93
|
+
// `toast("Saved")` is called from an event handler, from a `catch`, from a
|
|
94
|
+
// Server Action's error path — none of which have a component to put state in.
|
|
95
|
+
// So the queue is a store in this module, read through `useSyncExternalStore`,
|
|
96
|
+
// which is what `ubugeeei-redundancy.md` requires of an external store: cached
|
|
97
|
+
// immutable snapshots, and a server snapshot consistent with them. Nothing here
|
|
98
|
+
// is a mutable array a render reads.
|
|
99
|
+
//
|
|
100
|
+
// Three properties are what that hook is actually asking for, and the store
|
|
101
|
+
// below has those three and nothing more:
|
|
102
|
+
//
|
|
103
|
+
// - *An immutable snapshot whose reference changes only on a write.* The
|
|
104
|
+
// getter hands back the array it is holding rather than building one, and a
|
|
105
|
+
// write that computes the value already there is dropped rather than
|
|
106
|
+
// announced. A getter that returns a fresh `[]` is a new identity every time
|
|
107
|
+
// React asks, which renders, which asks again — the infinite loop React
|
|
108
|
+
// reports rather than tolerates.
|
|
109
|
+
// - *A server snapshot consistent with the first client render.* It is the
|
|
110
|
+
// same getter, so both sides see the same frozen `NONE` and hydration
|
|
111
|
+
// compares like with like instead of against a placeholder.
|
|
112
|
+
// - *Module scope.* `toast()` is a function rather than a hook, so it has no
|
|
113
|
+
// component and no context to reach state through — and an event handler, a
|
|
114
|
+
// `catch` and a Server Action's error path all have to be able to call it.
|
|
115
|
+
//
|
|
116
|
+
// The third of those settles the scoping question as well: one page, one set
|
|
117
|
+
// of notifications. A queue something could scope to a subtree would leave a
|
|
118
|
+
// region showing an empty stack while `toast()` filled up a store it had no
|
|
119
|
+
// way to find.
|
|
120
|
+
//
|
|
121
|
+
// # Why this is not an atom in `@uniflowed/state`
|
|
122
|
+
//
|
|
123
|
+
// It was one, and it should be one again. A queue read through
|
|
124
|
+
// `useSyncExternalStore` is exactly what an atom is for, `@uniflowed/state` is
|
|
125
|
+
// what this project offers instead of Jotai, and the version of this file that
|
|
126
|
+
// imported `atom`, `read`, `subscribe` and `write` was not making a mistake.
|
|
127
|
+
//
|
|
128
|
+
// It cannot ship that way today. `@uniflowed/ui` is published to npm and
|
|
129
|
+
// `@uniflowed/state` is not: its name has never been bound, binding it takes a
|
|
130
|
+
// person with an `npm login` session and a 2FA prompt, and that is
|
|
131
|
+
// ubugeeei-prod/uf#210. Until it happens the package waits in
|
|
132
|
+
// `tools/release/pending-packages.txt`. A published package whose dependency
|
|
133
|
+
// is missing installs as nothing — `ETARGET` on the first thing a user types —
|
|
134
|
+
// so `tools/release/verify-npm.sh` refuses to release `@uniflowed/ui` while it
|
|
135
|
+
// declares that dependency, and it is right to refuse.
|
|
136
|
+
//
|
|
137
|
+
// So the store below is the shippable design rather than the better one. It is
|
|
138
|
+
// a second implementation of something this repository already has, written
|
|
139
|
+
// out by hand because the first one cannot be installed. When #210 binds the
|
|
140
|
+
// name and `state` moves into `published-packages.txt`, this goes back to an
|
|
141
|
+
// atom and this section goes with it.
|
|
142
|
+
//
|
|
143
|
+
// This is the first `useSyncExternalStore` in this package, and `index.js` says
|
|
144
|
+
// the package deliberately does not use one. That sentence is about reading
|
|
145
|
+
// *layout* during a render, which is what the DOM-measuring components here
|
|
146
|
+
// avoid by writing what they measured into state from an effect. A queue that
|
|
147
|
+
// lives outside React is the case the API is for, and `index.js` now says both
|
|
148
|
+
// things.
|
|
149
|
+
|
|
150
|
+
"use client";
|
|
151
|
+
|
|
152
|
+
import * as React from "@uniflowed/react";
|
|
153
|
+
import {
|
|
154
|
+
createContext,
|
|
155
|
+
useContext,
|
|
156
|
+
useEffect,
|
|
157
|
+
useId,
|
|
158
|
+
useMemo,
|
|
159
|
+
useRef,
|
|
160
|
+
useState,
|
|
161
|
+
useSyncExternalStore,
|
|
162
|
+
} from "@uniflowed/react";
|
|
163
|
+
import { useDocumentVisible } from "@uniflowed/hooks/browser";
|
|
164
|
+
import { useElementRef, useFocusWithin, useHover } from "@uniflowed/hooks/dom";
|
|
165
|
+
import { useKeyCombo } from "@uniflowed/hooks/keyboard";
|
|
166
|
+
import { useTimeout } from "@uniflowed/hooks/timing";
|
|
167
|
+
|
|
168
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
169
|
+
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
170
|
+
|
|
171
|
+
/** How loudly a notification interrupts. */
|
|
172
|
+
export type Urgency = "polite" | "assertive";
|
|
173
|
+
|
|
174
|
+
/** One notification in the queue. */
|
|
175
|
+
export type Notification = {|
|
|
176
|
+
readonly id: string,
|
|
177
|
+
/** What a reader is told. A node, so a caller may render their own markup. */
|
|
178
|
+
readonly content: React.Node,
|
|
179
|
+
readonly urgency: Urgency,
|
|
180
|
+
/** How long it stays, in milliseconds, or null for one that never expires. */
|
|
181
|
+
readonly duration: number | null,
|
|
182
|
+
|};
|
|
183
|
+
|
|
184
|
+
/** What `toast` accepts beside the message. */
|
|
185
|
+
export type ToastOptions = {|
|
|
186
|
+
readonly urgency?: Urgency,
|
|
187
|
+
readonly duration?: number | null,
|
|
188
|
+
|};
|
|
189
|
+
|
|
190
|
+
/** What `updateToast` may change about a notification already queued. */
|
|
191
|
+
export type ToastChanges = {|
|
|
192
|
+
readonly content?: React.Node,
|
|
193
|
+
readonly urgency?: Urgency,
|
|
194
|
+
readonly duration?: number | null,
|
|
195
|
+
|};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Long enough to read a sentence, short enough not to be in the way.
|
|
199
|
+
*
|
|
200
|
+
* Anything carrying an action should pass `duration: null` instead of a longer
|
|
201
|
+
* number: "long enough to notice, decide and reach the button" is not a
|
|
202
|
+
* duration anybody can guess for somebody else.
|
|
203
|
+
*/
|
|
204
|
+
const DEFAULT_DURATION = 5000;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The empty queue, as one value.
|
|
208
|
+
*
|
|
209
|
+
* A fresh `[]` from the snapshot getter is a new identity every time React
|
|
210
|
+
* asks, which `useSyncExternalStore` reads as a change, which renders, which
|
|
211
|
+
* asks again — the infinite loop React reports rather than tolerates. One
|
|
212
|
+
* frozen constant is also what makes the server snapshot consistent with the
|
|
213
|
+
* client's first one.
|
|
214
|
+
*/
|
|
215
|
+
const NONE: $ReadOnlyArray<Notification> = Object.freeze([]);
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The queue, and what is watching it.
|
|
219
|
+
*
|
|
220
|
+
* Module scope is the requirement rather than a convenience: `toast()` has
|
|
221
|
+
* nowhere else to put this. It costs a server nothing — importing this file
|
|
222
|
+
* allocates one frozen array and one empty `Set` and starts no work, and
|
|
223
|
+
* nothing on a server calls `toast()`, because this module is `"use client"`.
|
|
224
|
+
*/
|
|
225
|
+
let queue: $ReadOnlyArray<Notification> = NONE;
|
|
226
|
+
const watchers: Set<() => void> = new Set();
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Replace the queue, and wake what is watching it.
|
|
230
|
+
*
|
|
231
|
+
* `change` is handed the current queue and returns the next one. Returning the
|
|
232
|
+
* same array is how a write that changed nothing says so, and such a write
|
|
233
|
+
* wakes nobody: `useSyncExternalStore` decides whether to render by comparing
|
|
234
|
+
* the reference it last read against this one, so a fresh array for an
|
|
235
|
+
* unchanged queue re-renders every region on the page, and a queue mutated in
|
|
236
|
+
* place re-renders none of them.
|
|
237
|
+
*
|
|
238
|
+
* The listeners are iterated over a copy, because one may unsubscribe while
|
|
239
|
+
* they run — React unmounts a `useSyncExternalStore` subscriber by calling
|
|
240
|
+
* exactly that unsubscribe — and a `Set` mutated mid-iteration skips entries.
|
|
241
|
+
* The membership test is against the live set, so one that has just left is
|
|
242
|
+
* not called anyway.
|
|
243
|
+
*/
|
|
244
|
+
function writeQueue(
|
|
245
|
+
change: (current: $ReadOnlyArray<Notification>) => $ReadOnlyArray<Notification>,
|
|
246
|
+
): void {
|
|
247
|
+
const next = change(queue);
|
|
248
|
+
if (next === queue) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
queue = next;
|
|
252
|
+
for (const watcher of Array.from(watchers)) {
|
|
253
|
+
if (watchers.has(watcher)) {
|
|
254
|
+
watcher();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Ids are this module's, because `useId` needs a component and `toast()` is not one. */
|
|
260
|
+
let sequence = 0;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Show a notification, and hand back the id that identifies it later.
|
|
264
|
+
*
|
|
265
|
+
* Callable from anywhere — an event handler, a `catch`, a Server Action's
|
|
266
|
+
* error path — because it is a function in a module rather than a hook.
|
|
267
|
+
*
|
|
268
|
+
* toast("Saved");
|
|
269
|
+
* toast("Could not save", { urgency: "assertive" });
|
|
270
|
+
* const id = toast("Uploading…", { duration: null });
|
|
271
|
+
* updateToast(id, { content: "Uploaded", duration: 4000 });
|
|
272
|
+
*/
|
|
273
|
+
export function toast(content: React.Node, options?: ToastOptions): string {
|
|
274
|
+
sequence += 1;
|
|
275
|
+
const notification: Notification = {
|
|
276
|
+
id: `uf-toast-${String(sequence)}`,
|
|
277
|
+
content,
|
|
278
|
+
urgency: options?.urgency ?? "polite",
|
|
279
|
+
duration: options?.duration === undefined ? DEFAULT_DURATION : options.duration,
|
|
280
|
+
};
|
|
281
|
+
writeQueue((current) => [...current, notification]);
|
|
282
|
+
return notification.id;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Change a notification that is already queued.
|
|
287
|
+
*
|
|
288
|
+
* "Uploading…" becoming "Uploaded" is one notification that changed, not two
|
|
289
|
+
* notifications — and a reader who is told the second thing without the first
|
|
290
|
+
* disappearing has been told the upload is both in progress and finished.
|
|
291
|
+
*
|
|
292
|
+
* Changing the content of a notification that is on screen re-announces the
|
|
293
|
+
* region it is in, which is the point.
|
|
294
|
+
*/
|
|
295
|
+
export function updateToast(id: string, changes: ToastChanges): void {
|
|
296
|
+
writeQueue((current) =>
|
|
297
|
+
current.map((each) =>
|
|
298
|
+
each.id === id
|
|
299
|
+
? {
|
|
300
|
+
id: each.id,
|
|
301
|
+
content: changes.content === undefined ? each.content : changes.content,
|
|
302
|
+
urgency: changes.urgency ?? each.urgency,
|
|
303
|
+
duration: changes.duration === undefined ? each.duration : changes.duration,
|
|
304
|
+
}
|
|
305
|
+
: each,
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Take a notification away, whether it expired, was dismissed, or was acted on. */
|
|
311
|
+
export function dismissToast(id: string): void {
|
|
312
|
+
writeQueue((current) => {
|
|
313
|
+
const left = current.filter((each) => each.id !== id);
|
|
314
|
+
// The same array back when nothing matched, so a stray dismissal is not a
|
|
315
|
+
// new snapshot and does not render every region on the page.
|
|
316
|
+
return left.length === current.length ? current : left;
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Empty the queue.
|
|
322
|
+
*
|
|
323
|
+
* For a route change that makes every pending notification stale, and for a
|
|
324
|
+
* test: the queue is one module-level value, so what one test queued is still
|
|
325
|
+
* there in the next one unless something clears it.
|
|
326
|
+
*/
|
|
327
|
+
export function dismissAllToasts(): void {
|
|
328
|
+
writeQueue(() => NONE);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Module-level and therefore stable, which is what stops React re-subscribing. */
|
|
332
|
+
function subscribeToQueue(listener: () => void): () => void {
|
|
333
|
+
watchers.add(listener);
|
|
334
|
+
return () => {
|
|
335
|
+
watchers.delete(listener);
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The current queue.
|
|
341
|
+
*
|
|
342
|
+
* Used for the server snapshot as well as the client one, because the value is
|
|
343
|
+
* the same frozen array on both sides until something writes: hydration then
|
|
344
|
+
* compares like with like rather than against a placeholder. On a server it is
|
|
345
|
+
* always `NONE`, since nothing on a server calls `toast()` — this module is
|
|
346
|
+
* `"use client"`.
|
|
347
|
+
*/
|
|
348
|
+
function readQueue(): $ReadOnlyArray<Notification> {
|
|
349
|
+
return queue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const NotificationContext: React.Context<Notification | null> = createContext(null);
|
|
353
|
+
|
|
354
|
+
hook useNotification(part: string): Notification {
|
|
355
|
+
const notification = useContext(NotificationContext);
|
|
356
|
+
if (notification == null) {
|
|
357
|
+
throw new Error(`${part} must be rendered inside a Toast.Region's children`);
|
|
358
|
+
}
|
|
359
|
+
return notification;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The ids of a notification's own parts, so it only names ones that exist. */
|
|
363
|
+
type ToastParts = {|
|
|
364
|
+
readonly titleId: string,
|
|
365
|
+
readonly descriptionId: string,
|
|
366
|
+
readonly registerTitle: (present: boolean) => void,
|
|
367
|
+
readonly registerDescription: (present: boolean) => void,
|
|
368
|
+
|};
|
|
369
|
+
|
|
370
|
+
const ToastPartsContext: React.Context<ToastParts | null> = createContext(null);
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The notifications, and the two regions that were watching before them.
|
|
374
|
+
*
|
|
375
|
+
* Render it once, in the layout. It is a page-level surface: every `toast()`
|
|
376
|
+
* anywhere in the application arrives here, and two of these would show the
|
|
377
|
+
* same notifications twice and bind `F6` twice.
|
|
378
|
+
*
|
|
379
|
+
* `children` is a function rather than elements, because the notifications are
|
|
380
|
+
* not known when the region is written — and it is typed `renders ToastRoot`,
|
|
381
|
+
* so a `<div>` where a notification belongs is a type error rather than a
|
|
382
|
+
* stack of notifications a reader cannot dismiss.
|
|
383
|
+
*/
|
|
384
|
+
export component ToastRegion(
|
|
385
|
+
children: (notification: Notification) => renders ToastRoot,
|
|
386
|
+
label?: string = "Notifications",
|
|
387
|
+
limit?: number = 3,
|
|
388
|
+
...rest: Rest
|
|
389
|
+
) {
|
|
390
|
+
const queued = useSyncExternalStore(subscribeToQueue, readQueue, readQueue);
|
|
391
|
+
const region = useElementRef<HTMLElement>();
|
|
392
|
+
// Where `F6` came from, so pressing it again gives focus back.
|
|
393
|
+
const cameFrom = useRef<HTMLElement | null>(null);
|
|
394
|
+
const passed = withoutComposed(rest, ["ref"]);
|
|
395
|
+
|
|
396
|
+
useKeyCombo(
|
|
397
|
+
"f6",
|
|
398
|
+
() => {
|
|
399
|
+
const element = region.current;
|
|
400
|
+
if (element == null) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const active: $FlowFixMe = element.ownerDocument.activeElement;
|
|
404
|
+
if (active != null && element.contains(active)) {
|
|
405
|
+
const back = cameFrom.current;
|
|
406
|
+
cameFrom.current = null;
|
|
407
|
+
back?.focus?.();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
cameFrom.current = active;
|
|
411
|
+
element.focus();
|
|
412
|
+
},
|
|
413
|
+
// The one case that matters is a notification arriving while the reader is
|
|
414
|
+
// filling in a form, so this must fire from inside a text field.
|
|
415
|
+
{ whileTyping: true },
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// The oldest first, so a burst of notifications is a queue rather than a
|
|
419
|
+
// pile that hides what arrived first. What is over the limit is not rendered
|
|
420
|
+
// at all, which is also what keeps its countdown from running before anyone
|
|
421
|
+
// has seen it.
|
|
422
|
+
const shown = queued.slice(0, limit);
|
|
423
|
+
|
|
424
|
+
const place = (notification: Notification) => (
|
|
425
|
+
<NotificationContext.Provider key={notification.id} value={notification}>
|
|
426
|
+
{children(notification)}
|
|
427
|
+
</NotificationContext.Provider>
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
return (
|
|
431
|
+
<div
|
|
432
|
+
{...passed}
|
|
433
|
+
aria-label={label}
|
|
434
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
435
|
+
region.current = element;
|
|
436
|
+
})}
|
|
437
|
+
role="region"
|
|
438
|
+
// So `F6` can put focus on the region itself, including while it is
|
|
439
|
+
// empty, without the region joining the tab order.
|
|
440
|
+
tabIndex={-1}
|
|
441
|
+
>
|
|
442
|
+
<div aria-atomic="true" aria-live="polite" role="status">
|
|
443
|
+
{shown.filter((each) => each.urgency === "polite").map(place)}
|
|
444
|
+
</div>
|
|
445
|
+
<div aria-atomic="true" aria-live="assertive" role="alert">
|
|
446
|
+
{shown.filter((each) => each.urgency === "assertive").map(place)}
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* One notification, and the countdown that stops.
|
|
454
|
+
*
|
|
455
|
+
* `role="group"` named by its `Toast.Title`, which is what turns a stack of
|
|
456
|
+
* three into three things a reader can move between after `F6` rather than one
|
|
457
|
+
* run of text. Named and described only while those parts are rendered, for
|
|
458
|
+
* the reason every part of this package repeats: an `aria-labelledby` naming
|
|
459
|
+
* an id that is not in the document makes a screen reader announce nothing at
|
|
460
|
+
* all.
|
|
461
|
+
*/
|
|
462
|
+
export component ToastRoot(children: React.Node, ...rest: Rest) {
|
|
463
|
+
const notification = useNotification("Toast.Root");
|
|
464
|
+
const base = useId();
|
|
465
|
+
const element = useElementRef<HTMLElement>();
|
|
466
|
+
const [titled, setTitled] = useState(false);
|
|
467
|
+
const [described, setDescribed] = useState(false);
|
|
468
|
+
const passed = withoutComposed(rest, ["ref"]);
|
|
469
|
+
|
|
470
|
+
const hovered = useHover(element);
|
|
471
|
+
const focusInside = useFocusWithin(element);
|
|
472
|
+
const documentVisible = useDocumentVisible();
|
|
473
|
+
const paused = hovered || focusInside || !documentVisible;
|
|
474
|
+
|
|
475
|
+
const { duration, id } = notification;
|
|
476
|
+
// What is left of the countdown. State rather than a ref because the render
|
|
477
|
+
// hands it to `useTimeout`, and reading a ref during a render is a rule this
|
|
478
|
+
// package does not break.
|
|
479
|
+
const [left, setLeft] = useState<number | null>(duration);
|
|
480
|
+
const startedAt = useRef(0);
|
|
481
|
+
|
|
482
|
+
useTimeout(() => dismissToast(id), paused || left == null ? null : left);
|
|
483
|
+
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
if (paused) {
|
|
486
|
+
// Bank what has run. Without this the unpause re-arms `useTimeout` with
|
|
487
|
+
// the whole duration again, and a pointer resting near the stack keeps a
|
|
488
|
+
// notification on screen for ever.
|
|
489
|
+
setLeft((current) =>
|
|
490
|
+
current == null ? null : Math.max(0, current - (Date.now() - startedAt.current)),
|
|
491
|
+
);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
startedAt.current = Date.now();
|
|
495
|
+
}, [paused]);
|
|
496
|
+
|
|
497
|
+
// A notification whose duration was changed under it — "Uploading…" with no
|
|
498
|
+
// duration becoming "Uploaded" with one — starts its countdown from there.
|
|
499
|
+
useEffect(() => {
|
|
500
|
+
setLeft(duration);
|
|
501
|
+
startedAt.current = Date.now();
|
|
502
|
+
}, [duration]);
|
|
503
|
+
|
|
504
|
+
const parts = useMemo(
|
|
505
|
+
() => ({
|
|
506
|
+
titleId: `${base}-title`,
|
|
507
|
+
descriptionId: `${base}-description`,
|
|
508
|
+
registerTitle: setTitled,
|
|
509
|
+
registerDescription: setDescribed,
|
|
510
|
+
}),
|
|
511
|
+
[base],
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
return (
|
|
515
|
+
<ToastPartsContext.Provider value={parts}>
|
|
516
|
+
<div
|
|
517
|
+
{...passed}
|
|
518
|
+
aria-describedby={described ? parts.descriptionId : undefined}
|
|
519
|
+
aria-labelledby={titled ? parts.titleId : undefined}
|
|
520
|
+
ref={composeRefs(rest.ref, (node) => {
|
|
521
|
+
element.current = node;
|
|
522
|
+
})}
|
|
523
|
+
role="group"
|
|
524
|
+
>
|
|
525
|
+
{children}
|
|
526
|
+
</div>
|
|
527
|
+
</ToastPartsContext.Provider>
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** What the notification is about, and the name of its group. */
|
|
532
|
+
export component ToastTitle(children: React.Node, ...rest: Rest) {
|
|
533
|
+
const parts = useToastParts("Toast.Title");
|
|
534
|
+
useRegistration(parts.registerTitle);
|
|
535
|
+
|
|
536
|
+
return (
|
|
537
|
+
<div {...rest} id={parts.titleId}>
|
|
538
|
+
{children}
|
|
539
|
+
</div>
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** The rest of it, and the group's description. */
|
|
544
|
+
export component ToastDescription(children: React.Node, ...rest: Rest) {
|
|
545
|
+
const parts = useToastParts("Toast.Description");
|
|
546
|
+
useRegistration(parts.registerDescription);
|
|
547
|
+
|
|
548
|
+
return (
|
|
549
|
+
<div {...rest} id={parts.descriptionId}>
|
|
550
|
+
{children}
|
|
551
|
+
</div>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* The thing the notification offers to do: Undo, Retry, View.
|
|
557
|
+
*
|
|
558
|
+
* Taking the action dismisses the notification, because a notification whose
|
|
559
|
+
* offer has been accepted is describing something that is no longer true.
|
|
560
|
+
*
|
|
561
|
+
* A notification carrying one of these should be given `duration: null`. The
|
|
562
|
+
* countdown stopping while focus is inside is what makes the button reachable
|
|
563
|
+
* at all; it is not a promise that four seconds was enough time to decide.
|
|
564
|
+
*/
|
|
565
|
+
export component ToastAction(children: React.Node, ...rest: Rest) {
|
|
566
|
+
const notification = useNotification("Toast.Action");
|
|
567
|
+
const passed = withoutComposed(rest, ["onClick"]);
|
|
568
|
+
|
|
569
|
+
return (
|
|
570
|
+
<button
|
|
571
|
+
{...passed}
|
|
572
|
+
onClick={composeHandlers(rest.onClick, () => dismissToast(notification.id))}
|
|
573
|
+
type="button"
|
|
574
|
+
>
|
|
575
|
+
{children}
|
|
576
|
+
</button>
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* The button that takes the notification away.
|
|
582
|
+
*
|
|
583
|
+
* `label` becomes the accessible name, and it has a default because the close
|
|
584
|
+
* on a notification is an icon almost every time — and an icon-only button
|
|
585
|
+
* with no name is announced as "button", which is a control a reader can find
|
|
586
|
+
* and cannot identify. `Dialog.Close` has no default for the opposite reason:
|
|
587
|
+
* its content is words.
|
|
588
|
+
*
|
|
589
|
+
* If you render visible text inside this, pass the same words as `label`.
|
|
590
|
+
* WCAG 2.5.3 asks that the name contain what a reader sees, and an
|
|
591
|
+
* `aria-label` that says "Dismiss" over a button that says "Close" breaks the
|
|
592
|
+
* speech reader who says "click Close" out loud.
|
|
593
|
+
*/
|
|
594
|
+
export component ToastClose(children?: React.Node, label?: string = "Dismiss", ...rest: Rest) {
|
|
595
|
+
const notification = useNotification("Toast.Close");
|
|
596
|
+
const passed = withoutComposed(rest, ["onClick"]);
|
|
597
|
+
|
|
598
|
+
return (
|
|
599
|
+
<button
|
|
600
|
+
{...passed}
|
|
601
|
+
aria-label={label}
|
|
602
|
+
onClick={composeHandlers(rest.onClick, () => dismissToast(notification.id))}
|
|
603
|
+
type="button"
|
|
604
|
+
>
|
|
605
|
+
{children}
|
|
606
|
+
</button>
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
hook useToastParts(part: string): ToastParts {
|
|
611
|
+
const parts = useContext(ToastPartsContext);
|
|
612
|
+
if (parts == null) {
|
|
613
|
+
throw new Error(`${part} must be rendered inside a Toast.Root`);
|
|
614
|
+
}
|
|
615
|
+
return parts;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Tell a `Toast.Root` that one of the parts it names is in the document. */
|
|
619
|
+
hook useRegistration(register: (present: boolean) => void): void {
|
|
620
|
+
useEffect(() => {
|
|
621
|
+
register(true);
|
|
622
|
+
return () => register(false);
|
|
623
|
+
}, [register]);
|
|
624
|
+
}
|