Skip to content
Open
51 changes: 51 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,55 @@ 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).toHaveAttribute('role', 'region');
expect(headerRegion).toHaveAttribute('aria-labelledby');

const ariaLabelledBy = headerRegion.getAttribute('aria-labelledby');
const headingElement = wrapper.find(`#${ariaLabelledBy}`)!.getElement();
expect(headingElement).toBeInTheDocument();
expect(headingElement.textContent).toBe(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
29 changes: 27 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,34 @@ export default function TutorialPanel({
const baseProps = getBaseProps(restProps);
const context = useContext(hotspotContext);

// should focus on the header (on exiting tutorial, we have to know and
// focus on header for accessiblity reasons)
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 +70,8 @@ export default function TutorialPanel({
loading={loading}
onStartTutorial={context.onStartTutorial}
downloadUrl={downloadUrl}
headerRef={headerCallbackRef}
headerId={headerId}
/>
)}
</div>
Expand Down
Loading