Skip to content
Open
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
48 changes: 48 additions & 0 deletions pages/button/loading-announcements.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useEffect, useState } from 'react';

import { FormField, Input, LiveRegion } from '~components';
import Button from '~components/button';

export default function ButtonIntegrationPage() {
const [loadingState, setLoadingState] = useState<'initial' | 'loading' | 'success'>('initial');
const [loadingTime, setLoadingTime] = useState(2000);
const [lastReloadTime, setLastReloadTime] = useState<Date | null>(null);
useEffect(() => {
if (loadingState === 'loading') {
const timer = setTimeout(() => {
setLoadingState('success');
setLastReloadTime(new Date());
}, loadingTime);
return () => clearTimeout(timer);
}
}, [loadingState, loadingTime]);
return (
<article>
<h1>Button with loading text and result</h1>
<FormField label="Loading duration">
<Input
type="number"
value={loadingTime.toString()}
onChange={e => setLoadingTime(parseInt(e.detail.value) || 0)}
/>
</FormField>
Trigger reload:
<Button
ariaLabel="Reload"
iconName="refresh"
loading={loadingState === 'loading'}
loadingText="Loading"
onClick={() => setLoadingState('loading')}
/>
<LiveRegion>
{loadingState === 'success' && (
<div data-testid="success-message">
Successfully reloaded at {lastReloadTime?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
)}
</LiveRegion>
</article>
);
}
45 changes: 44 additions & 1 deletion src/live-region/__tests__/live-region.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ import InternalLiveRegion, {
import styles from '../../../lib/components/live-region/test-classes/styles.css.js';

const renderLiveRegion = async (jsx: React.ReactElement) => {
const { container } = render(jsx);
const { container, rerender } = render(jsx);
await waitFor(() => expect(document.querySelector('[aria-live]')).toBeTruthy());
jest.runAllTimers();

return {
rerender,
source: container.querySelector(`.${styles.root}`),
politeRegion: document.querySelector('[aria-live=polite]')!,
assertiveRegion: document.querySelector('[aria-live=assertive]')!,
Expand Down Expand Up @@ -130,6 +131,48 @@ describe('LiveRegion', () => {
ref.current?.reannounce();
expect(politeRegion).toHaveTextContent('Announcement');
});

it('updates message after delay period', async () => {
jest.useFakeTimers();
const { politeRegion, rerender } = await renderLiveRegion(
<InternalLiveRegion delay={1} hidden={true}>
Announcement
</InternalLiveRegion>
);
// renderLiveRegion automatically advances all timers
expect(politeRegion).toHaveTextContent('Announcement');
rerender(
<InternalLiveRegion delay={1} hidden={true}>
Second announcement
</InternalLiveRegion>
);
expect(politeRegion).toHaveTextContent('Announcement');
jest.runAllTimers();
expect(politeRegion).toHaveTextContent('Second announcement');
});

it('re-announces the message if the content changed between debounce periods', async () => {
jest.useFakeTimers();
const { politeRegion, rerender } = await renderLiveRegion(
<InternalLiveRegion delay={1} hidden={true}>
Announcement
</InternalLiveRegion>
);
expect(politeRegion).toHaveTextContent('Announcement');
rerender(
<InternalLiveRegion delay={1} hidden={true}>
Second announcement
</InternalLiveRegion>
);
rerender(
<InternalLiveRegion delay={1} hidden={true}>
Announcement
</InternalLiveRegion>
);
jest.runAllTimers();
// Note the period to force re-announcement.
expect(politeRegion).toHaveTextContent('Announcement.');
});
});

describe('text extractor', () => {
Expand Down
26 changes: 19 additions & 7 deletions src/live-region/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ export class LiveRegionController {
* During internal unit testing, you can import this and explicitly set it to
* 0 to make the live region update the DOM without waiting for a timer.
*/
public static defaultDelay = 2;
public static defaultDelay = 1;

private _element: HTMLElement;
private _timeoutId: number | undefined;
private _lastAnnouncement: string | undefined;
private _contentChangedSinceLastAnnouncement = false;
private _addedTerminalPeriod = false;
private _nextAnnouncement = '';

Expand All @@ -48,16 +49,26 @@ export class LiveRegionController {
}
}

announce({ message, forceReannounce = false }: { message?: string; delay?: number; forceReannounce?: boolean }) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Realized while refactoring that the delay parameter here actually does nothing. I removed it from src/live-region/internal.tsx (and moved it to the class constructor).

announce({ message, forceReannounce = false }: { message?: string; forceReannounce?: boolean }) {
const trimmedMessage = message?.trim() ?? '';

// If the message before and after the throttle period is the same, we shouldn't
// announce anything. But if the component was rerendered with different content
// in the meantime, it's an indication that state changed enough that the same
// message should be reannounced.
if (trimmedMessage !== this._lastAnnouncement) {
this._contentChangedSinceLastAnnouncement = true;
}
this._nextAnnouncement = trimmedMessage;

// We're done with internal state updates. If there's nothing to actually announce, bail.
if (!message) {
return;
}

this._nextAnnouncement = message.trim();

// If the delay is 0, just skip the timeout shenanigans and update the
// element synchronously. Great for tests.
if (this.delay === 0 || forceReannounce) {
// If the delay is 0, just skip the timeout shenanigans and update the
// element synchronously. Great for tests.
return this._updateElement(forceReannounce);
}

Expand All @@ -73,7 +84,7 @@ export class LiveRegionController {
// we assign the source text content as a single node.
this._element.textContent = this._nextAnnouncement;
this._addedTerminalPeriod = false;
} else if (forceReannounce) {
} else if (forceReannounce || this._contentChangedSinceLastAnnouncement) {
// A (generally) safe way of forcing re-announcements is toggling the
// terminal period. If we only keep adding periods, it's going to be
// eventually interpreted as an ellipsis.
Expand All @@ -85,6 +96,7 @@ export class LiveRegionController {
this._lastAnnouncement = this._nextAnnouncement;

// Reset the state for the next announcement.
this._contentChangedSinceLastAnnouncement = false;
this._timeoutId = undefined;
}
}
8 changes: 4 additions & 4 deletions src/live-region/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,13 @@ export default React.forwardRef(function InternalLiveRegion(
// server.
const liveRegionControllerRef = useRef<LiveRegionController | undefined>();
useEffect(() => {
const liveRegionController = new LiveRegionController(assertive ? 'assertive' : 'polite');
const liveRegionController = new LiveRegionController(assertive ? 'assertive' : 'polite', delay);
liveRegionControllerRef.current = liveRegionController;
return () => {
liveRegionController.destroy();
liveRegionControllerRef.current = undefined;
};
}, [assertive]);
}, [assertive, delay]);

const getContent = () => {
if (sources) {
Expand All @@ -96,12 +96,12 @@ export default React.forwardRef(function InternalLiveRegion(
// Call the controller on every render. The controller will deduplicate the
// message against the previous announcement internally.
useEffect(() => {
liveRegionControllerRef.current?.announce({ message: getContent(), delay });
liveRegionControllerRef.current?.announce({ message: getContent() });
});

useImperativeHandle(ref, () => ({
reannounce() {
liveRegionControllerRef.current?.announce({ message: getContent(), delay, forceReannounce: true });
liveRegionControllerRef.current?.announce({ message: getContent(), forceReannounce: true });
},
}));

Expand Down
Loading