Skip to content

Commit ebd2df2

Browse files
humanbirdclaude
andauthored
fix(Toast): announce title and description as text, not JSON (#2612)
The aria-live announcement region rendered `announceTextContent` (a `string[]` returned by `getAnnounceTextContent`) directly with `{{ … }}`. In Vue 3 that interpolation goes through `toDisplayString`, which JSON-stringifies arrays: isArray(val) ? JSON.stringify(val, replacer, 2) : ... So a toast with a `ToastTitle` and a `ToastDescription` ended up being announced as the literal string `[\n "Title",\n "Description"\n]` instead of `Title Description`. The pattern was ported 1:1 from Radix React, where `{children}` of a `string[]` happens to render as concatenated text nodes. Fix: render each chunk via `<template v-for>`, which preserves the "natural pause break between nodes" the comment in `utils.ts` already calls out as the intent. Adds a regression test that asserts the live region's `textContent` contains the toast title and does not contain JSON syntax characters. Closes #2611 Co-authored-by: humanbird <27631243+humanbird@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 78efcf9 commit ebd2df2

2 files changed

Lines changed: 39 additions & 1 deletion

File tree

packages/core/src/Toast/Toast.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,31 @@ describe('given a default Toast', () => {
4141
expect(await axe(document.body)).toHaveNoViolations()
4242
})
4343

44+
it('should announce title and description as plain text (not JSON)', async () => {
45+
await fireEvent.click(trigger.element)
46+
await findByText(document.body, 'Scheduled: Catch up')
47+
48+
// ToastAnnounce renders the live region on the next animation frame
49+
// (see ToastAnnounce.vue's useRafFn) — wait for two RAFs so it's
50+
// guaranteed to be in the DOM.
51+
await new Promise<void>((resolve) => {
52+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
53+
})
54+
55+
const liveRegion = document.querySelector('[role="alert"][aria-live]')
56+
expect(liveRegion).toBeTruthy()
57+
const text = liveRegion!.textContent ?? ''
58+
59+
// Vue's `{{ array }}` would JSON-stringify the announceTextContent
60+
// array — guard against that regression by asserting the live region
61+
// does not contain JSON syntax characters.
62+
expect(text).not.toMatch(/[[\]"]/)
63+
64+
// The toast title and description must both be part of the announced
65+
// text so screen-reader users actually hear the toast.
66+
expect(text).toContain('Scheduled: Catch up')
67+
})
68+
4469
describe('after clicking the trigger', () => {
4570
beforeEach(async () => {
4671
fireEvent.click(trigger.element)

packages/core/src/Toast/ToastRootImpl.vue

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,20 @@ provideToastRootContext({ onClose: handleClose })
183183
role="alert"
184184
:aria-live="type === 'foreground' ? 'assertive' : 'polite'"
185185
>
186-
{{ announceTextContent }}
186+
<!--
187+
Render each chunk as its own text node so screen readers get the
188+
natural pause break between nodes (see comment in utils.ts).
189+
Interpolating the array directly with `{{ announceTextContent }}`
190+
would route through Vue's `toDisplayString`, which JSON-stringifies
191+
arrays — the live region would then announce literal `[`, quotes
192+
and commas instead of the toast title and description.
193+
-->
194+
<template
195+
v-for="(text, i) in announceTextContent"
196+
:key="i"
197+
>
198+
{{ text }}
199+
</template>
187200
</ToastAnnounce>
188201

189202
<Teleport

0 commit comments

Comments
 (0)