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:
@@ -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,
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user