Skip to content

Commit 2cbc7c1

Browse files
authored
feat: Progress bar updates are announced to the user (#555)
1 parent fe9e4c3 commit 2cbc7c1

File tree

10 files changed

+341
-76
lines changed

10 files changed

+341
-76
lines changed

pages/dynamic-aria-live.page.tsx

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React, { useState } from 'react';
4+
import DynamicAriaLive from '~components/internal/components/dynamic-aria-live';
5+
import Button from '~components/button';
6+
7+
export const INITIAL_TEXT = 'Initial text';
8+
export const UPDATED_TEXT = 'Updated text';
9+
export const DELAYED_TEXT = 'Delayed text';
10+
export const SKIPPED_TEXT = 'Skipped text';
11+
12+
function sleep(ms: number) {
13+
return new Promise(resolve => setTimeout(resolve, ms));
14+
}
15+
16+
export default function DynamicAriaLivePage() {
17+
const [text, setText] = useState(INITIAL_TEXT);
18+
const executeTextUpdate = async () => {
19+
setText(UPDATED_TEXT);
20+
setTimeout(() => setText(SKIPPED_TEXT), 1000);
21+
await sleep(2000);
22+
setTimeout(() => setText(DELAYED_TEXT), 1000);
23+
await sleep(3000);
24+
};
25+
26+
return (
27+
<>
28+
<h1>Dynamic aria live</h1>
29+
<Button id={'activation-button'} onClick={executeTextUpdate}>
30+
Start
31+
</Button>
32+
<div>{text}</div>
33+
<DynamicAriaLive delay={4000}>{text}</DynamicAriaLive>
34+
</>
35+
);
36+
}
+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React, { useState, useRef } from 'react';
4+
import ProgressBar from '~components/progress-bar';
5+
import SpaceBetween from '~components/space-between';
6+
import Header from '~components/header';
7+
import Button from '~components/button';
8+
import Box from '~components/box';
9+
10+
export default function ProgressBarWithUpdates() {
11+
const [progressStep1, setProgressStep1] = useState(0);
12+
const [progressStep10, setProgressStep10] = useState(0);
13+
const timeoutRef1 = useRef<NodeJS.Timeout | number>();
14+
const timeoutRef10 = useRef<NodeJS.Timeout | number>();
15+
16+
const activateTimerStep1 = () => {
17+
resetTimeoutStep1();
18+
function step(i: number) {
19+
setProgressStep1(i + 1);
20+
timeoutRef1.current = setTimeout(() => i < 99 && step(i + 1), 100);
21+
}
22+
step(0);
23+
};
24+
const resetTimeoutStep1 = () => {
25+
setProgressStep1(0);
26+
if (timeoutRef1.current !== undefined) {
27+
clearTimeout(timeoutRef1.current);
28+
timeoutRef1.current = undefined;
29+
}
30+
};
31+
32+
const activateTimerStep10 = () => {
33+
resetTimeoutStep10();
34+
function step(i: number) {
35+
setProgressStep10(i * 10);
36+
timeoutRef10.current = setTimeout(() => i < 10 && step(i + 1), 500);
37+
}
38+
step(0);
39+
};
40+
41+
const resetTimeoutStep10 = () => {
42+
setProgressStep10(0);
43+
if (timeoutRef10.current !== undefined) {
44+
clearTimeout(timeoutRef10.current);
45+
timeoutRef10.current = undefined;
46+
}
47+
};
48+
49+
return (
50+
<div>
51+
<Header variant={'h1'}>Dynamic progress bar</Header>
52+
<SpaceBetween direction={'vertical'} size={'s'}>
53+
<div>
54+
<Box variant={'div'} fontWeight={'bold'}>
55+
High granularity (step == 1)
56+
</Box>
57+
<ProgressBar
58+
status={progressStep1 < 100 ? 'in-progress' : 'success'}
59+
value={progressStep1}
60+
variant={'standalone'}
61+
label={'Tea'}
62+
description={'We will make a nice cup of tea ...'}
63+
additionalInfo={'Take some cookie as a desert'}
64+
resultText={'Your tea is ready!'}
65+
/>
66+
<div style={{ display: 'flex' }}>
67+
<Button onClick={activateTimerStep1}>Start</Button>
68+
<Button onClick={resetTimeoutStep1}>Reset</Button>
69+
</div>
70+
</div>
71+
<div>
72+
<Box variant={'div'} fontWeight={'bold'}>
73+
Low granularity (step == 10)
74+
</Box>
75+
<ProgressBar
76+
status={progressStep10 < 100 ? 'in-progress' : 'success'}
77+
value={progressStep10}
78+
variant={'standalone'}
79+
label={'Tea'}
80+
description={'We will make a nice cup of tea ...'}
81+
additionalInfo={'Take some cookie as a desert'}
82+
resultText={'Your tea is ready!'}
83+
/>
84+
<div style={{ display: 'flex' }}>
85+
<Button onClick={activateTimerStep10}>Start</Button>
86+
<Button onClick={resetTimeoutStep10}>Reset</Button>
87+
</div>
88+
</div>
89+
</SpaceBetween>
90+
</div>
91+
);
92+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
4+
import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
5+
6+
class LiveRegionPageObject extends BasePageObject {
7+
getInnerHTML(selector: string): Promise<string> {
8+
return this.browser.execute(function (selector) {
9+
return document.querySelector(selector)!.innerHTML;
10+
}, selector);
11+
}
12+
}
13+
14+
function setupTestDynamicAria(testFn: (pageObject: LiveRegionPageObject) => Promise<void>) {
15+
return useBrowser(async browser => {
16+
await browser.url('#/light/dynamic-aria-live');
17+
const pageObject = new LiveRegionPageObject(browser);
18+
await pageObject.waitForVisible('h1');
19+
return testFn(pageObject);
20+
});
21+
}
22+
23+
describe('Dynamic aria-live component', () => {
24+
test(
25+
`Dynamic aria-live announce changes not more often then given interval`,
26+
setupTestDynamicAria(async page => {
27+
await expect(page.getInnerHTML('[aria-live]')).resolves.toBe('Initial text');
28+
await page.click('#activation-button');
29+
30+
await page.waitForJsTimers(3000);
31+
await expect(page.getInnerHTML('[aria-live]')).resolves.not.toBe('Skipped text');
32+
await page.waitForJsTimers(3000);
33+
await expect(page.getInnerHTML('[aria-live]')).resolves.toBe('Delayed text');
34+
})
35+
);
36+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React, { memo, useEffect, useMemo, useRef } from 'react';
5+
import { throttle } from '../../utils/throttle';
6+
import { ScreenreaderOnlyProps } from '../screenreader-only';
7+
import { updateLiveRegion } from '../live-region/utils';
8+
import AriaLiveTag from '../live-region/aria-liva-tag';
9+
10+
export interface DynamicAriaLiveProps extends ScreenreaderOnlyProps {
11+
assertive?: boolean;
12+
delay?: number;
13+
children: React.ReactNode;
14+
}
15+
16+
/**
17+
* Dynamic aria live component is hidden in the layout, but visible for screen readers.
18+
* Purpose of this component is to announce recurring changes for a content.
19+
*
20+
* To avoid merging words, provide all text nodes wrapped with elements, like:
21+
* <LiveRegion>
22+
* <span>{title}</span>
23+
* <span><Details /></span>
24+
* </LiveRegion>
25+
* Or create a single text node if possible:
26+
* <LiveRegion>
27+
* {`${title} ${details}`}
28+
* </LiveRegion>
29+
*
30+
* @param delay time value in milliseconds to set minimal time interval between announcements.
31+
* @param assertive determine aria-live priority. Given value == false, aria-live have `polite` attribute value.
32+
*/
33+
export default memo(DynamicAriaLive);
34+
35+
function DynamicAriaLive({ delay = 5000, children, ...restProps }: DynamicAriaLiveProps) {
36+
const sourceRef = useRef<HTMLSpanElement>(null);
37+
const targetRef = useRef<HTMLSpanElement>(null);
38+
39+
const throttledUpdate = useMemo(() => {
40+
return throttle(() => updateLiveRegion(targetRef, sourceRef), delay);
41+
}, [delay]);
42+
43+
useEffect(() => {
44+
throttledUpdate();
45+
});
46+
47+
return (
48+
<AriaLiveTag targetRef={targetRef} sourceRef={sourceRef} {...restProps}>
49+
{children}
50+
</AriaLiveTag>
51+
);
52+
}

src/internal/components/live-region/__integ__/live-region.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ function setupTest(testFn: (pageObject: LiveRegionPageObject) => Promise<void>)
2222

2323
describe('Live region', () => {
2424
test(
25-
`doesn't render child contents as HTML`,
25+
`Live region doesn't render child contents as HTML`,
2626
setupTest(async page => {
2727
await expect(page.getInnerHTML('[aria-live]')).resolves.toBe('&lt;p&gt;Testing&lt;/p&gt; Testing');
2828
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/* eslint-disable @cloudscape-design/prefer-live-region */
5+
6+
import clsx from 'clsx';
7+
import React, { memo } from 'react';
8+
import ScreenreaderOnly, { ScreenreaderOnlyProps } from '../screenreader-only/index.js';
9+
import styles from './styles.css.js';
10+
11+
export interface AriaLiveTagProps extends ScreenreaderOnlyProps {
12+
assertive?: boolean;
13+
visible?: boolean;
14+
children: React.ReactNode;
15+
targetRef: React.RefObject<HTMLSpanElement>;
16+
sourceRef: React.RefObject<HTMLSpanElement>;
17+
}
18+
19+
export default memo(AriaLiveTag);
20+
21+
function AriaLiveTag({
22+
assertive = false,
23+
visible = false,
24+
targetRef,
25+
sourceRef,
26+
children,
27+
...restProps
28+
}: AriaLiveTagProps) {
29+
return (
30+
<>
31+
{visible && <span ref={sourceRef}>{children}</span>}
32+
33+
<ScreenreaderOnly {...restProps} className={clsx(styles.root, restProps.className)}>
34+
{!visible && (
35+
<span ref={sourceRef} aria-hidden="true">
36+
{children}
37+
</span>
38+
)}
39+
40+
<span ref={targetRef} aria-atomic="true" aria-live={assertive ? 'assertive' : 'polite'}></span>
41+
</ScreenreaderOnly>
42+
</>
43+
);
44+
}

0 commit comments

Comments
 (0)