diff --git a/src/tutorial-panel/__tests__/tutorial-panel.test.tsx b/src/tutorial-panel/__tests__/tutorial-panel.test.tsx index 23d77af8e7..6db5b744bd 100644 --- a/src/tutorial-panel/__tests__/tutorial-panel.test.tsx +++ b/src/tutorial-panel/__tests__/tutorial-panel.test.tsx @@ -270,6 +270,7 @@ describe('URL sanitization', () => { expect(wrapper.findDownloadLink()).toBeFalsy(); }); }); + describe('a11y', () => { test('task list expandable section should have aria-label joining task title and total step label', () => { const tutorials = getTutorials(); @@ -293,5 +294,49 @@ describe('URL sanitization', () => { 'LEARN_MORE_ABOUT_TUTORIA' ); }); + + test('header has correct accessibility attributes', () => { + const { container } = renderTutorialPanelWithContext(); + const wrapper = createWrapper(container).findTutorialPanel()!; + const headerRegion = wrapper.find('[role="region"]')!.getElement(); + expect(headerRegion).toHaveAttribute('tabIndex', '-1'); + expect(headerRegion).toHaveAccessibleName(i18nStrings.tutorialListTitle); + }); + + test('focus returns to header when exiting tutorial', () => { + const mockFocus = jest.fn(); + const tutorials = getTutorials(); + const originalFocus = HTMLElement.prototype.focus; + HTMLElement.prototype.focus = mockFocus; + + const { container, context, rerender } = renderTutorialPanelWithContext( + {}, + { + currentTutorial: tutorials[0], + } + ); + + const wrapper = createWrapper(container).findTutorialPanel()!; + wrapper.findDismissButton()!.click(); + expect(context.onExitTutorial).toHaveBeenCalledTimes(1); + rerender( + + {}} + tutorials={tutorials} + /> + + ); + + const wrapperAfterExit = createWrapper(container).findTutorialPanel()!; + const headerRegion = wrapperAfterExit.find('[role="region"]')!.getElement(); + + expect(mockFocus).toHaveBeenCalledTimes(1); + expect(mockFocus.mock.instances[0]).toBe(headerRegion); + + HTMLElement.prototype.focus = originalFocus; + }); }); }); diff --git a/src/tutorial-panel/components/tutorial-list/index.tsx b/src/tutorial-panel/components/tutorial-list/index.tsx index 2a773993fb..d8032aeb1d 100644 --- a/src/tutorial-panel/components/tutorial-list/index.tsx +++ b/src/tutorial-panel/components/tutorial-list/index.tsx @@ -28,6 +28,8 @@ interface TutorialListProps { onStartTutorial: HotspotContext['onStartTutorial']; i18nStrings: TutorialPanelProps['i18nStrings']; downloadUrl: TutorialPanelProps['downloadUrl']; + headerRef?: (node: HTMLDivElement | null) => void; + headerId?: string; } export default function TutorialList({ @@ -36,6 +38,8 @@ export default function TutorialList({ loading = false, onStartTutorial, downloadUrl, + headerRef, + headerId, }: TutorialListProps) { checkSafeUrl('TutorialPanel', downloadUrl); @@ -45,9 +49,22 @@ export default function TutorialList({ <> - - {i18nStrings.tutorialListTitle} - +
+ + {i18nStrings.tutorialListTitle} + +
{i18nStrings.tutorialListDescription} diff --git a/src/tutorial-panel/components/tutorial-list/styles.scss b/src/tutorial-panel/components/tutorial-list/styles.scss index 41c83e4a25..6d5e963524 100644 --- a/src/tutorial-panel/components/tutorial-list/styles.scss +++ b/src/tutorial-panel/components/tutorial-list/styles.scss @@ -17,6 +17,16 @@ margin-inline: 0; } +.tutorial-header-region { + @include focus-visible.when-visible { + @include styles.focus-highlight(0px); + } + + &:focus { + outline: none; + } +} + .tutorial-box { @include styles.styles-reset; list-style: none; diff --git a/src/tutorial-panel/index.tsx b/src/tutorial-panel/index.tsx index 9b8b50409a..9eec8118eb 100644 --- a/src/tutorial-panel/index.tsx +++ b/src/tutorial-panel/index.tsx @@ -1,11 +1,14 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 'use client'; -import React, { useContext } from 'react'; +import React, { useCallback, useContext, useRef } from 'react'; import clsx from 'clsx'; +import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; + import { hotspotContext } from '../annotation-context/context'; import { getBaseProps } from '../internal/base-component'; +import { NonCancelableCustomEvent } from '../internal/events'; import useBaseComponent from '../internal/hooks/use-base-component'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import TutorialDetailView from './components/tutorial-detail-view'; @@ -29,6 +32,29 @@ export default function TutorialPanel({ const baseProps = getBaseProps(restProps); const context = useContext(hotspotContext); + // When exiting a tutorial, we need to return focus to the header for accessibility. + // We cannot directly call ref.current.focus() in handleExitTutorial because at that point + // the TutorialDetailView is still rendered and TutorialList (which contains the header) hasn't + // been rendered yet. Instead, we use shouldFocusRef to coordinate: handleExitTutorial sets it to true, + // and then headerCallbackRef focuses the header once it's actually available in the DOM. + const shouldFocusRef = useRef(false); + const headerId = useUniqueId('tutorial-header-'); + + const headerCallbackRef = useCallback((node: HTMLDivElement | null) => { + if (node && shouldFocusRef.current) { + node.focus({ preventScroll: true }); + shouldFocusRef.current = false; + } + }, []); + + const handleExitTutorial = useCallback( + (e: NonCancelableCustomEvent) => { + shouldFocusRef.current = true; + context.onExitTutorial(e); + }, + [context] + ); + return ( <>
@@ -36,7 +62,7 @@ export default function TutorialPanel({ @@ -47,6 +73,8 @@ export default function TutorialPanel({ loading={loading} onStartTutorial={context.onStartTutorial} downloadUrl={downloadUrl} + headerRef={headerCallbackRef} + headerId={headerId} /> )}