Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export {
} from './public-auth.ts'

export {
getToastTimeout,
setToastTimeout,
Comment on lines +26 to +27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would be the use case to expose this?
The values are only used internally by the toast implementation?

showError,
showInfo,
showLoading,
Expand Down
114 changes: 114 additions & 0 deletions lib/toast.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
}))
})
})
54 changes: 53 additions & 1 deletion lib/toast.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is only used internally so the value specified on server should be provided in the capabilities of theming and then used in the implementation here

Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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
Expand All @@ -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')
Expand Down