feat(primitives): media-editor components, category reorg, perf + type cleanup

Reorganize components into category folders (forms/canvas/overlays/etc.); add the
media-editor headless family (timeline, curve-editor, waveform, crop, color
picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag
state, gesture-leak teardown, shallowRef color state, rect caching) and replace
source `any` with proper types.
This commit is contained in:
2026-06-15 16:54:29 +07:00
parent 661a55719e
commit eefd7abf83
1029 changed files with 65815 additions and 9449 deletions
@@ -0,0 +1,47 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* An actionable control inside a toast (e.g. "Undo" or "View"). Renders a button by
* default and requires `altText` so the action remains understandable to assistive
* technology even when the toast is announced out of context — `altText` is read by
* the announce region in place of the button's visible label.
*/
export interface ToastActionProps extends PrimitiveProps {
/**
* Accessible description for screen readers (required).
* Describes what happens when the user triggers the action.
*/
altText: string;
}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import ToastAnnounceExclude from './ToastAnnounceExclude.vue';
const { as = 'button', altText } = defineProps<ToastActionProps>();
if (!altText)
throw new Error('Missing required prop `altText` on `ToastAction`.');
const { forwardRef } = useForwardExpose();
</script>
<template>
<ToastAnnounceExclude
as="template"
:alt-text="altText"
>
<Primitive
:ref="forwardRef"
:as="as"
:aria-label="altText"
data-primitives-toast-action
>
<slot />
</Primitive>
</ToastAnnounceExclude>
</template>
@@ -0,0 +1,67 @@
<script lang="ts">
/**
* A visually-hidden live region that mirrors a toast's text for screen readers.
*
* The visible toast is announced via `aria-live` directly, but some screen
* readers (notably NVDA) only reliably announce live-region content that is
* injected *after* the region is already in the accessibility tree. This part
* therefore mounts empty and injects its text on the next frame (double
* `requestAnimationFrame`), with a 1s timeout fallback so the announcement
* still happens if frames are throttled (e.g. a background tab).
*/
export interface ToastAnnounceProps {
/** `aria-live` politeness for the announce region. */
ariaLive?: 'assertive' | 'polite';
}
</script>
<script setup lang="ts">
import { onScopeDispose, ref } from 'vue';
import { isClient } from '@robonen/platform/multi';
import { VisuallyHidden } from '../../utilities/visually-hidden';
import { useToastProviderContext } from './context';
defineProps<ToastAnnounceProps>();
const providerCtx = useToastProviderContext();
const renderAnnounceText = ref(false);
let raf1 = 0;
let raf2 = 0;
let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
if (isClient) {
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
renderAnnounceText.value = true;
});
});
// Fallback in case rAF is throttled (background tab) — still announce.
fallbackTimer = setTimeout(() => {
renderAnnounceText.value = true;
}, 1000);
onScopeDispose(() => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
if (fallbackTimer) clearTimeout(fallbackTimer);
});
}
</script>
<template>
<VisuallyHidden
v-if="renderAnnounceText"
feature="hidden"
role="status"
:aria-live="ariaLive"
:aria-atomic="true"
data-primitives-toast-announce
>
{{ providerCtx.label.value }}
<slot />
</VisuallyHidden>
</template>
@@ -0,0 +1,30 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Excludes its subtree from the screen-reader announcement harvested by
* `ToastAnnounce`. Use it around content whose visible text is meaningless out
* of context (e.g. an icon-only button) — optionally provide `altText` to be
* announced in its place. `ToastAction` and `ToastClose` use this internally.
*/
export interface ToastAnnounceExcludeProps extends PrimitiveProps {
/** Text announced in place of the excluded subtree. */
altText?: string;
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
const { as = 'div', altText } = defineProps<ToastAnnounceExcludeProps>();
</script>
<template>
<Primitive
:as="as"
data-primitives-toast-announce-exclude=""
:data-primitives-toast-announce-alt="altText || undefined"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,41 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A button that dismisses the toast it lives in. Renders a button by default and
* closes the parent `ToastRoot` via toast context on click. Its visible content
* is excluded from the screen-reader announcement (an icon-only "×" carries no
* meaning out of context).
*/
export interface ToastCloseProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useToastContext } from './context';
import ToastAnnounceExclude from './ToastAnnounceExclude.vue';
const { as = 'button' } = defineProps<ToastCloseProps>();
const { forwardRef } = useForwardExpose();
const toastCtx = useToastContext();
// Avoid an implicit form submit when rendered as a native button.
const buttonType = computed(() => (as === 'button' ? 'button' : undefined));
</script>
<template>
<ToastAnnounceExclude as="template">
<Primitive
:ref="forwardRef"
:as="as"
:type="buttonType"
data-primitives-toast-close
@click="toastCtx.onClose()"
>
<slot />
</Primitive>
</ToastAnnounceExclude>
</template>
@@ -0,0 +1,28 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The toast's supporting text. Renders the longer description beneath the
* `ToastTitle`, placed inside a `ToastRoot`.
*/
export interface ToastDescriptionProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
const { as = 'div' } = defineProps<ToastDescriptionProps>();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
data-primitives-toast-description
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,39 @@
<script lang="ts">
/**
* A visually-hidden focusable sentinel placed at the head and tail of the toast
* viewport. Because the viewport is portaled, its tab order does not match the
* document; these proxies let `Tab`/`Shift+Tab` re-enter the toast list from the
* surrounding document. When focus arrives from outside the viewport, the proxy
* emits `focusFromOutsideViewport` so the viewport can redirect focus to a toast.
*/
export interface ToastFocusProxyEmits {
focusFromOutsideViewport: [event: FocusEvent];
}
</script>
<script setup lang="ts">
import { VisuallyHidden } from '../../utilities/visually-hidden';
import { useToastProviderContext } from './context';
const emit = defineEmits<ToastFocusProxyEmits>();
const providerCtx = useToastProviderContext();
function handleFocus(event: FocusEvent) {
const prevFocused = event.relatedTarget as HTMLElement | null;
const isFromOutside = !providerCtx.viewportRef.value?.contains(prevFocused);
if (isFromOutside) emit('focusFromOutsideViewport', event);
}
</script>
<template>
<VisuallyHidden
aria-hidden="true"
tabindex="0"
data-primitives-toast-focus-proxy
style="position: fixed"
@focus="handleFocus"
>
<slot />
</VisuallyHidden>
</template>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { TeleportPrimitiveProps } from '../../utilities/teleport';
/**
* Renders the toast viewport into a different part of the DOM (a portal), so it
* escapes parent overflow/stacking contexts. Wrap `ToastViewport` in this part
* to mount it at the document body (default) or a custom container via `to`.
*/
export interface ToastPortalProps extends TeleportPrimitiveProps {}
</script>
<script setup lang="ts">
import { Teleport as TeleportPrimitive } from '../../utilities/teleport';
const props = defineProps<ToastPortalProps>();
</script>
<template>
<TeleportPrimitive v-bind="props">
<slot />
</TeleportPrimitive>
</template>
@@ -0,0 +1,79 @@
<script lang="ts">
import type { SwipeDirection } from './context';
/**
* Toast — a succinct, non-disruptive notification that appears in a corner of the
* screen and auto-dismisses after a timeout. Use it to confirm actions or surface
* background events without interrupting the user's flow.
*
* `ToastProvider` is the top-level wrapper that holds shared settings (label, default
* duration, swipe behaviour) and coordinates timer pausing across all toasts. Wrap
* your app (or the region that renders toasts) in a single provider, render one
* `ToastViewport` for placement, and mount a `ToastRoot` per notification.
*/
export interface ToastProviderProps {
/** Accessible label for the toast region. @default 'Notifications' */
label?: string;
/** Auto-dismiss duration in ms. Use `Infinity` to disable auto-dismiss. @default 5000 */
duration?: number;
/** Swipe direction that dismisses a toast. @default 'right' */
swipeDirection?: SwipeDirection;
/** Minimum swipe distance (px) before a dismiss gesture is recognised. @default 50 */
swipeThreshold?: number;
/** Disable swipe-to-dismiss gestures for every toast. @default false */
disableSwipe?: boolean;
}
</script>
<script setup lang="ts">
import { ref, shallowRef, toRef } from 'vue';
import { useCollectionProvider } from '../../utilities/collection';
import { provideToastProviderContext } from './context';
import { TOAST_COLLECTION_KEY } from './shared';
const {
label = 'Notifications',
duration = 5000,
swipeDirection = 'right',
swipeThreshold = 50,
disableSwipe = false,
} = defineProps<ToastProviderProps>();
if (typeof label === 'string' && label.trim() === '')
throw new Error('Invalid prop `label` supplied to `ToastProvider`. Expected a non-empty string.');
const labelRef = toRef(() => label);
const durationRef = toRef(() => duration);
const swipeDirectionRef = toRef(() => swipeDirection);
const swipeThresholdRef = toRef(() => swipeThreshold);
const disableSwipeRef = toRef(() => disableSwipe);
const toastCount = ref(0);
const viewportRef = shallowRef<HTMLElement | undefined>(undefined);
const isFocusedToastEscapeKeyDownRef = ref(false);
const isClosePausedRef = ref(false);
// Dedicated collection key so a nested collection provider (or another toast
// provider) does not shadow this one for descendant toasts/viewports.
useCollectionProvider(TOAST_COLLECTION_KEY);
provideToastProviderContext({
label: labelRef,
duration: durationRef,
swipeDirection: swipeDirectionRef,
swipeThreshold: swipeThresholdRef,
disableSwipe: disableSwipeRef,
toastCount,
viewportRef,
onViewportChange: (el) => { viewportRef.value = el; },
onToastAdd: () => { toastCount.value++; },
onToastRemove: () => { toastCount.value--; },
isFocusedToastEscapeKeyDownRef,
isClosePausedRef,
});
</script>
<template>
<slot />
</template>
@@ -0,0 +1,452 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { SwipeEvent } from './utils';
/**
* A single toast notification. Manages its own open state and auto-dismiss timer,
* and provides context to its `Title`, `Description`, `Action`, and `Close` children.
* Control visibility with `v-model:open`; rendering is gated by `Presence` so exit
* transitions can play before the element unmounts.
*
* The default slot receives `{ open, remaining, duration }` so you can render a
* progress bar or countdown synced to the dismiss timer.
*/
export interface ToastRootProps extends PrimitiveProps {
/** Override the provider's auto-dismiss duration. Use `Infinity` to disable. */
duration?: number;
/** Toast type — controls the `aria-live` politeness. @default 'background' */
type?: 'foreground' | 'background';
/** Initial open state for the uncontrolled mode (when `v-model:open` is not bound). @default true */
defaultOpen?: boolean;
/** Force the toast to stay mounted regardless of `open` (useful with external animation libraries). */
forceMount?: boolean;
/**
* Teleport this toast into the `ToastViewport` and register it for keyboard
* focus ordering. Lets toasts be authored anywhere in the tree. When the
* viewport is not mounted yet, the toast renders in place as a fallback.
* @default false
*/
toViewport?: boolean;
}
// Stable, stateless once-listener reused across swipe gestures so pointerup does
// not allocate a fresh closure per gesture end.
const preventClickOnce = (event: Event) => event.preventDefault();
export interface ToastRootEmits {
escapeKeyDown: [event: KeyboardEvent];
pause: [];
resume: [];
swipeStart: [event: SwipeEvent];
swipeMove: [event: SwipeEvent];
swipeCancel: [event: SwipeEvent];
swipeEnd: [event: SwipeEvent];
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue';
import { focus, getActiveElement } from '@robonen/platform/browsers';
import { useForwardExpose, useRafFn } from '@robonen/vue';
import { useCollectionInjector } from '../../utilities/collection';
import { useId } from '../../utilities/config-provider';
import { Presence } from '../../utilities/presence';
import { Primitive } from '../../internal/primitive';
import { Teleport as TeleportPrimitive } from '../../utilities/teleport';
import ToastAnnounce from './ToastAnnounce.vue';
import { provideToastContext, useToastProviderContext } from './context';
import { TOAST_COLLECTION_KEY } from './shared';
import {
TOAST_SWIPE_CANCEL,
TOAST_SWIPE_END,
TOAST_SWIPE_MOVE,
TOAST_SWIPE_START,
VIEWPORT_PAUSE,
VIEWPORT_RESUME,
getAnnounceTextContent,
handleAndDispatchCustomEvent,
isDeltaInDirection,
} from './utils';
defineOptions({
inheritAttrs: false,
});
const {
as = 'li',
duration,
type = 'background',
defaultOpen = true,
forceMount,
toViewport = false,
} = defineProps<ToastRootProps>();
const emit = defineEmits<ToastRootEmits>();
if (type !== 'foreground' && type !== 'background')
throw new Error('Invalid prop `type` supplied to `ToastRoot`. Expected `foreground | background`.');
const { forwardRef, currentElement } = useForwardExpose();
const providerCtx = useToastProviderContext();
const { CollectionItem } = useCollectionInjector(TOAST_COLLECTION_KEY);
const toastId = useId(undefined, 'toast');
const durationRef = toRef(() => duration);
const localOpen = ref(defaultOpen);
const open = defineModel<boolean>('open', {
default: undefined,
get: external => external ?? localOpen.value,
set: (value) => {
localOpen.value = value;
return value;
},
});
const resolvedDuration = computed(() => durationRef.value ?? providerCtx.duration.value);
// Swipe gesture state. Plain mutable locals — read/written only inside the JS
// pointer handlers, never bound in the template and no computed depends on them,
// so they need no reactivity. Keeping them non-reactive avoids per-pointer-move
// proxy wrapping of the fresh {x,y} object and dep track/trigger with zero subscribers.
let pointerStart: { x: number; y: number } | null = null;
let swipeDelta: { x: number; y: number } | null = null;
// Elapsed-time-preserving timer state. On pause we bank the remaining time and
// on resume we continue from it, rather than restarting the full duration.
let closeTimer: ReturnType<typeof setTimeout> | null = null;
let closeTimerStartTime = 0;
let closeTimerRemaining = resolvedDuration.value;
const remaining = ref(resolvedDuration.value);
const remainingRaf = useRafFn(() => {
const elapsed = Date.now() - closeTimerStartTime;
remaining.value = Math.max(closeTimerRemaining - elapsed, 0);
}, { immediate: false, fpsLimit: 60 });
function clearTimer() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
}
function startTimer(ms: number) {
clearTimer();
if (ms === Infinity || ms <= 0 || !Number.isFinite(ms)) return;
closeTimerStartTime = Date.now();
closeTimer = setTimeout(() => {
open.value = false;
}, ms);
}
function handleClose() {
// Move focus back to the viewport when the closing toast holds focus, so SR
// users keep context and focus is not lost to the document body.
const active = getActiveElement();
const isFocusInToast = !!currentElement.value && !!active && currentElement.value.contains(active);
if (isFocusInToast) focus(providerCtx.viewportRef.value);
providerCtx.isClosePausedRef.value = false;
open.value = false;
}
function handleEscapeKeyDown(event: KeyboardEvent) {
emit('escapeKeyDown', event);
// Pressing Escape while a toast is focused dismisses it (matches the
// ToastClose / auto-dismiss path).
if (!event.defaultPrevented) {
providerCtx.isFocusedToastEscapeKeyDownRef.value = true;
handleClose();
}
}
function pauseTimer() {
// Bank the time already elapsed so resume continues from the remainder.
const elapsed = Date.now() - closeTimerStartTime;
closeTimerRemaining = Math.max(closeTimerRemaining - elapsed, 0);
clearTimer();
remainingRaf.pause();
providerCtx.isClosePausedRef.value = true;
emit('pause');
}
function resumeTimer() {
providerCtx.isClosePausedRef.value = false;
startTimer(closeTimerRemaining);
remainingRaf.resume();
emit('resume');
}
// Restart timer when reactive duration changes (and we are not paused).
watch(
resolvedDuration,
(ms) => {
closeTimerRemaining = ms;
remaining.value = ms;
if (!open.value) return;
if (providerCtx.isClosePausedRef.value) return;
startTimer(ms);
},
);
watch(open, (value) => {
if (value) {
closeTimerRemaining = resolvedDuration.value;
remaining.value = resolvedDuration.value;
if (!providerCtx.isClosePausedRef.value) {
startTimer(closeTimerRemaining);
remainingRaf.resume();
}
}
else {
clearTimer();
remainingRaf.pause();
}
});
const dataState = computed(() => (open.value ? 'open' : 'closed'));
const ariaLive = computed(() => (type === 'foreground' ? 'assertive' : 'polite'));
// Harvested text chunks for the dedicated screen-reader announce region. Reading
// `currentElement` keeps this in sync once the visible toast has mounted; the
// announce region defers rendering with a double rAF so the harvested text is
// present by the time it lands in the accessibility tree.
const announceText = computed(() =>
currentElement.value ? getAnnounceTextContent(currentElement.value) : [],
);
// Swipe gesture handlers — only active when swipe is enabled on the provider.
function onPointerDown(event: PointerEvent) {
if (providerCtx.disableSwipe.value || event.button !== 0) return;
pointerStart = { x: event.clientX, y: event.clientY };
}
function onPointerMove(event: PointerEvent) {
if (providerCtx.disableSwipe.value || !pointerStart) return;
const x = event.clientX - pointerStart.x;
const y = event.clientY - pointerStart.y;
const hasSwipeMoveStarted = Boolean(swipeDelta);
const direction = providerCtx.swipeDirection.value;
const isHorizontal = direction === 'left' || direction === 'right';
const clamp = direction === 'left' || direction === 'up' ? Math.min : Math.max;
const clampedX = isHorizontal ? clamp(0, x) : 0;
const clampedY = !isHorizontal ? clamp(0, y) : 0;
const moveStartBuffer = event.pointerType === 'touch' ? 10 : 2;
const delta = { x: clampedX, y: clampedY };
const detail = { originalEvent: event, delta };
if (hasSwipeMoveStarted) {
swipeDelta = delta;
handleAndDispatchCustomEvent(TOAST_SWIPE_MOVE, onSwipeMove, detail);
}
else if (isDeltaInDirection(delta, direction, moveStartBuffer)) {
swipeDelta = delta;
handleAndDispatchCustomEvent(TOAST_SWIPE_START, onSwipeStart, detail);
(event.target as HTMLElement).setPointerCapture(event.pointerId);
}
else if (Math.abs(x) > moveStartBuffer || Math.abs(y) > moveStartBuffer) {
// Swiping in the wrong direction — abandon the gesture for this interaction.
pointerStart = null;
}
}
function onPointerUp(event: PointerEvent) {
if (providerCtx.disableSwipe.value) return;
const delta = swipeDelta;
const target = event.target as HTMLElement;
if (target.hasPointerCapture(event.pointerId))
target.releasePointerCapture(event.pointerId);
swipeDelta = null;
pointerStart = null;
if (!delta) return;
const toast = event.currentTarget as HTMLElement | null;
const detail = { originalEvent: event, delta };
if (isDeltaInDirection(delta, providerCtx.swipeDirection.value, providerCtx.swipeThreshold.value))
handleAndDispatchCustomEvent(TOAST_SWIPE_END, onSwipeEnd, detail);
else
handleAndDispatchCustomEvent(TOAST_SWIPE_CANCEL, onSwipeCancel, detail);
// Prevent a click firing on toast contents when pointerup ends a swipe.
toast?.addEventListener('click', preventClickOnce, { once: true });
}
function setSwipeVar(el: HTMLElement, name: string, value: string | null) {
if (value === null) el.style.removeProperty(name);
else el.style.setProperty(name, value);
}
// These run as the once-listener inside `handleAndDispatchCustomEvent`. They
// forward the Vue emit (so consumers can `preventDefault()` the SwipeEvent) and,
// unless prevented, apply the `data-swipe` state + CSS custom properties used to
// drive swipe animations.
function onSwipeStart(event: SwipeEvent) {
emit('swipeStart', event);
if (event.defaultPrevented) return;
event.currentTarget.setAttribute('data-swipe', 'start');
}
function onSwipeMove(event: SwipeEvent) {
emit('swipeMove', event);
if (event.defaultPrevented) return;
const { x, y } = event.detail.delta;
const el = event.currentTarget;
el.setAttribute('data-swipe', 'move');
setSwipeVar(el, '--primitives-toast-swipe-move-x', `${x}px`);
setSwipeVar(el, '--primitives-toast-swipe-move-y', `${y}px`);
}
function onSwipeCancel(event: SwipeEvent) {
emit('swipeCancel', event);
if (event.defaultPrevented) return;
const el = event.currentTarget;
el.setAttribute('data-swipe', 'cancel');
setSwipeVar(el, '--primitives-toast-swipe-move-x', null);
setSwipeVar(el, '--primitives-toast-swipe-move-y', null);
setSwipeVar(el, '--primitives-toast-swipe-end-x', null);
setSwipeVar(el, '--primitives-toast-swipe-end-y', null);
}
function onSwipeEnd(event: SwipeEvent) {
emit('swipeEnd', event);
if (event.defaultPrevented) return;
const { x, y } = event.detail.delta;
const el = event.currentTarget;
el.setAttribute('data-swipe', 'end');
setSwipeVar(el, '--primitives-toast-swipe-move-x', null);
setSwipeVar(el, '--primitives-toast-swipe-move-y', null);
setSwipeVar(el, '--primitives-toast-swipe-end-x', `${x}px`);
setSwipeVar(el, '--primitives-toast-swipe-end-y', `${y}px`);
open.value = false;
}
const swipeStyle = computed(() =>
providerCtx.disableSwipe.value ? undefined : { userSelect: 'none', touchAction: 'none' } as const,
);
const useTeleport = computed(() => toViewport && !!providerCtx.viewportRef.value);
onMounted(() => {
providerCtx.onToastAdd();
if (open.value && !providerCtx.isClosePausedRef.value) {
startTimer(closeTimerRemaining);
remainingRaf.resume();
}
const viewport = providerCtx.viewportRef.value;
if (viewport) {
viewport.addEventListener(VIEWPORT_PAUSE, pauseTimer);
viewport.addEventListener(VIEWPORT_RESUME, resumeTimer);
}
});
onBeforeUnmount(() => {
providerCtx.onToastRemove();
clearTimer();
remainingRaf.pause();
const viewport = providerCtx.viewportRef.value;
if (viewport) {
viewport.removeEventListener(VIEWPORT_PAUSE, pauseTimer);
viewport.removeEventListener(VIEWPORT_RESUME, resumeTimer);
}
});
provideToastContext({
onClose: handleClose,
duration: durationRef,
open,
toastId,
});
</script>
<template>
<ToastAnnounce
v-if="open && announceText.length > 0"
:aria-live="ariaLive"
>
<!--
Render each harvested chunk as its own text node so screen readers get a
natural pause break between the title and description. Interpolating the
array directly would route through `toDisplayString` and announce literal
brackets/commas instead.
-->
<template
v-for="(text, i) in announceText"
:key="i"
>
{{ text }}
</template>
</ToastAnnounce>
<TeleportPrimitive
v-if="useTeleport"
:to="providerCtx.viewportRef.value"
:force-mount="true"
>
<Presence :present="forceMount || open">
<CollectionItem>
<Primitive
:ref="forwardRef"
:as="as"
v-bind="$attrs"
role="status"
:aria-live="ariaLive"
:aria-atomic="true"
:data-state="dataState"
:data-type="type"
:data-swipe-direction="providerCtx.swipeDirection.value"
tabindex="-1"
:style="swipeStyle"
@keydown.escape="handleEscapeKeyDown"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
>
<slot
:open="open"
:remaining="remaining"
:duration="resolvedDuration"
/>
</Primitive>
</CollectionItem>
</Presence>
</TeleportPrimitive>
<Presence
v-else
:present="forceMount || open"
>
<CollectionItem>
<Primitive
:ref="forwardRef"
:as="as"
v-bind="$attrs"
role="status"
:aria-atomic="true"
:data-state="dataState"
:data-type="type"
:data-swipe-direction="providerCtx.swipeDirection.value"
tabindex="-1"
:style="swipeStyle"
@keydown.escape="handleEscapeKeyDown"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
>
<slot
:open="open"
:remaining="remaining"
:duration="resolvedDuration"
/>
</Primitive>
</CollectionItem>
</Presence>
</template>
@@ -0,0 +1,28 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The toast's heading. Renders the short, prominent line that names the
* notification, placed inside a `ToastRoot`.
*/
export interface ToastTitleProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
const { as = 'div' } = defineProps<ToastTitleProps>();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
data-primitives-toast-title
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,224 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The fixed-position region (an `<ol>`) where toasts are rendered. Provides the
* accessible landmark for the toast list, pauses auto-dismiss timers on
* hover/focus/window-blur, can be focused via a keyboard hotkey, and manages tab
* order across portaled toasts (newest-first) with head/tail focus proxies.
* Render exactly one per provider.
*/
export interface ToastViewportProps extends PrimitiveProps {
/**
* Accessible label for the toast region. Overrides the provider label.
* The `{hotkey}` placeholder is replaced with the configured hotkey, and a
* `(hotkey) => string` function form is also accepted.
*/
label?: string | ((hotkey: string) => string);
/** Keyboard shortcut to focus the viewport. @default ['F8'] */
hotkey?: string[];
}
</script>
<script setup lang="ts">
import { computed, shallowRef, watchPostEffect } from 'vue';
import { focusFirst, getActiveElement, getTabbableCandidates } from '@robonen/platform/browsers';
import { unrefElement, useEventListener, useForwardExpose } from '@robonen/vue';
import { useCollectionInjector } from '../../utilities/collection';
import { DismissableLayerBranch } from '../../utilities/dismissable-layer';
import { Primitive } from '../../internal/primitive';
import { useToastProviderContext } from './context';
import { TOAST_COLLECTION_KEY } from './shared';
import ToastFocusProxy from './ToastFocusProxy.vue';
import { VIEWPORT_PAUSE, VIEWPORT_RESUME } from './utils';
defineOptions({
inheritAttrs: false,
});
const { as = 'ol', hotkey = ['F8'], label } = defineProps<ToastViewportProps>();
const { forwardRef, currentElement } = useForwardExpose();
const providerCtx = useToastProviderContext();
const { CollectionSlot, getItems } = useCollectionInjector(TOAST_COLLECTION_KEY);
const hasToasts = computed(() => providerCtx.toastCount.value > 0);
// Cache the branch style object so the DismissableLayerBranch child receives a
// stable reference between renders instead of a freshly-allocated literal on
// every viewport re-render (viewport re-renders on each toast add/remove).
const branchStyle = computed(() => ({ pointerEvents: hasToasts.value ? undefined : ('none' as const) }));
const headFocusProxy = shallowRef<HTMLElement>();
const tailFocusProxy = shallowRef<HTMLElement>();
watchPostEffect(() => providerCtx.onViewportChange(currentElement.value));
const hotkeyMessage = computed(() =>
hotkey.join('+').replaceAll('Key', '').replaceAll('Digit', ''),
);
const viewportLabel = computed(() => {
const base = label ?? providerCtx.label.value;
if (typeof base === 'function') return base(hotkeyMessage.value);
return base.replace('{hotkey}', hotkeyMessage.value);
});
// Dispatch pause/resume only on an actual state transition. Guarding on the
// shared `isClosePausedRef` makes repeated `pointermove`/window events idempotent
// so the per-toast timer is not re-banked on every move.
function dispatchPause() {
if (providerCtx.isClosePausedRef.value) return;
providerCtx.isClosePausedRef.value = true;
currentElement.value?.dispatchEvent(new CustomEvent(VIEWPORT_PAUSE, { bubbles: true }));
}
function dispatchResume() {
if (!providerCtx.isClosePausedRef.value) return;
providerCtx.isClosePausedRef.value = false;
currentElement.value?.dispatchEvent(new CustomEvent(VIEWPORT_RESUME, { bubbles: true }));
}
function handlePointerEnter() {
dispatchPause();
}
function handlePointerLeave() {
// Don't resume if focus is still inside the viewport (focus pause wins).
if (currentElement.value?.contains(getActiveElement())) return;
dispatchResume();
}
function handleFocusIn() {
dispatchPause();
}
function handleFocusOut(event: FocusEvent) {
if (currentElement.value?.contains(event.relatedTarget as Node)) return;
dispatchResume();
}
function handleWindowBlur() {
dispatchPause();
}
function handleWindowFocus() {
// Only resume if focus is not parked inside the viewport.
if (currentElement.value?.contains(getActiveElement())) return;
dispatchResume();
}
// Newest-to-oldest tab order across portaled toasts. Portals can't influence
// source order, so we manage Tab/Shift+Tab manually and proxy out at the edges.
function getSortedTabbableCandidates(direction: 'forwards' | 'backwards') {
const toastNodes = getItems(true).map(i => i.ref);
const perToast = toastNodes.map((node) => {
const candidates = [node, ...getTabbableCandidates(node)];
return direction === 'forwards' ? candidates : candidates.reverse();
});
return (direction === 'forwards' ? perToast.reverse() : perToast).flat();
}
function handleKeyDown(event: KeyboardEvent) {
const viewport = currentElement.value;
if (!viewport) return;
const isModifier = event.altKey || event.ctrlKey || event.metaKey;
if (event.key !== 'Tab' || isModifier) return;
const focused = getActiveElement();
const isTabbingBackwards = event.shiftKey;
const targetIsViewport = event.target === viewport;
if (targetIsViewport && isTabbingBackwards) {
headFocusProxy.value?.focus();
return;
}
const direction = isTabbingBackwards ? 'backwards' : 'forwards';
const sorted = getSortedTabbableCandidates(direction);
const index = sorted.indexOf(focused);
if (focusFirst(sorted.slice(index + 1))) {
event.preventDefault();
}
else if (isTabbingBackwards) {
// At an edge — proxy out so the browser hands focus to the next document element.
headFocusProxy.value?.focus();
}
else {
tailFocusProxy.value?.focus();
}
}
function handleGlobalKeyDown(event: KeyboardEvent) {
if (!hotkey || hotkey.length === 0) return;
const isHotkey = hotkey.every((key) => {
if (key === event.key) return true;
if (key === 'altKey') return event.altKey;
if (key === 'ctrlKey') return event.ctrlKey;
if (key === 'shiftKey') return event.shiftKey;
if (key === 'metaKey') return event.metaKey;
return false;
});
if (isHotkey) currentElement.value?.focus();
}
useEventListener(document, 'keydown', handleGlobalKeyDown);
useEventListener(globalThis, 'blur', handleWindowBlur);
useEventListener(globalThis, 'focus', handleWindowFocus);
function setHeadProxy(node: unknown) {
headFocusProxy.value = (unrefElement(node as Parameters<typeof unrefElement>[0]) as HTMLElement) ?? undefined;
}
function setTailProxy(node: unknown) {
tailFocusProxy.value = (unrefElement(node as Parameters<typeof unrefElement>[0]) as HTMLElement) ?? undefined;
}
function focusToastsForwards() {
focusFirst(getSortedTabbableCandidates('forwards'));
}
function focusToastsBackwards() {
focusFirst(getSortedTabbableCandidates('backwards'));
}
</script>
<template>
<DismissableLayerBranch
:style="branchStyle"
>
<ToastFocusProxy
v-if="hasToasts"
:ref="setHeadProxy"
@focus-from-outside-viewport="focusToastsForwards"
/>
<CollectionSlot>
<Primitive
:ref="forwardRef"
:as="as"
v-bind="$attrs"
role="region"
:aria-label="viewportLabel"
tabindex="-1"
style="outline: none"
data-primitives-toast-viewport
@pointerenter="handlePointerEnter"
@pointermove="handlePointerEnter"
@pointerleave="handlePointerLeave"
@focusin="handleFocusIn"
@focusout="handleFocusOut"
@keydown="handleKeyDown"
>
<slot />
</Primitive>
</CollectionSlot>
<ToastFocusProxy
v-if="hasToasts"
:ref="setTailProxy"
@focus-from-outside-viewport="focusToastsBackwards"
/>
</DismissableLayerBranch>
</template>
@@ -0,0 +1,476 @@
// Feature/parity tests for the toast primitive's additive capabilities:
// - screen-reader announce region (deferred render + text harvesting + exclude)
// - ToastAction / ToastClose announce-exclude + ToastClose type=button
// - controlled / uncontrolled (defaultOpen) open state
// - forceMount keeps the toast mounted while closed
// - Escape closes a focused toast and flags isFocusedToastEscapeKeyDownRef
// - focus returns to the viewport when a focused toast closes
// - viewport {hotkey} label interpolation + function-form label
// - swipe-to-dismiss gesture (emits + data-swipe + CSS vars) and disableSwipe
// - elapsed-time-preserving pause/resume
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
ToastAction,
ToastClose,
ToastDescription,
ToastProvider,
ToastRoot,
ToastTitle,
ToastViewport,
useToastProviderContext,
} from '../index';
function press(el: Element, key: string, init: KeyboardEventInit = {}) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
}
function nextFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
}
function pointer(el: Element, type: string, init: PointerEventInit = {}) {
// jsdom lacks PointerEvent; fall back to a MouseEvent-ish CustomEvent carrying coords.
const Ctor = (globalThis as { PointerEvent?: typeof PointerEvent }).PointerEvent;
let event: Event;
if (Ctor) {
event = new Ctor(type, { bubbles: true, cancelable: true, ...init });
}
else {
event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, { button: 0, pointerId: 1, pointerType: 'mouse', ...init });
}
// Stub pointer-capture methods used by the handlers.
Object.assign(el, {
setPointerCapture: () => {},
releasePointerCapture: () => {},
hasPointerCapture: () => false,
});
el.dispatchEvent(event);
}
describe('toast — announce region', () => {
afterEach(() => vi.restoreAllMocks());
it('renders a hidden announce region after a frame and harvests toast text', async () => {
const wrapper = mount(
defineComponent({
components: { ToastProvider, ToastViewport, ToastRoot, ToastTitle, ToastDescription },
render() {
return h(ToastProvider, {}, {
default: () => [
h(ToastViewport),
h(ToastRoot, { duration: Infinity }, {
default: () => [
h(ToastTitle, {}, { default: () => 'Saved' }),
h(ToastDescription, {}, { default: () => 'Your changes are stored.' }),
],
}),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
await nextFrame();
await nextTick();
const announce = document.querySelector('[data-primitives-toast-announce]');
expect(announce).not.toBeNull();
expect(announce?.textContent).toContain('Saved');
expect(announce?.textContent).toContain('Your changes are stored.');
wrapper.unmount();
});
it('excludes ToastClose / ToastAction visible text and substitutes altText', async () => {
const wrapper = mount(
defineComponent({
components: { ToastProvider, ToastViewport, ToastRoot, ToastTitle, ToastAction, ToastClose },
render() {
return h(ToastProvider, {}, {
default: () => [
h(ToastViewport),
h(ToastRoot, { duration: Infinity }, {
default: () => [
h(ToastTitle, {}, { default: () => 'Archived' }),
h(ToastAction, { altText: 'Undo archiving' }, { default: () => 'Undo' }),
h(ToastClose, {}, { default: () => 'X' }),
],
}),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
await nextFrame();
await nextTick();
const announce = document.querySelector('[data-primitives-toast-announce]');
expect(announce?.textContent).toContain('Archived');
// altText is announced in place of the visible "Undo" label.
expect(announce?.textContent).toContain('Undo archiving');
// The close button's visible "X" is excluded.
expect(announce?.textContent).not.toContain('X');
wrapper.unmount();
});
});
describe('toast — ToastClose / ToastAction', () => {
it('ToastClose renders type="button" and carries the announce-exclude attribute', () => {
const wrapper = mount(
defineComponent({
components: { ToastProvider, ToastViewport, ToastRoot, ToastClose },
render() {
return h(ToastProvider, {}, {
default: () => [
h(ToastViewport),
h(ToastRoot, { duration: Infinity }, {
default: () => h(ToastClose, {}, { default: () => 'Close' }),
}),
],
});
},
}),
{ attachTo: document.body },
);
const close = wrapper.find('[data-primitives-toast-close]').element as HTMLButtonElement;
expect(close.getAttribute('type')).toBe('button');
expect(close.getAttribute('data-primitives-toast-announce-exclude')).toBe('');
wrapper.unmount();
});
it('ToastAction carries the announce-exclude alt text', () => {
const wrapper = mount(
defineComponent({
components: { ToastProvider, ToastViewport, ToastRoot, ToastAction },
render() {
return h(ToastProvider, {}, {
default: () => [
h(ToastViewport),
h(ToastRoot, { duration: Infinity }, {
default: () => h(ToastAction, { altText: 'Undo it' }, { default: () => 'Undo' }),
}),
],
});
},
}),
{ attachTo: document.body },
);
const action = wrapper.find('[data-primitives-toast-action]').element as HTMLElement;
expect(action.getAttribute('data-primitives-toast-announce-exclude')).toBe('');
expect(action.getAttribute('data-primitives-toast-announce-alt')).toBe('Undo it');
expect(action.getAttribute('aria-label')).toBe('Undo it');
wrapper.unmount();
});
});
describe('toast — open state', () => {
it('uncontrolled defaultOpen=false starts closed', async () => {
const wrapper = mount(
defineComponent({
components: { ToastProvider, ToastViewport, ToastRoot },
render() {
return h(ToastProvider, {}, {
default: () => [
h(ToastViewport),
h(ToastRoot, { duration: Infinity, defaultOpen: false }, { default: () => 'Body' }),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
expect(wrapper.find('[role="status"]').exists()).toBe(false);
wrapper.unmount();
});
it('controlled v-model:open drives visibility', async () => {
const open = ref(true);
const wrapper = mount(
defineComponent({
components: { ToastProvider, ToastViewport, ToastRoot },
setup: () => ({ open }),
render() {
return h(ToastProvider, {}, {
default: () => [
h(ToastViewport),
h(ToastRoot, {
duration: Infinity,
open: this.open,
'onUpdate:open': (v: boolean) => { this.open = v; },
}, { default: () => 'Body' }),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
expect(wrapper.find('[role="status"]').exists()).toBe(true);
open.value = false;
await nextTick();
await nextTick();
expect(wrapper.find('[role="status"]').exists()).toBe(false);
wrapper.unmount();
});
it('forceMount keeps the toast mounted while closed', async () => {
const wrapper = mount(
defineComponent({
components: { ToastProvider, ToastViewport, ToastRoot },
render() {
return h(ToastProvider, {}, {
default: () => [
h(ToastViewport),
h(ToastRoot, { duration: Infinity, open: false, forceMount: true }, { default: () => 'Body' }),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
const toast = wrapper.find('[role="status"]');
expect(toast.exists()).toBe(true);
expect(toast.attributes('data-state')).toBe('closed');
wrapper.unmount();
});
});
describe('toast — Escape + focus return', () => {
it('Escape on a focused toast closes it and flags the provider ref', async () => {
let providerCtx: ReturnType<typeof useToastProviderContext> | null = null;
const Probe = defineComponent({
setup() {
providerCtx = useToastProviderContext();
return () => null;
},
});
const escapeSpy = vi.fn();
const wrapper = mount(
defineComponent({
components: { ToastProvider, ToastViewport, ToastRoot, Probe },
render() {
return h(ToastProvider, {}, {
default: () => [
h(Probe),
h(ToastViewport),
h(ToastRoot, { duration: Infinity, onEscapeKeyDown: escapeSpy }, { default: () => 'Body' }),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
const toast = wrapper.find('[role="status"]').element as HTMLElement;
toast.focus();
press(toast, 'Escape');
await nextTick();
await nextTick();
expect(escapeSpy).toHaveBeenCalledOnce();
expect(providerCtx!.isFocusedToastEscapeKeyDownRef.value).toBe(true);
expect(wrapper.find('[role="status"]').exists()).toBe(false);
wrapper.unmount();
});
it('returns focus to the viewport when a focused toast closes via ToastClose', async () => {
const wrapper = mount(
defineComponent({
components: { ToastProvider, ToastViewport, ToastRoot, ToastClose },
render() {
return h(ToastProvider, {}, {
default: () => [
h(ToastViewport),
h(ToastRoot, { duration: Infinity }, {
default: () => h(ToastClose, {}, { default: () => 'Close' }),
}),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
const viewport = wrapper.find('[role="region"]').element as HTMLElement;
const close = wrapper.find('[data-primitives-toast-close]').element as HTMLButtonElement;
const focusSpy = vi.spyOn(viewport, 'focus');
close.focus();
close.click();
await nextTick();
expect(focusSpy).toHaveBeenCalled();
wrapper.unmount();
});
});
describe('toast — viewport label', () => {
it('interpolates the {hotkey} placeholder', () => {
const wrapper = mount(
defineComponent({
components: { ToastProvider, ToastViewport },
render() {
return h(ToastProvider, {}, {
default: () => h(ToastViewport, { label: 'Alerts ({hotkey})', hotkey: ['F8'] }),
});
},
}),
{ attachTo: document.body },
);
expect(wrapper.find('[role="region"]').attributes('aria-label')).toBe('Alerts (F8)');
wrapper.unmount();
});
it('supports a function-form label', () => {
const wrapper = mount(
defineComponent({
components: { ToastProvider, ToastViewport },
render() {
return h(ToastProvider, {}, {
default: () => h(ToastViewport, { label: (hk: string) => `Toasts [${hk}]`, hotkey: ['F8'] }),
});
},
}),
{ attachTo: document.body },
);
expect(wrapper.find('[role="region"]').attributes('aria-label')).toBe('Toasts [F8]');
wrapper.unmount();
});
});
describe('toast — swipe to dismiss', () => {
it('emits swipe events and closes when threshold is exceeded', async () => {
const swipeEnd = vi.fn();
const open = ref(true);
const wrapper = mount(
defineComponent({
components: { ToastProvider, ToastViewport, ToastRoot },
setup: () => ({ open }),
render() {
return h(ToastProvider, { swipeDirection: 'right', swipeThreshold: 10 }, {
default: () => [
h(ToastViewport),
h(ToastRoot, {
duration: Infinity,
open: this.open,
'onUpdate:open': (v: boolean) => { this.open = v; },
onSwipeEnd: swipeEnd,
}, { default: () => 'Body' }),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
const toast = wrapper.find('[role="status"]').element as HTMLElement;
pointer(toast, 'pointerdown', { clientX: 0, clientY: 0, button: 0, pointerType: 'mouse', pointerId: 1 });
// First move past the start buffer in the swipe direction.
pointer(toast, 'pointermove', { clientX: 30, clientY: 0, pointerType: 'mouse', pointerId: 1 });
pointer(toast, 'pointermove', { clientX: 60, clientY: 0, pointerType: 'mouse', pointerId: 1 });
pointer(toast, 'pointerup', { clientX: 60, clientY: 0, pointerType: 'mouse', pointerId: 1 });
await nextTick();
await nextTick();
expect(swipeEnd).toHaveBeenCalled();
expect(toast.getAttribute('data-swipe')).toBe('end');
expect(open.value).toBe(false);
wrapper.unmount();
});
it('disableSwipe prevents any swipe handling', async () => {
const swipeStart = vi.fn();
const wrapper = mount(
defineComponent({
components: { ToastProvider, ToastViewport, ToastRoot },
render() {
return h(ToastProvider, { disableSwipe: true, swipeDirection: 'right' }, {
default: () => [
h(ToastViewport),
h(ToastRoot, { duration: Infinity, onSwipeStart: swipeStart }, { default: () => 'Body' }),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
const toast = wrapper.find('[role="status"]').element as HTMLElement;
pointer(toast, 'pointerdown', { clientX: 0, clientY: 0, button: 0, pointerType: 'mouse', pointerId: 1 });
pointer(toast, 'pointermove', { clientX: 60, clientY: 0, pointerType: 'mouse', pointerId: 1 });
expect(swipeStart).not.toHaveBeenCalled();
expect(toast.getAttribute('data-swipe')).toBeNull();
wrapper.unmount();
});
});
describe('toast — elapsed-time-preserving pause/resume', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('resumes from the remaining time instead of restarting the full duration', async () => {
const wrapper = mount(
defineComponent({
components: { ToastProvider, ToastViewport, ToastRoot },
render() {
return h(ToastProvider, {}, {
default: () => [
h(ToastViewport),
h(ToastRoot, { duration: 1000 }, { default: () => 'Body' }),
],
});
},
}),
{ attachTo: document.body },
);
const viewport = wrapper.find('[role="region"]').element as HTMLElement;
// Elapse 800ms, then pause (banks ~200ms remaining).
vi.advanceTimersByTime(800);
viewport.dispatchEvent(new CustomEvent('toast.viewportPause', { bubbles: true }));
await nextTick();
// While paused, time passing does not close the toast.
vi.advanceTimersByTime(5000);
await nextTick();
expect(wrapper.find('[role="status"]').exists()).toBe(true);
// Resume — should close after the remaining ~200ms, not a full 1000ms.
viewport.dispatchEvent(new CustomEvent('toast.viewportResume', { bubbles: true }));
vi.advanceTimersByTime(250);
await nextTick();
await nextTick();
expect(wrapper.find('[role="status"]').exists()).toBe(false);
wrapper.unmount();
});
});
@@ -0,0 +1,103 @@
// Regression tests for confirmed bugs from Phase 1 audit:
// 1. ToastRoot tabindex must be -1 (programmatic only), not 0 (conflicts with roving focus).
// 2. ToastViewport hotkey must validate against empty array (otherwise vacuous-truth
// focuses the viewport on every keystroke).
// 3. ToastRoot must restart the auto-dismiss timer when `duration` changes reactively.
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { ToastProvider, ToastRoot, ToastViewport } from '../index';
function createHarness(props: {
duration?: number;
hotkey?: string[];
} = {}) {
return defineComponent({
components: { ToastProvider, ToastViewport, ToastRoot },
setup() {
const duration = ref(props.duration);
return { duration, hotkey: props.hotkey };
},
render() {
return h(ToastProvider, {}, {
default: () => [
h(ToastViewport, { hotkey: this.hotkey ?? ['F8'] }),
h(ToastRoot, { duration: this.duration }, { default: () => 'Toast body' }),
],
});
},
});
}
describe('toast — bug regression', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('ToastRoot has tabindex="-1" (programmatic focus only)', () => {
const wrapper = mount(createHarness({ duration: Infinity }), { attachTo: document.body });
const toast = wrapper.find('[role="status"]');
expect(toast.exists()).toBe(true);
expect(toast.attributes('tabindex')).toBe('-1');
wrapper.unmount();
});
it('ToastViewport ignores empty hotkey array (does not focus on every keypress)', async () => {
const wrapper = mount(createHarness({ duration: Infinity, hotkey: [] }), { attachTo: document.body });
const viewport = wrapper.find('[role="region"]').element as HTMLElement;
const focusSpy = vi.spyOn(viewport, 'focus');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'F8' }));
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(focusSpy).not.toHaveBeenCalled();
wrapper.unmount();
});
it('ToastViewport responds to F8 when hotkey=["F8"]', () => {
const wrapper = mount(createHarness({ duration: Infinity }), { attachTo: document.body });
const viewport = wrapper.find('[role="region"]').element as HTMLElement;
const focusSpy = vi.spyOn(viewport, 'focus');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'F8' }));
expect(focusSpy).toHaveBeenCalledOnce();
wrapper.unmount();
});
it('ToastRoot restarts auto-dismiss timer when duration prop changes reactively', async () => {
const Harness = createHarness({ duration: 1000 });
const wrapper = mount(Harness, { attachTo: document.body });
// Bump duration to 5000ms BEFORE original 1000ms elapses.
vi.advanceTimersByTime(500);
wrapper.vm.duration = 5000;
await nextTick();
// Original 1000ms total would have fired by 1100ms — but the watcher restarted the
// timer with the new duration. Toast must still be open.
vi.advanceTimersByTime(600); // 1100ms elapsed total
await nextTick();
expect(wrapper.find('[role="status"]').exists()).toBe(true);
// Now advance the full new duration (5000ms) — toast should close.
vi.advanceTimersByTime(5000);
await nextTick();
await nextTick();
expect(wrapper.find('[role="status"]').exists()).toBe(false);
wrapper.unmount();
});
it('ToastRoot timer does not fire when duration=Infinity', () => {
const wrapper = mount(createHarness({ duration: Infinity }), { attachTo: document.body });
vi.advanceTimersByTime(60_000);
expect(wrapper.find('[role="status"]').exists()).toBe(true);
wrapper.unmount();
});
});
@@ -0,0 +1,45 @@
import type { Ref, ShallowRef } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type SwipeDirection = 'up' | 'down' | 'left' | 'right';
export interface ToastProviderContext {
label: Ref<string>;
duration: Ref<number>;
swipeDirection: Ref<SwipeDirection>;
swipeThreshold: Ref<number>;
/** Whether swipe-to-dismiss gestures are disabled for every toast. */
disableSwipe: Ref<boolean>;
toastCount: Ref<number>;
viewportRef: ShallowRef<HTMLElement | undefined>;
onViewportChange: (el: HTMLElement | undefined) => void;
onToastAdd: () => void;
onToastRemove: () => void;
isFocusedToastEscapeKeyDownRef: Ref<boolean>;
isClosePausedRef: Ref<boolean>;
}
export interface ToastContext {
onClose: () => void;
duration: Ref<number | undefined>;
open: Ref<boolean>;
toastId: Ref<string>;
}
const {
inject: useToastProviderContext,
provide: provideToastProviderContext,
} = useContextFactory<ToastProviderContext>('ToastProviderContext');
const {
inject: useToastContext,
provide: provideToastContext,
} = useContextFactory<ToastContext>('ToastContext');
export {
useToastProviderContext,
provideToastProviderContext,
useToastContext,
provideToastContext,
};
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
ToastAction,
ToastClose,
ToastDescription,
ToastProvider,
ToastRoot,
ToastTitle,
ToastViewport,
} from '@robonen/primitives';
interface ToastItem {
id: number;
title: string;
description: string;
open: boolean;
}
const toasts = ref<ToastItem[]>([]);
let nextId = 0;
function notify() {
const id = nextId++;
toasts.value.push({
id,
title: 'Message archived',
description: 'Moved "Weekly digest" to your archive.',
open: true,
});
}
function undo(id: number) {
const toast = toasts.value.find((t) => t.id === id);
if (toast) toast.open = false;
}
// Drop a toast from the list once it has fully closed.
function remove(id: number) {
toasts.value = toasts.value.filter((t) => t.id !== id);
}
</script>
<template>
<ToastProvider :duration="4000" swipe-direction="right">
<div class="flex flex-col items-start gap-3 text-fg">
<button
type="button"
class="inline-flex items-center rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-colors hover:bg-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="notify"
>
Archive message
</button>
<p class="text-xs text-fg-muted">
Toasts auto-dismiss after 4s. Hover the stack to pause the timer.
</p>
</div>
<ToastRoot
v-for="toast in toasts"
:key="toast.id"
v-model:open="toast.open"
class="flex items-start gap-3 rounded-lg border border-border bg-bg-elevated p-3 shadow-lg data-[state=closed]:opacity-0 data-[state=open]:opacity-100"
@update:open="(open) => !open && remove(toast.id)"
>
<div class="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
<svg
class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</div>
<div class="min-w-0 flex-1">
<ToastTitle class="text-sm font-semibold text-fg">
{{ toast.title }}
</ToastTitle>
<ToastDescription class="mt-0.5 text-sm text-fg-muted">
{{ toast.description }}
</ToastDescription>
</div>
<div class="flex shrink-0 items-center gap-1">
<ToastAction
alt-text="Undo archiving this message"
class="rounded-md border border-border bg-bg px-2 py-1 text-xs font-medium text-fg transition-colors hover:bg-bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="undo(toast.id)"
>
Undo
</ToastAction>
<ToastClose
aria-label="Dismiss"
class="inline-flex size-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-bg-subtle hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg
class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</ToastClose>
</div>
</ToastRoot>
<ToastViewport
class="fixed bottom-4 right-4 z-50 flex w-[min(92vw,22rem)] flex-col gap-2 outline-none"
/>
</ToastProvider>
</template>
@@ -0,0 +1,28 @@
export { default as ToastProvider } from './ToastProvider.vue';
export { default as ToastRoot } from './ToastRoot.vue';
export { default as ToastTitle } from './ToastTitle.vue';
export { default as ToastDescription } from './ToastDescription.vue';
export { default as ToastAction } from './ToastAction.vue';
export { default as ToastClose } from './ToastClose.vue';
export { default as ToastViewport } from './ToastViewport.vue';
export { default as ToastAnnounce } from './ToastAnnounce.vue';
export { default as ToastAnnounceExclude } from './ToastAnnounceExclude.vue';
export { default as ToastPortal } from './ToastPortal.vue';
export {
useToastProviderContext,
useToastContext,
} from './context';
export type { SwipeDirection, ToastProviderContext, ToastContext } from './context';
export type { SwipeEvent } from './utils';
export type { ToastProviderProps } from './ToastProvider.vue';
export type { ToastRootProps, ToastRootEmits } from './ToastRoot.vue';
export type { ToastTitleProps } from './ToastTitle.vue';
export type { ToastDescriptionProps } from './ToastDescription.vue';
export type { ToastActionProps } from './ToastAction.vue';
export type { ToastCloseProps } from './ToastClose.vue';
export type { ToastViewportProps } from './ToastViewport.vue';
export type { ToastAnnounceProps } from './ToastAnnounce.vue';
export type { ToastAnnounceExcludeProps } from './ToastAnnounceExclude.vue';
export type { ToastPortalProps } from './ToastPortal.vue';
@@ -0,0 +1,6 @@
/**
* Dedicated collection namespace for the toast primitive. Used so a nested
* collection provider between `ToastProvider` and `ToastViewport`/`ToastRoot`
* does not shadow the toast collection.
*/
export const TOAST_COLLECTION_KEY = 'toast';
+107
View File
@@ -0,0 +1,107 @@
import type { SwipeDirection } from './context';
export const VIEWPORT_PAUSE = 'toast.viewportPause';
export const VIEWPORT_RESUME = 'toast.viewportResume';
export const TOAST_SWIPE_START = 'toast.swipeStart';
export const TOAST_SWIPE_MOVE = 'toast.swipeMove';
export const TOAST_SWIPE_CANCEL = 'toast.swipeCancel';
export const TOAST_SWIPE_END = 'toast.swipeEnd';
/** Data attribute marking a subtree that should be skipped by the screen-reader announce harvester. */
export const ANNOUNCE_EXCLUDE_ATTR = 'data-primitives-toast-announce-exclude';
/** Data attribute carrying alternate text to announce in place of an excluded subtree. */
export const ANNOUNCE_ALT_ATTR = 'data-primitives-toast-announce-alt';
export interface SwipeEventDetail {
originalEvent: PointerEvent;
delta: { x: number; y: number };
}
export type SwipeEvent = { currentTarget: EventTarget & HTMLElement } & Omit<
CustomEvent<SwipeEventDetail>,
'currentTarget'
>;
/**
* Dispatches a cancelable `CustomEvent` on the originating element and, when a
* handler is supplied, attaches it once before dispatch so listeners can call
* `preventDefault()`. Mirrors the dispatch pattern used by the focus-scope
* helpers but specialised for the swipe gesture detail payload.
*/
export function handleAndDispatchCustomEvent(
name: string,
handler: ((event: SwipeEvent) => void) | undefined,
detail: SwipeEventDetail,
) {
const currentTarget = detail.originalEvent.currentTarget as HTMLElement;
const event = new CustomEvent(name, {
bubbles: false,
cancelable: true,
detail,
});
if (handler)
currentTarget.addEventListener(name, handler as EventListener, { once: true });
currentTarget.dispatchEvent(event);
}
/**
* Whether a pointer delta is dominant along the swipe axis and past the
* threshold. Horizontal directions compare `x`, vertical compare `y`.
*/
export function isDeltaInDirection(
delta: { x: number; y: number },
direction: SwipeDirection,
threshold = 0,
): boolean {
const deltaX = Math.abs(delta.x);
const deltaY = Math.abs(delta.y);
const isDeltaX = deltaX > deltaY;
if (direction === 'left' || direction === 'right')
return isDeltaX && deltaX > threshold;
return !isDeltaX && deltaY > threshold;
}
function isHTMLElement(node: Node): node is HTMLElement {
return node.nodeType === node.ELEMENT_NODE;
}
/**
* Walks a toast element collecting its visible text as discrete chunks for a
* screen-reader live region. Hidden subtrees (`aria-hidden`, `hidden`,
* `display: none`) are skipped, and subtrees flagged with
* `data-primitives-toast-announce-exclude` are replaced by their
* `data-primitives-toast-announce-alt` text (if any).
*
* Returning an array (rather than a single joined string) lets the live region
* render one text node per chunk, giving assistive tech a natural pause break
* between the title and description.
*/
export function getAnnounceTextContent(container: HTMLElement): string[] {
const textContent: string[] = [];
for (const node of Array.from(container.childNodes)) {
if (node.nodeType === node.TEXT_NODE && node.textContent)
textContent.push(node.textContent);
if (isHTMLElement(node)) {
const isHidden = node.ariaHidden === 'true' || node.hidden || node.style.display === 'none';
if (isHidden) continue;
const isExcluded = node.getAttribute(ANNOUNCE_EXCLUDE_ATTR) === '';
if (isExcluded) {
const altText = node.getAttribute(ANNOUNCE_ALT_ATTR);
if (altText) textContent.push(altText);
}
else {
textContent.push(...getAnnounceTextContent(node));
}
}
}
return textContent;
}