@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/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
+ }