Skip to content

Commit

Permalink
experiment: Centralized announcer prototype
Browse files Browse the repository at this point in the history
  • Loading branch information
avinashbot committed Apr 9, 2024
1 parent e5b90e8 commit 443d9e6
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 83 deletions.
68 changes: 68 additions & 0 deletions src/internal/components/live-region/controller.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

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;

Check warning on line 34 in src/internal/components/live-region/controller.ts

View check run for this annotation

Codecov / codecov/patch

src/internal/components/live-region/controller.ts#L32-L34

Added lines #L32 - L34 were not covered by tests
}

if (this._timeoutId === undefined) {
this._timeoutId = setTimeout(() => this._updateElement(), this._delay);
}
}

destroy() {

Check warning on line 42 in src/internal/components/live-region/controller.ts

View check run for this annotation

Codecov / codecov/patch

src/internal/components/live-region/controller.ts#L42

Added line #L42 was not covered by tests
this._element?.remove();
if (this._timeoutId) {
clearTimeout(this._timeoutId);

Check warning on line 45 in src/internal/components/live-region/controller.ts

View check run for this annotation

Codecov / codecov/patch

src/internal/components/live-region/controller.ts#L45

Added line #L45 was not covered by tests
}
}

private _updateElement() {
if (!this._element) {
return;

Check warning on line 51 in src/internal/components/live-region/controller.ts

View check run for this annotation

Codecov / codecov/patch

src/internal/components/live-region/controller.ts#L51

Added line #L51 was not covered by tests
}

// 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');
132 changes: 49 additions & 83 deletions src/internal/components/live-region/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { polite, assertive } 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
Expand Down Expand Up @@ -71,98 +74,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<HTMLSpanElement & HTMLDivElement>(null);
const targetRef = useRef<HTMLSpanElement & HTMLDivElement>(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).
const previousSourceContentRef = useRef<string>();

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.
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 && (
<TagName ref={sourceRef} id={id}>
{children}
</TagName>
)}

<ScreenreaderOnly {...restProps} className={clsx(styles.root, restProps.className)}>
{!visible && !source && (
<TagName ref={sourceRef} aria-hidden="true">
{children}
</TagName>
)}
if (!visible || source) {
return null;
}

<span ref={targetRef} aria-atomic="true" aria-live={assertive ? 'assertive' : 'polite'}></span>
</ScreenreaderOnly>
</>
return (
<TagName ref={sourceRef} id={id} {...restProps} className={clsx(styles.root, restProps.className)}>
{children}
</TagName>
);
}

Expand All @@ -172,3 +121,20 @@ function LiveRegion({
function extractInnerText(node: HTMLElement) {
return (node.innerText || '').replace(/\s+/g, ' ').trim();
}

function getSourceContent(source: Exclude<LiveRegionProps['source'], undefined>) {
return source
.map(item => {
if (!item) {
return undefined;
}
if (typeof item === 'string') {
return item;
}
if (item.current) {
return extractInnerText(item.current);
}
})
.filter(Boolean)
.join(' ');
}
6 changes: 6 additions & 0 deletions src/internal/components/live-region/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

0 comments on commit 443d9e6

Please sign in to comment.