+
+
+
+ {{ message }}
+
+
+
+
+
+
+
+ {{ message }}
+
+ {{ t('Undo') }}
+
+
+
+
+
+
+
+ {{ message }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/index.ts b/lib/index.ts
index 62b86d585..623993972 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -43,6 +43,7 @@ export {
} from './toast.js'
export type {
+ ToastHandle,
ToastOptions,
} from './toast.js'
diff --git a/lib/toast.spec.ts b/lib/toast.spec.ts
new file mode 100644
index 000000000..6480e8b81
--- /dev/null
+++ b/lib/toast.spec.ts
@@ -0,0 +1,249 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
+import {
+ showError,
+ showInfo,
+ showLoading,
+ showMessage,
+ showSuccess,
+ showUndo,
+ showWarning,
+ ToastAriaLive,
+} from './toast.ts'
+
+/** Wait for the 50 ms setTimeout used in announceToLiveRegion to fire. */
+async function waitForAnnouncement() {
+ await vi.runAllTimersAsync()
+}
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ // Reset the module-level singleton live region references between tests so
+ // each test gets a fresh DOM state.
+ document.body.innerHTML = ''
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+// ---------------------------------------------------------------------------
+// Persistent live regions
+// ---------------------------------------------------------------------------
+
+describe('live regions', () => {
+ test('creates a polite live region on first polite toast', async () => {
+ showInfo('Hello')
+ await waitForAnnouncement()
+
+ const regions = document.querySelectorAll('[aria-live="polite"]')
+ expect(regions.length).toBeGreaterThanOrEqual(1)
+ })
+
+ test('creates an assertive live region on first error toast', async () => {
+ showError('Oops')
+ await waitForAnnouncement()
+
+ const regions = document.querySelectorAll('[aria-live="assertive"]')
+ expect(regions.length).toBeGreaterThanOrEqual(1)
+ })
+
+ test('reuses the same live region across multiple toasts', async () => {
+ showInfo('First')
+ showInfo('Second')
+ await waitForAnnouncement()
+
+ const regions = document.querySelectorAll('[aria-live="polite"]')
+ const ourRegions = Array.from(regions).filter((el) => (el as HTMLElement).style.cssText.includes('clip'))
+ expect(ourRegions).toHaveLength(1)
+ })
+
+ test('live region has aria-atomic="true"', async () => {
+ showInfo('Hello')
+ await waitForAnnouncement()
+
+ const region = document.querySelector('[aria-live="polite"][aria-atomic="true"]')
+ expect(region).not.toBeNull()
+ })
+
+ test('live region is visually hidden', async () => {
+ showInfo('Hello')
+ await waitForAnnouncement()
+
+ const region = document.querySelector('[aria-live="polite"]') as HTMLElement | null
+ expect(region?.style.cssText).toContain('clip')
+ expect(region?.style.position).toBe('absolute')
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Announcement content
+// ---------------------------------------------------------------------------
+
+describe('announcement text', () => {
+ test('showError prefixes message with "Error:"', async () => {
+ showError('File not found')
+ await waitForAnnouncement()
+
+ const region = document.querySelector('[aria-live="assertive"]') as HTMLElement
+ expect(region.textContent).toBe('Error: File not found')
+ })
+
+ test('showWarning prefixes message with "Warning:"', async () => {
+ showWarning('Low disk space')
+ await waitForAnnouncement()
+
+ const region = document.querySelector('[aria-live="polite"]') as HTMLElement
+ expect(region.textContent).toBe('Warning: Low disk space')
+ })
+
+ test('showInfo prefixes message with "Info:"', async () => {
+ showInfo('Update available')
+ await waitForAnnouncement()
+
+ const region = document.querySelector('[aria-live="polite"]') as HTMLElement
+ expect(region.textContent).toBe('Info: Update available')
+ })
+
+ test('showSuccess prefixes message with "Success:"', async () => {
+ showSuccess('File uploaded')
+ await waitForAnnouncement()
+
+ const region = document.querySelector('[aria-live="polite"]') as HTMLElement
+ expect(region.textContent).toBe('Success: File uploaded')
+ })
+
+ test('showMessage without type has no prefix', async () => {
+ showMessage('Plain message')
+ await waitForAnnouncement()
+
+ const region = document.querySelector('[aria-live="polite"]') as HTMLElement
+ expect(region.textContent).toBe('Plain message')
+ })
+
+ test('same message announced twice is re-read (region cleared first)', async () => {
+ showError('Duplicate')
+ await waitForAnnouncement()
+
+ const region = document.querySelector('[aria-live="assertive"]') as HTMLElement
+ expect(region.textContent).toBe('Error: Duplicate')
+
+ // Second identical call: the clear happens synchronously, the re-set after timeout
+ showError('Duplicate')
+ // After clear but before timeout fires, content should be empty
+ expect(region.textContent).toBe('')
+ await waitForAnnouncement()
+ expect(region.textContent).toBe('Error: Duplicate')
+ })
+
+ test('ariaLive OFF skips announcement entirely', async () => {
+ showError('Silent error', { ariaLive: ToastAriaLive.OFF })
+ await waitForAnnouncement()
+
+ const assertiveRegion = document.querySelector('[aria-live="assertive"]')
+ // Either no region was created, or it is empty
+ expect(assertiveRegion?.textContent ?? '').toBe('')
+ })
+})
+
+// ---------------------------------------------------------------------------
+// role on toast element
+// ---------------------------------------------------------------------------
+
+describe('toast element role', () => {
+ test('showError renders toast with role="alert"', async () => {
+ showError('Boom')
+ expect(document.querySelector('[role="alert"]')).not.toBeNull()
+ })
+
+ test('showUndo renders toast with role="alert"', async () => {
+ showUndo('Item deleted', vi.fn())
+ expect(document.querySelector('[role="alert"]')).not.toBeNull()
+ })
+
+ test('showInfo renders toast with role="status"', async () => {
+ showInfo('FYI')
+ expect(document.querySelector('[role="status"]')).not.toBeNull()
+ })
+
+ test('showSuccess renders toast with role="status"', async () => {
+ showSuccess('Done')
+ expect(document.querySelector('[role="status"]')).not.toBeNull()
+ })
+
+ test('showWarning renders toast with role="status"', async () => {
+ showWarning('Careful')
+ expect(document.querySelector('[role="status"]')).not.toBeNull()
+ })
+
+ test('explicit assertive option produces role="alert"', async () => {
+ showWarning('Urgent warning', { ariaLive: ToastAriaLive.ASSERTIVE })
+ expect(document.querySelector('[role="alert"]')).not.toBeNull()
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Close button accessible name
+// ---------------------------------------------------------------------------
+
+describe('close button', () => {
+ test('close button has aria-label="Close"', () => {
+ showInfo('Something happened')
+ const closeBtn = document.querySelector('.nc-toast__close') as HTMLButtonElement | null
+ expect(closeBtn).not.toBeNull()
+ expect(closeBtn?.getAttribute('aria-label')).toBe('Close')
+ })
+
+ test('no close button rendered when close=false', () => {
+ showMessage('No close', { close: false })
+ expect(document.querySelector('.nc-toast__close')).toBeNull()
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Loading spinner – aria-hidden
+// ---------------------------------------------------------------------------
+
+describe('showLoading spinner', () => {
+ test('spinner element has aria-hidden="true"', () => {
+ showLoading('Uploading…')
+ const spinner = document.querySelector('.nc-toast__loader') as HTMLElement | null
+ expect(spinner).not.toBeNull()
+ expect(spinner?.getAttribute('aria-hidden')).toBe('true')
+ })
+
+ test('showLoading announces text without spinner noise', async () => {
+ showLoading('Uploading…')
+ await waitForAnnouncement()
+
+ const region = document.querySelector('[aria-live="polite"]') as HTMLElement
+ // Should contain the text but NOT SVG markup
+ expect(region.textContent).toBe('Uploading…')
+ })
+})
+
+// ---------------------------------------------------------------------------
+// ariaLive option forwarding
+// ---------------------------------------------------------------------------
+
+describe('ariaLive option', () => {
+ test('custom ariaLive POLITE on error uses polite region', async () => {
+ showError('Batch error', { ariaLive: ToastAriaLive.POLITE })
+ await waitForAnnouncement()
+
+ const politeRegion = document.querySelector('[aria-live="polite"]') as HTMLElement
+ expect(politeRegion?.textContent).toContain('Batch error')
+ })
+
+ test('custom ariaLive ASSERTIVE on info uses assertive region', async () => {
+ showInfo('Critical info', { ariaLive: ToastAriaLive.ASSERTIVE })
+ await waitForAnnouncement()
+
+ const assertiveRegion = document.querySelector('[aria-live="assertive"]') as HTMLElement
+ expect(assertiveRegion?.textContent).toContain('Critical info')
+ })
+})
diff --git a/lib/toast.ts b/lib/toast.ts
index fead2c23a..9f6f4e0ae 100644
--- a/lib/toast.ts
+++ b/lib/toast.ts
@@ -1,13 +1,88 @@
/**
- * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import Toastify from 'toastify-js'
-import LoaderSvg from '../styles/loader.svg?raw'
+import { createApp } from 'vue'
+import ToastNotification from './components/ToastNotification.vue'
import { t } from './utils/l10n.js'
-import '../styles/toast.scss'
+/**
+ * Persistent aria-live regions that exist in the DOM before any toast is shown.
+ * Screen readers require the live region to already be present when content is added;
+ * injecting an element that already carries aria-live is unreliable (NVDA, JAWS).
+ */
+let _politeRegion: HTMLElement | null = null
+let _assertiveRegion: HTMLElement | null = null
+
+const VISUALLY_HIDDEN_STYLE = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0'
+
+/**
+ * Get or create a persistent live region element for the given aria-live level.
+ * The returned element is visually hidden but will be announced by
+ * screen readers when its text content changes.
+ *
+ * @param level The aria-live level ('polite' or 'assertive') to determine which live region to use
+ * @return The live region HTMLElement
+ */
+function getOrCreateLiveRegion(level: 'polite' | 'assertive'): HTMLElement {
+ const cached = level === 'assertive' ? _assertiveRegion : _politeRegion
+ if (cached && document.body.contains(cached)) {
+ return cached
+ }
+
+ const region = document.createElement('div')
+ region.setAttribute('aria-live', level)
+ region.setAttribute('aria-atomic', 'true')
+ region.setAttribute('aria-relevant', 'additions text')
+ region.style.cssText = VISUALLY_HIDDEN_STYLE
+ document.body.appendChild(region)
+
+ if (level === 'assertive') {
+ _assertiveRegion = region
+ } else {
+ _politeRegion = region
+ }
+ return region
+}
+
+/**
+ * Announce a message to the appropriate live region for screen readers, based on the given aria-live level.
+ *
+ * @param text The message text to announce
+ * @param level The aria-live level ('polite' or 'assertive') to determine which live region to use for the announcement
+ */
+function announceToLiveRegion(text: string, level: 'polite' | 'assertive'): void {
+ const region = getOrCreateLiveRegion(level)
+ // Clear first so the same message announced twice is re-read
+ region.textContent = ''
+ setTimeout(() => {
+ region.textContent = text
+ }, 50)
+}
+
+/**
+ * Extract visible text from a node, skipping subtrees marked aria-hidden.
+ * This prevents decorative elements (e.g. spinner SVGs) from leaking into
+ * the live-region announcement.
+ *
+ * @param node The DOM node to extract text from
+ * @return The concatenated visible text content of the node and its children
+ */
+function getVisibleText(node: Node): string {
+ // Skip any nodes that are hidden from assistive technologies
+ if (node instanceof Element && node.getAttribute('aria-hidden') === 'true') {
+ return ''
+ }
+
+ // For text nodes, return the text content directly
+ if (node.nodeType === Node.TEXT_NODE) {
+ return node.textContent ?? ''
+ }
+
+ // For element nodes, recursively extract text from child nodes
+ return Array.from(node.childNodes).map(getVisibleText).join('')
+}
/**
* Enum of available Toast types
@@ -34,20 +109,13 @@ export enum ToastAriaLive {
ASSERTIVE = TOAST_ARIA_LIVE_ASSERTIVE,
}
-/** Timeout in ms of a undo toast */
+/** Timeout in ms of an undo toast */
export const TOAST_UNDO_TIMEOUT = 10000
/** Default timeout in ms of toasts */
export const TOAST_DEFAULT_TIMEOUT = 7000
/** Timeout value to show a toast permanently */
export const TOAST_PERMANENT_TIMEOUT = -1
-/**
- * Type of a toast
- *
- * @see https://apvarun.github.io/toastify-js/
- */
-type Toast = ReturnType