diff --git a/pages/dynamic-aria-live.page.tsx b/pages/dynamic-aria-live.page.tsx new file mode 100644 index 0000000000..7703ab5263 --- /dev/null +++ b/pages/dynamic-aria-live.page.tsx @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; +import DynamicAriaLive from '~components/internal/components/dynamic-aria-live'; +import Button from '~components/button'; + +export const INITIAL_TEXT = 'Initial text'; +export const UPDATED_TEXT = 'Updated text'; +export const DELAYED_TEXT = 'Delayed text'; +export const SKIPPED_TEXT = 'Skipped text'; + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export default function DynamicAriaLivePage() { + const [text, setText] = useState(INITIAL_TEXT); + const executeTextUpdate = async () => { + setText(UPDATED_TEXT); + setTimeout(() => setText(SKIPPED_TEXT), 1000); + await sleep(2000); + setTimeout(() => setText(DELAYED_TEXT), 1000); + await sleep(3000); + }; + + return ( + <> +

Dynamic aria live

+ +
{text}
+ {text} + + ); +} diff --git a/pages/progress-bar/with-updates.page.tsx b/pages/progress-bar/with-updates.page.tsx new file mode 100644 index 0000000000..363dda9022 --- /dev/null +++ b/pages/progress-bar/with-updates.page.tsx @@ -0,0 +1,92 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState, useRef } from 'react'; +import ProgressBar from '~components/progress-bar'; +import SpaceBetween from '~components/space-between'; +import Header from '~components/header'; +import Button from '~components/button'; +import Box from '~components/box'; + +export default function ProgressBarWithUpdates() { + const [progressStep1, setProgressStep1] = useState(0); + const [progressStep10, setProgressStep10] = useState(0); + const timeoutRef1 = useRef(); + const timeoutRef10 = useRef(); + + const activateTimerStep1 = () => { + resetTimeoutStep1(); + function step(i: number) { + setProgressStep1(i + 1); + timeoutRef1.current = setTimeout(() => i < 99 && step(i + 1), 100); + } + step(0); + }; + const resetTimeoutStep1 = () => { + setProgressStep1(0); + if (timeoutRef1.current !== undefined) { + clearTimeout(timeoutRef1.current); + timeoutRef1.current = undefined; + } + }; + + const activateTimerStep10 = () => { + resetTimeoutStep10(); + function step(i: number) { + setProgressStep10(i * 10); + timeoutRef10.current = setTimeout(() => i < 10 && step(i + 1), 500); + } + step(0); + }; + + const resetTimeoutStep10 = () => { + setProgressStep10(0); + if (timeoutRef10.current !== undefined) { + clearTimeout(timeoutRef10.current); + timeoutRef10.current = undefined; + } + }; + + return ( +
+
Dynamic progress bar
+ +
+ + High granularity (step == 1) + + +
+ + +
+
+
+ + Low granularity (step == 10) + + +
+ + +
+
+
+
+ ); +} diff --git a/src/internal/components/dynamic-aria-live/__integ__/dynamic-aria-live.test.ts b/src/internal/components/dynamic-aria-live/__integ__/dynamic-aria-live.test.ts new file mode 100644 index 0000000000..9814fb7597 --- /dev/null +++ b/src/internal/components/dynamic-aria-live/__integ__/dynamic-aria-live.test.ts @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; +import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; + +class LiveRegionPageObject extends BasePageObject { + getInnerHTML(selector: string): Promise { + return this.browser.execute(function (selector) { + return document.querySelector(selector)!.innerHTML; + }, selector); + } +} + +function setupTestDynamicAria(testFn: (pageObject: LiveRegionPageObject) => Promise) { + return useBrowser(async browser => { + await browser.url('#/light/dynamic-aria-live'); + const pageObject = new LiveRegionPageObject(browser); + await pageObject.waitForVisible('h1'); + return testFn(pageObject); + }); +} + +describe('Dynamic aria-live component', () => { + test( + `Dynamic aria-live announce changes not more often then given interval`, + setupTestDynamicAria(async page => { + await expect(page.getInnerHTML('[aria-live]')).resolves.toBe('Initial text'); + await page.click('#activation-button'); + + await page.waitForJsTimers(3000); + await expect(page.getInnerHTML('[aria-live]')).resolves.not.toBe('Skipped text'); + await page.waitForJsTimers(3000); + await expect(page.getInnerHTML('[aria-live]')).resolves.toBe('Delayed text'); + }) + ); +}); diff --git a/src/internal/components/dynamic-aria-live/index.tsx b/src/internal/components/dynamic-aria-live/index.tsx new file mode 100644 index 0000000000..41dc9435e2 --- /dev/null +++ b/src/internal/components/dynamic-aria-live/index.tsx @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { memo, useEffect, useMemo, useRef } from 'react'; +import { throttle } from '../../utils/throttle'; +import { ScreenreaderOnlyProps } from '../screenreader-only'; +import { updateLiveRegion } from '../live-region/utils'; +import AriaLiveTag from '../live-region/aria-liva-tag'; + +export interface DynamicAriaLiveProps extends ScreenreaderOnlyProps { + assertive?: boolean; + delay?: number; + children: React.ReactNode; +} + +/** + * Dynamic aria live component is hidden in the layout, but visible for screen readers. + * Purpose of this component is to announce recurring changes for a content. + * + * To avoid merging words, provide all text nodes wrapped with elements, like: + * + * {title} + *
+ * + * Or create a single text node if possible: + * + * {`${title} ${details}`} + * + * + * @param delay time value in milliseconds to set minimal time interval between announcements. + * @param assertive determine aria-live priority. Given value == false, aria-live have `polite` attribute value. + */ +export default memo(DynamicAriaLive); + +function DynamicAriaLive({ delay = 5000, children, ...restProps }: DynamicAriaLiveProps) { + const sourceRef = useRef(null); + const targetRef = useRef(null); + + const throttledUpdate = useMemo(() => { + return throttle(() => updateLiveRegion(targetRef, sourceRef), delay); + }, [delay]); + + useEffect(() => { + throttledUpdate(); + }); + + return ( + + {children} + + ); +} diff --git a/src/internal/components/live-region/__integ__/live-region.test.ts b/src/internal/components/live-region/__integ__/live-region.test.ts index 078fda702f..641f96f5a0 100644 --- a/src/internal/components/live-region/__integ__/live-region.test.ts +++ b/src/internal/components/live-region/__integ__/live-region.test.ts @@ -22,7 +22,7 @@ function setupTest(testFn: (pageObject: LiveRegionPageObject) => Promise) describe('Live region', () => { test( - `doesn't render child contents as HTML`, + `Live region doesn't render child contents as HTML`, setupTest(async page => { await expect(page.getInnerHTML('[aria-live]')).resolves.toBe('<p>Testing</p> Testing'); }) diff --git a/src/internal/components/live-region/aria-liva-tag.tsx b/src/internal/components/live-region/aria-liva-tag.tsx new file mode 100644 index 0000000000..65961f6e09 --- /dev/null +++ b/src/internal/components/live-region/aria-liva-tag.tsx @@ -0,0 +1,44 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @cloudscape-design/prefer-live-region */ + +import clsx from 'clsx'; +import React, { memo } from 'react'; +import ScreenreaderOnly, { ScreenreaderOnlyProps } from '../screenreader-only/index.js'; +import styles from './styles.css.js'; + +export interface AriaLiveTagProps extends ScreenreaderOnlyProps { + assertive?: boolean; + visible?: boolean; + children: React.ReactNode; + targetRef: React.RefObject; + sourceRef: React.RefObject; +} + +export default memo(AriaLiveTag); + +function AriaLiveTag({ + assertive = false, + visible = false, + targetRef, + sourceRef, + children, + ...restProps +}: AriaLiveTagProps) { + return ( + <> + {visible && {children}} + + + {!visible && ( + + )} + + + + + ); +} diff --git a/src/internal/components/live-region/index.tsx b/src/internal/components/live-region/index.tsx index fe6764a837..a447a3ca10 100644 --- a/src/internal/components/live-region/index.tsx +++ b/src/internal/components/live-region/index.tsx @@ -3,10 +3,10 @@ /* eslint-disable @cloudscape-design/prefer-live-region */ -import clsx from 'clsx'; import React, { memo, useEffect, useRef } from 'react'; -import ScreenreaderOnly, { ScreenreaderOnlyProps } from '../screenreader-only/index.js'; -import styles from './styles.css.js'; +import { ScreenreaderOnlyProps } from '../screenreader-only'; +import { updateLiveRegion } from './utils'; +import AriaLiveTag from './aria-liva-tag'; export interface LiveRegionProps extends ScreenreaderOnlyProps { assertive?: boolean; @@ -16,41 +16,41 @@ export interface LiveRegionProps extends ScreenreaderOnlyProps { } /** - 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. - - 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 - differently and produce unexpected results. To imitate non-atomic announcements simply use - multiple live regions: - <> - {title} -
- -*/ + * 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. + * + * 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 + * differently and produce unexpected results. To imitate non-atomic announcements simply use + * multiple live regions: + * <> + * {title} + *
+ * + */ export default memo(LiveRegion); -function LiveRegion({ assertive = false, delay = 10, visible = false, children, ...restProps }: LiveRegionProps) { +function LiveRegion({ delay = 10, children, ...restProps }: LiveRegionProps) { const sourceRef = useRef(null); const targetRef = useRef(null); @@ -66,24 +66,11 @@ function LiveRegion({ assertive = false, delay = 10, visible = false, children, does not impact the performance. If it does, prefer using a string as children prop. */ useEffect(() => { - function updateLiveRegion() { - if (targetRef.current && sourceRef.current) { - const sourceContent = extractInnerText(sourceRef.current); - 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); + timeoutId = setTimeout(() => updateLiveRegion(targetRef, sourceRef), delay); } else { - updateLiveRegion(); + updateLiveRegion(targetRef, sourceRef); } return () => { @@ -94,25 +81,8 @@ function LiveRegion({ assertive = false, delay = 10, visible = false, children, }); return ( - <> - {visible && {children}} - - - {!visible && ( - - )} - - - - + + {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) { - return (node.innerText || '').replace(/\s+/g, ' ').trim(); -} diff --git a/src/internal/components/live-region/utils.ts b/src/internal/components/live-region/utils.ts new file mode 100644 index 0000000000..6b2d9ff8c7 --- /dev/null +++ b/src/internal/components/live-region/utils.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +/** + * Updates text of the target node if it's differ from source node. + * @param targetRef - Ref to the element with aria-live + * @param sourceRef - Ref to the element to be announced + */ +export function updateLiveRegion( + targetRef: React.RefObject, + sourceRef: React.RefObject +) { + if (targetRef.current && sourceRef.current) { + const sourceContent = extractInnerText(sourceRef.current); + 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; + } + } +} + +// 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) { + return (node.innerText || '').replace(/\s+/g, ' ').trim(); +} diff --git a/src/progress-bar/index.tsx b/src/progress-bar/index.tsx index 484951323c..a751d7646b 100644 --- a/src/progress-bar/index.tsx +++ b/src/progress-bar/index.tsx @@ -12,6 +12,7 @@ import { useUniqueId } from '../internal/hooks/use-unique-id'; import { Progress, ResultState, SmallText } from './internal'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import useBaseComponent from '../internal/hooks/use-base-component'; +import DynamicAriaLive from '../internal/components/dynamic-aria-live'; export { ProgressBarProps }; @@ -53,9 +54,12 @@ export default function ProgressBar({ {label} {description && {description}} -
+
{isInProgressState ? ( - + <> + + {`${label ?? ''}: ${value}%`} + ) : ( +
{resultText}
); } return ( -
+
{resultText}