Skip to content

Commit eadf44c

Browse files
authored
fix: Restore focus to tutorial panel on dismiss (#4167)
1 parent 6366a66 commit eadf44c

File tree

4 files changed

+79
-4
lines changed

4 files changed

+79
-4
lines changed

src/tutorial-panel/__tests__/tutorial-panel.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,5 +293,41 @@ describe('URL sanitization', () => {
293293
'LEARN_MORE_ABOUT_TUTORIA'
294294
);
295295
});
296+
297+
test('focus returns to panel when exiting tutorial', () => {
298+
const mockFocus = jest.fn();
299+
const tutorials = getTutorials();
300+
const originalFocus = HTMLElement.prototype.focus;
301+
HTMLElement.prototype.focus = mockFocus;
302+
303+
const { container, context, rerender } = renderTutorialPanelWithContext(
304+
{},
305+
{
306+
currentTutorial: tutorials[0],
307+
}
308+
);
309+
310+
const wrapper = createWrapper(container).findTutorialPanel()!;
311+
wrapper.findDismissButton()!.click();
312+
expect(context.onExitTutorial).toHaveBeenCalledTimes(1);
313+
rerender(
314+
<HotspotContext.Provider value={{ ...context, currentTutorial: null }}>
315+
<TutorialPanel
316+
i18nStrings={i18nStrings}
317+
downloadUrl="DOWNLOAD_URL"
318+
onFeedbackClick={() => {}}
319+
tutorials={tutorials}
320+
/>
321+
</HotspotContext.Provider>
322+
);
323+
324+
const wrapperAfterExit = createWrapper(container).findTutorialPanel()!;
325+
const panelElement = wrapperAfterExit.getElement();
326+
327+
expect(mockFocus).toHaveBeenCalledTimes(1);
328+
expect(mockFocus.mock.instances[0]).toBe(panelElement);
329+
330+
HTMLElement.prototype.focus = originalFocus;
331+
});
296332
});
297333
});

src/tutorial-panel/components/tutorial-list/index.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface TutorialListProps {
2828
onStartTutorial: HotspotContext['onStartTutorial'];
2929
i18nStrings: TutorialPanelProps['i18nStrings'];
3030
downloadUrl: TutorialPanelProps['downloadUrl'];
31+
headingId?: string;
3132
}
3233

3334
export default function TutorialList({
@@ -36,6 +37,7 @@ export default function TutorialList({
3637
loading = false,
3738
onStartTutorial,
3839
downloadUrl,
40+
headingId,
3941
}: TutorialListProps) {
4042
checkSafeUrl('TutorialPanel', downloadUrl);
4143

@@ -45,7 +47,12 @@ export default function TutorialList({
4547
<>
4648
<InternalSpaceBetween size="s">
4749
<InternalSpaceBetween size="m">
48-
<InternalBox variant="h2" fontSize={isRefresh ? 'heading-m' : 'heading-l'} padding={{ bottom: 'n' }}>
50+
<InternalBox
51+
variant="h2"
52+
fontSize={isRefresh ? 'heading-m' : 'heading-l'}
53+
padding={{ bottom: 'n' }}
54+
id={headingId}
55+
>
4956
{i18nStrings.tutorialListTitle}
5057
</InternalBox>
5158
<InternalBox variant="p" color="text-body-secondary" padding="n">

src/tutorial-panel/index.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
33
'use client';
4-
import React, { useContext } from 'react';
4+
import React, { useContext, useRef } from 'react';
55
import clsx from 'clsx';
66

7+
import { useMergeRefs, useUniqueId } from '@cloudscape-design/component-toolkit/internal';
8+
79
import { hotspotContext } from '../annotation-context/context';
810
import { getBaseProps } from '../internal/base-component';
11+
import { NonCancelableCustomEvent } from '../internal/events';
912
import useBaseComponent from '../internal/hooks/use-base-component';
1013
import { applyDisplayName } from '../internal/utils/apply-display-name';
1114
import TutorialDetailView from './components/tutorial-detail-view';
@@ -25,18 +28,38 @@ export default function TutorialPanel({
2528
...restProps
2629
}: TutorialPanelProps) {
2730
const { __internalRootRef } = useBaseComponent('TutorialPanel');
31+
const panelRef = useRef<HTMLDivElement>(null);
32+
const headingId = useUniqueId();
2833

2934
const baseProps = getBaseProps(restProps);
3035
const context = useContext(hotspotContext);
3136

37+
function handleExitTutorial(event: NonCancelableCustomEvent<TutorialPanelProps.TutorialDetail>) {
38+
context.onExitTutorial(event);
39+
panelRef.current?.focus();
40+
}
41+
42+
const mergedRef = useMergeRefs(panelRef, __internalRootRef);
43+
3244
return (
3345
<>
34-
<div {...baseProps} className={clsx(baseProps.className, styles['tutorial-panel'])} ref={__internalRootRef}>
46+
<div
47+
{...baseProps}
48+
className={clsx(baseProps.className, styles['tutorial-panel'])}
49+
ref={mergedRef}
50+
tabIndex={-1}
51+
// Adding attributes conditionally since we don't want to point to
52+
// a non existent header in the aria-labelledby
53+
{...(!context.currentTutorial && {
54+
role: 'region',
55+
'aria-labelledby': headingId,
56+
})}
57+
>
3558
{context.currentTutorial ? (
3659
<TutorialDetailView
3760
i18nStrings={i18nStrings}
3861
tutorial={context.currentTutorial}
39-
onExitTutorial={context.onExitTutorial}
62+
onExitTutorial={handleExitTutorial}
4063
currentStepIndex={context.currentStepIndex}
4164
onFeedbackClick={onFeedbackClick}
4265
/>
@@ -47,6 +70,7 @@ export default function TutorialPanel({
4770
loading={loading}
4871
onStartTutorial={context.onStartTutorial}
4972
downloadUrl={downloadUrl}
73+
headingId={headingId}
5074
/>
5175
)}
5276
</div>

src/tutorial-panel/styles.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,18 @@
55

66
@use '../internal/styles/tokens' as awsui;
77
@use '../internal/styles' as styles;
8+
@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible;
89

910
.tutorial-panel {
1011
@include styles.styles-reset;
1112
padding-block-start: 0;
1213
padding-block-end: awsui.$space-m;
1314
padding-inline: awsui.$space-l;
15+
16+
&:focus {
17+
outline: none;
18+
}
19+
@include focus-visible.when-visible {
20+
@include styles.focus-highlight(0px);
21+
}
1422
}

0 commit comments

Comments
 (0)