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
2 changes: 1 addition & 1 deletion build-tools/eslint/__tests__/prefer-live-region.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const useLiveRegionOverAriaLive = require('../prefer-live-region');
const ruleTester = new RuleTester({ languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } } });

ruleTester.run('no-aria-live', useLiveRegionOverAriaLive, {
valid: ['<div></div>'],
valid: ['<div></div>', '<div aria-live="off"></div>'],

invalid: [
{
Expand Down
6 changes: 5 additions & 1 deletion build-tools/eslint/prefer-live-region.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ module.exports = {
JSXElement(node) {
if (
node.openingElement.attributes.some(
attribute => attribute.type === 'JSXAttribute' && attribute.name.name === 'aria-live'
attribute =>
attribute.type === 'JSXAttribute' &&
attribute.name.name === 'aria-live' &&
// Allow aria-live="off" to disable implicit aria-live behaviors of certain roles.
!(attribute.value && attribute.value.type === 'Literal' && attribute.value.value === 'off')
)
) {
context.report({
Expand Down
17 changes: 9 additions & 8 deletions pages/steps/with-updates.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import React, { useRef, useState } from 'react';
import Box from '~components/box';
import Button from '~components/button';
import Header from '~components/header';
import LiveRegion from '~components/live-region';
import Steps from '~components/steps';

import {
Expand Down Expand Up @@ -124,13 +125,13 @@ export default function StepsPermutationsWithUpdates() {
<Box id="successfull-execution-description" fontWeight={'light'} margin={{ bottom: 'm' }}>
Steps Component Description
</Box>
<span aria-live="polite">
<LiveRegion>
<Steps
steps={stepsExecution1[stepIndex1]}
ariaLabelledby="successfull-execution-label"
ariaDescribedby="successfull-execution-description"
/>
</span>
</LiveRegion>
<div style={{ display: 'flex' }}>
<Button onClick={activateTimerStep1}>Start</Button>
<Button onClick={resetTimeoutStep1}>Reset</Button>
Expand All @@ -140,9 +141,9 @@ export default function StepsPermutationsWithUpdates() {
<Box variant={'div'} fontWeight={'bold'} margin={'s'}>
Blocked Execution
</Box>
<span aria-live="polite">
<LiveRegion>
<Steps steps={stepsExecution2[stepIndex2]} ariaLabel="Blocked Execution Label" />
</span>
</LiveRegion>
<div style={{ display: 'flex' }}>
<Button onClick={activateTimerStep2}>Start</Button>
<Button onClick={resetTimeoutStep2}>Reset</Button>
Expand All @@ -152,9 +153,9 @@ export default function StepsPermutationsWithUpdates() {
<Box variant={'div'} fontWeight={'bold'} margin={'s'}>
Failed Execution
</Box>
<span aria-live="polite">
<LiveRegion>
<Steps steps={stepsExecution3[stepIndex3]} ariaLabel="Failed Execution Label" />
</span>
</LiveRegion>
<div style={{ display: 'flex' }}>
<Button onClick={activateTimerStep3}>Start</Button>
<Button onClick={resetTimeoutStep3}>Reset</Button>
Expand All @@ -164,9 +165,9 @@ export default function StepsPermutationsWithUpdates() {
<Box variant={'div'} fontWeight={'bold'} margin={'s'}>
Failed Execution with Retry
</Box>
<span aria-live="polite">
<LiveRegion>
<Steps steps={stepsExecution4[stepIndex4]} ariaLabel="Failed Execution with Retry Label" />
</span>
</LiveRegion>
<div style={{ display: 'flex' }}>
<Button onClick={activateTimerStep4}>Start</Button>
<Button onClick={resetTimeoutStep4}>Reset</Button>
Expand Down
14 changes: 8 additions & 6 deletions src/flashbar/__tests__/collapsible.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,12 +317,14 @@ describe('Collapsible Flashbar', () => {
});

it('announces updates to the item counter with aria-live', () => {
const flashbar = renderFlashbar();
const counter = findOuterCounter(flashbar);
expect(counter).toHaveAttribute('aria-live', 'polite');
// We add `role="status"` as well, to maximize compatibility
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions#roles_with_implicit_live_region_attributes
expect(counter).toHaveAttribute('role', 'status');
// Fake timers to bypass the live region delay.
const { rerender } = render(<Flashbar {...defaultProps} items={defaultItems} />);
jest.useFakeTimers();
rerender(<Flashbar {...defaultProps} items={[...defaultItems, { type: 'info', content: 'Info' }]} />);
jest.runAllTimers();
jest.useRealTimers();
const liveRegion = document.querySelector('[aria-live=polite]')!;
expect(liveRegion).toHaveTextContent('Notifications Error 1 Warning 0 Success 1 Information 1 In progress 0');
});

it('renders the toggle element header as H2 element', () => {
Expand Down
10 changes: 9 additions & 1 deletion src/flashbar/collapsible-flashbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useDebounceCallback } from '../internal/hooks/use-debounce-callback';
import { useEffectOnUpdate } from '../internal/hooks/use-effect-on-update';
import { useThrottleCallback } from '../internal/hooks/use-throttle-callback';
import { scrollElementIntoView } from '../internal/utils/scrollable-containers';
import InternalLiveRegion from '../live-region/internal';
import {
GeneratedAnalyticsMetadataFlashbarCollapse,
GeneratedAnalyticsMetadataFlashbarExpand,
Expand Down Expand Up @@ -361,7 +362,7 @@ export default function CollapsibleFlashbar({ items, style, ...restProps }: Inte
},
} as GeneratedAnalyticsMetadataFlashbarExpand | GeneratedAnalyticsMetadataFlashbarCollapse)}
>
<span aria-live="polite" className={styles.status} role="status" id={itemCountElementId}>
<span className={styles.status} id={itemCountElementId} role="status" aria-live="off">
{notificationBarText && <h2 className={styles.header}>{notificationBarText}</h2>}
<span className={styles['item-count']}>
{counterTypes.map(({ type, labelName, iconName }) => (
Expand All @@ -374,6 +375,13 @@ export default function CollapsibleFlashbar({ items, style, ...restProps }: Inte
))}
</span>
</span>
<InternalLiveRegion
preventInitialAnnouncement={true}
sources={[
notificationBarText,
...counterTypes.flatMap(({ type, labelName }) => [iconAriaLabels[labelName], `${countByType[type]}`]),
]}
/>
<button
aria-controls={flashbarElementId}
aria-describedby={itemCountElementId}
Expand Down
5 changes: 1 addition & 4 deletions src/flashbar/flash.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,8 @@ export const Flash = React.forwardRef(
}

return (
// We're not using "polite" or "assertive" here, just turning default behavior off.
// eslint-disable-next-line @cloudscape-design/components/prefer-live-region
<div
ref={mergedRef}
role={ariaRole}
aria-live={ariaRole ? 'off' : undefined}
data-itemid={id}
className={clsx(
styles.flash,
Expand All @@ -232,6 +228,7 @@ export const Flash = React.forwardRef(
initialHidden && styles['initial-hidden']
)}
style={getFlashStyles(style, effectiveType)}
{...(ariaRole ? { role: ariaRole, 'aria-live': 'off' } : {})}
{...analyticsAttributes}
>
<div className={styles['flash-body']}>
Expand Down
24 changes: 24 additions & 0 deletions src/live-region/__tests__/live-region.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,30 @@ describe('LiveRegion', () => {
// Note the period to force re-announcement.
expect(politeRegion).toHaveTextContent('Announcement.');
});

it("doesn't announce on the initial message if preventInitialAnnouncement is true", async () => {
jest.useFakeTimers();
const { politeRegion, rerender } = await renderLiveRegion(
<InternalLiveRegion delay={1} hidden={true} preventInitialAnnouncement={true}>
Announcement
</InternalLiveRegion>
);
expect(politeRegion).toHaveTextContent('');
rerender(
<InternalLiveRegion delay={1} hidden={true} preventInitialAnnouncement={true}>
Announcement
</InternalLiveRegion>
);
jest.runAllTimers();
expect(politeRegion).toHaveTextContent('');
rerender(
<InternalLiveRegion delay={1} hidden={true} preventInitialAnnouncement={true}>
Second announcement
</InternalLiveRegion>
);
jest.runAllTimers();
expect(politeRegion).toHaveTextContent('Second announcement');
});
});

describe('text extractor', () => {
Expand Down
18 changes: 17 additions & 1 deletion src/live-region/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ interface InternalLiveRegionProps extends InternalBaseComponentProps, LiveRegion
*/
delay?: number;

/**
* By default, the live region will announce the message immediately on mount.
* This attribute prevents that.
*/
preventInitialAnnouncement?: boolean;

/**
* Use a list of strings and/or refs to existing elements for building the
* announcement text. If this property is set, `children` and `message` will
Expand Down Expand Up @@ -49,6 +55,7 @@ export default React.forwardRef(function InternalLiveRegion(
tagName: TagName = 'div',
delay,
sources,
preventInitialAnnouncement,
children,
__internalRootRef,
className,
Expand Down Expand Up @@ -93,10 +100,19 @@ export default React.forwardRef(function InternalLiveRegion(
}
};

const initialAnnouncementContent = useRef<string | undefined>();

// Call the controller on every render. The controller will deduplicate the
// message against the previous announcement internally.
useEffect(() => {
liveRegionControllerRef.current?.announce({ message: getContent() });
const message = getContent();
if (initialAnnouncementContent.current === undefined) {
initialAnnouncementContent.current = message;
}
if (preventInitialAnnouncement && initialAnnouncementContent.current === message) {
return;
}
liveRegionControllerRef.current?.announce({ message });
});

useImperativeHandle(ref, () => ({
Expand Down
12 changes: 8 additions & 4 deletions src/tutorial-panel/__tests__/tutorial-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,14 @@ describe('tutorial detail view', () => {
test('completed screen should have role status', () => {
const tutorials = getTutorials();
const context = getContext({ currentTutorial: tutorials[1] });
const { container } = renderTutorialPanelWithContext({ tutorials }, context);
const completedScreen = createWrapper(container).findTutorialPanel()!.find('[role="status"]')!.getElement();
expect(completedScreen).toHaveTextContent('COMPLETION_SCREEN_TITLE');
expect(completedScreen).toHaveTextContent('COMPLETED_SCREEN_DESCRIPTION_TEST');
// Fake timers to bypass the live region delay.
jest.useFakeTimers();
renderTutorialPanelWithContext({ tutorials }, context);
const liveRegion = document.querySelector('[aria-live=polite]')!;
jest.runAllTimers();
jest.useRealTimers();
expect(liveRegion).toHaveTextContent('COMPLETION_SCREEN_TITLE');
expect(liveRegion).toHaveTextContent('COMPLETED_SCREEN_DESCRIPTION_TEST');
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import InternalBox from '../../../box/internal';
import { InternalButton } from '../../../button/internal';
import { fireNonCancelableEvent } from '../../../internal/events/index';
import { useVisualRefresh } from '../../../internal/hooks/use-visual-mode';
import InternalLiveRegion from '../../../live-region/internal';
import InternalSpaceBetween from '../../../space-between/internal';
import { TutorialPanelProps } from '../../interfaces';
import { CongratulationScreen } from './congratulation-screen';
Expand Down Expand Up @@ -60,13 +61,13 @@ export default function TutorialDetailView({
</InternalBox>
</div>
<div>
<div role="status">
<InternalLiveRegion>
{tutorial.completed && (
<CongratulationScreen onFeedbackClick={onFeedbackClick} i18nStrings={i18nStrings}>
{tutorial.completedScreenDescription}
</CongratulationScreen>
)}
</div>
</InternalLiveRegion>
{!tutorial.completed && (
<TaskList
tasks={tutorial.tasks}
Expand Down
108 changes: 53 additions & 55 deletions src/tutorial-panel/components/tutorial-list/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,65 +157,63 @@ function Tutorial({
) : null}
</InternalSpaceBetween>

<div aria-live="polite">
Copy link
Copy Markdown
Member Author

@avinashbot avinashbot Apr 27, 2026

Choose a reason for hiding this comment

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

After a bit of manual testing, I'm honestly not sure why this was wrapped with an aria-live — there's nothing dynamically changing in the tutorial list. I believe the purpose this was supposed to serve was essentially replaced by the newly added focus-on-complete behavior in #4167.

<CSSTransition
in={expanded}
timeout={30}
classNames={{ enter: styles['content-enter'] }}
nodeRef={expandableSectionRef}
<CSSTransition
in={expanded}
timeout={30}
classNames={{ enter: styles['content-enter'] }}
nodeRef={expandableSectionRef}
>
<div
className={clsx(styles['expandable-section'], expanded && styles.expanded)}
id={controlId}
ref={expandableSectionRef}
>
<div
className={clsx(styles['expandable-section'], expanded && styles.expanded)}
id={controlId}
ref={expandableSectionRef}
>
<InternalSpaceBetween size="l">
<InternalSpaceBetween size="m">
{tutorial.prerequisitesNeeded && tutorial.prerequisitesAlert && (
<InternalAlert type="info" className={styles['prerequisites-alert']}>
{tutorial.prerequisitesAlert}
</InternalAlert>
<InternalSpaceBetween size="l">
<InternalSpaceBetween size="m">
{tutorial.prerequisitesNeeded && tutorial.prerequisitesAlert && (
<InternalAlert type="info" className={styles['prerequisites-alert']}>
{tutorial.prerequisitesAlert}
</InternalAlert>
)}
<InternalSpaceBetween size="s">
<InternalBox color="text-body-secondary">
<div
className={clsx(
styles['tutorial-description'],
typeof tutorial.description === 'string' && styles['tutorial-description-plaintext']
)}
>
{tutorial.description}
</div>
</InternalBox>
{tutorial.learnMoreUrl && (
<InternalLink
href={tutorial.learnMoreUrl}
className={styles['learn-more-link']}
externalIconAriaLabel={i18nStrings.labelLearnMoreExternalIcon}
ariaLabel={i18nStrings.labelLearnMoreLink}
external={true}
variant="primary"
>
{i18nStrings.learnMoreLinkText}
</InternalLink>
)}
<InternalSpaceBetween size="s">
<InternalBox color="text-body-secondary">
<div
className={clsx(
styles['tutorial-description'],
typeof tutorial.description === 'string' && styles['tutorial-description-plaintext']
)}
>
{tutorial.description}
</div>
</InternalBox>
{tutorial.learnMoreUrl && (
<InternalLink
href={tutorial.learnMoreUrl}
className={styles['learn-more-link']}
externalIconAriaLabel={i18nStrings.labelLearnMoreExternalIcon}
ariaLabel={i18nStrings.labelLearnMoreLink}
external={true}
variant="primary"
>
{i18nStrings.learnMoreLinkText}
</InternalLink>
)}
</InternalSpaceBetween>
</InternalSpaceBetween>

<InternalBox margin={{ bottom: 'xxs' }}>
<InternalButton
onClick={onStartTutorial}
disabled={tutorial.prerequisitesNeeded ?? false}
formAction="none"
className={styles.start}
>
{tutorial.completed ? i18nStrings.restartTutorialButtonText : i18nStrings.startTutorialButtonText}
</InternalButton>
</InternalBox>
</InternalSpaceBetween>
</div>
</CSSTransition>
</div>

<InternalBox margin={{ bottom: 'xxs' }}>
<InternalButton
onClick={onStartTutorial}
disabled={tutorial.prerequisitesNeeded ?? false}
formAction="none"
className={styles.start}
>
{tutorial.completed ? i18nStrings.restartTutorialButtonText : i18nStrings.startTutorialButtonText}
</InternalButton>
</InternalBox>
</InternalSpaceBetween>
</div>
</CSSTransition>
</li>
);
}
Loading