+ );
+}
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 && (
+
+ {children}
+
+ )}
+
+
+
+ >
+ );
+}
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}
-
- )}
-
-
-
- >
+
+ {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}}
-