diff --git a/README.md b/README.md index 09487302d..3b994538a 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,12 @@ There are several options that can be passed in as a second parameter, like the showError('This is an error shown without a timeout', { timeout: -1 }) ``` +Ordinary toasts (success, error, warning, info, and bare messages) use the user-configured toast timeout +from the theming capabilities (`theming.toastTimeout`) when available, and fall back to +`TOAST_DEFAULT_TIMEOUT` (7 seconds) otherwise. + +Loading toasts stay permanent until hidden manually, and undo toasts keep their fixed undo duration. + A full list of available options can be found in the [documentation](https://nextcloud-libraries.github.io/nextcloud-dialogs/). ### FilePicker diff --git a/lib/toast.spec.ts b/lib/toast.spec.ts new file mode 100644 index 000000000..a7af0f8d9 --- /dev/null +++ b/lib/toast.spec.ts @@ -0,0 +1,103 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import { showMessage, TOAST_DEFAULT_TIMEOUT, TOAST_PERMANENT_TIMEOUT, ToastType } from './toast.ts' + +const getCapabilities = vi.hoisted(() => vi.fn()) + +vi.mock('@nextcloud/capabilities', () => ({ + getCapabilities, +})) + +vi.mock('toastify-js', () => ({ + default: vi.fn((options: { duration?: number }) => ({ + options, + showToast: vi.fn(), + hideToast: vi.fn(), + })), +})) + +import Toastify from 'toastify-js' + +beforeEach(() => { + getCapabilities.mockReset() + vi.mocked(Toastify).mockClear() +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +test('uses default timeout when capabilities are missing', () => { + getCapabilities.mockImplementation(() => { + throw new Error('no capabilities') + }) + + showMessage('hello') + + expect(Toastify).toHaveBeenCalledWith(expect.objectContaining({ + duration: TOAST_DEFAULT_TIMEOUT, + })) +}) + +test('uses toastTimeout from theming capabilities', () => { + getCapabilities.mockReturnValue({ + theming: { + toastTimeout: 15_000, + }, + }) + + showMessage('hello') + + expect(Toastify).toHaveBeenCalledWith(expect.objectContaining({ + duration: 15_000, + })) +}) + +test('allows permanent timeout from capabilities', () => { + getCapabilities.mockReturnValue({ + theming: { + toastTimeout: TOAST_PERMANENT_TIMEOUT, + }, + }) + + showMessage('hello') + + expect(Toastify).toHaveBeenCalledWith(expect.objectContaining({ + duration: TOAST_PERMANENT_TIMEOUT, + })) +}) + +test('falls back for invalid capability values', () => { + getCapabilities.mockReturnValue({ + theming: { + toastTimeout: 0, + }, + }) + + showMessage('hello') + + expect(Toastify).toHaveBeenCalledWith(expect.objectContaining({ + duration: TOAST_DEFAULT_TIMEOUT, + })) +}) + +test('does not override loading toast duration', () => { + getCapabilities.mockReturnValue({ + theming: { + toastTimeout: 30_000, + }, + }) + + showMessage('loading', { + type: ToastType.LOADING, + timeout: TOAST_PERMANENT_TIMEOUT, + }) + + expect(Toastify).toHaveBeenCalledWith(expect.objectContaining({ + duration: TOAST_PERMANENT_TIMEOUT, + })) +}) diff --git a/lib/toast.ts b/lib/toast.ts index fead2c23a..8112693ac 100644 --- a/lib/toast.ts +++ b/lib/toast.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { getCapabilities } from '@nextcloud/capabilities' import Toastify from 'toastify-js' import LoaderSvg from '../styles/loader.svg?raw' import { t } from './utils/l10n.js' @@ -41,6 +42,37 @@ export const TOAST_DEFAULT_TIMEOUT = 7000 /** Timeout value to show a toast permanently */ export const TOAST_PERMANENT_TIMEOUT = -1 +type ThemingCapabilities = { + theming?: { + toastTimeout?: number + } +} + +/** + * Whether a timeout value is valid for ordinary toasts. + * + * @param timeout Timeout in milliseconds + */ +function isValidToastTimeout(timeout: number): boolean { + return timeout === TOAST_PERMANENT_TIMEOUT || timeout > 0 +} + +/** + * Resolve the user-configured toast timeout from theming capabilities. + * Falls back to {@link TOAST_DEFAULT_TIMEOUT} when unset or invalid. + */ +function getToastTimeout(): number { + try { + const timeout = (getCapabilities() as ThemingCapabilities)?.theming?.toastTimeout + if (typeof timeout === 'number' && isValidToastTimeout(timeout)) { + return timeout + } + } catch { + // Capabilities may be unavailable outside the browser (e.g. unit tests). + } + return TOAST_DEFAULT_TIMEOUT +} + /** * Type of a toast * @@ -104,7 +136,7 @@ export interface ToastOptions { */ export function showMessage(data: string | Node, options?: ToastOptions): Toast { options = { - timeout: TOAST_DEFAULT_TIMEOUT, + timeout: getToastTimeout(), isHTML: false, type: undefined, // An undefined selector defaults to the body element @@ -115,6 +147,16 @@ export function showMessage(data: string | Node, options?: ToastOptions): Toast ...options, } + // Accessibility preference overrides ordinary/app-specific timeouts. + // Loading and undo toasts keep their forced durations; permanent stays permanent. + if ( + options.type !== ToastType.LOADING + && options.type !== ToastType.UNDO + && options.timeout !== TOAST_PERMANENT_TIMEOUT + ) { + options.timeout = getToastTimeout() + } + if (typeof data === 'string' && !options.isHTML) { // fime mae sure that text is extracted const element = document.createElement('div') diff --git a/package-lock.json b/package-lock.json index 050e1495f..c3ffa0520 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/browser-storage": "^0.5.0", + "@nextcloud/capabilities": "^1.2.1", "@nextcloud/event-bus": "^3.3.3", "@nextcloud/files": "^4.0.0", "@nextcloud/initial-state": "^3.0.0", diff --git a/package.json b/package.json index 85a4d7e98..8edf24287 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/browser-storage": "^0.5.0", + "@nextcloud/capabilities": "^1.2.1", "@nextcloud/event-bus": "^3.3.3", "@nextcloud/files": "^4.0.0", "@nextcloud/initial-state": "^3.0.0",