# vue-toast-kit — AI Reference Toast/notification system for Vue 3 (+ Nuxt 3): priority queue with preemption, promise-based (loading→success/error) toasts, undo-with- countdown, grouping/collapsing, swipe-to-dismiss, pause on hover/focus- loss, SSR buffering, rate limiting, localStorage persistence, and a real ARIA implementation (role/aria-live per toast, focus return). Zero runtime dependencies beyond the `vue ^3.3.0` peer. Version 1.1.0+ (this document reflects source with 2 real bugs fixed via PR #10, merged into `master` — one of them critical for TypeScript consumers, one a real cross-instance/SSR state-sharing bug — if you're reading an installed `dist/` older than 1.1.0 (npm still serves 1.0.7 as of this writing — the fix is merged but not yet published), expect the specific behaviors called out as "fixed"/"as of this fix" below to instead match their "before" state, described alongside each one). This document is hand-written for AI agents and other tools that generate code against this package: every signature, default, and behavior note below is verified directly against the TypeScript source (not summarized from prose docs). For human-readable narrative docs, see the interactive site instead: - Full docs (EN): https://npm.vuecraft.ru/en/packages/vue-toast-kit/guide/overview - Full docs (RU): https://npm.vuecraft.ru/packages/vue-toast-kit/guide/overview - GitHub: https://github.com/macrulezru/vue-toast-kit - npm: https://www.npmjs.com/package/vue-toast-kit Links below starting with "/" are relative to https://npm.vuecraft.ru. --- ## 1. Package map | Import path | Contains | |---|---| | `vue-toast-kit` | Everything runtime: `toast`/`useToast`/`useToastState`, context factories, `ToastQueue`/`UndoTimer`/`GroupManager` classes, all types, 5 components, `VueToastPlugin`. | | `vue-toast-kit/nuxt` | Nuxt 3 module (`defineNuxtModule`) — auto-registers the plugin (client-only) + `useNetworkDashboard`-style auto-imports + ``. | | `vue-toast-kit/testing` | `createMockToast()`, `mockUseToast()` — Vitest/Jest test doubles. | | `vue-toast-kit/style` / `vue-toast-kit/style.css` | Required CSS — `sideEffects: ["*.css"]`, import once at app entry. | --- ## 2. Core types (`core/types.ts`) ```ts type ToastType = 'info' | 'success' | 'warning' | 'error' | 'loading' | 'custom' type ToastPriority = 'critical' | 'high' | 'normal' | 'low' type ToastPosition = 'top-left'|'top-center'|'top-right'|'bottom-left'|'bottom-center'|'bottom-right' interface ToastOptions { id?: string; type?: ToastType; priority?: ToastPriority; duration?: number position?: ToastPosition; closable?: boolean; groupKey?: string icon?: Component | string | false; action?: {label; onClick}; undo?: {label?; onUndo; duration?} onClose?: () => void; onAutoClose?: () => void pauseOnHover?: boolean; pauseOnFocusLoss?: boolean; swipeToDismiss?: boolean persist?: boolean; component?: Component; componentProps?: Record ariaLive?: 'assertive'|'polite'; theme?: 'light'|'dark'|'system'|ToastDesignTokens } interface ToastItem { id: string; message: string | VNode options: /* Required version of ToastOptions, minus component/componentProps/icon/action/undo/theme/position which stay optional */ createdAt: number remaining: Ref // 1 → 0 countdown fraction, ticks every 50ms isPaused: Ref groupCount: Ref // 1 = not grouped or sole member; >1 = leader with N-1 hidden followers isGrouped: Ref // ALWAYS false — dead field, see gotcha 1 pause(): void; resume(): void; dismiss(): void; update(opts: Partial): void } interface GlobalToastOptions { position?: ToastPosition; maxVisible?: number; duration?: number theme?: 'light'|'dark'|'system'; ignoreSSR?: boolean pauseOnHover?: boolean; pauseOnFocusLoss?: boolean; closable?: boolean rateLimit?: number; rateLimitWindowMs?: number // default 1000 persistStorage?: boolean } const PRIORITY_ORDER = { critical: 3, high: 2, normal: 1, low: 0 } const DEFAULT_OPTIONS = { type:'info', priority:'normal', duration:4000, closable:true, pauseOnHover:true, pauseOnFocusLoss:true, swipeToDismiss:true, persist:false } ``` `ToastContext` (as of this fix, section 8): `{ queue: ToastQueue; buffer: ToastBuffer; addToast(message, options?): string; dismiss(id?): void; update(id, options): void; isActive(id): boolean }`. --- ## 3. `toast` API — `composables/useToast.ts` ```ts const toast = useToast(context?: ToastContext): ToastApi // or: import { toast } from 'vue-toast-kit' (bare singleton) toast(message: string, options?: ToastOptions): string // type: 'info' toast.success/.warning/.info(message, options?): string toast.error(message, options?): string // default priority: 'high' toast.loading(message, options?): string // duration:0, closable:false — sticky until updated/dismissed toast.custom(component: Component, options?): string // duration:0, message:'', renders `component` full-replacement toast.dismiss(id?: string): void // omit id → dismiss everything toast.update(id, partial: Partial & {message?}): void // does NOT restart the auto-dismiss timer (gotcha 3) toast.updateMessage(id, message): void // message only, no option changes toast.isActive(id): boolean toast.promise(promise, {loading, success, error}, options?): Promise // shows .loading() immediately, mutates the SAME ToastItem in place on settle // (type/closable/duration change to 3000ms success / 5000ms error), restarts // the timer for the new duration, returns/rethrows the original promise unchanged toast.undo(message, options: ToastOptions & { undo: {onUndo; duration?; label?} }): string // duration = undo.duration ?? 5000, closable:false forced toast.dismissAll(position?: ToastPosition): void toast.pauseAll(): void / toast.resumeAll(): void ``` **`useToast()` resolution order**: explicit `context` arg → injected `TOAST_CONTEXT_KEY` (works inside any component under `app.use(VueToastPlugin)` or ``) → the process/page-wide fallback singleton (`getOrCreateGlobalContext()`, same one the bare `toast` export uses) if neither is available. **Works with zero setup** — calling `useToast()`/`toast.xxx()` with no plugin installed at all still functions, backed entirely by the fallback singleton. **`ToastQueue.add()` dedup-by-id**: calling `toast(...)` (or any `ctx.addToast`) with an explicit `id` that matches an already-ACTIVE toast merges the new options into the existing item instead of creating a duplicate — useful for idempotent re-triggering, but means reusing an id you didn't intend to "refresh" silently mutates the existing toast. --- ## 4. Queue mechanics (`core/ToastQueue.ts`) ```ts new ToastQueue(maxVisible = 5, options?: { rateLimit?: number; rateLimitWindowMs?: number; persistStorage?: boolean duration?: number; closable?: boolean; pauseOnHover?: boolean; pauseOnFocusLoss?: boolean }) ``` `active`/`pending: ToastItem[]` are `shallowReactive`. Public methods: `onAdd/onDismiss/onUpdate(fn)` (each returns an unsubscribe function), `add/remove/update/dismiss/dismissAll/isActive/pauseAll/resumeAll/ setMaxVisible/toggleGroupExpand/isGroupExpanded/destroy`, plus the `visibleActive`/`isHidden` getters used to filter out collapsed group followers. **Overflow / priority preemption** (`add()`): if `visibleActive.length < maxVisible`, the new toast goes straight to `active`. Otherwise, if its priority is above `'normal'` AND strictly higher than the CURRENT lowest-priority active toast, that lowest one is evicted to the FRONT of `pending` (`unshift`, so it's shown again first once room opens up) and the new one takes its place in `active`. Otherwise the new toast goes to `pending`, re-sorted by priority desc then `createdAt` asc. `remove()` promotes `pending[0]` (the highest-sorted) into `active` whenever a slot frees up. **Rate limiting**: `isRateLimited()` checks a sliding window (`rateLimitWindowMs`, default 1000ms) — once `rateLimit` toasts have been added within that window, EVERY further `add()` call (not just overflow ones) returns `''` (empty id) and is silently dropped, no error, no callback fired at all — until the window rolls forward. **`persistStorage`**: on construction, `restoreFromStorage()` replays every localStorage-saved item through `add()` again. Only toasts with `options.persist: true` are ever saved (`saveToStorage()`, called from every `add()` success path), and only text (`message as string`) is serialized — a VNode message with `persist: true` would serialize incorrectly (cast, not validated). Removed from storage on `remove()`. **`update()`/`mergeOptions()`**: plain `Object.assign(item.options, partial)` — as of no fix needed, this already correctly also updates `item.message` if `'message' in partial`. **Does NOT touch the running `UndoTimer`** — changing `duration` via `update()` has no effect on when the toast actually auto-dismisses (only `toast.promise()`'s internal transitions call `restartTimer()` directly). See gotcha 3. --- ## 5. Undo/countdown timer (`core/UndoTimer.ts`) ```ts new UndoTimer(duration: number, onExpire: () => void, onTick?: (remaining: number) => void) ``` `remaining` is a **fraction (1 → 0)**, not milliseconds — `1 - totalElapsed/duration`, ticked via `setInterval` every 50ms (fixed, not configurable). `pause()` accumulates `elapsed` and clears both the expiry `setTimeout` and the tick `setInterval`; `resume()` reschedules using `duration - elapsed` (correctly resumes at the REAL remaining time, not a fresh full duration). `destroy()` clears both timers — called on every dismissal path (manual, auto, eviction), confirmed no leak. Zero `duration` (falsy) is guarded by every caller BEFORE constructing a timer at all (`ToastQueue.startTimer`/`useToast.ts`'s `restartTimer`) — `UndoTimer` itself has no internal zero-duration guard, but is never actually constructed with `duration: 0`/`undefined` in practice. --- ## 6. Grouping (`core/GroupManager.ts`) ```ts new GroupManager( getItems: (ids: string[]) => ToastItem[], hideItem: (id: string) => void, showItem: (id: string) => void, ) add(id, groupKey) / remove(id, groupKey) / toggleExpand(groupKey) / isExpanded(groupKey) getGroupIds(groupKey) / hasGroup(groupKey) / clear() ``` First toast with a given `groupKey` becomes the **leader** (stays visible, `groupCount` tracks total members). Every subsequent member is hidden (via `ToastQueue`'s `hiddenItems` Set, injected as `hideItem`/`showItem`) unless the group is already expanded. Removing the leader promotes the next id in the group and re-hides the rest (unless expanded). `toggleExpand()` shows/hides all-but-the-first members in bulk. --- ## 7. SSR buffering (`core/ToastBuffer.ts`) ```ts class ToastBuffer { push(message, options): void // no-op once flushed onFlush(cb: (items) => void): void flush(): void // one-time: sets flushed=true, drains buffer, clears callbacks isFlushed(): boolean get size(): number } const isServer = typeof window === 'undefined' // computed once at module load const globalBuffer = new ToastBuffer() // process-wide singleton — see section 8 ``` `addToast()` (in `useToastContext.ts`): on the server, if `ignoreSSR` is set, generates a placeholder id and discards the message entirely (never buffered); otherwise pushes `{message, options}` into the context's `buffer` (as of this fix — see section 8). `ToastContainer` flushes its OWN context's buffer 100ms after mount (fixed, non- configurable delay — just padding before the flush attempt, not a hard cutoff; the flush still runs correctly even if hydration took longer). --- ## 8. Context lifecycle — READ BEFORE USING IN SSR OR MULTI-APP SETUPS ```ts createToastContext(opts?: GlobalToastOptions): ToastContext // ALWAYS fresh — own ToastQueue + own ToastBuffer getOrCreateGlobalContext(opts?: GlobalToastOptions): ToastContext // process/page-wide singleton, created once, opts only honored on the FIRST call ever useToastContext(): ToastContext // inject(TOAST_CONTEXT_KEY) → falls back to getOrCreateGlobalContext() installContext(app, opts?): ToastContext // called by VueToastPlugin.install() ``` **As of this fix**, `installContext()` calls `createToastContext(opts)` — every `app.use(VueToastPlugin, {...})` gets its own fully isolated `ToastQueue` and `ToastBuffer`, with that call's own `opts` genuinely applied. **Before this fix**, `installContext()` called `getOrCreateGlobalContext(opts)` — a module-level singleton cached on the FIRST ever call. Confirmed via a direct test: two separate `createApp()` instances, each doing `app.use(VueToastPlugin, { maxVisible: N })` with DIFFERENT `N`, ended up sharing the exact same `ToastContext`/`ToastQueue` object — the second call's options were silently ignored entirely, and a toast added through one app's context appeared in the other's queue. This was a real bug for: - **SSR**: one Node process handling concurrent requests, each doing its own `createApp()` + `app.use(VueToastPlugin, opts)` per request (the standard pattern) — every request after the first silently reused the first request's queue/config, and toast state could leak between unrelated requests. - **Multi-instance apps in general**: micro-frontends, a test suite mounting multiple `createApp()` instances, Storybook, etc. **Also as of this fix**: the SSR buffer is no longer a bare shared import either — each `createToastContext()` call gets its own `new ToastBuffer()`, stored on `ctx.buffer`, and `ToastContainer.vue` reads `ctx.buffer` (not a global import) when flushing on mount. **Before this fix**, ALL contexts (isolated or not) funneled server-buffered toasts through the one shared `globalBuffer` — and since that buffer is only ever flushed client-side (`onMounted`, which Vue never invokes during SSR), calling `toast.xxx()` during server rendering just accumulated in `globalBuffer.buffer` forever, across every request handled by the process — an unbounded memory leak for any SSR app using the (advertised, intentional) SSR-toast-buffering feature. **`getOrCreateGlobalContext()` itself is untouched and still a genuine, intentional singleton** — it backs the bare `toast` export (evaluated once at module load: `export const toast: ToastApi = buildToastApi(getOrCreateGlobalContext())`) and `useToast()`'s fallback when called with no plugin installed and no component tree to inject from. This remains explicitly NOT request-isolated by design — it's meant for genuinely page/process-wide convenience use ("Pinia stores, axios interceptors" per the source comment). **If you need per-request isolation in SSR, always ensure `app.use(VueToastPlugin, opts)` runs per request** (the now-correctly-isolated path) rather than relying on the bare `toast` singleton or `useToast()` outside any component tree for anything user/request-specific. --- ## 9. Components ```ts // ToastContainer.vue — the root renderer, one per app (or per `context` if multiple) props: { position?: ToastPosition // falls back to GLOBAL_OPTIONS_KEY.position, then 'bottom-right' maxVisible?: number // falls back to GLOBAL_OPTIONS_KEY.maxVisible, then 5 — watched, syncs queue.setMaxVisible() gap?: number = 8; offsetX?: number = 16; offsetY?: number = 16; zIndex?: number = 9999 expand?: boolean = false // DEAD — never read anywhere (gotcha 1) teleportTo?: string = 'body' context?: ToastContext // explicit context, bypasses inject() — used heavily in this package's own tests theme?: 'light'|'dark'|'system'|ToastDesignTokens // falls back to GLOBAL_OPTIONS_KEY.theme stackMode?: boolean = false // preserves insertion order (priority sort explicitly disabled), depth-based translateY/scale/opacity, hover reveals full stack } slots: toast, toast-icon, toast-content, toast-action, toast-close, toast-undo // each receives { toast: ToastItem, dismiss? } — overriding `toast` replaces the whole per-item render, the others patch one piece of the default Toast.vue layout ``` All 6 positions are ALWAYS rendered in the DOM (even empty) so `TransitionGroup` enter/leave animations work correctly even for a lone toast in an otherwise-empty region — `role="region"`/ `aria-label="Notifications"` are only set on a position's wrapper when it actually has toasts. ```ts // Toast.vue — single toast, used internally by ToastContainer unless the `toast` slot is overridden props: { toast: ToastItem; onDismiss: (id) => void; onGroupToggle?: (groupKey) => void } ``` Full ARIA: `role="alert"` for error/warning/critical-priority, else `"status"`; `aria-live` = explicit `options.ariaLive` override, else `"assertive"` for critical priority else `"polite"`; `aria-atomic="true"`; `tabindex="0"` when interactive (closable/action/undo present); `Escape` key dismisses; focus returns to whatever was focused before the toast appeared, on dismiss. Swipe-to-dismiss is touch-only (`@touchstart/move/end`, threshold = 40% of the element's own rendered width, opacity fades proportionally). Pause on hover / `visibilitychange` (tab focus loss) both delegate to `toast.pause()/resume()` → the same `UndoTimer` pause/resume (section 5). `ToastIcon.vue`: `icon` prop resolution order — a Component renders directly, a string renders as literal text/emoji, `type === 'loading'` renders a CSS spinner, otherwise one of 4 built-in inline SVGs (success/error/warning/info) — `type === 'custom'` with no icon override renders NOTHING (no default SVG for `'custom'`). `ToastProgressBar.vue`: pure `scaleX(remaining)` bar, no internal timing logic of its own. `ToastActions.vue`: renders the `action` button and/or `undo` button; `handleUndo()` calls `onUndo()` THEN dismisses — if `onUndo` throws or rejects, the toast still dismisses regardless (no error surfacing back to the caller). --- ## 10. Vue plugin (`plugin.ts`) ```ts app.use(VueToastPlugin, options?: VueToastPluginOptions) // VueToastPluginOptions extends GlobalToastOptions with: registerComponent?: boolean (default true) ``` `install()` calls `installContext(app, options)` (section 8) then, if `registerComponent !== false`, globally registers `` via `app.component()` — so a plain `app.use(VueToastPlugin)` lets you use `` anywhere in that app's templates without an explicit import. --- ## 11. Nuxt 3 module (`vue-toast-kit/nuxt`) ```ts // nuxt.config.ts export default defineNuxtConfig({ modules: ['vue-toast-kit/nuxt'], vueToastKit: { position, theme, maxVisible, registerComponent } // configKey: 'vueToastKit' }) ``` Module defaults: `enabled: true` is NOT itself read by the module (no `GlobalToastOptions.enabled` field exists — this key would just be silently spread into runtimeConfig and ignored by the client plugin). Registers the CSS (`nuxt.options.css.push('vue-toast-kit/style.css')`), a client-only plugin (`addPluginTemplate({mode:'client', ...})` that reads `$config.public.vueToastKit` and calls `app.use(VueToastPlugin, options)`), auto-imports (`useToast`, `useToastState`, `createToastContext`, `toast`), and auto-registers `` (`mode: 'client'`) — all client-only since network/ DOM-adjacent toast rendering has no meaningful SSR role beyond the buffering mechanism (section 7). Every reference to the package name throughout the module (CSS path, plugin import, addImports/addComponent `from`/`filePath`) is consistently the correct unscoped `vue-toast-kit` string — no scope-name mismatch bug here. --- ## 12. Fixed-bug history (verify against your installed version) Both fixed via PR #10 (merged), across two commits, each verified by toggling `git stash` between pre-fix and post-fix source and confirming the relevant behavior flips; full suite 107/107 passing after both: 1. **Published TypeScript types were completely broken** for the main entry AND the `/nuxt` subpath. `vite-plugin-dts`'s `rollupTypes: true` used a bundled API Extractor engine (TS 5.4.2) older than the project's installed TypeScript (5.9.3); the build script's second, full-tree `tsc --emitDeclarationOnly` pass then overwrote the correctly-bundled `dist/index.d.ts` with a plain-tsc mirror that can't resolve `.vue` imports — `dist/components/` never existed. Verified end-to-end: with this project's own `skipLibCheck: true` (the default in virtually every real Vue+TS project), assigning a completely nonexistent prop to `ToastContainer` produced **zero type errors** pre-fix — all 5 components silently degraded to untyped `any` props. Separately, `vue-toast-kit/nuxt`'s published types were entirely blank (`ModuleOptions` import failed outright, `TS2305`) — `vite-plugin-dts` produces no declaration output at all for `src/nuxt/module.ts`/`src/nuxt/plugin.ts` regardless of config. Fixed by dropping `rollupTypes`, and generating the two Nuxt files' declarations via a separate, narrowly-scoped `tsc` pass (`tsconfig.nuxt.json`) that plain `tsc` handles fine on its own. 2. **`installContext()`/the SSR buffer silently shared one singleton across every app instance** — section 8, in full detail above. --- ## 13. Consolidated gotcha list 1. **`ToastItem.isGrouped: Ref` is always `false`** — dead public field; use `groupCount.value > 1` instead, which is what `Toast.vue` itself actually reads internally (section 2, 9). 2. **`ToastContainer`'s `expand` prop is dead** — never read anywhere; `isHovered` alone drives stack expansion (section 9). 3. **`toast.update()`/`queue.update()` never restarts the auto-dismiss timer** — changing `duration` via `update()` doesn't reschedule the already-running countdown; only `toast.promise()`'s internal transitions restart it (section 4). 4. **`getOrCreateGlobalContext()` (the bare `toast` export, and `useToast()`'s no-plugin fallback) remains an intentional, non- request-isolated singleton** — by design, for page/process-wide convenience use outside components. Don't rely on it for anything request-specific in SSR; use `app.use(VueToastPlugin, opts)` (now correctly isolated, section 8) instead (section 8). 5. **Rate-limited toasts are dropped completely silently** — `add()` returns `''` and fires no callback at all once `rateLimit` is exceeded within `rateLimitWindowMs` — no way to detect a drop happened from the public API (section 4). 6. **`persistStorage` only serializes string messages correctly** — a `persist: true` toast with a VNode message will be cast, not validated, into the JSON blob written to localStorage (section 4). 7. **`ToastActions`'s undo button always dismisses**, even if `onUndo()` throws or its returned promise rejects — no error surfaces back to the caller (section 9). 8. **`ToastIcon`'s `'custom'` type renders no default icon** if `icon` isn't explicitly provided — unlike every other `ToastType`, which has a built-in SVG fallback (section 9). 9. **`UndoTimer`'s `remaining` is a 0–1 fraction, not milliseconds** — don't confuse it with `ToastOptions.duration` (milliseconds) when reading `toast.remaining.value` directly, e.g. in a custom `toast-undo` slot (section 5). 10. **`UndoTimer`'s tick interval is a fixed, non-configurable 50ms constant** (`TICK_INTERVAL`) — not exposed via any option, so `remaining`/progress-bar updates always happen at that cadence regardless of the toast's own `duration` (section 5).