Component contract
Every toast in the admin is fired through the canonical useToast() hook which mounts <UiToast>. No hand-rolled toasts.
title: string— required, the toast's primary line.description?: string— optional secondary line.variant?: "info" | "success" | "warning" | "error"— defaultsinfo. Affects color and icon, not auto-dismiss timing.action?: { label, onClick }— optional button inside the toast (e.g., "Undo").autoDismissMs?: number | null— defaults to a content-length-aware computation:min(8000, max(3000, 2500 + 50ms × text length)). Set tonullto require manual dismiss (used for errors that need user acknowledgment).id?: string— for deduplication when the same toast is fired multiple times.
Interaction surface
-
Caller fires a toast via
useToast().show({...}).A new toast appears in the top-right region (or top-center on mobile). If a toast with the same
idis currently visible, the new one replaces it (no duplicate flash). Up to 3 toasts can be visible at once; further toasts queue. -
Toast announces itself.
The toast region is an
aria-live="polite"for info/success,aria-live="assertive"for warning/error. Title + description are read in sequence. -
Auto-dismiss timer.
Default formula gives ~5s for a typical confirmation. Pause when hovered. Pause when focused (e.g., user tabbing to the action button). Resume on mouseleave / blur.
-
Action button.
If
actionis provided, the button is keyboard-reachable via Tab. Clicking it firesonClickAND dismisses the toast. Tab from the toast region also moves out without dismissing. -
Manual dismiss.
Each toast has a close button (X icon) with
aria-label="Dismiss". Esc focused on the toast region dismisses the focused toast.
Failure modes
Auto-dismiss too short for long content
Trigger: a toast with description "We've sent invitations to 247 guests across 3 access types and 2 designs. Email deliverability events will appear in the report center within 2 hours." auto-dismisses in 3 seconds.
Auto-dismiss timing is content-length-aware. The formula gives that toast ~14 seconds (capped at 8s by the formula's max — so we'd actually adjust the cap, OR the toast warrants autoDismissMs: null requiring manual dismiss). Component clamps to a minimum of 3s. The harness asserts: long-content toast survives at least the computed timing.
Auto-dismiss does not pause on hover
Trigger: user hovers a toast to read it; toast disappears mid-sentence.
Hover pauses the timer. Mouseleave resumes (with the remaining time, not a full reset). The harness asserts: hover before timer expires, wait past expiry, observe toast still visible.
Stacked toasts overflow the viewport
Trigger: 5 toasts fire in 2 seconds. All 5 render stacked vertically; the lowest toasts run off the bottom.
Maximum 3 visible toasts at once. Further toasts queue and appear as earlier ones dismiss. The harness asserts: fire 5 toasts, observe count of data-test=ui-toast elements ≤ 3.
Action button steals focus
Trigger: user is mid-type in a form, a success toast with an Undo action fires, the Undo button auto-focuses, user's keystroke hits the toast.
Toast does NOT auto-focus. Action button is reachable via Tab from the body, but focus is not shifted automatically. The harness asserts: fire toast with action, observe document.activeElement unchanged from before fire.
Duplicate toasts on rapid fire
Trigger: a button click fires the same "Saved" toast twice via a race in the caller. Both render briefly, looking like a stutter.
If the caller provides an id, dedup by id — the second show replaces the first in place. If no id, two toasts render (caller didn't ask for dedup). The harness asserts: fire two toasts with same id within 100ms, observe one visible toast.
Toast covers the user's primary action
Trigger: top-right placement covers the workspace switcher in the page header at narrow viewports.
On viewports < 768px wide, toast moves to top-center. Doesn't overlap header chrome regardless of width. The harness asserts: at 375x667, fire a toast, compute its bounding rect, observe it does not overlap [data-test=workspace-name] bbox.
Accessibility
- Toast region is
role="region" aria-label="Notifications". - Each toast is
role="status"(info/success) orrole="alert"(warning/error). - Live region politeness matches the variant.
- Close button has
aria-label="Dismiss"with the toast title context. - Action buttons have descriptive labels (not just "Undo" — "Undo invitation send" with context).
- Color contrast: text ≥ 4.5:1, action button ≥ 4.5:1 against toast background.
- axe-clean at severity ≥ serious.
Stable test attributes
Visibility teeth. Each attribute must be present AND effectively visible while the toast is mounted. After auto-dismiss, the toast should be removed from the DOM (preferred) or marked display:none — both render the selector absent per the visibility-teeth contract.
| data-test | Where | Purpose |
|---|---|---|
ui-toast-region | Document body portal | The aria-live region; always present (empty when no toasts) |
ui-toast | Inside ui-toast-region | Each toast; multiple instances |
ui-toast-title | Inside ui-toast | Title text |
ui-toast-description | Inside ui-toast | Description text; absent if not provided |
ui-toast-action | Inside ui-toast | Action button; absent if no action prop |
ui-toast-dismiss | Inside ui-toast | Close button (X icon) |
Agent test plan
Probe list
- auto-dismiss-default-timing: fire toast with 50-char title, advance clock past computed timing, assert toast absent
- auto-dismiss-min-3s-floor: fire toast with 5-char title, observe minimum 3000ms before dismiss
- auto-dismiss-pauses-on-hover: fire toast, hover before expiry, advance clock past expiry, assert toast still visible
- auto-dismiss-resumes-on-mouseleave: continuation of above; mouseleave, advance remaining time, assert toast dismissed
- max-3-stacked: fire 5 toasts within 100ms, count visible ui-toast elements, assert ≤ 3
- queue-after-dismiss: continuation; dismiss one of the 3, observe a 4th appear
- dedup-by-id: fire two toasts with same id within 100ms, assert exactly one visible
- no-auto-focus-steal: focus an input, fire toast with action, assert document.activeElement unchanged
- action-tab-reachable: fire toast with action, Tab from body, assert action button is in tab sequence
- esc-dismisses-focused: focus a toast, press Esc, assert toast dismissed
- close-button-dismisses: click ui-toast-dismiss, assert toast removed
- mobile-placement: at 375x667, fire toast, compute bbox, assert not overlapping workspace-name bbox
- aria-live-polite-info: fire info variant, assert ui-toast-region aria-live="polite"
- aria-live-assertive-error: fire error variant, assert ui-toast-region aria-live="assertive"
- color-contrast: assert ui-toast-title text ≥ 4.5:1