From 0fb0cb1a51ae25daea00b61aff0997d0e16c6347 Mon Sep 17 00:00:00 2001 From: Kristian Zendato Date: Thu, 30 Jul 2026 19:40:37 +0800 Subject: [PATCH] feat: toast timeout configuration Signed-off-by: Kristian Zendato --- README.md | 15 ++++++ lib/index.ts | 2 + lib/toast.spec.ts | 114 ++++++++++++++++++++++++++++++++++++++++++++++ lib/toast.ts | 54 +++++++++++++++++++++- 4 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 lib/toast.spec.ts diff --git a/README.md b/README.md index 09487302d..f5095935a 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,21 @@ 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 }) ``` +You can also configure the timeout used by ordinary toasts (success, error, warning, info, and bare messages). +This is stored on `window`, so separately bundled copies of the library share the same value: + +``` +import { setToastTimeout, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs' + +// Keep toasts visible for 30 seconds +setToastTimeout(30000) + +// Or never auto-dismiss ordinary toasts +setToastTimeout(TOAST_PERMANENT_TIMEOUT) +``` + +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/index.ts b/lib/index.ts index df5bb346d..6a2129c7a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -23,6 +23,8 @@ export { } from './public-auth.ts' export { + getToastTimeout, + setToastTimeout, showError, showInfo, showLoading, diff --git a/lib/toast.spec.ts b/lib/toast.spec.ts new file mode 100644 index 000000000..3496000a2 --- /dev/null +++ b/lib/toast.spec.ts @@ -0,0 +1,114 @@ +/** + * 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 { + getToastTimeout, + setToastTimeout, + showLoading, + showMessage, + showUndo, + TOAST_DEFAULT_TIMEOUT, + TOAST_PERMANENT_TIMEOUT, + TOAST_UNDO_TIMEOUT, +} from './toast.ts' + +const mocks = vi.hoisted(() => ({ + hideToast: vi.fn(), + showToast: vi.fn(), + toastify: vi.fn(), +})) + +vi.mock('toastify-js', () => ({ + default: mocks.toastify, +})) + +const GLOBAL_TOAST_TIMEOUT_KEY = '__nextcloud_dialogs_toast_timeout__' + +beforeEach(() => { + delete window[GLOBAL_TOAST_TIMEOUT_KEY] + vi.clearAllMocks() + mocks.toastify.mockReturnValue({ + hideToast: mocks.hideToast, + showToast: mocks.showToast, + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('toast timeout configuration', () => { + test('uses the default timeout when no valid timeout is configured', () => { + expect(getToastTimeout()).toBe(TOAST_DEFAULT_TIMEOUT) + + window[GLOBAL_TOAST_TIMEOUT_KEY] = 0 + expect(getToastTimeout()).toBe(TOAST_DEFAULT_TIMEOUT) + }) + + test('stores positive and permanent timeouts globally', () => { + setToastTimeout(30_000) + expect(window[GLOBAL_TOAST_TIMEOUT_KEY]).toBe(30_000) + expect(getToastTimeout()).toBe(30_000) + + setToastTimeout(TOAST_PERMANENT_TIMEOUT) + expect(getToastTimeout()).toBe(TOAST_PERMANENT_TIMEOUT) + }) + + test.each([0, -2, Number.NaN])('rejects invalid timeout %s', (timeout) => { + expect(() => setToastTimeout(timeout)) + .toThrow('Toast timeout must be a positive number or TOAST_PERMANENT_TIMEOUT') + }) + + test('falls back safely when window is unavailable', () => { + vi.stubGlobal('window', undefined) + + expect(getToastTimeout()).toBe(TOAST_DEFAULT_TIMEOUT) + expect(() => setToastTimeout(30_000)).not.toThrow() + }) +}) + +describe('toast timeout behavior', () => { + test('uses the configured timeout for ordinary toasts', () => { + setToastTimeout(30_000) + + showMessage('Message', { timeout: 1_000 }) + + expect(mocks.toastify).toHaveBeenCalledWith(expect.objectContaining({ + duration: 30_000, + })) + expect(mocks.showToast).toHaveBeenCalledOnce() + }) + + test('preserves explicitly permanent toasts', () => { + setToastTimeout(30_000) + + showMessage('Message', { timeout: TOAST_PERMANENT_TIMEOUT }) + + expect(mocks.toastify).toHaveBeenCalledWith(expect.objectContaining({ + duration: TOAST_PERMANENT_TIMEOUT, + })) + }) + + test('preserves the loading timeout', () => { + setToastTimeout(30_000) + + showLoading('Loading') + + expect(mocks.toastify).toHaveBeenCalledWith(expect.objectContaining({ + duration: TOAST_PERMANENT_TIMEOUT, + })) + }) + + test('preserves the undo timeout', () => { + setToastTimeout(30_000) + + showUndo('Deleted', vi.fn()) + + expect(mocks.toastify).toHaveBeenCalledWith(expect.objectContaining({ + duration: TOAST_UNDO_TIMEOUT, + })) + }) +}) diff --git a/lib/toast.ts b/lib/toast.ts index fead2c23a..89e36fd7d 100644 --- a/lib/toast.ts +++ b/lib/toast.ts @@ -41,6 +41,48 @@ export const TOAST_DEFAULT_TIMEOUT = 7000 /** Timeout value to show a toast permanently */ export const TOAST_PERMANENT_TIMEOUT = -1 +/** + * Shared browser-global key for the configured toast timeout. + * Using `window` ensures separately bundled copies of this package share the same value. + */ +const GLOBAL_TOAST_TIMEOUT_KEY = '__nextcloud_dialogs_toast_timeout__' + +declare global { + interface Window { + [GLOBAL_TOAST_TIMEOUT_KEY]?: number + } +} + +/** + * Get the configured toast timeout in milliseconds. + * Falls back to {@link TOAST_DEFAULT_TIMEOUT} when unset. + */ +export function getToastTimeout(): number { + if (typeof window !== 'undefined') { + const timeout = window[GLOBAL_TOAST_TIMEOUT_KEY] + if (typeof timeout === 'number' && (timeout === TOAST_PERMANENT_TIMEOUT || timeout > 0)) { + return timeout + } + } + return TOAST_DEFAULT_TIMEOUT +} + +/** + * Set the toast timeout used by ordinary toast helpers. + * Stored on `window` so independently bundled copies of `@nextcloud/dialogs` share it. + * + * @param timeout Timeout in milliseconds, or {@link TOAST_PERMANENT_TIMEOUT} for never dismiss + */ +export function setToastTimeout(timeout: number): void { + if (typeof window === 'undefined') { + return + } + if (timeout !== TOAST_PERMANENT_TIMEOUT && !(timeout > 0)) { + throw new Error('Toast timeout must be a positive number or TOAST_PERMANENT_TIMEOUT') + } + window[GLOBAL_TOAST_TIMEOUT_KEY] = timeout +} + /** * Type of a toast * @@ -104,7 +146,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 +157,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')