Skip to content

feat: accessible toast and drop toastify#2324

Open
skjnldsv wants to merge 6 commits into
mainfrom
fix/a11y
Open

feat: accessible toast and drop toastify#2324
skjnldsv wants to merge 6 commits into
mainfrom
fix/a11y

Conversation

@skjnldsv

@skjnldsv skjnldsv commented Mar 17, 2026

Copy link
Copy Markdown
Contributor

Fix #2254

⚠️ Initially just a fix, afetr @susnux's comment, I decided it was a good thing to rewrite the implementation

Summary

Drop toastify-js

Removes toastify-js (last third-party UI dep for toasts) and replaces it with a self-contained ToastNotification.vue. The public API is unchanged.

  • Styling moved into the component using BEM, old SVG assets removed
  • Close and undo buttons now use NcButton, spinner uses NcLoadingIcon
  • Toasts slide in with a short fade+translate animation
  • Height aligned to --clickable-area-large, positioned below the header

Accessibility

  • Persistent aria-live regions (polite + assertive) are created once and reused, injecting a live region that already carries aria-live is unreliable in NVDA/JAWS, so they are pre-created before any toast is shown
  • Announcements are prefixed with the toast type using a translatable {message} placeholder ("Error: {message}") so the colour cue is also conveyed to screen readers (WCAG 1.4.1)
  • The live region is synchronously cleared before each announcement so repeating the same message is always re-read
  • role="alert" / role="status" set on each toast based on the resolved ariaLive level
  • NcLoadingIcon is wrapped in aria-hidden="true" so the spinner doesn't pollute announcements

Demo

Peek.18-03-2026.15-40.mp4

@skjnldsv skjnldsv self-assigned this Mar 17, 2026
@skjnldsv
skjnldsv requested review from ShGKme and susnux March 17, 2026 16:46
@skjnldsv skjnldsv added bug Something isn't working 3. to review accessibility for accessibility work or changes labels Mar 17, 2026
@codecov

codecov Bot commented Mar 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.16%. Comparing base (a30d13d) to head (9d645d2).

Additional details and impacted files
@@             Coverage Diff             @@
##             main    #2324       +/-   ##
===========================================
+ Coverage   43.79%   59.16%   +15.36%     
===========================================
  Files          15       15               
  Lines         427      453       +26     
  Branches       81       95       +14     
===========================================
+ Hits          187      268       +81     
+ Misses        211      164       -47     
+ Partials       29       21        -8     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

susnux

This comment was marked as resolved.

susnux

This comment was marked as resolved.

szaimen

This comment was marked as resolved.

@skjnldsv

This comment was marked as resolved.

@skjnldsv skjnldsv changed the title fix: make toasts accessible feat: accessible toast and drop toastify Mar 18, 2026
@skjnldsv
skjnldsv requested review from susnux and szaimen March 18, 2026 14:49
@skjnldsv

Copy link
Copy Markdown
Contributor Author

I wonder if we should completely remove the toasts dependency and write it from scratch as NcToast or similar to not have to monkey patch it all the time 😅

Done @susnux 🚀

@skjnldsv
skjnldsv force-pushed the fix/a11y branch 2 times, most recently from 6ca5db6 to e245c3e Compare March 18, 2026 14:51

@szaimen szaimen left a comment

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.

🐘

@susnux

susnux commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

Oh wow I did not expect this to happen 😆
Did you test how it works if old + new version are used next to each other (e.g. because one app uses an old version?)

@susnux
susnux dismissed their stale review March 18, 2026 15:01

outdated changes

Comment thread package.json Outdated
@skjnldsv

skjnldsv commented Mar 18, 2026

Copy link
Copy Markdown
Contributor Author

Did you test how it works if old + new version are used next to each other (e.g. because one app uses an old version?)

Good call, I did not test it 🤔
EDIT: I guess they would overlap, but we rarely have two apps over the other, I think it's unlikely we face that issue.
Having simultaneously two messages dispatched by one app and one call from another app seems unlikely, no ?

@susnux

susnux commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

Having simultaneously two messages dispatched by one app and one call from another app seems unlikely, no ?

I can only imagine integrations like e.g. text in files or similar.

@skjnldsv

Copy link
Copy Markdown
Contributor Author

I can only imagine integrations like e.g. text in files or similar.

image

Yeah, that seems like a very specific race condition and context to have.
Honestly I think it's fine to leave as is

Comment thread lib/components/ToastNotification.vue Outdated
susnux
susnux previously requested changes Mar 24, 2026
Comment thread lib/components/ToastNotification.vue Outdated

@susnux susnux left a comment

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 one question we should solve would be if we want to keep HTML support, otherwise we could simplify this a lot.
We could still allow custom toast content by exposing the component from nextcloud-vue.

So in that case the component would have a onBeforeCreate that checks if the container is available or creates it if necessary.
And the NcToast component would roughly look like:

<template>
    <Teleport :to="toastContainerSelector">
        <div><!--- .... --->
            <slot><!-- ... --></slot>
        </div>
    </Teleport>
</template>

This would allow us to drop all of the custom "mounting" and announcement logic.
As then the announcement would also just teleport to the corresponding live region¹.

¹: This is currently a problem if you get 2 notifications quickly then the content is announced but directly cleared again and new notification is announced.
So teleporting into it would solve it because then its a list of active announcements.

Comment thread lib/toast.ts Outdated
Comment thread lib/toast.ts Outdated
Comment thread lib/components/ToastNotification.vue Outdated
@skjnldsv

Copy link
Copy Markdown
Contributor Author

¹: This is currently a problem if you get 2 notifications quickly then the content is announced but directly cleared again and new notification is announced.
So teleporting into it would solve it because then its a list of active announcements.

Interesting topic! So, regarding the announcements, announcements != toasts.
But it also is weird to erase any previous announcement. There's also the topic of alert level priority. Not sure how it would behave if we keep a list of announcements.

I'll think about it tomorrow. I'll check if I can come up with something clever.

@GVodyanov

Copy link
Copy Markdown

Hey @skjnldsv, thanks for working on this. Is this still a PR you want to get in or should I make a new one, thanks!

@skjnldsv

Copy link
Copy Markdown
Contributor Author

@GVodyanov it should still go in at some point. But it might require a revive :)

skjnldsv added 2 commits July 17, 2026 09:11
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
Addresses susnux's blocking review comment: toast/container styles
were global unscoped BEM classes, risking collisions when multiple
library bundle versions are loaded on the same page. Both components
now use `<style module>`, matching the ConflictPicker convention
already in the repo.

Also:
- close prop renamed to noClose (defaults false), per susnux
- toast container + live regions extracted into ToastContainer.vue,
  shared via a window-global singleton so multiple bundle versions
  render into one stack instead of stacking separately
- polite announcements now queue as separate <li> items instead of
  clobbering each other on rapid succession
- toast stack repositions next to NcAppNavigation via the real
  navigation-toggled event
- dropped stray @eslint/js devDependency bump that had crept in,
  unrelated to this change

Assisted-by: ClaudeCode:claude-sonnet-5
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
Codecov flagged ~88% patch coverage on toast.ts with the onDismiss
cleanup path and showUndo's invalid-callback guard uncovered. Adds:
- hideToast() / close button removal + onRemove callback
- auto-dismiss timeout and permanent (loading) toast
- showUndo: invalid callback throws, undo button calls onUndo and
  dismisses
- selector option mounts into a custom host element
- toast container gets the nav-open modifier on navigation-toggled

toast.ts coverage: 80.76% -> 93.58% lines.

Assisted-by: ClaudeCode:claude-sonnet-5
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
@skjnldsv

Copy link
Copy Markdown
Contributor Author

Revived and addressed your comments Ferdinand, please feel free to have another look @susnux @GVodyanov

@skjnldsv
skjnldsv enabled auto-merge July 17, 2026 07:27
@skjnldsv
skjnldsv requested a review from susnux July 17, 2026 07:27
showMessage(html, { isHTML: true }) announced the raw markup string to
the live region instead of its visible text — getVisibleText() (which
strips aria-hidden subtrees) was only applied to Node messages, never
to HTML strings, so screen readers would read out literal tag syntax.
Introduces getAnnouncementText() so HTML strings go through the same
parse-and-strip path as Node messages.

Also covers accessibility-relevant paths that had no tests:
- isHTML announcements are stripped to plain text
- isHTML and Node messages both skip aria-hidden subtrees in
  announcements (getVisibleText was previously untested)
- undo toasts expose two distinguishable interactive elements
  (Undo action + Close button) rather than one ambiguous one
- toasts stack in the order they were shown

Assisted-by: ClaudeCode:claude-sonnet-5
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
Comment thread lib/toast.ts Fixed
…ment

CodeQL flagged the innerHTML assignment used to strip/extract text from
HTML message strings (js/xss-through-dom, "DOM text reinterpreted as
HTML") on lib/toast.ts:49. Not exploitable as written — the element was
never attached to the document, and innerHTML-inserted <script> tags
never execute regardless of attachment — but the pattern is still
assigning untrusted input to .innerHTML of a document.createElement'd
node, which CodeQL's dataflow can't distinguish from a live-DOM sink.

Switch both spots (the isHTML-announcement path added in the previous
commit, and the pre-existing plain-text stripping path) to
`new DOMParser().parseFromString(data, 'text/html')`. The parsed
result is a standalone Document, never part of the page's DOM at all,
which removes the ambiguity outright rather than relying on
"detached == safe" reasoning.

Assisted-by: ClaudeCode:claude-sonnet-5
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
Comment thread lib/toast.ts Outdated
Comment on lines +11 to +53
/**
* 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('')
}

/**
* Derive the plain text to announce in the live region for a given message.
* HTML strings are parsed so tags don't leak into the announcement and
* aria-hidden subtrees are skipped, matching the Node message behaviour.
*
* @param data The message passed to showMessage: plain text, HTML string, or a DOM Node
* @param isHTML Whether `data` should be parsed as HTML
* @return The visible plain-text announcement
*/
function getAnnouncementText(data: string | Node, isHTML: boolean): string {
if (typeof data === 'string') {
if (!isHTML) {
return data
}
// Parse in a standalone document (never attached to the page) so the
// markup is never live DOM, not just visually inert.
return getVisibleText(new DOMParser().parseFromString(data, 'text/html').body)
}
return getVisibleText(data)
}

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.

This should be placed below the exported consts and interfaces.
(you can also place them below exported functions if you like as they are functions they get hoisted on execution anyways and are defined when needed by other function)

Per susnux's review comment: getVisibleText/getAnnouncementText were
declared before the public ToastType/ToastAriaLive/ToastOptions/
ToastHandle exports. Function declarations are hoisted, so this was
never a runtime issue, just readability — move them next to their only
caller (showMessage) so the exported API surface reads first.

Assisted-by: ClaudeCode:claude-sonnet-5
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3. to review accessibility for accessibility work or changes bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Making toasts more accessible

5 participants