Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions pages/dynamic-aria-live.page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<h1>Dynamic aria live</h1>
<Button id={'activation-button'} onClick={executeTextUpdate}>
Start
</Button>
<div>{text}</div>
<DynamicAriaLive delay={4000}>{text}</DynamicAriaLive>
</>
);
}
92 changes: 92 additions & 0 deletions pages/progress-bar/with-updates.page.tsx
Original file line number Diff line number Diff line change
@@ -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<NodeJS.Timeout | number>();
const timeoutRef10 = useRef<NodeJS.Timeout | number>();

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 (
<div>
<Header variant={'h1'}>Dynamic progress bar</Header>
<SpaceBetween direction={'vertical'} size={'s'}>
<div>
<Box variant={'div'} fontWeight={'bold'}>
High granularity (step == 1)
</Box>
<ProgressBar
status={progressStep1 < 100 ? 'in-progress' : 'success'}
value={progressStep1}
variant={'standalone'}
label={'Tea'}
description={'We will make a nice cup of tea ...'}
additionalInfo={'Take some cookie as a desert'}
resultText={'Your tea is ready!'}
/>
<div style={{ display: 'flex' }}>
<Button onClick={activateTimerStep1}>Start</Button>
<Button onClick={resetTimeoutStep1}>Reset</Button>
</div>
</div>
<div>
<Box variant={'div'} fontWeight={'bold'}>
Low granularity (step == 10)
</Box>
<ProgressBar
status={progressStep10 < 100 ? 'in-progress' : 'success'}
value={progressStep10}
variant={'standalone'}
label={'Tea'}
description={'We will make a nice cup of tea ...'}
additionalInfo={'Take some cookie as a desert'}
resultText={'Your tea is ready!'}
/>
<div style={{ display: 'flex' }}>
<Button onClick={activateTimerStep10}>Start</Button>
<Button onClick={resetTimeoutStep10}>Reset</Button>
</div>
</div>
</SpaceBetween>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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<string> {
return this.browser.execute(function (selector) {
return document.querySelector(selector)!.innerHTML;
}, selector);
}
}

function setupTestDynamicAria(testFn: (pageObject: LiveRegionPageObject) => Promise<void>) {
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');
})
);
});
52 changes: 52 additions & 0 deletions src/internal/components/dynamic-aria-live/index.tsx
Original file line number Diff line number Diff line change
@@ -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:
* <LiveRegion>
* <span>{title}</span>
* <span><Details /></span>
* </LiveRegion>
* Or create a single text node if possible:
* <LiveRegion>
* {`${title} ${details}`}
* </LiveRegion>
*
* @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<HTMLSpanElement>(null);
const targetRef = useRef<HTMLSpanElement>(null);

const throttledUpdate = useMemo(() => {
return throttle(() => updateLiveRegion(targetRef, sourceRef), delay);
}, [delay]);

useEffect(() => {
throttledUpdate();
});

return (
<AriaLiveTag targetRef={targetRef} sourceRef={sourceRef} {...restProps}>
{children}
</AriaLiveTag>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function setupTest(testFn: (pageObject: LiveRegionPageObject) => Promise<void>)

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('&lt;p&gt;Testing&lt;/p&gt; Testing');
})
Expand Down
44 changes: 44 additions & 0 deletions src/internal/components/live-region/aria-liva-tag.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLSpanElement>;
sourceRef: React.RefObject<HTMLSpanElement>;
}

export default memo(AriaLiveTag);

function AriaLiveTag({
assertive = false,
visible = false,
targetRef,
sourceRef,
children,
...restProps
}: AriaLiveTagProps) {
return (
<>
{visible && <span ref={sourceRef}>{children}</span>}

<ScreenreaderOnly {...restProps} className={clsx(styles.root, restProps.className)}>
{!visible && (
<span ref={sourceRef} aria-hidden="true">
{children}
</span>
)}

<span ref={targetRef} aria-atomic="true" aria-live={assertive ? 'assertive' : 'polite'}></span>
</ScreenreaderOnly>
</>
);
}
Loading