From bbf01f3858b2f45f5a204f13a5f6ef44123147ef Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Tue, 9 Apr 2024 12:57:43 +0200 Subject: [PATCH] experiment: Centralized announcer prototype --- .../components/live-region/controller.ts | 65 +++++++++ src/internal/components/live-region/index.tsx | 135 +++++++----------- .../components/live-region/styles.scss | 6 + 3 files changed, 125 insertions(+), 81 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..e72a0e1a25 --- /dev/null +++ b/src/internal/components/live-region/controller.ts @@ -0,0 +1,65 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import styles from './styles.css.js'; + +export 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(); + } +} diff --git a/src/internal/components/live-region/index.tsx b/src/internal/components/live-region/index.tsx index 8e1dba4ffa..6dc4b90677 100644 --- a/src/internal/components/live-region/index.tsx +++ b/src/internal/components/live-region/index.tsx @@ -3,17 +3,20 @@ /* eslint-disable @cloudscape-design/prefer-live-region */ -import clsx from 'clsx'; import React, { memo, useEffect, useRef } from 'react'; -import ScreenreaderOnly, { ScreenreaderOnlyProps } from '../screenreader-only'; +import clsx from 'clsx'; +import { BaseComponentProps } from '../../base-component'; +import { LiveRegionController } 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 @@ -70,99 +73,52 @@ export interface LiveRegionProps extends ScreenreaderOnlyProps { */ export default memo(LiveRegion); +const politeController = new LiveRegionController('polite'); +const assertiveController = new LiveRegionController('assertive'); + function LiveRegion({ assertive = false, - delay = 10, 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(); + politeController.initialize(); + assertiveController.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; - } + useEffect(() => { + const content = source + ? getSourceContent(source) + : sourceRef.current + ? extractInnerText(sourceRef.current) + : undefined; + + if (content && content !== previousSourceContentRef.current) { + if (assertive) { + assertiveController.announce(content, delay); + } else { + politeController.announce(content, delay); } + previousSourceContentRef.current = content; } - - let timeoutId: null | number; - if (delay) { - timeoutId = setTimeout(updateLiveRegion, delay); - } else { - updateLiveRegion(); - } - - return () => { - if (timeoutId) { - clearTimeout(timeoutId); - } - }; }); - return ( - <> - {visible && !source && ( - - {children} - - )} - - - {!visible && !source && ( - - )} + if (!visible || source) { + return null; + } - - - + return ( + + {children} + ); } @@ -172,3 +128,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 5a54f6dcc3..dd0b0dfca0 100644 --- a/src/internal/components/live-region/styles.scss +++ b/src/internal/components/live-region/styles.scss @@ -3,6 +3,12 @@ SPDX-License-Identifier: Apache-2.0 */ +@use '../../styles' as styles; + .root { /* used in test-utils */ } + +.announcer { + @include styles.awsui-util-hide; +}