From e6b9298fed53e64bbcc1b0ccf3a7bf2e00a49ad4 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Tue, 9 Apr 2024 12:57:43 +0200 Subject: [PATCH 1/9] experiment: Centralized announcer prototype --- .../components/live-region/controller.ts | 68 +++++++++ src/internal/components/live-region/index.tsx | 129 +++++++----------- .../components/live-region/styles.scss | 7 +- 3 files changed, 120 insertions(+), 84 deletions(-) create mode 100644 src/internal/components/live-region/controller.ts diff --git a/src/internal/components/live-region/controller.ts b/src/internal/components/live-region/controller.ts new file mode 100644 index 0000000000..8f5e6f9f84 --- /dev/null +++ b/src/internal/components/live-region/controller.ts @@ -0,0 +1,68 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import styles from './styles.css.js'; + +class LiveRegionController { + private _element: HTMLElement | undefined; + private _timeoutId: number | undefined; + private _delay: number; + private readonly _nextMessage = new Set(); + + constructor(public readonly politeness: 'polite' | 'assertive', public readonly defaultDelay: number = 50) { + this._delay = defaultDelay; + } + + initialize() { + if (!this._element) { + this._element = document.createElement('div'); + this._element.className = styles.announcer; + this._element.ariaLive = this.politeness; + this._element.ariaAtomic = 'true'; + document.body.appendChild(this._element); + } + } + + announce(message: string, minDelay?: number) { + this._nextMessage.add(message); + + // A message was added with a longer delay, so we delay the whole announcement. + // This is cleaner than potentially having valid announcements collide. + if (this._timeoutId !== undefined && minDelay !== undefined && this._delay < minDelay) { + this._delay = minDelay; + clearTimeout(this._timeoutId); + this._timeoutId = undefined; + } + + if (this._timeoutId === undefined) { + this._timeoutId = setTimeout(() => this._updateElement(), this._delay); + } + } + + destroy() { + this._element?.remove(); + if (this._timeoutId) { + clearTimeout(this._timeoutId); + } + } + + private _updateElement() { + if (!this._element) { + return; + } + + // TODO: check if next announcement was the same as the last one? + + // The aria-atomic does not work properly in Voice Over, causing + // certain parts of the content to be ignored. To fix that, + // we assign the source text content as a single node. + this._element.innerText = [...this._nextMessage].join(' '); + + this._timeoutId = undefined; + this._delay = this.defaultDelay; + this._nextMessage.clear(); + } +} + +export const polite = new LiveRegionController('polite'); +export const assertive = new LiveRegionController('assertive'); diff --git a/src/internal/components/live-region/index.tsx b/src/internal/components/live-region/index.tsx index a5afb3dea8..75470abb9d 100644 --- a/src/internal/components/live-region/index.tsx +++ b/src/internal/components/live-region/index.tsx @@ -6,16 +6,18 @@ import React, { memo, useEffect, useRef } from 'react'; import clsx from 'clsx'; -import ScreenreaderOnly, { ScreenreaderOnlyProps } from '../screenreader-only'; +import { BaseComponentProps } from '../../base-component'; +import { assertive, polite } from './controller'; import styles from './styles.css.js'; -export interface LiveRegionProps extends ScreenreaderOnlyProps { +export interface LiveRegionProps extends BaseComponentProps { + tagName?: 'span' | 'div'; assertive?: boolean; - delay?: number; visible?: boolean; - tagName?: 'span' | 'div'; - id?: string; + delay?: number; + children?: React.ReactNode; + /** * Use a list of strings and/or existing DOM elements for building the * announcement text. This avoids rendering separate content just for this @@ -73,98 +75,44 @@ export interface LiveRegionProps extends ScreenreaderOnlyProps { export default memo(LiveRegion); function LiveRegion({ - assertive = false, - delay = 10, + assertive: isAssertive = false, visible = false, tagName: TagName = 'span', + delay, children, id, source, ...restProps }: LiveRegionProps) { const sourceRef = useRef(null); - const targetRef = useRef(null); - - /* - When React state changes, React often produces too many DOM updates, causing NVDA to - issue many announcements for the same logical event (See https://github.com/nvaccess/nvda/issues/7996). - - The code below imitates a debouncing, scheduling a callback every time new React state - update is detected. When a callback resolves, it copies content from a muted element - to the live region, which is recognized by screen readers as an update. + const previousSourceContentRef = useRef(); - If the use case requires no announcement to be ignored, use delay = 0, but ensure it - does not impact the performance. If it does, prefer using a string as children prop. - */ useEffect(() => { - function getSourceContent() { - if (source) { - return source - .map(item => { - if (!item) { - return undefined; - } - if (typeof item === 'string') { - return item; - } - if (item.current) { - return extractInnerText(item.current); - } - }) - .filter(Boolean) - .join(' '); - } - - if (sourceRef.current) { - return extractInnerText(sourceRef.current); - } - } - function updateLiveRegion() { - const sourceContent = getSourceContent(); + polite.initialize(); + assertive.initialize(); + }, []); - if (targetRef.current && sourceContent) { - const targetContent = extractInnerText(targetRef.current); - if (targetContent !== sourceContent) { - // The aria-atomic does not work properly in Voice Over, causing - // certain parts of the content to be ignored. To fix that, - // we assign the source text content as a single node. - targetRef.current.innerText = sourceContent; - } - } - } - - let timeoutId: null | number; - if (delay) { - timeoutId = setTimeout(updateLiveRegion, delay); - } else { - updateLiveRegion(); + useEffect(() => { + const content = source + ? getSourceContent(source) + : sourceRef.current + ? extractInnerText(sourceRef.current) + : undefined; + + if (content && content !== previousSourceContentRef.current) { + (isAssertive ? assertive : polite).announce(content, delay); + previousSourceContentRef.current = content; } - - return () => { - if (timeoutId) { - clearTimeout(timeoutId); - } - }; }); - return ( - <> - {visible && !source && ( - - {children} - - )} - - - {!visible && !source && ( - - )} + if (!visible || source) { + return null; + } - - - + return ( + + {children} + ); } @@ -174,3 +122,20 @@ function LiveRegion({ function extractInnerText(node: HTMLElement) { return (node.innerText || '').replace(/\s+/g, ' ').trim(); } + +function getSourceContent(source: Exclude) { + return source + .map(item => { + if (!item) { + return undefined; + } + if (typeof item === 'string') { + return item; + } + if (item.current) { + return extractInnerText(item.current); + } + }) + .filter(Boolean) + .join(' '); +} diff --git a/src/internal/components/live-region/styles.scss b/src/internal/components/live-region/styles.scss index e9e9610f2a..dd0b0dfca0 100644 --- a/src/internal/components/live-region/styles.scss +++ b/src/internal/components/live-region/styles.scss @@ -3,9 +3,12 @@ SPDX-License-Identifier: Apache-2.0 */ +@use '../../styles' as styles; + .root { /* used in test-utils */ } -.source { - /* used in test-utils */ + +.announcer { + @include styles.awsui-util-hide; } From bee8cc699fcf4de4fab3a21ef7ee43a9c5d4d5f3 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Thu, 11 Apr 2024 11:44:06 +0200 Subject: [PATCH 2/9] More comments --- .../components/live-region/controller.ts | 4 + src/internal/components/live-region/index.tsx | 108 ++++++++++-------- 2 files changed, 62 insertions(+), 50 deletions(-) diff --git a/src/internal/components/live-region/controller.ts b/src/internal/components/live-region/controller.ts index 8f5e6f9f84..457120b595 100644 --- a/src/internal/components/live-region/controller.ts +++ b/src/internal/components/live-region/controller.ts @@ -19,6 +19,10 @@ class LiveRegionController { this._element.className = styles.announcer; this._element.ariaLive = this.politeness; this._element.ariaAtomic = 'true'; + + // Doesn't serve a technical purpose, just helps to track this element in the DOM. + this._element.dataset.awsuiLiveAnnouncer = 'true'; + document.body.appendChild(this._element); } } diff --git a/src/internal/components/live-region/index.tsx b/src/internal/components/live-region/index.tsx index 75470abb9d..3f9e97e720 100644 --- a/src/internal/components/live-region/index.tsx +++ b/src/internal/components/live-region/index.tsx @@ -3,29 +3,59 @@ /* eslint-disable @cloudscape-design/prefer-live-region */ -import React, { memo, useEffect, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import clsx from 'clsx'; import { BaseComponentProps } from '../../base-component'; import { assertive, polite } from './controller'; import styles from './styles.css.js'; +import ScreenreaderOnly from '../screenreader-only'; export interface LiveRegionProps extends BaseComponentProps { - tagName?: 'span' | 'div'; + /** + * Whether the announcements should be made using assertive aria-live. + * You should almost always leave this set to false unless you have a good + * reason. + * @default false + */ assertive?: boolean; - visible?: boolean; + + /** + * The delay between each announcement from this live region. You should + * leave this set to the default unless this live region is commonly + * interrupted by other actions (like text entry in text filtering). + */ delay?: number; - children?: React.ReactNode; /** - * Use a list of strings and/or existing DOM elements for building the - * announcement text. This avoids rendering separate content just for this - * LiveRegion. + * Use a list of strings and/or refs to existing elements for building the + * announcement text. This avoids rendering separate content twice just for + * this LiveRegion. * * If this property is set, the `children` will be ignored. */ source?: Array | undefined>; + + /** + * Use the rendered content as the source for the announcement text. + * + * If interactive content is rendered inside `children`, it will be visually + * hidden, but still interactive. Consider using `source` instead. + */ + children?: React.ReactNode; + + /** + * Visibly render the contents of the live region. + * @default false + */ + visible?: boolean; + + /** + * The tag to render the live region as. + * @default "span" + */ + tagName?: 'span' | 'div'; } /** @@ -35,28 +65,8 @@ export interface LiveRegionProps extends BaseComponentProps { * The way live region works differently in different browsers and screen readers and * it is recommended to manually test every new implementation. * - * If you notice there are different words being merged together, - * check if there are text nodes not being wrapped in elements, like: - * ``` - * - * {title} - *
- * - * ``` - * - * To fix, wrap "title" in an element: * ``` - * - * {title} - *
- * - * ``` - * - * Or create a single text node if possible: - * ``` - * - * {`${title} ${details}`} - * + * * ``` * * The live region is always atomic, because non-atomic regions can be treated by screen readers @@ -64,27 +74,27 @@ export interface LiveRegionProps extends BaseComponentProps { * multiple live regions: * ``` * <> - * {title} - *
+ * + * * * ``` - * - * If you place interactive content inside the LiveRegion, the content will still be - * interactive (e.g. as a tab stop). Consider using the `source` property instead. */ -export default memo(LiveRegion); - -function LiveRegion({ +export default function LiveRegion({ assertive: isAssertive = false, visible = false, tagName: TagName = 'span', delay, children, - id, source, + className, ...restProps }: LiveRegionProps) { const sourceRef = useRef(null); + + // The announcer is a globally managed singleton. We're using a ref + // here because we're entering imperative land when using the controller + // and we don't want things like double-rendering to double-announce + // content. const previousSourceContentRef = useRef(); useEffect(() => { @@ -100,36 +110,34 @@ function LiveRegion({ : undefined; if (content && content !== previousSourceContentRef.current) { - (isAssertive ? assertive : polite).announce(content, delay); + const announcer = isAssertive ? assertive : polite; + announcer.announce(content, delay); previousSourceContentRef.current = content; } }); - if (!visible || source) { + if (source) { return null; } return ( - - {children} + + {visible ? children : {children}} ); } -// This only extracts text content from the node including all its children which is enough for now. -// To make it more powerful, it is possible to create a more sophisticated extractor with respect to -// ARIA properties to ignore aria-hidden nodes and read ARIA labels from the live content. -function extractInnerText(node: HTMLElement) { +function extractInnerText(node: HTMLElement): string { + // This only extracts text content from the node including all its children which is enough for now. + // To make it more powerful, it is possible to create a more sophisticated extractor with respect to + // ARIA properties to ignore aria-hidden nodes and read ARIA labels from the live content. return (node.innerText || '').replace(/\s+/g, ' ').trim(); } -function getSourceContent(source: Exclude) { +function getSourceContent(source: Array | undefined>): string { return source .map(item => { - if (!item) { - return undefined; - } - if (typeof item === 'string') { + if (!item || typeof item === 'string') { return item; } if (item.current) { From 9051c06d6536f1fe02c16cae8576edd4c1f82019 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Thu, 11 Apr 2024 17:22:49 +0200 Subject: [PATCH 3/9] Re-announce duplicate announcements. --- .../components/live-region/controller.ts | 19 ++++++++++++++----- src/internal/components/live-region/index.tsx | 1 + 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/internal/components/live-region/controller.ts b/src/internal/components/live-region/controller.ts index 457120b595..31eb151e1c 100644 --- a/src/internal/components/live-region/controller.ts +++ b/src/internal/components/live-region/controller.ts @@ -7,7 +7,8 @@ class LiveRegionController { private _element: HTMLElement | undefined; private _timeoutId: number | undefined; private _delay: number; - private readonly _nextMessage = new Set(); + private _lastAnnouncement = ''; + private readonly _nextMessages = new Set(); constructor(public readonly politeness: 'polite' | 'assertive', public readonly defaultDelay: number = 50) { this._delay = defaultDelay; @@ -28,7 +29,7 @@ class LiveRegionController { } announce(message: string, minDelay?: number) { - this._nextMessage.add(message); + this._nextMessages.add(message); // A message was added with a longer delay, so we delay the whole announcement. // This is cleaner than potentially having valid announcements collide. @@ -55,16 +56,24 @@ class LiveRegionController { return; } - // TODO: check if next announcement was the same as the last one? + let nextAnnouncement = [...this._nextMessages].join(' '); + if (nextAnnouncement === this._lastAnnouncement) { + // A (generally) safe way of forcing re-announcements is toggling the + // terminal period. If we keep adding periods, it's going to be + // eventually interpreted as an ellipsis. + nextAnnouncement = nextAnnouncement.endsWith('.') ? nextAnnouncement.slice(0, -1) : nextAnnouncement + '.'; + } // The aria-atomic does not work properly in Voice Over, causing // certain parts of the content to be ignored. To fix that, // we assign the source text content as a single node. - this._element.innerText = [...this._nextMessage].join(' '); + this._element.innerText = nextAnnouncement; + this._lastAnnouncement = nextAnnouncement; + // Reset the state for the next announcement. this._timeoutId = undefined; this._delay = this.defaultDelay; - this._nextMessage.clear(); + this._nextMessages.clear(); } } diff --git a/src/internal/components/live-region/index.tsx b/src/internal/components/live-region/index.tsx index 3f9e97e720..d3f0deadad 100644 --- a/src/internal/components/live-region/index.tsx +++ b/src/internal/components/live-region/index.tsx @@ -72,6 +72,7 @@ export interface LiveRegionProps extends BaseComponentProps { * The live region is always atomic, because non-atomic regions can be treated by screen readers * differently and produce unexpected results. To imitate non-atomic announcements simply use * multiple live regions: + * * ``` * <> * From 0867a0c82446335e100696a75c49e79b0177ae0a Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Thu, 25 Apr 2024 15:30:30 +0200 Subject: [PATCH 4/9] Update tests. --- .../__tests__/live-region.test.tsx | 72 ++++++++++++------- .../components/live-region/controller.ts | 33 +++++---- src/internal/components/live-region/index.tsx | 10 +-- 3 files changed, 71 insertions(+), 44 deletions(-) diff --git a/src/internal/components/live-region/__tests__/live-region.test.tsx b/src/internal/components/live-region/__tests__/live-region.test.tsx index 1758b57889..28e876ebdc 100644 --- a/src/internal/components/live-region/__tests__/live-region.test.tsx +++ b/src/internal/components/live-region/__tests__/live-region.test.tsx @@ -3,53 +3,66 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; -import LiveRegion from '../../../../../lib/components/internal/components/live-region'; -import createWrapper from '../../../../../lib/components/test-utils/dom'; +import LiveRegion, { assertive, polite } from '../../../../../lib/components/internal/components/live-region'; import { mockInnerText } from '../../../../internal/analytics/__tests__/mocks'; +import styles from '../../../../../lib/components/internal/components/live-region/styles.css.js'; + mockInnerText(); const renderLiveRegion = async (jsx: React.ReactElement) => { const { container } = render(jsx); - const wrapper = createWrapper(container); - - await waitFor(() => expect(wrapper.find('[aria-live]')!.getElement()).not.toBeEmptyDOMElement()); + await waitFor(() => expect(document.querySelector('[aria-live=polite]'))); return { - wrapper, - container, - visibleSource: wrapper.find(':first-child')?.getElement(), - hiddenSource: wrapper.find('[aria-hidden=true]')?.getElement(), - liveRegion: wrapper.find('[aria-live]')!.getElement(), + visibleSource: container.querySelector(`.${styles.root}`), + hiddenSource: container.querySelector('[hidden]'), + politeRegion: document.querySelector('[aria-live=polite]')!, + assertiveRegion: document.querySelector('[aria-live=assertive]')!, }; }; +// The announcers persist throughout the lifecycle of the application. +// We need to reset them after each test. +afterEach(() => { + polite.reset(); + assertive.reset(); +}); + describe('LiveRegion', () => { it('renders', async () => { - const { hiddenSource, liveRegion } = await renderLiveRegion(Announcement); + const { hiddenSource, politeRegion } = await renderLiveRegion(Announcement); expect(hiddenSource).toHaveTextContent('Announcement'); - expect(liveRegion).toHaveAttribute('aria-live', 'polite'); - expect(liveRegion).toHaveAttribute('aria-atomic', 'true'); - expect(liveRegion).toHaveTextContent('Announcement'); + expect(politeRegion).toHaveAttribute('aria-live', 'polite'); + expect(politeRegion).toHaveAttribute('aria-atomic', 'true'); + expect(politeRegion).toHaveTextContent('Announcement'); }); it('renders with a span by default', async () => { - const { hiddenSource, liveRegion } = await renderLiveRegion(Announcement); + const { hiddenSource, politeRegion } = await renderLiveRegion(Announcement); expect(hiddenSource!.tagName).toBe('SPAN'); - expect(liveRegion).toHaveTextContent('Announcement'); + expect(politeRegion).toHaveTextContent('Announcement'); }); it('wraps visible content in a span by default', async () => { - const { visibleSource } = await renderLiveRegion(Announcement); + const { visibleSource } = await renderLiveRegion( + + Announcement + + ); expect(visibleSource!.tagName).toBe('SPAN'); expect(visibleSource).toHaveTextContent('Announcement'); }); it('can render with a div', async () => { - const { hiddenSource } = await renderLiveRegion(Announcement); + const { hiddenSource } = await renderLiveRegion( + + Announcement + + ); expect(hiddenSource!.tagName).toBe('DIV'); expect(hiddenSource).toHaveTextContent('Announcement'); @@ -57,7 +70,7 @@ describe('LiveRegion', () => { it('can wrap visible content in a div', async () => { const { visibleSource } = await renderLiveRegion( - + Announcement ); @@ -66,19 +79,28 @@ describe('LiveRegion', () => { }); it('can render assertive live region', async () => { - const { liveRegion } = await renderLiveRegion(Announcement); - expect(liveRegion).toHaveAttribute('aria-live', 'assertive'); + const { politeRegion, assertiveRegion } = await renderLiveRegion( + + Announcement + + ); + console.log({ assertiveRegion, politeRegion }); + expect(assertiveRegion).toHaveAttribute('aria-live', 'assertive'); + expect(assertiveRegion).toHaveTextContent('Announcement'); + expect(politeRegion).toBeEmptyDOMElement(); }); it('uses the `source` parameter if provided', async () => { const ref = { current: null }; - const { liveRegion } = await renderLiveRegion( + const { politeRegion } = await renderLiveRegion( <> - Announcement + + Announcement + Element text ); - expect(liveRegion).toHaveTextContent('static text Element text more static text'); - expect(liveRegion).not.toHaveTextContent('Announcement'); + expect(politeRegion).toHaveTextContent('static text Element text more static text'); + expect(politeRegion).not.toHaveTextContent('Announcement'); }); }); diff --git a/src/internal/components/live-region/controller.ts b/src/internal/components/live-region/controller.ts index 31eb151e1c..00558fafa9 100644 --- a/src/internal/components/live-region/controller.ts +++ b/src/internal/components/live-region/controller.ts @@ -6,29 +6,25 @@ import styles from './styles.css.js'; class LiveRegionController { private _element: HTMLElement | undefined; private _timeoutId: number | undefined; - private _delay: number; + private _delay = 0; private _lastAnnouncement = ''; private readonly _nextMessages = new Set(); - constructor(public readonly politeness: 'polite' | 'assertive', public readonly defaultDelay: number = 50) { - this._delay = defaultDelay; - } + constructor(public readonly politeness: 'polite' | 'assertive') {} initialize() { if (!this._element) { this._element = document.createElement('div'); this._element.className = styles.announcer; - this._element.ariaLive = this.politeness; - this._element.ariaAtomic = 'true'; - - // Doesn't serve a technical purpose, just helps to track this element in the DOM. - this._element.dataset.awsuiLiveAnnouncer = 'true'; + this._element.setAttribute('aria-live', this.politeness); + this._element.setAttribute('aria-atomic', 'true'); + this._element.setAttribute('data-awsui-live-announcer', 'true'); document.body.appendChild(this._element); } } - announce(message: string, minDelay?: number) { + announce(message: string, minDelay = 50) { this._nextMessages.add(message); // A message was added with a longer delay, so we delay the whole announcement. @@ -39,15 +35,22 @@ class LiveRegionController { this._timeoutId = undefined; } - if (this._timeoutId === undefined) { + if (this._delay === 0 && minDelay === 0) { + // If the delay is 0, just skip the timeout shenanigans and update the + // element synchronously. Great for tests. + this._updateElement(); + } else if (this._timeoutId === undefined) { this._timeoutId = setTimeout(() => this._updateElement(), this._delay); } } - destroy() { - this._element?.remove(); + reset() { + if (this._element) { + this._element.textContent = ''; + } if (this._timeoutId) { clearTimeout(this._timeoutId); + this._timeoutId = undefined; } } @@ -67,12 +70,12 @@ class LiveRegionController { // The aria-atomic does not work properly in Voice Over, causing // certain parts of the content to be ignored. To fix that, // we assign the source text content as a single node. - this._element.innerText = nextAnnouncement; + this._element.textContent = nextAnnouncement; this._lastAnnouncement = nextAnnouncement; // Reset the state for the next announcement. this._timeoutId = undefined; - this._delay = this.defaultDelay; + this._delay = 0; this._nextMessages.clear(); } } diff --git a/src/internal/components/live-region/index.tsx b/src/internal/components/live-region/index.tsx index d3f0deadad..9d6ea6b7ce 100644 --- a/src/internal/components/live-region/index.tsx +++ b/src/internal/components/live-region/index.tsx @@ -10,7 +10,9 @@ import { BaseComponentProps } from '../../base-component'; import { assertive, polite } from './controller'; import styles from './styles.css.js'; -import ScreenreaderOnly from '../screenreader-only'; + +// Export announcers for components that want to imperatively announce content. +export { polite, assertive }; export interface LiveRegionProps extends BaseComponentProps { /** @@ -113,8 +115,8 @@ export default function LiveRegion({ if (content && content !== previousSourceContentRef.current) { const announcer = isAssertive ? assertive : polite; announcer.announce(content, delay); - previousSourceContentRef.current = content; } + previousSourceContentRef.current = content; }); if (source) { @@ -122,8 +124,8 @@ export default function LiveRegion({ } return ( - - {visible ? children : {children}} + ); } From b6dd60d0d4a46c7f7f9dfdde157085bf43389ada Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Fri, 18 Oct 2024 10:34:32 +0200 Subject: [PATCH 5/9] Update internal usage to reflect signed-off API. --- pages/live-region-content-test.page.tsx | 2 +- pages/live-region.page.tsx | 2 +- .../test-utils-selectors.test.tsx.snap | 1 + src/attribute-editor/additional-info.tsx | 6 +- src/attribute-editor/internal.tsx | 12 +- src/button-group/icon-button-item.tsx | 7 +- src/button/internal.tsx | 14 +- src/cards/index.tsx | 8 +- src/code-editor/index.tsx | 6 +- src/code-editor/status-bar.tsx | 6 +- src/date-picker/index.tsx | 6 +- .../__tests__/date-range-picker.test.tsx | 3 +- .../calendar/header/index.tsx | 4 +- src/date-range-picker/calendar/index.tsx | 7 +- src/date-range-picker/dropdown.tsx | 6 +- src/drawer/implementation.tsx | 6 +- src/flashbar/__tests__/flashbar.test.tsx | 4 +- src/flashbar/flash.tsx | 6 +- .../__tests__/form-field-rendering.test.tsx | 14 +- src/form-field/internal.tsx | 6 +- src/form/internal.tsx | 6 +- src/help-panel/implementation.tsx | 4 +- src/internal/components/chart-plot/index.tsx | 6 +- .../__tests__/dropdown-footer.test.tsx | 7 - .../components/dropdown-footer/index.tsx | 6 +- .../__tests__/live-region.test.tsx | 60 ++++--- .../components/live-region/controller.ts | 67 +++++--- src/internal/components/live-region/index.tsx | 159 +++--------------- .../components/live-region/interfaces.ts | 43 +++++ .../components/live-region/internal.tsx | 115 +++++++++++++ .../components/live-region/styles.scss | 6 +- src/internal/is-development.ts | 7 + src/pie-chart/pie-chart.tsx | 4 +- .../__tests__/progress-bar.test.tsx | 4 +- src/progress-bar/index.tsx | 6 +- .../s3-in-context/index.tsx | 8 +- .../s3-modal/__tests__/fetching.test.tsx | 12 +- .../s3-modal/basic-table.tsx | 4 +- src/table/__tests__/body-cell.test.tsx | 15 +- .../__tests__/progressive-loading.test.tsx | 2 +- .../body-cell/disabled-inline-editor.tsx | 3 +- src/table/body-cell/index.tsx | 9 +- src/table/body-cell/inline-editor.tsx | 15 +- src/table/internal.tsx | 6 +- src/table/no-data-cell.tsx | 4 +- .../progressive-loading/items-loader.tsx | 6 +- src/tag-editor/index.tsx | 4 +- src/test-utils/dom/internal/live-region.ts | 9 + src/text-filter/search-results.tsx | 6 +- .../components/tutorial-list/index.tsx | 4 +- 50 files changed, 413 insertions(+), 320 deletions(-) create mode 100644 src/internal/components/live-region/interfaces.ts create mode 100644 src/internal/components/live-region/internal.tsx create mode 100644 src/test-utils/dom/internal/live-region.ts diff --git a/pages/live-region-content-test.page.tsx b/pages/live-region-content-test.page.tsx index 5dce9dc510..82c278475b 100644 --- a/pages/live-region-content-test.page.tsx +++ b/pages/live-region-content-test.page.tsx @@ -33,7 +33,7 @@ export default function LiveRegionContentTestPage() { Live region
- +
Before list
    diff --git a/pages/live-region.page.tsx b/pages/live-region.page.tsx index 38dafcc546..8c582120c6 100644 --- a/pages/live-region.page.tsx +++ b/pages/live-region.page.tsx @@ -8,7 +8,7 @@ export default function LiveRegionXSS() { return ( <>

    Live region

    - + diff --git a/src/__tests__/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/__snapshots__/test-utils-selectors.test.tsx.snap index 90591abf2b..ecfb1cdd9f 100644 --- a/src/__tests__/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/__snapshots__/test-utils-selectors.test.tsx.snap @@ -360,6 +360,7 @@ exports[`test-utils selectors 1`] = ` "awsui_root_1kjc7", "awsui_root_1qprf", "awsui_root_1t44z", + "awsui_root_3bgfn", "awsui_root_qwoo0", "awsui_root_vrgzu", "awsui_selectable-item_15o6u", diff --git a/src/attribute-editor/additional-info.tsx b/src/attribute-editor/additional-info.tsx index b6efd979b0..8e01053b7f 100644 --- a/src/attribute-editor/additional-info.tsx +++ b/src/attribute-editor/additional-info.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import LiveRegion from '../internal/components/live-region'; +import InternalLiveRegion from '../internal/components/live-region/internal'; import styles from './styles.css.js'; @@ -12,9 +12,9 @@ interface AdditionalInfoProps { } export const AdditionalInfo = ({ children, id }: AdditionalInfoProps) => ( - +
    {children}
    -
    + ); diff --git a/src/attribute-editor/internal.tsx b/src/attribute-editor/internal.tsx index ada94fc4fe..cd56944e6f 100644 --- a/src/attribute-editor/internal.tsx +++ b/src/attribute-editor/internal.tsx @@ -7,7 +7,7 @@ import InternalBox from '../box/internal'; import { ButtonProps } from '../button/interfaces'; import { InternalButton } from '../button/internal'; import { getBaseProps } from '../internal/base-component'; -import LiveRegion from '../internal/components/live-region'; +import InternalLiveRegion from '../internal/components/live-region/internal'; import { useContainerBreakpoints } from '../internal/hooks/container-queries'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useMergeRefs } from '../internal/hooks/use-merge-refs'; @@ -110,9 +110,15 @@ const InternalAttributeEditor = React.forwardRef( > {addButtonText} - + + {!!additionalInfo && {additionalInfo}}
); diff --git a/src/button-group/icon-button-item.tsx b/src/button-group/icon-button-item.tsx index 43129db41b..33099c200c 100644 --- a/src/button-group/icon-button-item.tsx +++ b/src/button-group/icon-button-item.tsx @@ -7,7 +7,7 @@ import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; import { ButtonProps } from '../button/interfaces.js'; import { InternalButton } from '../button/internal.js'; -import LiveRegion from '../internal/components/live-region/index.js'; +import InternalLiveRegion from '../internal/components/live-region/internal'; import Tooltip from '../internal/components/tooltip/index.js'; import { CancelableEventHandler, ClickDetail } from '../internal/events/index.js'; import { ButtonGroupProps } from './interfaces.js'; @@ -60,7 +60,10 @@ const IconButtonItem = forwardRef( {item.popoverFeedback}
) || item.text} + value={ + (showFeedback && {item.popoverFeedback}) || + item.text + } className={clsx(testUtilStyles.tooltip, testUtilStyles['button-group-tooltip'])} /> )} diff --git a/src/button/internal.tsx b/src/button/internal.tsx index c6aab3305f..45ca951452 100644 --- a/src/button/internal.tsx +++ b/src/button/internal.tsx @@ -16,7 +16,7 @@ import { getSubStepAllSelector, getTextFromSelector, } from '../internal/analytics/selectors'; -import LiveRegion from '../internal/components/live-region'; +import InternalLiveRegion from '../internal/components/live-region/internal'; import Tooltip from '../internal/components/tooltip/index.js'; import { useButtonContext } from '../internal/context/button-context'; import { useSingleTabStopNavigation } from '../internal/context/single-tab-stop-navigation-context'; @@ -250,7 +250,11 @@ export const InternalButton = React.forwardRef( > {buttonContent} - {loading && loadingText && {loadingText}} + {loading && loadingText && ( + + )} ); } @@ -282,7 +286,11 @@ export const InternalButton = React.forwardRef( )} - {loading && loadingText && {loadingText}} + {loading && loadingText && ( + + )} ); } diff --git a/src/cards/index.tsx b/src/cards/index.tsx index 6e557c5759..381a76abda 100644 --- a/src/cards/index.tsx +++ b/src/cards/index.tsx @@ -9,7 +9,7 @@ import { InternalContainerAsSubstep } from '../container/internal'; import { useInternalI18n } from '../i18n/context'; import { AnalyticsFunnelSubStep } from '../internal/analytics/components/analytics-funnel'; import { getBaseProps } from '../internal/base-component'; -import LiveRegion from '../internal/components/live-region'; +import InternalLiveRegion from '../internal/components/live-region/internal'; import { CollectionLabelContext } from '../internal/context/collection-label-context'; import { LinkDefaultVariantContext } from '../internal/context/link-default-variant-context'; import useBaseComponent from '../internal/hooks/use-base-component'; @@ -132,7 +132,7 @@ const Cards = React.forwardRef(function ( status = (
- {loadingText} + {loadingText}
); @@ -178,11 +178,11 @@ const Cards = React.forwardRef(function ( )} > {!!renderAriaLive && !!firstIndex && ( - + + )} {status ?? ( {loading && ( - {i18n('i18nStrings.loadingState', i18nStrings?.loadingState)} + + {i18n('i18nStrings.loadingState', i18nStrings?.loadingState)} + )} diff --git a/src/code-editor/status-bar.tsx b/src/code-editor/status-bar.tsx index b4533196d0..3765111601 100644 --- a/src/code-editor/status-bar.tsx +++ b/src/code-editor/status-bar.tsx @@ -5,7 +5,7 @@ import clsx from 'clsx'; import { InternalButton } from '../button/internal'; import { useInternalI18n } from '../i18n/context.js'; -import LiveRegion from '../internal/components/live-region/index'; +import InternalLiveRegion from '../internal/components/live-region/internal'; import { CodeEditorProps } from './interfaces'; import { TabButton } from './tab-button'; import { getStatusButtonId, PaneStatus } from './util'; @@ -107,10 +107,10 @@ export function StatusBar({ isRefresh={isRefresh} /> - + +
diff --git a/src/date-picker/index.tsx b/src/date-picker/index.tsx index d88d1a1553..ef92cb44d1 100644 --- a/src/date-picker/index.tsx +++ b/src/date-picker/index.tsx @@ -13,7 +13,7 @@ import { InputProps } from '../input/interfaces'; import { getBaseProps } from '../internal/base-component'; import Dropdown from '../internal/components/dropdown'; import FocusLock from '../internal/components/focus-lock'; -import LiveRegion from '../internal/components/live-region'; +import InternalLiveRegion from '../internal/components/live-region/internal'; import { fireNonCancelableEvent } from '../internal/events'; import checkControlled from '../internal/hooks/check-controlled'; import useForwardFocus from '../internal/hooks/forward-focus'; @@ -213,9 +213,9 @@ const DatePicker = React.forwardRef( previousMonthAriaLabel: i18nStrings?.previousMonthAriaLabel ?? previousMonthAriaLabel, }} /> - + +
)} diff --git a/src/date-range-picker/__tests__/date-range-picker.test.tsx b/src/date-range-picker/__tests__/date-range-picker.test.tsx index 2f6592418c..c588392d23 100644 --- a/src/date-range-picker/__tests__/date-range-picker.test.tsx +++ b/src/date-range-picker/__tests__/date-range-picker.test.tsx @@ -16,7 +16,6 @@ import { changeMode } from './change-mode'; import { i18nStrings } from './i18n-strings'; import { isValidRange } from './is-valid-range'; -import styles from '../../../lib/components/date-range-picker/styles.css.js'; import segmentedStyles from '../../../lib/components/segmented-control/styles.css.js'; jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ @@ -256,7 +255,7 @@ describe('Date range picker', () => { wrapper.findDropdown()!.findApplyButton().click(); expect(wrapper.findDropdown()!.findValidationError()?.getElement()).toHaveTextContent('10 is not allowed.'); - expect(wrapper.findDropdown()!.findByClassName(styles['validation-section'])!.find('[aria-live]')).not.toBe(null); + expect(createWrapper().find('[aria-live]')!.getElement()).toHaveTextContent('10 is not allowed.'); }); test('after rendering the error once, displays subsequent errors in real time', () => { diff --git a/src/date-range-picker/calendar/header/index.tsx b/src/date-range-picker/calendar/header/index.tsx index 6bf60cc33d..66fcaf1fe9 100644 --- a/src/date-range-picker/calendar/header/index.tsx +++ b/src/date-range-picker/calendar/header/index.tsx @@ -5,7 +5,7 @@ import { add } from 'date-fns'; import { renderMonthAndYear } from '../../../calendar/utils/intl'; import { useInternalI18n } from '../../../i18n/context.js'; -import LiveRegion from '../../../internal/components/live-region'; +import InternalLiveRegion from '../../../internal/components/live-region/internal'; import { NextMonthButton, PrevMonthButton } from './header-button'; import styles from '../../styles.css.js'; @@ -57,7 +57,7 @@ export default function CalendarHeader({ onChangeMonth={onChangeMonth} /> - {isSingleGrid ? currentMonthLabel : `${prevMonthLabel}, ${currentMonthLabel}`} + ); } diff --git a/src/date-range-picker/calendar/index.tsx b/src/date-range-picker/calendar/index.tsx index b653f1e526..a17b64dc9a 100644 --- a/src/date-range-picker/calendar/index.tsx +++ b/src/date-range-picker/calendar/index.tsx @@ -9,7 +9,7 @@ import { getDateLabel, renderTimeLabel } from '../../calendar/utils/intl'; import { getBaseDay } from '../../calendar/utils/navigation'; import { useInternalI18n } from '../../i18n/context.js'; import { BaseComponentProps } from '../../internal/base-component'; -import LiveRegion from '../../internal/components/live-region'; +import InternalLiveRegion from '../../internal/components/live-region/internal'; import { useMobile } from '../../internal/hooks/use-mobile/index.js'; import { useUniqueId } from '../../internal/hooks/use-unique-id'; import { formatDateTime, parseDate, splitDateTime } from '../../internal/utils/date-time'; @@ -271,7 +271,10 @@ export default function DateRangePickerCalendar({ {customAbsoluteRangeControl &&
{customAbsoluteRangeControl(value, interceptedSetValue)}
} - {announcement} + {/* Can't use message here because the contents are checked in tests */} + ); } diff --git a/src/date-range-picker/dropdown.tsx b/src/date-range-picker/dropdown.tsx index d6a98edb85..7af5faa120 100644 --- a/src/date-range-picker/dropdown.tsx +++ b/src/date-range-picker/dropdown.tsx @@ -10,7 +10,7 @@ import { ButtonProps } from '../button/interfaces'; import { InternalButton } from '../button/internal'; import { useInternalI18n } from '../i18n/context'; import FocusLock from '../internal/components/focus-lock'; -import LiveRegion from '../internal/components/live-region'; +import InternalLiveRegion from '../internal/components/live-region/internal'; import InternalSpaceBetween from '../space-between/internal'; import Calendar from './calendar'; import { DateRangePickerProps } from './interfaces'; @@ -222,7 +222,9 @@ export function DateRangePickerDropdown({ > {validationResult.errorMessage} - {validationResult.errorMessage} + )} diff --git a/src/drawer/implementation.tsx b/src/drawer/implementation.tsx index 90fb26a1fc..dcc3902fe9 100644 --- a/src/drawer/implementation.tsx +++ b/src/drawer/implementation.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx'; import { useAppLayoutToolbarEnabled } from '../app-layout/utils/feature-flags'; import { useInternalI18n } from '../i18n/context'; import { getBaseProps } from '../internal/base-component'; -import LiveRegion from '../internal/components/live-region'; +import InternalLiveRegion from '../internal/components/live-region/internal'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { createWidgetizedComponent } from '../internal/widgets'; import InternalStatusIndicator from '../status-indicator/internal'; @@ -34,7 +34,9 @@ export function DrawerImplementation({ return loading ? (
- {i18n('i18nStrings.loadingText', i18nStrings?.loadingText)} + + {i18n('i18nStrings.loadingText', i18nStrings?.loadingText)} +
) : ( diff --git a/src/flashbar/__tests__/flashbar.test.tsx b/src/flashbar/__tests__/flashbar.test.tsx index 9a3ed08e70..ccd55f7bb5 100644 --- a/src/flashbar/__tests__/flashbar.test.tsx +++ b/src/flashbar/__tests__/flashbar.test.tsx @@ -410,7 +410,7 @@ describe('Flashbar component', () => { }); test('renders the label, header, and content in an aria-live region for ariaRole="status"', async () => { - const { rerender, container } = reactRender(); + const { rerender } = reactRender(); rerender( { ); await waitFor(() => { - expect(container.querySelector('span[aria-live]')).toHaveTextContent('Error The header The content'); + expect(document.querySelector('[aria-live="polite"]')).toHaveTextContent('Error The header The content'); }); }); }); diff --git a/src/flashbar/flash.tsx b/src/flashbar/flash.tsx index ecd0cca1d5..8634320cd3 100644 --- a/src/flashbar/flash.tsx +++ b/src/flashbar/flash.tsx @@ -12,7 +12,7 @@ import { InternalButton } from '../button/internal'; import InternalIcon from '../icon/internal'; import { DATA_ATTR_ANALYTICS_FLASHBAR } from '../internal/analytics/selectors'; import { BasePropsWithAnalyticsMetadata, getAnalyticsMetadataProps } from '../internal/base-component'; -import LiveRegion from '../internal/components/live-region'; +import InternalLiveRegion from '../internal/components/live-region/internal'; import { getVisualContextClassname } from '../internal/components/visual-context'; import { PACKAGE_VERSION } from '../internal/environment'; import { useMergeRefs } from '../internal/hooks/use-merge-refs'; @@ -229,7 +229,9 @@ export const Flash = React.forwardRef( /> {dismissible && dismissButton(dismissLabel, handleDismiss)} - {ariaRole === 'status' && } + {ariaRole === 'status' && ( + + )} ); } diff --git a/src/form-field/__tests__/form-field-rendering.test.tsx b/src/form-field/__tests__/form-field-rendering.test.tsx index fe9d4eee06..935cbfdb6f 100644 --- a/src/form-field/__tests__/form-field-rendering.test.tsx +++ b/src/form-field/__tests__/form-field-rendering.test.tsx @@ -147,27 +147,21 @@ describe('FormField component', () => { test('Should render live region for error text', () => { const errorText = 'Nope do it again'; const errorIconAriaLabel = 'Error'; - const wrapper = renderFormField({ - errorText, - i18nStrings: { errorIconAriaLabel }, - }); + renderFormField({ errorText, i18nStrings: { errorIconAriaLabel } }); // Since live region in this componennt uses 'source' prop // it is too complex to successfully assert the aria live message - expect(wrapper.findByClassName(liveRegionStyles.root)?.getElement()).toBeInTheDocument(); + expect(createWrapper().findByClassName(liveRegionStyles.announcer)?.getElement()).toBeInTheDocument(); }); test('Should render live region for warning text', () => { const warningText = 'Are you sure?'; const warningIconAriaLabel = 'Warning'; - const wrapper = renderFormField({ - warningText, - i18nStrings: { warningIconAriaLabel }, - }); + renderFormField({ warningText, i18nStrings: { warningIconAriaLabel } }); // Since live region in this componennt uses 'source' prop // it is too complex to successfully assert the aria live message - expect(wrapper.findByClassName(liveRegionStyles.root)?.getElement()).toBeInTheDocument(); + expect(createWrapper().findByClassName(liveRegionStyles.announcer)?.getElement()).toBeInTheDocument(); }); }); }); diff --git a/src/form-field/internal.tsx b/src/form-field/internal.tsx index 34600713c4..322473482f 100644 --- a/src/form-field/internal.tsx +++ b/src/form-field/internal.tsx @@ -19,7 +19,7 @@ import { getTextFromSelector, } from '../internal/analytics/selectors'; import { getBaseProps } from '../internal/base-component'; -import LiveRegion from '../internal/components/live-region'; +import InternalLiveRegion from '../internal/components/live-region/internal'; import { FormFieldContext, useFormFieldContext } from '../internal/context/form-field-context'; import { InfoLinkLabelContext } from '../internal/context/info-link-label-context'; import { useUniqueId } from '../internal/hooks/use-unique-id'; @@ -61,7 +61,7 @@ export function FormFieldError({ id, children, errorIconAriaLabel }: FormFieldEr - + ); } @@ -84,7 +84,7 @@ export function FormFieldWarning({ id, children, warningIconAriaLabel }: FormFie - + ); } diff --git a/src/form/internal.tsx b/src/form/internal.tsx index 66ef411fd6..6cb7d7646f 100644 --- a/src/form/internal.tsx +++ b/src/form/internal.tsx @@ -9,7 +9,7 @@ import InternalAlert from '../alert/internal'; import InternalBox from '../box/internal'; import { useInternalI18n } from '../i18n/context'; import { getBaseProps } from '../internal/base-component'; -import LiveRegion from '../internal/components/live-region'; +import InternalLiveRegion from '../internal/components/live-region/internal'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { GeneratedAnalyticsMetadataFormFragment } from './analytics-metadata/interfaces'; import { FormProps } from './interfaces'; @@ -70,9 +70,9 @@ export default function InternalForm({ )} {errorText && ( - + + )} ); diff --git a/src/help-panel/implementation.tsx b/src/help-panel/implementation.tsx index 7bdd6873e0..1c2a6ee11f 100644 --- a/src/help-panel/implementation.tsx +++ b/src/help-panel/implementation.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx'; import { useAppLayoutToolbarEnabled } from '../app-layout/utils/feature-flags'; import { useInternalI18n } from '../i18n/context'; import { getBaseProps } from '../internal/base-component'; -import LiveRegion from '../internal/components/live-region'; +import InternalLiveRegion from '../internal/components/live-region/internal'; import { LinkDefaultVariantContext } from '../internal/context/link-default-variant-context'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { createWidgetizedComponent } from '../internal/widgets'; @@ -36,7 +36,7 @@ export function HelpPanelImplementation({ return loading ? (
- {i18n('loadingText', loadingText)} + {i18n('loadingText', loadingText)}
) : ( diff --git a/src/internal/components/chart-plot/index.tsx b/src/internal/components/chart-plot/index.tsx index 976458f13c..e2ebbdf717 100644 --- a/src/internal/components/chart-plot/index.tsx +++ b/src/internal/components/chart-plot/index.tsx @@ -7,7 +7,7 @@ import { useInternalI18n } from '../../../i18n/context'; import { useUniqueId } from '../../hooks/use-unique-id'; import { KeyCode } from '../../keycode'; import { Offset } from '../interfaces'; -import LiveRegion from '../live-region/index'; +import InternalLiveRegion from '../live-region/internal'; import ApplicationController, { ApplicationRef } from './application-controller'; import FocusOutline from './focus-outline'; @@ -224,7 +224,9 @@ function ChartPlot( - {ariaLiveRegion} + ); } diff --git a/src/internal/components/dropdown-footer/__tests__/dropdown-footer.test.tsx b/src/internal/components/dropdown-footer/__tests__/dropdown-footer.test.tsx index 72cabd3f45..90413d874f 100644 --- a/src/internal/components/dropdown-footer/__tests__/dropdown-footer.test.tsx +++ b/src/internal/components/dropdown-footer/__tests__/dropdown-footer.test.tsx @@ -29,11 +29,4 @@ describe('Dropdown footer', () => { expect(element).toHaveClass(dropdownFooterStyles.root); expect(element).toHaveClass(dropdownFooterStyles.hidden); }); - - test('adds correct aria attributes', () => { - const { wrapper } = renderComponent(); - const element = wrapper.find('span')!.getElement(); - expect(element.firstElementChild).toHaveAttribute('aria-live', 'polite'); - expect(element.firstElementChild).toHaveAttribute('aria-atomic', 'true'); - }); }); diff --git a/src/internal/components/dropdown-footer/index.tsx b/src/internal/components/dropdown-footer/index.tsx index ef1a63bf3a..aa52fcdd18 100644 --- a/src/internal/components/dropdown-footer/index.tsx +++ b/src/internal/components/dropdown-footer/index.tsx @@ -4,7 +4,7 @@ import React from 'react'; import clsx from 'clsx'; import DropdownStatus from '../dropdown-status/index.js'; -import LiveRegion from '../live-region/index.js'; +import InternalLiveRegion from '../live-region/internal'; import styles from './styles.css.js'; @@ -16,9 +16,7 @@ interface DropdownFooter { const DropdownFooter: React.FC = ({ content, id, hasItems = true }: DropdownFooter) => (
- - {content && {content}} - + {content && {content}}
); diff --git a/src/internal/components/live-region/__tests__/live-region.test.tsx b/src/internal/components/live-region/__tests__/live-region.test.tsx index 28e876ebdc..4dac4d2ebe 100644 --- a/src/internal/components/live-region/__tests__/live-region.test.tsx +++ b/src/internal/components/live-region/__tests__/live-region.test.tsx @@ -3,7 +3,10 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; -import LiveRegion, { assertive, polite } from '../../../../../lib/components/internal/components/live-region'; +import InternalLiveRegion, { + assertive, + polite, +} from '../../../../../lib/components/internal/components/live-region/internal'; import { mockInnerText } from '../../../../internal/analytics/__tests__/mocks'; import styles from '../../../../../lib/components/internal/components/live-region/styles.css.js'; @@ -15,8 +18,7 @@ const renderLiveRegion = async (jsx: React.ReactElement) => { await waitFor(() => expect(document.querySelector('[aria-live=polite]'))); return { - visibleSource: container.querySelector(`.${styles.root}`), - hiddenSource: container.querySelector('[hidden]'), + source: container.querySelector(`.${styles.root}`), politeRegion: document.querySelector('[aria-live=polite]')!, assertiveRegion: document.querySelector('[aria-live=assertive]')!, }; @@ -31,58 +33,60 @@ afterEach(() => { describe('LiveRegion', () => { it('renders', async () => { - const { hiddenSource, politeRegion } = await renderLiveRegion(Announcement); + const { source, politeRegion } = await renderLiveRegion( + Announcement + ); - expect(hiddenSource).toHaveTextContent('Announcement'); + expect(source).toHaveTextContent('Announcement'); expect(politeRegion).toHaveAttribute('aria-live', 'polite'); expect(politeRegion).toHaveAttribute('aria-atomic', 'true'); expect(politeRegion).toHaveTextContent('Announcement'); }); - it('renders with a span by default', async () => { - const { hiddenSource, politeRegion } = await renderLiveRegion(Announcement); + it('renders with a div by default', async () => { + const { source, politeRegion } = await renderLiveRegion( + Announcement + ); - expect(hiddenSource!.tagName).toBe('SPAN'); + expect(source!.tagName).toBe('DIV'); expect(politeRegion).toHaveTextContent('Announcement'); }); - it('wraps visible content in a span by default', async () => { - const { visibleSource } = await renderLiveRegion( - - Announcement - + it('wraps visible content in a div by default', async () => { + const { source: visibleSource } = await renderLiveRegion( + Announcement ); - expect(visibleSource!.tagName).toBe('SPAN'); + expect(visibleSource!.tagName).toBe('DIV'); expect(visibleSource).toHaveTextContent('Announcement'); }); - it('can render with a div', async () => { - const { hiddenSource } = await renderLiveRegion( - + it('can render with a span', async () => { + const { source } = await renderLiveRegion( + +
); - expect(hiddenSource!.tagName).toBe('DIV'); - expect(hiddenSource).toHaveTextContent('Announcement'); + expect(source!.tagName).toBe('SPAN'); + expect(source).toHaveTextContent('Announcement'); }); it('can wrap visible content in a div', async () => { - const { visibleSource } = await renderLiveRegion( - + const { source } = await renderLiveRegion( + Announcement - +
); - expect(visibleSource!.tagName).toBe('DIV'); + expect(source!.tagName).toBe('DIV'); }); it('can render assertive live region', async () => { const { politeRegion, assertiveRegion } = await renderLiveRegion( - + +
); console.log({ assertiveRegion, politeRegion }); expect(assertiveRegion).toHaveAttribute('aria-live', 'assertive'); @@ -94,9 +98,9 @@ describe('LiveRegion', () => { const ref = { current: null }; const { politeRegion } = await renderLiveRegion( <> - + Announcement - + Element text ); diff --git a/src/internal/components/live-region/controller.ts b/src/internal/components/live-region/controller.ts index 00558fafa9..5c2ccc5b53 100644 --- a/src/internal/components/live-region/controller.ts +++ b/src/internal/components/live-region/controller.ts @@ -1,17 +1,32 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { isTest } from '../../is-development.js'; + import styles from './styles.css.js'; +const DEFAULT_MIN_DELAY = isTest ? 0 : 2000; + +/** + * The controller singleton that manages a single live region container. It has a timer and + * a queue to make sure announcements don't collide and messages are debounced correctly. + * It also explicitly makes sure that a message is announced again even if it matches the + * previous content of the live region. + * + * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions + */ class LiveRegionController { private _element: HTMLElement | undefined; private _timeoutId: number | undefined; - private _delay = 0; private _lastAnnouncement = ''; + private _nextDelay = DEFAULT_MIN_DELAY; private readonly _nextMessages = new Set(); constructor(public readonly politeness: 'polite' | 'assertive') {} + /** + * Lazily create a live region container element in the DOM. + */ initialize() { if (!this._element) { this._element = document.createElement('div'); @@ -24,33 +39,41 @@ class LiveRegionController { } } - announce(message: string, minDelay = 50) { - this._nextMessages.add(message); - - // A message was added with a longer delay, so we delay the whole announcement. - // This is cleaner than potentially having valid announcements collide. - if (this._timeoutId !== undefined && minDelay !== undefined && this._delay < minDelay) { - this._delay = minDelay; + /** + * Reset the state of the controller and clear any active announcements. + */ + reset() { + if (this._element) { + this._element.textContent = ''; + } + if (this._timeoutId) { clearTimeout(this._timeoutId); this._timeoutId = undefined; } + } + + announce(message: string, minDelay = DEFAULT_MIN_DELAY) { + this._nextMessages.add(message); - if (this._delay === 0 && minDelay === 0) { + if (this._nextDelay < minDelay) { + this._nextDelay = minDelay; + + // A message was added with a longer delay, so we delay the whole announcement. + // This is cleaner than potentially having valid announcements collide. + if (this._timeoutId !== undefined) { + clearTimeout(this._timeoutId); + this._timeoutId = undefined; + } + } + + if (this._nextDelay === 0 && minDelay === 0) { // If the delay is 0, just skip the timeout shenanigans and update the // element synchronously. Great for tests. - this._updateElement(); - } else if (this._timeoutId === undefined) { - this._timeoutId = setTimeout(() => this._updateElement(), this._delay); + return this._updateElement(); } - } - reset() { - if (this._element) { - this._element.textContent = ''; - } - if (this._timeoutId) { - clearTimeout(this._timeoutId); - this._timeoutId = undefined; + if (this._timeoutId === undefined) { + this._timeoutId = setTimeout(() => this._updateElement(), this._nextDelay); } } @@ -64,7 +87,7 @@ class LiveRegionController { // A (generally) safe way of forcing re-announcements is toggling the // terminal period. If we keep adding periods, it's going to be // eventually interpreted as an ellipsis. - nextAnnouncement = nextAnnouncement.endsWith('.') ? nextAnnouncement.slice(0, -1) : nextAnnouncement + '.'; + nextAnnouncement = nextAnnouncement.endsWith('..') ? nextAnnouncement.slice(0, -1) : nextAnnouncement + '.'; } // The aria-atomic does not work properly in Voice Over, causing @@ -75,7 +98,7 @@ class LiveRegionController { // Reset the state for the next announcement. this._timeoutId = undefined; - this._delay = 0; + this._nextDelay = DEFAULT_MIN_DELAY; this._nextMessages.clear(); } } diff --git a/src/internal/components/live-region/index.tsx b/src/internal/components/live-region/index.tsx index 9d6ea6b7ce..6aa0f9397d 100644 --- a/src/internal/components/live-region/index.tsx +++ b/src/internal/components/live-region/index.tsx @@ -1,152 +1,31 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -/* eslint-disable @cloudscape-design/prefer-live-region */ +import React from 'react'; -import React, { useEffect, useRef } from 'react'; -import clsx from 'clsx'; +// import useBaseComponent from '../../hooks/use-base-component'; +import { applyDisplayName } from '../../utils/apply-display-name'; +import { LiveRegionProps } from './interfaces'; +import InternalLiveRegion from './internal'; -import { BaseComponentProps } from '../../base-component'; -import { assertive, polite } from './controller'; +export { LiveRegionProps }; -import styles from './styles.css.js'; - -// Export announcers for components that want to imperatively announce content. -export { polite, assertive }; - -export interface LiveRegionProps extends BaseComponentProps { - /** - * Whether the announcements should be made using assertive aria-live. - * You should almost always leave this set to false unless you have a good - * reason. - * @default false - */ - assertive?: boolean; - - /** - * The delay between each announcement from this live region. You should - * leave this set to the default unless this live region is commonly - * interrupted by other actions (like text entry in text filtering). - */ - delay?: number; - - /** - * Use a list of strings and/or refs to existing elements for building the - * announcement text. This avoids rendering separate content twice just for - * this LiveRegion. - * - * If this property is set, the `children` will be ignored. - */ - source?: Array | undefined>; - - /** - * Use the rendered content as the source for the announcement text. - * - * If interactive content is rendered inside `children`, it will be visually - * hidden, but still interactive. Consider using `source` instead. - */ - children?: React.ReactNode; - - /** - * Visibly render the contents of the live region. - * @default false - */ - visible?: boolean; - - /** - * The tag to render the live region as. - * @default "span" - */ - tagName?: 'span' | 'div'; -} - -/** - * The live region is hidden in the layout, but visible for screen readers. - * It's purpose it to announce changes e.g. when custom navigation logic is used. - * - * The way live region works differently in different browsers and screen readers and - * it is recommended to manually test every new implementation. - * - * ``` - * - * ``` - * - * The live region is always atomic, because non-atomic regions can be treated by screen readers - * differently and produce unexpected results. To imitate non-atomic announcements simply use - * multiple live regions: - * - * ``` - * <> - * - * - * - * ``` - */ -export default function LiveRegion({ - assertive: isAssertive = false, - visible = false, - tagName: TagName = 'span', - delay, - children, - source, - className, - ...restProps -}: LiveRegionProps) { - const sourceRef = useRef(null); - - // The announcer is a globally managed singleton. We're using a ref - // here because we're entering imperative land when using the controller - // and we don't want things like double-rendering to double-announce - // content. - const previousSourceContentRef = useRef(); - - useEffect(() => { - polite.initialize(); - assertive.initialize(); - }, []); - - useEffect(() => { - const content = source - ? getSourceContent(source) - : sourceRef.current - ? extractInnerText(sourceRef.current) - : undefined; - - if (content && content !== previousSourceContentRef.current) { - const announcer = isAssertive ? assertive : polite; - announcer.announce(content, delay); - } - previousSourceContentRef.current = content; - }); - - if (source) { - return null; - } +function LiveRegion({ assertive = false, hidden = false, tagName = 'div', ...restProps }: LiveRegionProps) { + // TODO: Switch this out when moving this component out of internal + // const baseComponentProps = useBaseComponent('LiveRegion'); + const baseComponentProps = { __internalRootRef: React.useRef() }; return ( - +