diff --git a/pages/button/loading-announcements.page.tsx b/pages/button/loading-announcements.page.tsx new file mode 100644 index 0000000000..5a87ca7b23 --- /dev/null +++ b/pages/button/loading-announcements.page.tsx @@ -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(null); + useEffect(() => { + if (loadingState === 'loading') { + const timer = setTimeout(() => { + setLoadingState('success'); + setLastReloadTime(new Date()); + }, loadingTime); + return () => clearTimeout(timer); + } + }, [loadingState, loadingTime]); + return ( +
+

Button with loading text and result

+ + setLoadingTime(parseInt(e.detail.value) || 0)} + /> + + Trigger reload: +
+ ); +} diff --git a/src/live-region/__tests__/live-region.test.tsx b/src/live-region/__tests__/live-region.test.tsx index a7637783dc..fa8144cd77 100644 --- a/src/live-region/__tests__/live-region.test.tsx +++ b/src/live-region/__tests__/live-region.test.tsx @@ -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]')!, @@ -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( + + ); + // renderLiveRegion automatically advances all timers + expect(politeRegion).toHaveTextContent('Announcement'); + rerender( + + ); + 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( + + ); + expect(politeRegion).toHaveTextContent('Announcement'); + rerender( + + ); + rerender( + + ); + jest.runAllTimers(); + // Note the period to force re-announcement. + expect(politeRegion).toHaveTextContent('Announcement.'); + }); }); describe('text extractor', () => { diff --git a/src/live-region/controller.ts b/src/live-region/controller.ts index 4276cf690f..8a4e978da9 100644 --- a/src/live-region/controller.ts +++ b/src/live-region/controller.ts @@ -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 = ''; @@ -48,16 +49,26 @@ export class LiveRegionController { } } - announce({ message, forceReannounce = false }: { message?: string; delay?: number; forceReannounce?: boolean }) { + 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); } @@ -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. @@ -85,6 +96,7 @@ export class LiveRegionController { this._lastAnnouncement = this._nextAnnouncement; // Reset the state for the next announcement. + this._contentChangedSinceLastAnnouncement = false; this._timeoutId = undefined; } } diff --git a/src/live-region/internal.tsx b/src/live-region/internal.tsx index 90b077a229..9fc77d21a8 100644 --- a/src/live-region/internal.tsx +++ b/src/live-region/internal.tsx @@ -76,13 +76,13 @@ export default React.forwardRef(function InternalLiveRegion( // server. const liveRegionControllerRef = useRef(); 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) { @@ -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 }); }, }));