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
45 changes: 45 additions & 0 deletions src/tutorial-panel/__tests__/tutorial-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(
<HotspotContext.Provider value={{ ...context, currentTutorial: null }}>
<TutorialPanel
i18nStrings={i18nStrings}
downloadUrl="DOWNLOAD_URL"
onFeedbackClick={() => {}}
tutorials={tutorials}
/>
</HotspotContext.Provider>
);

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;
});
});
});
23 changes: 20 additions & 3 deletions src/tutorial-panel/components/tutorial-list/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -36,6 +38,8 @@ export default function TutorialList({
loading = false,
onStartTutorial,
downloadUrl,
headerRef,
headerId,
}: TutorialListProps) {
checkSafeUrl('TutorialPanel', downloadUrl);

Expand All @@ -45,9 +49,22 @@ export default function TutorialList({
<>
<InternalSpaceBetween size="s">
<InternalSpaceBetween size="m">
<InternalBox variant="h2" fontSize={isRefresh ? 'heading-m' : 'heading-l'} padding={{ bottom: 'n' }}>
{i18nStrings.tutorialListTitle}
</InternalBox>
<div
ref={headerRef}
tabIndex={-1}
role="region"
aria-labelledby={headerId}
className={styles['tutorial-header-region']}
>
<InternalBox
variant="h2"
fontSize={isRefresh ? 'heading-m' : 'heading-l'}
padding={{ bottom: 'n' }}
id={headerId}
>
{i18nStrings.tutorialListTitle}
</InternalBox>
</div>
<InternalBox variant="p" color="text-body-secondary" padding="n">
{i18nStrings.tutorialListDescription}
</InternalBox>
Expand Down
10 changes: 10 additions & 0 deletions src/tutorial-panel/components/tutorial-list/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 30 additions & 2 deletions src/tutorial-panel/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -29,14 +32,37 @@ 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<TutorialPanelProps.TutorialDetail>) => {
shouldFocusRef.current = true;
context.onExitTutorial(e);
},
[context]
);

return (
<>
<div {...baseProps} className={clsx(baseProps.className, styles['tutorial-panel'])} ref={__internalRootRef}>
{context.currentTutorial ? (
<TutorialDetailView
i18nStrings={i18nStrings}
tutorial={context.currentTutorial}
onExitTutorial={context.onExitTutorial}
onExitTutorial={handleExitTutorial}
currentStepIndex={context.currentStepIndex}
onFeedbackClick={onFeedbackClick}
/>
Expand All @@ -47,6 +73,8 @@ export default function TutorialPanel({
loading={loading}
onStartTutorial={context.onStartTutorial}
downloadUrl={downloadUrl}
headerRef={headerCallbackRef}
headerId={headerId}
/>
)}
</div>
Expand Down
Loading