From 20aa8f1f414dc45d9cb55b7941244b219ca51012 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sat, 18 Jul 2026 11:57:15 +0200 Subject: [PATCH 1/2] feat: add the Toast primitive Anatomy: Toast.Provider / Toast.Viewport / Toast (Root, Title, Description, Action, Close). A status live-region toast that auto-dismisses after its duration and pauses timers while the viewport is hovered or focused. Co-Authored-By: Claude Fable 5 --- .changeset/toast.md | 39 +++ packages/core/toast/README.md | 28 ++ packages/core/toast/SPEC.md | 161 ++++++++++ packages/core/toast/package.json | 40 +++ packages/core/toast/src/connect.ts | 94 ++++++ packages/core/toast/src/index.ts | 12 + packages/core/toast/src/machine.ts | 59 ++++ packages/core/toast/src/types.ts | 75 +++++ packages/core/toast/tests/machine.test.ts | 198 ++++++++++++ packages/react/toast/README.md | 40 +++ packages/react/toast/SPEC.md | 131 ++++++++ packages/react/toast/package.json | 50 +++ packages/react/toast/src/context.ts | 43 +++ packages/react/toast/src/effects.ts | 50 +++ packages/react/toast/src/index.ts | 12 + packages/react/toast/src/registry.ts | 49 +++ packages/react/toast/src/toast.tsx | 238 ++++++++++++++ packages/react/toast/src/use-toast.ts | 25 ++ .../react/toast/stories/toast.stories.tsx | 148 +++++++++ packages/react/toast/tests/toast.test.tsx | 304 ++++++++++++++++++ pnpm-lock.yaml | 34 ++ tsconfig.json | 2 + tsdown.config.ts | 2 + 23 files changed, 1834 insertions(+) create mode 100644 .changeset/toast.md create mode 100644 packages/core/toast/README.md create mode 100644 packages/core/toast/SPEC.md create mode 100644 packages/core/toast/package.json create mode 100644 packages/core/toast/src/connect.ts create mode 100644 packages/core/toast/src/index.ts create mode 100644 packages/core/toast/src/machine.ts create mode 100644 packages/core/toast/src/types.ts create mode 100644 packages/core/toast/tests/machine.test.ts create mode 100644 packages/react/toast/README.md create mode 100644 packages/react/toast/SPEC.md create mode 100644 packages/react/toast/package.json create mode 100644 packages/react/toast/src/context.ts create mode 100644 packages/react/toast/src/effects.ts create mode 100644 packages/react/toast/src/index.ts create mode 100644 packages/react/toast/src/registry.ts create mode 100644 packages/react/toast/src/toast.tsx create mode 100644 packages/react/toast/src/use-toast.ts create mode 100644 packages/react/toast/stories/toast.stories.tsx create mode 100644 packages/react/toast/tests/toast.test.tsx diff --git a/.changeset/toast.md b/.changeset/toast.md new file mode 100644 index 0000000..c3c9596 --- /dev/null +++ b/.changeset/toast.md @@ -0,0 +1,39 @@ +--- +'@dunky.dev/toast': minor +'@dunky.dev/react-toast': minor +--- + +Add the Toast primitive — a declarative, Radix-style toast (one component per +toast, not an imperative toaster store), shipped as an agnostic core +(`@dunky.dev/toast`) plus a React binding (`@dunky.dev/react-toast`). + +A toast announces as a `role="status"` live region (`assertive` for the +`foreground` type, `polite` for `background`), auto-dismisses after its +duration (`Infinity` = persistent), and pauses every timer while the viewport +is hovered or focused — resuming restarts the full duration, a documented +deviation from Radix that keeps the timer contract inside the state machine. +Dismissing the toast that holds keyboard focus parks focus on the viewport +(`tabindex="-1"`), so the user keeps their place and the hover/focus pause +tracking stays truthful. + +```tsx +import { Toast } from '@dunky.dev/react-toast' + +function App() { + return ( + + {/* app */} + + + + Saved + Your changes are safe. + Undo + Dismiss + + + + + ) +} +``` diff --git a/packages/core/toast/README.md b/packages/core/toast/README.md new file mode 100644 index 0000000..5710994 --- /dev/null +++ b/packages/core/toast/README.md @@ -0,0 +1,28 @@ +# @dunky.dev/toast + +The framework-agnostic toast interaction, modeled as a state machine on +`@dunky.dev/state-machine`. Pure logic — no substrate, no framework. Consumers +pair it with a substrate driver rather than driving the machine directly. + +The behavior contract — scenarios, guarantees, driver obligations (including +the timer wiring the substrate owns) — lives in [SPEC.md](./SPEC.md). + +## Install + +```sh +npm install @dunky.dev/toast +``` + +## Usage + +```ts +import { machine, connector } from '@dunky.dev/state-machine' +import { toastMachine, toastConnect } from '@dunky.dev/toast' + +// `id` is substrate-minted (SSR-safe); the connect derives the per-part ids. +const service = machine(toastMachine({ ...options, id: 'my-toast' })) +connector(service, toastConnect, options) // wires the consumer callbacks +service.start() +// the substrate's timer sends this when `context.duration` elapses in `open` +service.send({ type: 'timer.elapsed' }) +``` diff --git a/packages/core/toast/SPEC.md b/packages/core/toast/SPEC.md new file mode 100644 index 0000000..7919f9d --- /dev/null +++ b/packages/core/toast/SPEC.md @@ -0,0 +1,161 @@ +# SPEC / Toast + +## Reference + +- **W3C pattern**: there is no APG toast pattern; the normative ground is + [WAI-ARIA 1.2 `status`](https://www.w3.org/TR/wai-aria-1.2/#status) (a live + region with implicit `aria-live`) plus the + [APG alert pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alert/) guidance + that time-based messages must not steal focus. +- **State machine**: built on `@dunky.dev/state-machine`. +- **Prior art**: API shape modeled on the Radix Toast (declarative, one + component per toast — not an imperative toaster store), cross-checked against + Base UI and Ark. + +## Overview + +A toast is a short, time-limited message that announces the outcome of an +action or a background event without interrupting the user's flow: it appears +in a dedicated region, is announced by assistive technology as a live update, +and dismisses itself after a duration — or earlier, when the user acts on it. +It is the opposite of a dialog: it must never take over interaction, never +steal focus, and never require an answer. + +## Anatomy + +One machine per toast. The Provider and Viewport are shared coordination +surfaces around the toasts — they carry configuration and the landmark, not +machine state — and each substrate provides them (see [Design](#design)). + +``` + — shared config: the default duration, the region's label + |_ — the landmark region listing the toasts; hovering or + | focusing it pauses every toast's timer + |_ — root; owns one toast's state, renders nothing of its own + |_ — the toast surface: the announced live element + |_ — names the toast + |_ <Description> — the toast's message body + |_ <Action> — an optional action; pressing it dismisses + |_ <Close> — the explicit dismissal affordance +``` + +## Behavior + +Using the toast is a walkthrough of intent, not a prop list: + +- The **root** owns one toast's open/close state, exposed controlled and + uncontrolled, mirroring the dialog: a controlled consumer drives it from + outside, and every open/close intent — including auto-dismiss — is reported + back so the consumer stays in sync. The controlled prop is a synced input, + not a gate: an internal dismissal (timer, Close, Action) still closes the + toast, and the report is what lets the consumer mirror it back. A rendered + uncontrolled toast is open by default: rendering it is the intent to show it. +- A toast **auto-dismisses** after its duration. The duration comes from the + toast itself, falling back to the provider's default. A non-finite duration + (`Infinity`) makes the toast persistent — it only dismisses on user action or + from outside. +- Hovering or focusing the **viewport pauses** every toast's timer — the user + is reading or about to act, so nothing may vanish under the pointer or + focus. Leaving/blurring resumes. Resuming restarts the full duration (the + [Design](#design) section records why). +- **Title / Description** name and describe the toast; the root's ARIA + relationships always follow what is actually rendered — an omitted part never + leaves a dangling reference. +- **Action** is the toast's optional interactive affordance (undo, retry). + Pressing it dismisses the toast; the consumer's own handler decides what the + action does. +- **Close** dismisses from inside — the explicit affordance for a user who + won't wait out the timer, and what a persistent toast relies on. + +## States + +| State | Behavior | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `closed` | Nothing is shown. An open intent (controlled prop, imperative open) moves to `open`. | +| `open` | The toast is visible and its dismiss timer runs. The timer elapsing, a Close/Action press, or an outside close intent dismisses; a pause intent moves to `paused`. | +| `paused` | The toast is visible with its timer suspended. A resume intent returns to `open` (restarting the duration); Close/Action and outside close intents still dismiss. | + +`open` and `paused` are both "shown" — parts expose `data-state="open"` for +either, so pausing never disturbs styling or animation. + +### Timer + +The machine owns the timing _decision_ as explicit states: entering `open` +means "the dismiss timer is running", `paused` means "it is suspended", and +the elapse is an ordinary event that only `open` accepts. The substrate owns +the _clock_: entering the timed state schedules a timeout for the toast's +duration that sends the elapse event; leaving it cancels the timeout. A late +or stray elapse can never dismiss a paused or closed toast — the state graph, +not the scheduler, is the authority. + +## Accessibility + +Per WAI-ARIA `status` and the Radix mapping: + +- **Role**: the root is `role="status"` with an explicit politeness driven by + the toast's type — `foreground` (the direct result of a user action) maps to + `aria-live="assertive"`, `background` (a task the user didn't just perform) + maps to `aria-live="polite"`. `aria-atomic` announces the whole toast on + update. +- **No focus steal**: showing a toast never moves focus. Its controls are + reached in the normal tab order through the viewport. +- **Focus survives dismissal**: dismissing the toast that holds focus moves + focus to the viewport. Removing a focused element dispatches no blur, so + parking focus there keeps the keyboard user's place in the region and keeps + the viewport's focus-derived pause truthful. +- **Name**: the root is labelled by the rendered Title and described by the + rendered Description, when present. +- **Viewport**: a `role="region"` landmark with an accessible label (the + provider's `label`), so assistive-tech users can find pending toasts; the + toasts are a list inside it. +- **Timing**: hover and focus pause the timer so the message stays readable + and operable for keyboard and pointer users alike. + +## Constraints + +- A toast never steals focus and never blocks interaction with the page. +- Every open ⇄ close transition, whatever its cause (timer, Close, Action, + outside intent), is reported to the consumer; pause/resume is not an + open/close change and is never reported as one. +- ARIA labelled-by / described-by must only reference elements that are + actually rendered. +- A dismiss timer can only elapse in `open`: pausing or closing invalidates + any in-flight timer. +- Out of scope for v0, recorded as deliberate exclusions: swipe-to-dismiss + gestures, an imperative `toast()` queue/store API, the F8 hotkey to jump to + the viewport landmark, Escape-to-dismiss of the focused toast, + toast stacking limits/queueing, and positioning — the consumer styles and + positions the viewport. +- Also deferred, as a recorded limitation rather than a choice: the hidden + announcer. The live element mounts together with its content, and a live + region inserted pre-populated is announced unreliably by some screen + readers — the Radix-style off-screen announcer that guarantees the initial + announcement is a known v0 gap. + +## Design + +- **One machine per toast; no store.** The Radix-style declarative shape wins + over an imperative toaster: each rendered toast owns exactly one machine and + the dialog's controlled/uncontrolled contract applies to it. A queue/store + API can be layered on later without touching this core. +- **The substrate schedules, the machine decides.** The core has no clock: the + `open`/`paused` split is the machine's, while the substrate's effect wires + `setTimeout` to the state — enter `open`, schedule the duration; leave, + cancel. Any substrate inherits identical pause/dismiss semantics by wiring + one timer. +- **Resume restarts the full duration.** Resuming with the remaining time + would need a clock inside the machine (or timing state inside the binding, + which a binding must not own). Restarting keeps the timer contract fully in + the state graph, and errs in the reader's favor — a paused toast gets its + whole duration back. This deviates from Radix, which resumes with the + remaining time. +- **Provider and Viewport are substrate surfaces.** They coordinate _between_ + machines — the default duration, the region landmark, broadcasting + pause/resume to every registered toast — so they hold no machine state and + make no decisions: a broadcast pause is just the pause intent delivered to + each machine, and each machine's state graph decides what it means. A toast + that starts (or opens) while the viewport is already hovered or focused + joins paused — the broadcast reaches late joiners too. +- **Duration is read at toast creation.** Like every config option in this + repo's cores, `duration` and `type` seed the machine context at build time; + changing them on a live toast has no effect. diff --git a/packages/core/toast/package.json b/packages/core/toast/package.json new file mode 100644 index 0000000..56efe7b --- /dev/null +++ b/packages/core/toast/package.json @@ -0,0 +1,40 @@ +{ + "name": "@dunky.dev/toast", + "version": "0.0.0", + "description": "Framework-agnostic toast interaction, modeled as a state machine.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/core/toast" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/state-machine": "^0.1.0", + "@dunky.dev/state-machine-bindings": "^0.1.0" + } +} diff --git a/packages/core/toast/src/connect.ts b/packages/core/toast/src/connect.ts new file mode 100644 index 0000000..ed4e229 --- /dev/null +++ b/packages/core/toast/src/connect.ts @@ -0,0 +1,94 @@ +import { makeReaction, type Connect } from '@dunky.dev/state-machine' +import type { AttrBindings, EventBindings } from '@dunky.dev/state-machine-bindings' +import type { + ToastContext, + ToastIds, + ToastMachineEvent, + ToastOptions, + ToastStateName, + ToastType, +} from './types' + +// Open and paused are both "shown": pausing never disturbs styling/animation. +type ToastDataState = 'open' | 'closed' + +// The bindings a part carries, drawn from the shared agnostic vocabulary; the +// index signature keeps parts assignable to the loose shape each substrate's +// normalize() accepts. `data-state` is the styling/animation hook. +export type ToastPartBindings = EventBindings & + AttrBindings & { 'data-state'?: ToastDataState } & Record<string, unknown> + +// The cross-part ids all derive from the one base id, so Root's labelledby/ +// describedby and the title/description ids always agree. +function toastIds(id: string): ToastIds { + return { root: `${id}-root`, title: `${id}-title`, description: `${id}-description` } +} + +/** The view-facing surface a driver reads from the running toast machine. */ +export interface ToastApi { + open: boolean + type: ToastType + ids: ToastIds + setOpen: (open: boolean) => void + parts: { + root: ToastPartBindings + title: ToastPartBindings + description: ToastPartBindings + action: ToastPartBindings + close: ToastPartBindings + } +} + +export const toastConnect: Connect< + ToastStateName, + ToastContext, + ToastMachineEvent, + ToastOptions, + ToastApi +> = ({ state, context, send }) => { + const open = state !== 'closed' + const dataState: ToastDataState = open ? 'open' : 'closed' + const ids = toastIds(context.id) + const close = (): void => send({ type: 'close' }) + + return { + open, + type: context.type, + ids, + setOpen(next) { + if (open === next) return + send({ type: next ? 'open' : 'close' }) + }, + parts: { + root: { + // A status live region; the toast type only decides how assertively + // it is announced. + role: 'status', + live: context.type === 'foreground' ? 'assertive' : 'polite', + atomic: true, + id: ids.root, + labelledBy: context.parts.title ? ids.title : undefined, + describedBy: context.parts.description ? ids.description : undefined, + 'data-state': dataState, + }, + title: { id: ids.title }, + description: { id: ids.description }, + // Action and Close both dismiss; what the action does is the consumer's + // own handler, composed by the substrate. + action: { onPress: close }, + close: { onPress: close }, + }, + } +} + +const reaction = makeReaction<ToastStateName, ToastContext, ToastMachineEvent, ToastOptions>() + +// One reaction per consumer callback. Reactions fire in registration order within +// a single setContext — that order is the callback-order contract. See SPEC.md. +// The selector spans open+paused, so pause/resume never reads as an open change. +toastConnect.reactions = [ + reaction( + m => !m.matches('closed'), + (open, props) => props.onOpenChange?.(open), + ), +] diff --git a/packages/core/toast/src/index.ts b/packages/core/toast/src/index.ts new file mode 100644 index 0000000..3469091 --- /dev/null +++ b/packages/core/toast/src/index.ts @@ -0,0 +1,12 @@ +export { toastMachine, type ToastMachine } from './machine' +export { toastConnect, type ToastApi, type ToastPartBindings } from './connect' +export type { + ToastCallbacks, + ToastContext, + ToastIds, + ToastMachineEvent, + ToastOptions, + ToastPart, + ToastStateName, + ToastType, +} from './types' diff --git a/packages/core/toast/src/machine.ts b/packages/core/toast/src/machine.ts new file mode 100644 index 0000000..b720df2 --- /dev/null +++ b/packages/core/toast/src/machine.ts @@ -0,0 +1,59 @@ +import { setup, type Action, type Machine, type TransitionConfig } from '@dunky.dev/state-machine' +import type { ToastContext, ToastMachineEvent, ToastOptions, ToastStateName } from './types' + +/** The running toast machine — what a substrate holds and sends events to. */ +export type ToastMachine = Machine<ToastStateName, ToastContext, ToastMachineEvent> + +type ToastAction = Action<ToastContext, ToastMachineEvent> + +const setPartPresence: ToastAction = ({ event, context, setContext }) => { + if (event.type !== 'part.presence') return + setContext({ parts: { ...context.parts, [event.part]: event.present } }) +} + +export function toastMachine( + options: ToastOptions, +): TransitionConfig<ToastStateName, ToastContext, ToastMachineEvent> { + // Annotated so createMachine infers Context as ToastContext, not the narrowed literal. + const context: ToastContext = { + type: options.type ?? 'foreground', + duration: options.duration ?? 5000, + // The substrate supplies a unique id; `toast` is only a bare fallback. + id: options.id ?? 'toast', + parts: { title: false, description: false }, + } + + return setup.as<ToastContext, ToastMachineEvent>().createMachine({ + // Rendering an uncontrolled toast is the intent to show it — open by default. + initial: (options.open ?? options.defaultOpen ?? true) ? 'open' : 'closed', + context, + // Top-level: parts report their presence from any state. + on: { + 'part.presence': { actions: setPartPresence }, + }, + states: { + closed: { + on: { + open: { target: 'open' }, + }, + }, + // `open` means "the dismiss timer is running": the substrate's timer + // effect schedules on enter and cancels on exit. + open: { + on: { + close: { target: 'closed' }, + 'timer.elapsed': { target: 'closed' }, + 'timer.pause': { target: 'paused' }, + }, + }, + // Shown with the timer suspended. No `timer.elapsed` here: a late elapse + // from the scheduler can never dismiss a paused toast. + paused: { + on: { + close: { target: 'closed' }, + 'timer.resume': { target: 'open' }, + }, + }, + }, + }) +} diff --git a/packages/core/toast/src/types.ts b/packages/core/toast/src/types.ts new file mode 100644 index 0000000..7f16bf5 --- /dev/null +++ b/packages/core/toast/src/types.ts @@ -0,0 +1,75 @@ +// Public + machine-facing types for the framework-agnostic toast primitive. +// The state machine is substrate-free: all event reading and timer scheduling +// live in a per-substrate driver. + +export type ToastStateName = 'closed' | 'open' | 'paused' + +/** + * How assertively the toast is announced: `foreground` (the direct result of + * a user action) maps to `aria-live="assertive"`, `background` (a task the + * user didn't just perform) to `aria-live="polite"`. + */ +export type ToastType = 'foreground' | 'background' + +/** The parts whose presence drives the ARIA relationships on Root. */ +export type ToastPart = 'title' | 'description' + +/** + * The cross-part ids, derived from the one `id` on context: title/description + * render as `id` on their element and as an ARIA reference (labelledby / + * describedby) on Root, and the connect wires both sides. + */ +export interface ToastIds { + root: string + title: string + description: string +} + +export interface ToastContext { + type: ToastType + // Auto-dismiss duration in ms; a non-finite value means persistent. The + // substrate's timer effect reads it when scheduling. + duration: number + // The base id (substrate-minted, SSR-safe); the connect derives the per-part + // ids from it. + id: string + // Which optional parts are currently present, so Root only references the + // ones actually rendered. + parts: Record<ToastPart, boolean> +} + +// The timer events are distinct from `close` so the state graph — not the +// substrate's scheduler — stays the authority: only `open` accepts an elapse. +export type ToastMachineEvent = + | { type: 'open' } + | { type: 'close' } + | { type: 'timer.elapsed' } + | { type: 'timer.pause' } + | { type: 'timer.resume' } + | { type: 'part.presence'; part: ToastPart; present: boolean } + +export interface ToastCallbacks { + /** Fired on every open/close transition — including auto-dismiss — with the + * new value. Pause/resume is never reported. */ + onOpenChange?: (open: boolean) => void +} + +/** + * The agnostic toast options — the behavior a consumer configures. A + * substrate's props extend this with its own concerns (e.g. `children`). + */ +export interface ToastOptions extends ToastCallbacks { + /** Base id for the toast's parts; the substrate supplies a unique, SSR-safe + * one. The per-part ids (root/title/description) are derived from it. */ + id?: string + /** Controlled open state; every open/close intent is reported through `onOpenChange`. */ + open?: boolean + /** Initial open state for the uncontrolled toast — rendering a toast is the + * intent to show it. @default true */ + defaultOpen?: boolean + /** Announcement politeness. @default 'foreground' */ + type?: ToastType + /** Auto-dismiss duration in ms; `Infinity` makes the toast persistent. The + * substrate defaults it from its provider surface. @default 5000 */ + duration?: number +} diff --git a/packages/core/toast/tests/machine.test.ts b/packages/core/toast/tests/machine.test.ts new file mode 100644 index 0000000..a345201 --- /dev/null +++ b/packages/core/toast/tests/machine.test.ts @@ -0,0 +1,198 @@ +// The agnostic core of the Toast — machine + connect, no DOM, no framework. +import { describe, expect, it, vi } from 'vitest' +import { connector, machine, type Connector, type Machine } from '@dunky.dev/state-machine' +import { toastMachine, toastConnect } from '@dunky.dev/toast' +import type { + ToastApi, + ToastContext, + ToastIds, + ToastMachineEvent, + ToastOptions, + ToastStateName, +} from '@dunky.dev/toast' + +// The per-part ids the connect derives from a base id of `toast`. +const ids: ToastIds = { + root: 'toast-root', + title: 'toast-title', + description: 'toast-description', +} + +interface Harness { + service: Machine<ToastStateName, ToastContext, ToastMachineEvent> + connection: Connector<ToastStateName, ToastContext, ToastApi, ToastOptions> +} + +const build = (options: ToastOptions = {}): Harness => { + const service = machine(toastMachine({ id: 'toast', ...options })) + const connection = connector(service, toastConnect, options) + service.start() + return { service, connection } +} + +describe('toast machine — open/close', () => { + it('starts open by default — rendering a toast is the intent to show it', () => { + const { service } = build() + expect(service.state).toBe('open') + }) + + it('starts closed when defaultOpen=false', () => { + const { service } = build({ defaultOpen: false }) + expect(service.state).toBe('closed') + }) + + it('starts closed when controlled open=false', () => { + const { service } = build({ open: false }) + expect(service.state).toBe('closed') + }) + + it('open/close events are one-directional (close while closed is a no-op)', () => { + const { service } = build({ defaultOpen: false }) + service.send({ type: 'open' }) + expect(service.state).toBe('open') + service.send({ type: 'close' }) + expect(service.state).toBe('closed') + service.send({ type: 'close' }) + expect(service.state).toBe('closed') + }) +}) + +describe('toast machine — timer states', () => { + it('the timer elapsing dismisses an open toast', () => { + const { service } = build() + service.send({ type: 'timer.elapsed' }) + expect(service.state).toBe('closed') + }) + + it('pause suspends the timer; resume restarts it', () => { + const { service } = build() + service.send({ type: 'timer.pause' }) + expect(service.state).toBe('paused') + service.send({ type: 'timer.resume' }) + expect(service.state).toBe('open') + }) + + it('a stray elapse never dismisses a paused toast', () => { + const { service } = build() + service.send({ type: 'timer.pause' }) + service.send({ type: 'timer.elapsed' }) + expect(service.state).toBe('paused') + }) + + it('pause is meaningless while closed', () => { + const { service } = build({ defaultOpen: false }) + service.send({ type: 'timer.pause' }) + expect(service.state).toBe('closed') + }) + + it('close dismisses a paused toast', () => { + const { service } = build() + service.send({ type: 'timer.pause' }) + service.send({ type: 'close' }) + expect(service.state).toBe('closed') + }) +}) + +describe('toast machine — context seeding', () => { + it('defaults to foreground type and a 5000ms duration', () => { + const { service } = build() + expect(service.context.type).toBe('foreground') + expect(service.context.duration).toBe(5000) + }) + + it('seeds the duration override from options', () => { + const { service } = build({ duration: 200 }) + expect(service.context.duration).toBe(200) + }) + + it('tracks title/description presence in context, in any state', () => { + const { service } = build({ defaultOpen: false }) + service.send({ type: 'part.presence', part: 'title', present: true }) + service.send({ type: 'part.presence', part: 'description', present: true }) + expect(service.context.parts).toEqual({ title: true, description: true }) + + service.send({ type: 'part.presence', part: 'title', present: false }) + expect(service.context.parts).toEqual({ title: false, description: true }) + }) +}) + +describe('toast connect — logical bindings', () => { + it('root is an assertive status live region for the default foreground type', () => { + const { connection } = build() + const root = connection.snapshot.parts.root + expect(root.role).toBe('status') + expect(root.live).toBe('assertive') + expect(root.atomic).toBe(true) + expect(root.id).toBe(ids.root) + }) + + it('background type announces politely', () => { + const { connection } = build({ type: 'background' }) + expect(connection.snapshot.parts.root.live).toBe('polite') + }) + + it('labelledBy/describedBy only reference rendered parts', () => { + const { service, connection } = build() + expect(connection.snapshot.parts.root.labelledBy).toBeUndefined() + expect(connection.snapshot.parts.root.describedBy).toBeUndefined() + + service.send({ type: 'part.presence', part: 'title', present: true }) + service.send({ type: 'part.presence', part: 'description', present: true }) + expect(connection.snapshot.parts.root.labelledBy).toBe(ids.title) + expect(connection.snapshot.parts.root.describedBy).toBe(ids.description) + + expect(connection.snapshot.parts.title.id).toBe(ids.title) + expect(connection.snapshot.parts.description.id).toBe(ids.description) + }) + + it('action press dismisses through the machine', () => { + const { service, connection } = build() + connection.snapshot.parts.action.onPress?.() + expect(service.state).toBe('closed') + }) + + it('close press dismisses; setOpen drives both directions', () => { + const { service, connection } = build() + connection.snapshot.parts.close.onPress?.() + expect(service.state).toBe('closed') + + connection.snapshot.setOpen(true) + expect(service.state).toBe('open') + connection.snapshot.setOpen(false) + expect(service.state).toBe('closed') + }) + + it('data-state stays "open" while paused — pausing never disturbs styling', () => { + const { service, connection } = build() + expect(connection.snapshot.parts.root['data-state']).toBe('open') + + service.send({ type: 'timer.pause' }) + expect(connection.snapshot.parts.root['data-state']).toBe('open') + expect(connection.snapshot.open).toBe(true) + + service.send({ type: 'close' }) + expect(connection.snapshot.parts.root['data-state']).toBe('closed') + }) +}) + +describe('toast connect — reactions', () => { + it('fires onOpenChange on every open/close flip, not on subscribe', () => { + const onOpenChange = vi.fn() + const { service } = build({ defaultOpen: false, onOpenChange }) + expect(onOpenChange).not.toHaveBeenCalled() + + service.send({ type: 'open' }) + expect(onOpenChange).toHaveBeenLastCalledWith(true) + service.send({ type: 'timer.elapsed' }) + expect(onOpenChange).toHaveBeenLastCalledWith(false) + expect(onOpenChange).toHaveBeenCalledTimes(2) + }) + + it('never reports pause/resume as an open/close change', () => { + const onOpenChange = vi.fn() + const { service } = build({ onOpenChange }) + service.send({ type: 'timer.pause' }) + service.send({ type: 'timer.resume' }) + expect(onOpenChange).not.toHaveBeenCalled() + }) +}) diff --git a/packages/react/toast/README.md b/packages/react/toast/README.md new file mode 100644 index 0000000..f5dbd5d --- /dev/null +++ b/packages/react/toast/README.md @@ -0,0 +1,40 @@ +# @dunky.dev/react-toast + +React binding for [`@dunky.dev/toast`](../../core/toast): a compound +component — `Toast` plus its parts — that drives the framework-free toast +machine. The root owns the machine; parts translate the core's logical +bindings into DOM attributes and handlers, and wire the DOM-only concerns +(the dismiss timer, the provider/viewport pause broadcast). + +Behavior contract: [`../../core/toast/SPEC.md`](../../core/toast/SPEC.md). +React-specific surface: [SPEC.md](./SPEC.md). + +## Install + +```sh +npm install @dunky.dev/react-toast +``` + +## Usage + +```tsx +import { Toast } from '@dunky.dev/react-toast' + +function Notifications() { + return ( + <Toast.Provider label='Notifications'> + {/* app */} + <Toast.Viewport> + <Toast> + <Toast.Root> + <Toast.Title>Saved</Toast.Title> + <Toast.Description>Your changes are safe.</Toast.Description> + <Toast.Action>Undo</Toast.Action> + <Toast.Close>Dismiss</Toast.Close> + </Toast.Root> + </Toast> + </Toast.Viewport> + </Toast.Provider> + ) +} +``` diff --git a/packages/react/toast/SPEC.md b/packages/react/toast/SPEC.md new file mode 100644 index 0000000..592c82a --- /dev/null +++ b/packages/react/toast/SPEC.md @@ -0,0 +1,131 @@ +# SPEC / React / Toast + +The React implementation of the [core spec](../../core/toast/SPEC.md). + +## Docs + +🔗 [`dunky.dev/ui/components/toast`](https://dunky.dev/ui/components/toast). + +## Install + +```sh +npm install @dunky.dev/react-toast +``` + +## Usage + +```tsx +import { Toast } from '@dunky.dev/react-toast' +;<Toast.Provider label='Notifications'> + {/* app */} + <Toast.Viewport> + <Toast> + <Toast.Root> + <Toast.Title>Saved</Toast.Title> + <Toast.Description>Your changes are safe.</Toast.Description> + <Toast.Action>Undo</Toast.Action> + <Toast.Close>Dismiss</Toast.Close> + </Toast.Root> + </Toast> + </Toast.Viewport> +</Toast.Provider> +``` + +React-specific notes on top of the core contract: + +- **`Toast`** (the root) owns one toast's machine and renders no DOM; + **`Toast.Root`** is the rendered surface — the `<li>` announced to assistive + tech — and mounts only while the toast is open. +- **`Toast.Provider`** and **`Toast.Viewport`** are the core's substrate + coordination surfaces: the provider carries the default duration and the + region label; the viewport is the `role="region"` landmark (`<ol>`) that + pauses every toast's timer on hover/focus and resumes on leave/blur. Every + toast must live under a provider. +- The dismiss timer is a substrate effect wired to the machine state: + entering `open` schedules a `setTimeout` for the toast's duration; leaving + cancels it. Resuming restarts the full duration, per the core design. +- Everything ships headless — the consumer styles and positions the viewport; + `data-state` (`open`) on Root is the styling and animation hook. + +## API + +### `Toast.Provider` + +Shared configuration for every toast beneath it. Renders no DOM. + +| Prop | Type | Default | Description | +| ---------- | ----------- | ----------------- | ---------------------------------------------------------------- | +| `duration` | `number` | `5000` | Default auto-dismiss duration (ms) for toasts without their own. | +| `label` | `string` | `'Notifications'` | Accessible label of the viewport region. | +| `children` | `ReactNode` | — | The app and the viewport. | + +### `Toast.Viewport` + +The landmark region listing the toasts; renders an `<ol role="region" +tabindex="-1">`. Hovering (entering or moving the pointer) or focusing it +pauses every toast's timer; leaving/blurring resumes. Dismissing the toast +that holds focus parks focus here, per the core spec — programmatically +focusable, never in the tab order. + +| Prop | Type | Default | Description | +| ---------- | ---------------------- | ------- | --------------------------------- | +| `...props` | `ComponentProps<'ol'>` | — | Forwarded to the rendered `<ol>`. | + +### `Toast` + +The root of one toast: owns its machine, renders no DOM. Accepts the core +`ToastOptions`. + +| Prop | Type | Default | Description | +| -------------- | ------------------------------ | -------------- | ----------------------------------------------------------------------------- | +| `open` | `boolean` | — | Controlled open state. | +| `defaultOpen` | `boolean` | `true` | Initial open state for the uncontrolled toast. | +| `onOpenChange` | `(open: boolean) => void` | — | Fired on every open/close transition — including auto-dismiss. | +| `type` | `'foreground' \| 'background'` | `'foreground'` | Announcement politeness: `assertive` for foreground, `polite` for background. | +| `duration` | `number` | the provider's | Auto-dismiss duration (ms); `Infinity` makes the toast persistent. | +| `id` | `string` | auto (`useId`) | Base id for the parts; per-part ids are derived from it. | +| `children` | `ReactNode` | — | The toast's parts. | + +### `Toast.Root` + +The toast surface: the `role="status"` live element. Renders an `<li>`, only +while the toast is open. + +| Prop | Type | Default | Description | +| ---------- | ---------------------- | ------- | --------------------------------- | +| `...props` | `ComponentProps<'li'>` | — | Forwarded to the rendered `<li>`. | + +### `Toast.Title` + +Names the toast (wires `aria-labelledby` on Root). Renders a `<div>` — a +transient message carries no document-outline heading. + +| Prop | Type | Default | Description | +| ---------- | ----------------------- | ------- | ---------------------------------- | +| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `<div>`. | + +### `Toast.Description` + +The toast's message body (wires `aria-describedby` on Root). + +| Prop | Type | Default | Description | +| ---------- | ----------------------- | ------- | ---------------------------------- | +| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `<div>`. | + +### `Toast.Action` + +The toast's optional action (undo, retry); pressing it dismisses the toast. +The consumer's `onClick` carries what the action does and runs first — calling +`event.preventDefault()` in it keeps the toast open. + +| Prop | Type | Default | Description | +| ---------- | -------------------------- | ------- | ------------------------------------- | +| `...props` | `ComponentProps<'button'>` | — | Forwarded to the rendered `<button>`. | + +### `Toast.Close` + +Dismisses the toast from inside. + +| Prop | Type | Default | Description | +| ---------- | -------------------------- | ------- | ------------------------------------- | +| `...props` | `ComponentProps<'button'>` | — | Forwarded to the rendered `<button>`. | diff --git a/packages/react/toast/package.json b/packages/react/toast/package.json new file mode 100644 index 0000000..40b9bbb --- /dev/null +++ b/packages/react/toast/package.json @@ -0,0 +1,50 @@ +{ + "name": "@dunky.dev/react-toast", + "version": "0.0.0", + "description": "React binding for @dunky.dev/toast.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/react/toast" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/react-state-machine": "^0.1.0", + "@dunky.dev/toast": "workspace:^" + }, + "devDependencies": { + "@testing-library/react": "^16.1.0", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "peerDependencies": { + "react": "*" + } +} diff --git a/packages/react/toast/src/context.ts b/packages/react/toast/src/context.ts new file mode 100644 index 0000000..672e4e3 --- /dev/null +++ b/packages/react/toast/src/context.ts @@ -0,0 +1,43 @@ +import { createContext, useContext, type Context, type RefObject } from 'react' +import type { ToastApi, ToastMachine } from '@dunky.dev/toast' + +import type { ToastRegistry } from './registry' + +export interface ToastContextValue { + api: ToastApi + machine: ToastMachine +} + +export const ToastContext: Context<ToastContextValue | undefined> = createContext< + ToastContextValue | undefined +>(undefined) + +export const useToastContext = (): ToastContextValue => { + const context = useContext(ToastContext) + if (context === undefined) { + throw new Error('Toast parts must be rendered within a <Toast> root') + } + return context +} + +// The provider's shared surface: the default duration and region label, plus +// the registry the viewport broadcasts pause/resume through and the viewport +// element itself — where focus parks when the toast holding it dismisses. +export interface ToastProviderContextValue { + duration: number + label: string + registry: ToastRegistry + viewportRef: RefObject<HTMLOListElement | null> +} + +export const ToastProviderContext: Context<ToastProviderContextValue | undefined> = createContext< + ToastProviderContextValue | undefined +>(undefined) + +export const useToastProviderContext = (): ToastProviderContextValue => { + const context = useContext(ToastProviderContext) + if (context === undefined) { + throw new Error('Toast and Toast.Viewport must be rendered within a <Toast.Provider>') + } + return context +} diff --git a/packages/react/toast/src/effects.ts b/packages/react/toast/src/effects.ts new file mode 100644 index 0000000..b407e4e --- /dev/null +++ b/packages/react/toast/src/effects.ts @@ -0,0 +1,50 @@ +import type { ComponentEffect } from '@dunky.dev/react-state-machine' +import type { ToastMachine, ToastOptions, ToastStateName } from '@dunky.dev/toast' + +// Substrate effects: prop-driven or platform work the machine can't own. +// useMachine runs one useEffect per entry, keyed on the listed prop deps. +type ToastEffect = ComponentEffect<ToastMachine, ToastOptions> + +// Controlled open: follow the `open` prop in both directions. +const syncControlledOpen: ToastEffect = [ + (machine, props) => { + if (props.open === undefined) return + if (props.open !== !machine.matches('closed')) { + machine.send({ type: props.open ? 'open' : 'close' }) + } + }, + ['open'], +] + +// The substrate owns the clock, the machine owns the decision: entering `open` +// schedules the dismiss timeout, leaving it (pause/close) cancels — so a late +// timeout can only ever deliver an elapse the state graph already ignores. +const scheduleDismissTimer: ToastEffect = [ + machine => { + let timer: ReturnType<typeof setTimeout> | undefined + + const sync = (state: ToastStateName): void => { + if (timer !== undefined) { + clearTimeout(timer) + timer = undefined + } + if (state !== 'open') return + const { duration } = machine.context + // A non-finite duration means persistent — nothing to schedule. + if (!Number.isFinite(duration)) return + timer = setTimeout(() => machine.send({ type: 'timer.elapsed' }), duration) + } + + const state = machine.select.state() + const unsubscribe = state.subscribe(sync) + sync(state.value) + + return () => { + if (timer !== undefined) clearTimeout(timer) + unsubscribe() + } + }, + [], +] + +export const toastEffects: ToastEffect[] = [syncControlledOpen, scheduleDismissTimer] diff --git a/packages/react/toast/src/index.ts b/packages/react/toast/src/index.ts new file mode 100644 index 0000000..c48e6dc --- /dev/null +++ b/packages/react/toast/src/index.ts @@ -0,0 +1,12 @@ +export { + Toast, + type ToastProps, + type ToastProviderProps, + type ToastViewportProps, + type ToastRootProps, + type ToastTitleProps, + type ToastDescriptionProps, + type ToastActionProps, + type ToastCloseProps, +} from './toast' +export type { ToastCallbacks, ToastOptions, ToastType } from '@dunky.dev/toast' diff --git a/packages/react/toast/src/registry.ts b/packages/react/toast/src/registry.ts new file mode 100644 index 0000000..1889253 --- /dev/null +++ b/packages/react/toast/src/registry.ts @@ -0,0 +1,49 @@ +import type { ToastMachine } from '@dunky.dev/toast' + +// The per-provider registry: pure wiring between the shared viewport and the +// independent toast machines. It holds no behavior of its own — pause/resume +// are just intents broadcast to every machine, and each machine's state graph +// decides what they mean. +export interface ToastRegistry { + /** Track a machine; returns the unregister. */ + register: (machine: ToastMachine) => () => void + /** Broadcast the pause intent (viewport hovered/focused) to every toast. */ + pause: () => void + /** Broadcast the resume intent (viewport left/blurred) to every toast. */ + resume: () => void +} + +export function createToastRegistry(): ToastRegistry { + const machines = new Set<ToastMachine>() + let paused = false + + return { + register(machine) { + machines.add(machine) + // The pause broadcast reaches late joiners too: a toast that mounts or + // opens while the viewport is already hovered/focused joins paused. + // Redelivering to an already-paused machine is a no-op by state graph. + const deliverStandingPause = (): void => { + if (paused) machine.send({ type: 'timer.pause' }) + } + const unsubscribe = machine.select.state().subscribe(deliverStandingPause) + deliverStandingPause() + return () => { + machines.delete(machine) + unsubscribe() + } + }, + // Both intents are idempotent so the viewport can deliver them from every + // raw signal it gets (enter, move, focus) without re-broadcasting. + pause() { + if (paused) return + paused = true + for (const machine of machines) machine.send({ type: 'timer.pause' }) + }, + resume() { + if (!paused) return + paused = false + for (const machine of machines) machine.send({ type: 'timer.resume' }) + }, + } +} diff --git a/packages/react/toast/src/toast.tsx b/packages/react/toast/src/toast.tsx new file mode 100644 index 0000000..a405470 --- /dev/null +++ b/packages/react/toast/src/toast.tsx @@ -0,0 +1,238 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type ComponentPropsWithoutRef, + type FocusEvent, + type ForwardRefExoticComponent, + type PointerEvent, + type ReactNode, + type RefAttributes, +} from 'react' +import type { ToastOptions } from '@dunky.dev/toast' + +import { mergeProps, normalize } from '@dunky.dev/react-state-machine' +import { + ToastContext, + ToastProviderContext, + useToastContext, + useToastProviderContext, + type ToastProviderContextValue, +} from './context' +import { createToastRegistry } from './registry' +import { useToast } from './use-toast' + +// Explicit so the exports satisfy --isolatedDeclarations (a bare forwardRef +// call gives the variable no annotatable type). +type PartComponent<Props, Element> = ForwardRefExoticComponent<Props & RefAttributes<Element>> + +// ============================================================================= +// <Toast.Provider> — shared config for every toast beneath it, renders no DOM +// ============================================================================= + +export interface ToastProviderProps { + /** Default auto-dismiss duration (ms) for toasts without their own. @default 5000 */ + duration?: number + /** Accessible label of the viewport region. @default 'Notifications' */ + label?: string + children?: ReactNode +} + +export const Provider = ({ + duration = 5000, + label = 'Notifications', + children, +}: ToastProviderProps): ReactNode => { + // One registry per provider, for the provider's lifetime. + const [registry] = useState(createToastRegistry) + const viewportRef = useRef<HTMLOListElement>(null) + const value = useMemo<ToastProviderContextValue>( + () => ({ duration, label, registry, viewportRef }), + [duration, label, registry], + ) + return <ToastProviderContext.Provider value={value}>{children}</ToastProviderContext.Provider> +} + +// ============================================================================= +// <Toast.Viewport> — the landmark region listing the toasts +// ============================================================================= + +export interface ToastViewportProps extends ComponentPropsWithoutRef<'ol'> {} + +export const Viewport: PartComponent<ToastViewportProps, HTMLOListElement> = forwardRef< + HTMLOListElement, + ToastViewportProps +>((props, forwardedRef) => { + const { label, registry, viewportRef } = useToastProviderContext() + useImperativeHandle(forwardedRef, () => viewportRef.current as HTMLOListElement) + + // Substrate wiring, not behavior: hover/focus become the pause intent, and + // each toast's machine decides what pausing means. + const merged = mergeProps(props as Record<string, unknown>, { + role: 'region', + 'aria-label': label, + // Programmatically focusable: dismissing the toast that holds focus parks + // focus here (see <Toast.Root>), never in the tab order itself. + tabIndex: -1, + onPointerEnter: () => registry.pause(), + // Movement re-pauses after a resume that fired under a resting pointer + // (e.g. focus blurred out while hovering) — enter alone would not recur. + onPointerMove: () => registry.pause(), + onPointerLeave: (event: PointerEvent<HTMLOListElement>) => { + // Focus still inside keeps the timers paused after the pointer leaves. + if (!event.currentTarget.contains(document.activeElement)) registry.resume() + }, + onFocus: () => registry.pause(), + onBlur: (event: FocusEvent<HTMLOListElement>) => { + // Only a blur that moves focus out of the viewport resumes. + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) registry.resume() + }, + }) + + return <ol {...merged} ref={viewportRef} /> +}) + +// ============================================================================= +// <Toast> — root, owns one toast's machine and renders no DOM +// ============================================================================= + +export interface ToastProps extends ToastOptions { + children?: ReactNode +} + +export const Toast: ((props: ToastProps) => ReactNode) & Parts = ({ children, ...options }) => { + const value = useToast(options) + return <ToastContext.Provider value={value}>{children}</ToastContext.Provider> +} + +// ============================================================================= +// <Toast.Root> — the toast surface: the announced live element +// ============================================================================= + +export interface ToastRootProps extends ComponentPropsWithoutRef<'li'> {} + +export const Root: PartComponent<ToastRootProps, HTMLLIElement> = forwardRef< + HTMLLIElement, + ToastRootProps +>((props, forwardedRef) => { + const { api, machine } = useToastContext() + const { viewportRef } = useToastProviderContext() + const rootRef = useRef<HTMLLIElement>(null) + useImperativeHandle(forwardedRef, () => rootRef.current as HTMLLIElement) + + // Removing the element that holds focus dispatches no blur/focusout, which + // would silently drop focus on <body> and strand the viewport's standing + // pause. The subscription fires synchronously on close — while the surface + // is still mounted — so focus can be parked on the viewport (Radix parity): + // the keyboard user keeps their place and the focus tracking stays truthful. + useEffect( + () => + machine.select.state().subscribe(state => { + if (state !== 'closed') return + const root = rootRef.current + if (root !== null && root.contains(document.activeElement)) viewportRef.current?.focus() + }), + [machine, viewportRef], + ) + + // Nothing is kept mounted while closed; `data-state` covers open styling. + if (!api.open) return null + const merged = mergeProps(props as Record<string, unknown>, normalize(api.parts.root)) + return <li {...merged} ref={rootRef} /> +}) + +// ============================================================================= +// <Toast.Title> — names the toast +// ============================================================================= + +export interface ToastTitleProps extends ComponentPropsWithoutRef<'div'> {} + +export const Title: PartComponent<ToastTitleProps, HTMLDivElement> = forwardRef< + HTMLDivElement, + ToastTitleProps +>((props, forwardedRef) => { + const { api, machine } = useToastContext() + + useEffect(() => { + machine.send({ type: 'part.presence', part: 'title', present: true }) + return () => machine.send({ type: 'part.presence', part: 'title', present: false }) + }, [machine]) + + const merged = mergeProps(props as Record<string, unknown>, normalize(api.parts.title)) + return <div {...merged} ref={forwardedRef} /> +}) + +// ============================================================================= +// <Toast.Description> — the toast's message body +// ============================================================================= + +export interface ToastDescriptionProps extends ComponentPropsWithoutRef<'div'> {} + +export const Description: PartComponent<ToastDescriptionProps, HTMLDivElement> = forwardRef< + HTMLDivElement, + ToastDescriptionProps +>((props, forwardedRef) => { + const { api, machine } = useToastContext() + + useEffect(() => { + machine.send({ type: 'part.presence', part: 'description', present: true }) + return () => machine.send({ type: 'part.presence', part: 'description', present: false }) + }, [machine]) + + const merged = mergeProps(props as Record<string, unknown>, normalize(api.parts.description)) + return <div {...merged} ref={forwardedRef} /> +}) + +// ============================================================================= +// <Toast.Action> — the optional action; pressing it dismisses +// ============================================================================= + +export interface ToastActionProps extends ComponentPropsWithoutRef<'button'> {} + +export const Action: PartComponent<ToastActionProps, HTMLButtonElement> = forwardRef< + HTMLButtonElement, + ToastActionProps +>((props, forwardedRef) => { + const { api } = useToastContext() + const merged = mergeProps({ type: 'button' as const, ...props }, normalize(api.parts.action)) + return <button {...merged} ref={forwardedRef} /> +}) + +// ============================================================================= +// <Toast.Close> — the explicit dismissal affordance +// ============================================================================= + +export interface ToastCloseProps extends ComponentPropsWithoutRef<'button'> {} + +export const Close: PartComponent<ToastCloseProps, HTMLButtonElement> = forwardRef< + HTMLButtonElement, + ToastCloseProps +>((props, forwardedRef) => { + const { api } = useToastContext() + const merged = mergeProps({ type: 'button' as const, ...props }, normalize(api.parts.close)) + return <button {...merged} ref={forwardedRef} /> +}) + +// Parts +// ----------------------------------------------------------------------------- + +export interface Parts { + Provider: typeof Provider + Viewport: typeof Viewport + Root: typeof Root + Title: typeof Title + Description: typeof Description + Action: typeof Action + Close: typeof Close +} + +Toast.Provider = Provider +Toast.Viewport = Viewport +Toast.Root = Root +Toast.Title = Title +Toast.Description = Description +Toast.Action = Action +Toast.Close = Close diff --git a/packages/react/toast/src/use-toast.ts b/packages/react/toast/src/use-toast.ts new file mode 100644 index 0000000..af864b2 --- /dev/null +++ b/packages/react/toast/src/use-toast.ts @@ -0,0 +1,25 @@ +import { useEffect, useId } from 'react' +import { useMachine } from '@dunky.dev/react-state-machine' +import { toastMachine, toastConnect } from '@dunky.dev/toast' +import type { ToastOptions } from '@dunky.dev/toast' + +import { useToastProviderContext, type ToastContextValue } from './context' +import { toastEffects } from './effects' + +export function useToast(options: ToastOptions): ToastContextValue { + const provider = useToastProviderContext() + const id = useId() + const value = useMachine(toastMachine, toastConnect, toastEffects, { + id, + ...options, + duration: options.duration ?? provider.duration, + }) + + // Joining the registry is what subscribes this toast to the viewport's + // pause/resume broadcasts. + const { registry } = provider + const { machine } = value + useEffect(() => registry.register(machine), [registry, machine]) + + return value +} diff --git a/packages/react/toast/stories/toast.stories.tsx b/packages/react/toast/stories/toast.stories.tsx new file mode 100644 index 0000000..625b7c7 --- /dev/null +++ b/packages/react/toast/stories/toast.stories.tsx @@ -0,0 +1,148 @@ +import { useState, type CSSProperties } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Toast } from '@dunky.dev/react-toast' + +const meta: Meta<typeof Toast> = { + title: 'Primitives/Toast', + component: Toast, +} + +export default meta +type StoryType = StoryObj<typeof Toast> + +// The primitive ships headless — the story is the consumer, so it brings the +// styles (including the viewport's position). `data-state` is the styling hook. +const viewport: CSSProperties = { + position: 'fixed', + bottom: 24, + insetInlineEnd: 24, + display: 'flex', + flexDirection: 'column', + gap: 8, + width: 320, + margin: 0, + padding: 0, + listStyle: 'none', + zIndex: 1, +} +const root: CSSProperties = { + display: 'grid', + gridTemplateColumns: '1fr auto', + gap: '4px 12px', + alignItems: 'center', + padding: 12, + background: 'white', + border: '1px solid #ccc', + borderRadius: 8, + boxShadow: '0 4px 16px rgba(0, 0, 0, 0.16)', +} +const title: CSSProperties = { fontWeight: 600 } +const description: CSSProperties = { gridColumn: 1, margin: 0, color: '#555' } + +const ToastBody = () => ( + <> + <Toast.Title style={title}>Board renamed</Toast.Title> + <Toast.Close aria-label='Dismiss'>×</Toast.Close> + <Toast.Description style={description}> + Hover or focus the toast to pause its timer; leave to restart it. + </Toast.Description> + </> +) + +export const standard: StoryType = { + render: () => ( + <Toast.Provider> + <Toast.Viewport style={viewport}> + <Toast onOpenChange={open => console.log('open:', open)}> + <Toast.Root style={root}> + <ToastBody /> + </Toast.Root> + </Toast> + </Toast.Viewport> + </Toast.Provider> + ), +} + +// A controlled toast re-shown from a trigger: the consumer owns `open` and +// follows every dismissal — timer, Close, Action — through onOpenChange. +const ControlledToast = () => { + const [open, setOpen] = useState(false) + return ( + <Toast.Provider> + <button type='button' onClick={() => setOpen(true)}> + Delete file + </button> + <Toast.Viewport style={viewport}> + <Toast open={open} onOpenChange={setOpen}> + <Toast.Root style={root}> + <Toast.Title style={title}>File deleted</Toast.Title> + <Toast.Action onClick={() => console.log('undo!')}>Undo</Toast.Action> + <Toast.Description style={description}> + Pressing Undo runs your handler, then dismisses. + </Toast.Description> + </Toast.Root> + </Toast> + </Toast.Viewport> + </Toast.Provider> + ) +} + +export const controlled: StoryType = { + render: () => <ControlledToast />, +} + +export const background: StoryType = { + render: () => ( + <Toast.Provider duration={8000} label='Background updates'> + <Toast.Viewport style={viewport}> + <Toast type='background'> + <Toast.Root style={root}> + <Toast.Title style={title}>Export ready</Toast.Title> + <Toast.Close aria-label='Dismiss'>×</Toast.Close> + <Toast.Description style={description}> + A background toast announces politely and uses the provider's 8s duration. + </Toast.Description> + </Toast.Root> + </Toast> + </Toast.Viewport> + </Toast.Provider> + ), +} + +export const persistent: StoryType = { + render: () => ( + <Toast.Provider> + <Toast.Viewport style={viewport}> + <Toast duration={Infinity}> + <Toast.Root style={root}> + <Toast.Title style={title}>Connection lost</Toast.Title> + <Toast.Close aria-label='Dismiss'>×</Toast.Close> + <Toast.Description style={description}> + duration=Infinity never auto-dismisses — only the Close button does. + </Toast.Description> + </Toast.Root> + </Toast> + </Toast.Viewport> + </Toast.Provider> + ), +} + +export const stacked: StoryType = { + render: () => ( + <Toast.Provider> + <Toast.Viewport style={viewport}> + {['First', 'Second', 'Third'].map((label, index) => ( + <Toast key={label} duration={4000 + index * 2000}> + <Toast.Root style={root}> + <Toast.Title style={title}>{label} toast</Toast.Title> + <Toast.Close aria-label='Dismiss'>×</Toast.Close> + <Toast.Description style={description}> + Hovering the viewport pauses every toast at once. + </Toast.Description> + </Toast.Root> + </Toast> + ))} + </Toast.Viewport> + </Toast.Provider> + ), +} diff --git a/packages/react/toast/tests/toast.test.tsx b/packages/react/toast/tests/toast.test.tsx new file mode 100644 index 0000000..9809e3f --- /dev/null +++ b/packages/react/toast/tests/toast.test.tsx @@ -0,0 +1,304 @@ +// @vitest-environment jsdom +// The React edge of the Toast — behavior only; the machine's own contract is +// covered in @dunky.dev/toast's tests. +import { StrictMode } from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Toast, type ToastProps, type ToastProviderProps } from '@dunky.dev/react-toast' + +interface FixtureProps extends ToastProps { + provider?: Omit<ToastProviderProps, 'children'> + /** Renders a second toast next to the first one. */ + sibling?: boolean +} + +const DefaultToast = ({ provider, sibling, ...props }: FixtureProps) => ( + <Toast.Provider {...provider}> + <Toast.Viewport data-testid='viewport'> + <Toast {...props}> + <Toast.Root data-testid='root'> + <Toast.Title>Saved</Toast.Title> + <Toast.Description>Your changes are safe.</Toast.Description> + <Toast.Close>Dismiss</Toast.Close> + </Toast.Root> + </Toast> + {sibling && ( + <Toast> + <Toast.Root data-testid='sibling'>Second</Toast.Root> + </Toast> + )} + </Toast.Viewport> + </Toast.Provider> +) + +const advance = (ms: number): void => { + act(() => vi.advanceTimersByTime(ms)) +} + +// React synthesizes onPointerEnter/Leave from over/out pairs. +const hoverViewport = (): void => { + fireEvent.pointerOver(screen.getByTestId('viewport')) +} +const unhoverViewport = (): void => { + fireEvent.pointerOut(screen.getByTestId('viewport')) +} + +// The auto-dismiss timer is the component's clock — every test runs on a fake one. +beforeEach(() => vi.useFakeTimers()) + +// RTL auto-cleanup needs vitest globals; this repo runs with globals: false. +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +describe('Toast', () => { + describe('rendering / aria', () => { + it('shows an open toast as an assertive status live region by default', () => { + render(<DefaultToast />) + const root = screen.getByRole('status') + expect(root.tagName).toBe('LI') + expect(root.getAttribute('aria-live')).toBe('assertive') + expect(root.getAttribute('aria-atomic')).toBe('true') + expect(root.getAttribute('data-state')).toBe('open') + }) + + it('announces a background toast politely', () => { + render(<DefaultToast type='background' />) + expect(screen.getByRole('status').getAttribute('aria-live')).toBe('polite') + }) + + it('is labelled by the Title and described by the Description', () => { + render(<DefaultToast />) + const root = screen.getByRole('status', { name: 'Saved' }) + const describedBy = root.getAttribute('aria-describedby') + expect(describedBy).not.toBeNull() + expect(document.getElementById(describedBy as string)?.textContent).toBe( + 'Your changes are safe.', + ) + }) + + it('lists the toasts in a labelled region landmark', () => { + render(<DefaultToast />) + const region = screen.getByRole('region', { name: 'Notifications' }) + expect(region.tagName).toBe('OL') + expect(region.contains(screen.getByTestId('root'))).toBe(true) + }) + + it('names the region from the provider label', () => { + render(<DefaultToast provider={{ label: 'Alerts' }} />) + expect(screen.queryByRole('region', { name: 'Alerts' })).not.toBeNull() + }) + + it('renders nothing while closed', () => { + render(<DefaultToast defaultOpen={false} />) + expect(screen.queryByTestId('root')).toBeNull() + }) + }) + + describe('dismissal', () => { + it('dismisses on Close press and reports it', () => { + const onOpenChange = vi.fn() + render(<DefaultToast onOpenChange={onOpenChange} />) + act(() => screen.getByText('Dismiss').click()) + expect(screen.queryByTestId('root')).toBeNull() + expect(onOpenChange).toHaveBeenLastCalledWith(false) + }) + + it('dismisses on Action press, after the consumer handler runs', () => { + const onUndo = vi.fn() + render( + <Toast.Provider> + <Toast.Viewport> + <Toast> + <Toast.Root> + <Toast.Action onClick={onUndo}>Undo</Toast.Action> + </Toast.Root> + </Toast> + </Toast.Viewport> + </Toast.Provider>, + ) + act(() => screen.getByText('Undo').click()) + expect(onUndo).toHaveBeenCalledTimes(1) + expect(screen.queryByText('Undo')).toBeNull() + }) + + it('a consumer onClick that prevents default keeps the toast open', () => { + const onOpenChange = vi.fn() + render( + <Toast.Provider> + <Toast.Viewport> + <Toast onOpenChange={onOpenChange}> + <Toast.Root> + <Toast.Action onClick={event => event.preventDefault()}>Undo</Toast.Action> + </Toast.Root> + </Toast> + </Toast.Viewport> + </Toast.Provider>, + ) + act(() => screen.getByText('Undo').click()) + expect(screen.queryByText('Undo')).not.toBeNull() + expect(onOpenChange).not.toHaveBeenCalled() + }) + + it('parks focus on the viewport when the toast holding focus dismisses', () => { + render(<DefaultToast sibling />) + const close = screen.getByText('Dismiss') + act(() => close.focus()) + act(() => close.click()) + expect(screen.queryByTestId('root')).toBeNull() + expect(document.activeElement).toBe(screen.getByTestId('viewport')) + + // Focus genuinely sits in the viewport, so the sibling stays paused... + advance(60_000) + expect(screen.queryByTestId('sibling')).not.toBeNull() + + // ...and blurring out still resumes — the standing pause is not stranded. + fireEvent.focusOut(screen.getByTestId('viewport'), { relatedTarget: document.body }) + advance(5000) + expect(screen.queryByTestId('sibling')).toBeNull() + }) + }) + + describe('auto-dismiss', () => { + it('dismisses when the default duration elapses, under StrictMode', () => { + const onOpenChange = vi.fn() + render( + <StrictMode> + <DefaultToast onOpenChange={onOpenChange} /> + </StrictMode>, + ) + advance(4999) + expect(screen.queryByTestId('root')).not.toBeNull() + + advance(1) + expect(screen.queryByTestId('root')).toBeNull() + expect(onOpenChange).toHaveBeenLastCalledWith(false) + }) + + it('uses the provider duration when the toast has none', () => { + render(<DefaultToast provider={{ duration: 1000 }} />) + advance(1000) + expect(screen.queryByTestId('root')).toBeNull() + }) + + it('the toast duration overrides the provider default', () => { + render(<DefaultToast provider={{ duration: 1000 }} duration={3000} />) + advance(2999) + expect(screen.queryByTestId('root')).not.toBeNull() + + advance(1) + expect(screen.queryByTestId('root')).toBeNull() + }) + + it('duration=Infinity makes the toast persistent', () => { + render(<DefaultToast duration={Infinity} />) + advance(60_000) + expect(screen.queryByTestId('root')).not.toBeNull() + }) + }) + + describe('pause / resume', () => { + it('hovering the viewport pauses the timer; leaving restarts the full duration', () => { + render(<DefaultToast />) + advance(3000) + hoverViewport() + advance(60_000) + expect(screen.queryByTestId('root')).not.toBeNull() + + unhoverViewport() + advance(4999) + expect(screen.queryByTestId('root')).not.toBeNull() + + advance(1) + expect(screen.queryByTestId('root')).toBeNull() + }) + + it('focus within the viewport pauses the timer; blurring out resumes', () => { + render(<DefaultToast />) + fireEvent.focusIn(screen.getByText('Dismiss')) + advance(60_000) + expect(screen.queryByTestId('root')).not.toBeNull() + + fireEvent.focusOut(screen.getByText('Dismiss'), { relatedTarget: document.body }) + advance(5000) + expect(screen.queryByTestId('root')).toBeNull() + }) + + it('does not resume on pointer leave while focus stays inside', () => { + render(<DefaultToast />) + // Real focus: the leave check reads document.activeElement. + act(() => screen.getByText('Dismiss').focus()) + hoverViewport() + unhoverViewport() + advance(60_000) + expect(screen.queryByTestId('root')).not.toBeNull() + }) + + it('does not resume on a blur that moves focus within the viewport', () => { + render(<DefaultToast />) + fireEvent.focusIn(screen.getByText('Dismiss')) + fireEvent.focusOut(screen.getByText('Dismiss'), { + relatedTarget: screen.getByTestId('viewport'), + }) + advance(60_000) + expect(screen.queryByTestId('root')).not.toBeNull() + }) + + it('pointer movement inside the viewport pauses the timer', () => { + render(<DefaultToast />) + // No enter: a resume can land while the pointer is already resting + // inside, and the next jitter must re-pause. + fireEvent.pointerMove(screen.getByTestId('viewport')) + advance(60_000) + expect(screen.queryByTestId('root')).not.toBeNull() + }) + + it('a toast that opens while the viewport is hovered joins paused', () => { + const { rerender } = render(<DefaultToast open={false} />) + hoverViewport() + rerender(<DefaultToast open />) + advance(60_000) + expect(screen.queryByTestId('root')).not.toBeNull() + + unhoverViewport() + advance(5000) + expect(screen.queryByTestId('root')).toBeNull() + }) + + it('a toast that mounts while the viewport is hovered joins paused', () => { + const { rerender } = render(<DefaultToast />) + hoverViewport() + rerender(<DefaultToast sibling />) + advance(60_000) + expect(screen.queryByTestId('sibling')).not.toBeNull() + + unhoverViewport() + advance(5000) + expect(screen.queryByTestId('sibling')).toBeNull() + }) + }) + + describe('controlled open', () => { + it('follows the open prop in both directions', () => { + const { rerender } = render(<DefaultToast open={false} />) + expect(screen.queryByTestId('root')).toBeNull() + + rerender(<DefaultToast open />) + expect(screen.queryByTestId('root')).not.toBeNull() + + rerender(<DefaultToast open={false} />) + expect(screen.queryByTestId('root')).toBeNull() + }) + + it('reports auto-dismiss intent through onOpenChange while controlled', () => { + const onOpenChange = vi.fn() + render(<DefaultToast open onOpenChange={onOpenChange} />) + advance(5000) + expect(onOpenChange).toHaveBeenLastCalledWith(false) + // The controlled prop is a synced input, not a gate: the dismissal + // closes the toast, and the report is how the consumer stays in sync. + expect(screen.queryByTestId('root')).toBeNull() + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c9ff48..e752efd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,15 @@ importers: specifier: ^0.1.0 version: 0.1.0 + packages/core/toast: + dependencies: + '@dunky.dev/state-machine': + specifier: ^0.1.0 + version: 0.1.0 + '@dunky.dev/state-machine-bindings': + specifier: ^0.1.0 + version: 0.1.0 + packages/dom/utils/focus-trap: {} packages/dom/utils/scroll-lock: {} @@ -162,6 +171,31 @@ importers: specifier: ^19.2.6 version: 19.2.7(react@19.2.7) + packages/react/toast: + dependencies: + '@dunky.dev/react-state-machine': + specifier: ^0.1.0 + version: 0.1.0(react@19.2.7) + '@dunky.dev/toast': + specifier: workspace:^ + version: link:../../core/toast + devDependencies: + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/react': + specifier: ^19.2.15 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + react: + specifier: ^19.2.6 + version: 19.2.7 + react-dom: + specifier: ^19.2.6 + version: 19.2.7(react@19.2.7) + packages: '@adobe/css-tools@4.5.0': diff --git a/tsconfig.json b/tsconfig.json index cbffd69..65bf10e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,8 @@ "skipLibCheck": true, "strict": true, "paths": { + "@dunky.dev/toast": ["./packages/core/toast/src"], + "@dunky.dev/react-toast": ["./packages/react/toast/src"], "@dunky.dev/dialog": ["./packages/core/dialog/src"], "@dunky.dev/react-dialog": ["./packages/react/dialog/src"], "@dunky.dev/dom-focus-trap": ["./packages/dom/utils/focus-trap/src"], diff --git a/tsdown.config.ts b/tsdown.config.ts index d2af0e4..350bb3e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -13,11 +13,13 @@ export default defineConfig({ workspace: [ 'packages/core', 'packages/core/dialog', + 'packages/core/toast', 'packages/dom/utils/focus-trap', 'packages/dom/utils/scroll-lock', 'packages/react/dialog', 'packages/react/hooks/use-focus-trap', 'packages/react/hooks/use-scroll-lock', + 'packages/react/toast', ], entry: ['src/index.ts'], format: ['esm'], From ff1e453260f53943d3aa51f282c83c2ac72988aa Mon Sep 17 00:00:00 2001 From: Ivan Banov <ivanbanov@gmail.com> Date: Sun, 19 Jul 2026 01:21:29 +0200 Subject: [PATCH 2/2] docs(spec): rename the Design section to Internals Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- packages/core/toast/SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/toast/SPEC.md b/packages/core/toast/SPEC.md index 7919f9d..d48dfdc 100644 --- a/packages/core/toast/SPEC.md +++ b/packages/core/toast/SPEC.md @@ -132,7 +132,7 @@ Per WAI-ARIA `status` and the Radix mapping: readers — the Radix-style off-screen announcer that guarantees the initial announcement is a known v0 gap. -## Design +## Internals - **One machine per toast; no store.** The Radix-style declarative shape wins over an imperative toaster: each rendered toast owns exactly one machine and