diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt deleted file mode 100644 index 137069b82..000000000 --- a/LICENSES/Apache-2.0.txt +++ /dev/null @@ -1,73 +0,0 @@ -Apache License -Version 2.0, January 2004 -http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - -"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. - -"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. - -"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. - -"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. - -"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. - -"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. - -"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). - -"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. - -"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." - -"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: - - (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. - - You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - -To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/REUSE.toml b/REUSE.toml index 4d56978d4..7debece85 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -22,9 +22,3 @@ path = ".env.development" precedence = "aggregate" SPDX-FileCopyrightText = "2025 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "CC0-1.0" - -[[annotations]] -path = ["styles/close-dark.svg", "styles/close.svg", "styles/loader.svg"] -precedence = "aggregate" -SPDX-FileCopyrightText = "2018-2024 Google LLC" -SPDX-License-Identifier = "Apache-2.0" diff --git a/l10n/messages.pot b/l10n/messages.pot index 7d8e0597b..035ebed87 100644 --- a/l10n/messages.pot +++ b/l10n/messages.pot @@ -47,6 +47,9 @@ msgid_plural "Choose %n files" msgstr[0] "" msgstr[1] "" +msgid "Close" +msgstr "" + msgid "Confirm" msgstr "" @@ -77,6 +80,9 @@ msgstr "" msgid "Enter your name" msgstr "" +msgid "Error: {message}" +msgstr "" + msgid "Existing version" msgstr "" @@ -107,6 +113,9 @@ msgstr "" msgid "If you select both versions, the incoming file will have a number added to its name." msgstr "" +msgid "Info: {message}" +msgstr "" + msgid "Invalid folder name." msgstr "" @@ -199,12 +208,18 @@ msgstr "" msgid "Submit name" msgstr "" +msgid "Success: {message}" +msgstr "" + msgid "Undo" msgstr "" msgid "Upload some content or sync with your devices!" msgstr "" +msgid "Warning: {message}" +msgstr "" + msgid "When an incoming folder is selected, any conflicting files within it will also be overwritten." msgstr "" diff --git a/lib/components/ToastNotification.vue b/lib/components/ToastNotification.vue new file mode 100644 index 000000000..3bd734c13 --- /dev/null +++ b/lib/components/ToastNotification.vue @@ -0,0 +1,231 @@ + + + + + + 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 - export interface ToastOptions { /** * Defines the timeout in milliseconds after which the toast is closed. Set to -1 to have a persistent toast. @@ -91,69 +159,156 @@ export interface ToastOptions { * See the following docs for an explanation when to use which: * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions * - * By default, errors are announced assertive and other messages "polite". + * By default, errors and undo toasts are announced assertive; others are polite. */ ariaLive?: ToastAriaLive } +/** + * Handle returned by all show* functions, allowing the toast to be hidden programmatically. + */ +export interface ToastHandle { + hideToast(): void +} + +// The container element for toasts. Shared across all toasts to ensure they stack together. Created on demand. +let _container: HTMLElement | null = null + +/** + * Get the container element for toasts, creating it if it doesn't exist. + * If a selector is provided, it tries to find an existing element matching + * the selector and uses it as the container; otherwise, it falls back to + * a default container appended to the body. + * + * @param selector Optional CSS selector to find an existing container element + * @return The container HTMLElement for toasts + */ +function getContainer(selector?: string): HTMLElement { + if (selector) { + const el = document.querySelector(selector) + if (el) { + return el + } + } + if (_container && document.body.contains(_container)) { + return _container + } + _container = document.createElement('div') + _container.className = 'nc-toast-container' + document.body.appendChild(_container) + return _container +} + +interface _InternalProps { + message: string | Node + isHTML: boolean + type: ToastType | undefined + timeout: number + close: boolean + role: 'alert' | 'status' + selector: string | undefined + onRemove: (() => void) | undefined + onClick: (() => void) | undefined + onUndo: ((e: MouseEvent) => void) | undefined +} + +/** + * Mount a toast notification component with the given properties, and return a handle to control it. + * + * @param internal The internal properties for the toast, including message, type, timeout, callbacks, etc. + */ +function _mountToast(internal: _InternalProps): ToastHandle { + const container = getContainer(internal.selector) + + // display:contents so the wrapper does not affect the flex layout of the container + const wrapper = document.createElement('div') + wrapper.style.cssText = 'display:contents' + container.appendChild(wrapper) + + const app = createApp(ToastNotification, { + message: internal.message, + isHTML: internal.isHTML, + type: internal.type, + timeout: internal.timeout, + close: internal.close, + role: internal.role, + onClick: internal.onClick, + onUndo: internal.onUndo, + onDismiss: () => { + internal.onRemove?.() + app.unmount() + wrapper.remove() + }, + }) + + const vm = app.mount(wrapper) as unknown as { hide(): void } + + return { + hideToast: () => vm.hide(), + } +} + /** * Show a toast message * * @param data Message to be shown in the toast, any HTML is removed by default * @param options ToastOptions */ -export function showMessage(data: string | Node, options?: ToastOptions): Toast { - options = { +export function showMessage(data: string | Node, options?: ToastOptions): ToastHandle { + const opts = { timeout: TOAST_DEFAULT_TIMEOUT, isHTML: false, - type: undefined, - // An undefined selector defaults to the body element - selector: undefined, - onRemove: () => { }, - onClick: undefined, + type: undefined as ToastType | undefined, + selector: undefined as string | undefined, + onRemove: undefined as (() => void) | undefined, + onClick: undefined as (() => void) | undefined, close: true, + ariaLive: undefined as ToastAriaLive | undefined, ...options, } - if (typeof data === 'string' && !options.isHTML) { - // fime mae sure that text is extracted - const element = document.createElement('div') - element.innerHTML = data - data = element.innerText + // Strip HTML from plain-text messages to prevent XSS + if (typeof data === 'string' && !opts.isHTML) { + const el = document.createElement('div') + el.innerHTML = data + data = el.innerText } - let classes = options.type ?? '' - - if (typeof options.onClick === 'function') { - classes += ' toast-with-click ' - } - - const isNode = data instanceof Node + // Resolve aria-live level: explicit option > type default > polite let ariaLive: ToastAriaLive = ToastAriaLive.POLITE - if (options.ariaLive) { - ariaLive = options.ariaLive - } else if (options.type === ToastType.ERROR || options.type === ToastType.UNDO) { + if (opts.ariaLive) { + ariaLive = opts.ariaLive + } else if (opts.type === ToastType.ERROR || opts.type === ToastType.UNDO) { ariaLive = ToastAriaLive.ASSERTIVE } - const toast = Toastify({ - [!isNode ? 'text' : 'node']: data, - duration: options.timeout, - callback: options.onRemove, - onClick: options.onClick, - close: options.close, - gravity: 'top', - selector: options.selector, - position: 'right', - backgroundColor: '', - className: 'dialogs ' + classes, - escapeMarkup: !options.isHTML, - ariaLive, - }) - - toast.showToast() + // Announce to the persistent live region. + // Prefix with a translated type label so the colour-coded border cue is also + // conveyed to screen readers (WCAG 1.4.1). + if (ariaLive !== ToastAriaLive.OFF) { + const text = typeof data === 'string' ? data : getVisibleText(data as Node) + const typeTemplates: Partial> = { + [ToastType.ERROR]: t('Error: {message}', { message: text }), + [ToastType.WARNING]: t('Warning: {message}', { message: text }), + [ToastType.INFO]: t('Info: {message}', { message: text }), + [ToastType.SUCCESS]: t('Success: {message}', { message: text }), + } + const announcement = (opts.type && typeTemplates[opts.type]) ?? text + announceToLiveRegion(announcement, ariaLive === ToastAriaLive.ASSERTIVE ? 'assertive' : 'polite') + } - return toast + return _mountToast({ + message: data, + isHTML: opts.isHTML, + type: opts.type, + timeout: opts.timeout, + close: opts.close, + role: ariaLive === ToastAriaLive.ASSERTIVE ? 'alert' : 'status', + selector: opts.selector, + onRemove: opts.onRemove, + onClick: opts.onClick, + onUndo: undefined, + }) } /** @@ -162,7 +317,7 @@ export function showMessage(data: string | Node, options?: ToastOptions): Toast * @param text Message to be shown in the toast, any HTML is removed by default * @param options ToastOptions */ -export function showError(text: string, options?: ToastOptions): Toast { +export function showError(text: string, options?: ToastOptions): ToastHandle { return showMessage(text, { ...options, type: ToastType.ERROR }) } @@ -172,7 +327,7 @@ export function showError(text: string, options?: ToastOptions): Toast { * @param text Message to be shown in the toast, any HTML is removed by default * @param options ToastOptions */ -export function showWarning(text: string, options?: ToastOptions): Toast { +export function showWarning(text: string, options?: ToastOptions): ToastHandle { return showMessage(text, { ...options, type: ToastType.WARNING }) } @@ -182,7 +337,7 @@ export function showWarning(text: string, options?: ToastOptions): Toast { * @param text Message to be shown in the toast, any HTML is removed by default * @param options ToastOptions */ -export function showInfo(text: string, options?: ToastOptions): Toast { +export function showInfo(text: string, options?: ToastOptions): ToastHandle { return showMessage(text, { ...options, type: ToastType.INFO }) } @@ -192,30 +347,19 @@ export function showInfo(text: string, options?: ToastOptions): Toast { * @param text Message to be shown in the toast, any HTML is removed by default * @param options ToastOptions */ -export function showSuccess(text: string, options?: ToastOptions): Toast { +export function showSuccess(text: string, options?: ToastOptions): ToastHandle { return showMessage(text, { ...options, type: ToastType.SUCCESS }) } /** - * Show a toast message with a loading spinner - * The toast will be shown permanently and needs to be hidden manually by calling hideToast() + * Show a toast message with a loading spinner. + * The toast is permanent, call hideToast() on the returned handle to remove it. * * @param text Message to be shown in the toast, any HTML is removed by default * @param options ToastOptions */ -export function showLoading(text: string, options?: ToastOptions): Toast { - // Generate loader svg - const loader = document.createElement('span') - loader.innerHTML = LoaderSvg - loader.classList.add('toast-loader') - - // Generate loader layout - const loaderContent = document.createElement('span') - loaderContent.classList.add('toast-loader-container') - loaderContent.innerText = text - loaderContent.appendChild(loader) - - return showMessage(loaderContent, { +export function showLoading(text: string, options?: ToastOptions): ToastHandle { + return showMessage(text, { ...options, close: false, timeout: TOAST_PERMANENT_TIMEOUT, @@ -230,37 +374,28 @@ export function showLoading(text: string, options?: ToastOptions): Toast { * @param onUndo Function that is called when the undo button is clicked * @param options ToastOptions */ -export function showUndo(text: string, onUndo: (e: MouseEvent) => void, options?: ToastOptions): Toast { - // onUndo callback is mandatory +export function showUndo(text: string, onUndo: (e: MouseEvent) => void, options?: ToastOptions): ToastHandle { if (!(onUndo instanceof Function)) { throw new Error('Please provide a valid onUndo method') } - options = Object.assign(options || {}, { - // force 10 seconds of timeout - timeout: TOAST_UNDO_TIMEOUT, - }) - - // Generate undo layout - const undoContent = document.createElement('span') - const undoButton = document.createElement('button') - undoContent.classList.add('toast-undo-container') - undoButton.classList.add('toast-undo-button') - undoButton.innerText = t('Undo') - undoContent.innerText = text - undoContent.appendChild(undoButton) + // UNDO defaults to assertive; the caller can override via the ariaLive option + const ariaLive = options?.ariaLive ?? ToastAriaLive.ASSERTIVE - const toast = showMessage(undoContent, { ...options, type: ToastType.UNDO }) - - undoButton.addEventListener('click', function(event) { - event.stopPropagation() - onUndo(event) + if (ariaLive !== ToastAriaLive.OFF) { + announceToLiveRegion(text, ariaLive === ToastAriaLive.ASSERTIVE ? 'assertive' : 'polite') + } - // Hide toast - if (toast?.hideToast instanceof Function) { - toast.hideToast() - } + return _mountToast({ + message: text, + isHTML: false, + type: ToastType.UNDO, + timeout: TOAST_UNDO_TIMEOUT, + close: options?.close ?? true, + role: ariaLive === ToastAriaLive.ASSERTIVE ? 'alert' : 'status', + selector: options?.selector, + onRemove: options?.onRemove, + onClick: options?.onClick, + onUndo, }) - - return toast } diff --git a/package.json b/package.json index 34f01c039..bf97fb3ba 100644 --- a/package.json +++ b/package.json @@ -59,10 +59,8 @@ "@nextcloud/router": "^3.1.0", "@nextcloud/sharing": "^0.4.0", "@nextcloud/vue": "^9.5.0", - "@types/toastify-js": "^1.12.4", - "@vueuse/core": "^14.2.1", + "@nextcloud/vue": "^9.5.0", "p-queue": "^9.0.1", - "toastify-js": "^1.12.0", "vue": "^3.5.30", "webdav": "^5.8.0" }, diff --git a/styles/close-dark.svg b/styles/close-dark.svg deleted file mode 100644 index 4a451fea4..000000000 --- a/styles/close-dark.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/styles/close.svg b/styles/close.svg deleted file mode 100644 index 8ad996e1e..000000000 --- a/styles/close.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/styles/loader.svg b/styles/loader.svg deleted file mode 100644 index 0d48a53f4..000000000 --- a/styles/loader.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/styles/toast.scss b/styles/toast.scss deleted file mode 100644 index 0a2a5a7d0..000000000 --- a/styles/toast.scss +++ /dev/null @@ -1,131 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -// using a different class than server -// remember to import this scss file into your app -.toastify.dialogs { - min-width: 200px; - background: none; - background-color: var(--color-main-background); - color: var(--color-main-text); - box-shadow: 0 0 6px 0 var(--color-box-shadow); - padding: 0 12px; - margin-top: 45px; - position: fixed; - z-index: 10100; - border-radius: var(--border-radius); - display: flex; - align-items: center; - min-height: 50px; - - .toast-loader-container, - .toast-undo-container { - display: flex; - align-items: center; - width: 100%; - } - - .toast-undo-button, - .toast-close { - position: static; - overflow: hidden; - box-sizing: border-box; - min-width: 44px; - height: 100%; - padding: 12px; - white-space: nowrap; - background-repeat: no-repeat; - background-position: center; - background-color: transparent; - min-height: 0; - - // icon styling - &.toast-close { - text-indent: 0; - opacity: .4; - border: none; - min-height: 44px; - margin-left: 10px; - font-size: 0; - - /* dark theme overrides for Nextcloud 25 and later */ - &::before { - background-image: url('./close.svg'); - content: ' '; - filter: var(--background-invert-if-dark); - - display: inline-block; - width: 16px; - height: 16px; - } - } - - &.toast-undo-button { - $margin: 3px; - margin: $margin; - height: calc(100% - 2 * #{$margin}); - margin-left: 12px; - } - - &:hover, &:focus, &:active { - cursor: pointer; - opacity: 1; - } - } - - &.toastify-top { - right: 10px; - } - - // Toast with onClick callback - &.toast-with-click { - cursor: pointer; - } - - // Various toasts types - &.toast-error { - border-left: 3px solid var(--color-element-error, var(--color-error)); - } - - &.toast-info { - border-left: 3px solid var(--color-element-info, var(--color-primary)); - } - - &.toast-warning { - border-left: 3px solid var(--color-element-warning, var(--color-warning)); - } - - &.toast-success { - border-left: 3px solid var(--color-element-success, var(--color-success)); - } - - &.toast-undo { - border-left: 3px solid var(--color-element-success, var(--color-success)); - } - - &.toast-loading { - border-left: 3px solid var(--color-element-info, var(--color-primary)); - - .toast-loader { - display: inline-block; - width: 20px; - height: 20px; - animation: rotate var(--animation-duration, 0.8s) linear infinite; - margin-left: auto; - } - } -} - -/* dark theme overrides for Nextcloud 24 and earlier */ -.theme--dark { - .toastify.dialogs { - .toast-close { - /* close icon style */ - &.toast-close::before { - background-image: url('./close-dark.svg'); - } - } - } -}