Skip to content

Commit

Permalink
feat: Focus on a step header after wizard content updated (#440)
Browse files Browse the repository at this point in the history
  • Loading branch information
Al-Dani authored Nov 16, 2022
1 parent 85277a2 commit e42bba8
Show file tree
Hide file tree
Showing 7 changed files with 66 additions and 41 deletions.
8 changes: 7 additions & 1 deletion pages/wizard/simple.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { useState } from 'react';
import Wizard, { WizardProps } from '~components/wizard';
import Toggle from '~components/toggle';
import Button from '~components/button';
import Link from '~components/link';
import styles from './styles.scss';

import { i18nStrings } from './common';
Expand All @@ -27,9 +28,14 @@ const steps: WizardProps.Step[] = [
},
{
title: 'Step 3',
info: <Link variant="info">Info</Link>,
content: (
<div className={styles['step-content']}>
<div id="content-text">Content 3</div>
{Array.from(Array(15).keys()).map(key => (
<div key={key} className={styles['content-item']}>
Item {key}
</div>
))}
</div>
),
},
Expand Down
4 changes: 4 additions & 0 deletions pages/wizard/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@
height: 200px;
overflow: scroll;
}

.content-item {
height: 100px;
}
29 changes: 29 additions & 0 deletions src/wizard/__integ__/wizard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,35 @@ describe('Wizard keyboard navigation', () => {
await expect(page.getText('#content-text')).resolves.toBe('Content 1');
})
);

test(
'should focus on header after navigation to the next step',
setupTest(async page => {
await page.resetFocus();
await page.keys(['Tab', 'Tab', 'Space']);
await expect(page.getFocusedElementText()).resolves.toBe('Step 2');
})
);

test(
'should focus on header after navigation to the previous step',
setupTest(async page => {
await page.clickPrimaryButton();
await page.keys(['Shift', 'Tab', 'Space']);
await expect(page.getFocusedElementText()).resolves.toBe('Step 1');
})
);

test(
'header should receive focus only programmatically',
setupTest(async page => {
await page.resetFocus();
await page.keys(['Tab', 'Tab', 'Space']);
await expect(page.getFocusedElementText()).resolves.toBe('Step 2');
await page.keys(['Tab', 'Shift', 'Tab']);
await expect(page.getFocusedElementText()).resolves.not.toBe('Step 2');
})
);
});
});

Expand Down
17 changes: 0 additions & 17 deletions src/wizard/__tests__/wizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -402,23 +402,6 @@ describe('Form', () => {
});
});

describe('Focus delegation', () => {
test('when previous button is not focused and unmounted no focus delegation occurs', () => {
const [wrapper] = renderDefaultWizard();
wrapper.findPrimaryButton()!.click();
wrapper.findPreviousButton()!.click();
expect(wrapper.findPrimaryButton()!.getElement()).not.toBe(document.activeElement);
});

test('when previous button is focused and unmounted the focus is delegated to the next button', () => {
const [wrapper] = renderDefaultWizard();
wrapper.findPrimaryButton()!.click();
wrapper.findPreviousButton()!.focus();
wrapper.findPreviousButton()!.click();
expect(wrapper.findPrimaryButton()!.getElement()).toBe(document.activeElement);
});
});

test('sets last step as active when activeStepIndex is out of bound, and raises warning', () => {
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
expect(consoleWarnSpy).not.toHaveBeenCalled();
Expand Down
21 changes: 1 addition & 20 deletions src/wizard/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useEffect, useRef } from 'react';
import React, { useRef } from 'react';
import clsx from 'clsx';
import { getBaseProps } from '../internal/base-component';
import { fireNonCancelableEvent } from '../internal/events';
Expand All @@ -18,19 +18,6 @@ import { useVisualRefresh } from '../internal/hooks/use-visual-mode';

export { WizardProps };

const scrollToTop = (ref: React.RefObject<HTMLDivElement>) => {
const overflowRegex = /(auto|scroll)/;
let parent = ref?.current?.parentElement;
while (parent && !overflowRegex.test(getComputedStyle(parent).overflow)) {
parent = parent.parentElement;
}
if (parent) {
parent.scrollTop = 0;
} else {
window.scrollTo(window.pageXOffset, 0);
}
};

export default function Wizard({
steps,
activeStepIndex: controlledActiveStepIndex,
Expand Down Expand Up @@ -61,11 +48,6 @@ export default function Wizard({
const farthestStepIndex = useRef<number>(actualActiveStepIndex);
farthestStepIndex.current = Math.max(farthestStepIndex.current, actualActiveStepIndex);

const internalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollToTop(internalRef);
}, [actualActiveStepIndex]);

const isVisualRefresh = useVisualRefresh();
const isLastStep = actualActiveStepIndex >= steps.length - 1;

Expand Down Expand Up @@ -101,7 +83,6 @@ export default function Wizard({
<div {...baseProps} className={clsx(styles.root, baseProps.className)} ref={ref}>
<div
className={clsx(styles.wizard, isVisualRefresh && styles.refresh, smallContainer && styles['small-container'])}
ref={internalRef}
>
<WizardNavigation
activeStepIndex={actualActiveStepIndex}
Expand Down
9 changes: 9 additions & 0 deletions src/wizard/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,15 @@
}
}

.form-header-component {
&-wrapper {
outline: none;
@include focus-visible.when-visible {
@include styles.link-focus;
}
}
}

.form-header-component,
.navigation-link,
.navigation-link-item,
Expand Down
19 changes: 16 additions & 3 deletions src/wizard/wizard-form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import React, { useRef } from 'react';
import clsx from 'clsx';
import InternalForm from '../form/internal';
import InternalHeader from '../header/internal';
Expand All @@ -9,6 +9,8 @@ import WizardActions from './wizard-actions';
import { WizardProps } from './interfaces';
import WizardFormHeader from './wizard-form-header';
import styles from './styles.css.js';
import useFocusVisible from '../internal/hooks/focus-visible';
import { useEffectOnUpdate } from '../internal/hooks/use-effect-on-update';

interface WizardFormProps {
steps: ReadonlyArray<WizardProps.Step>;
Expand Down Expand Up @@ -43,6 +45,15 @@ export default function WizardForm({
const isLastStep = activeStepIndex >= steps.length - 1;
const skipToTargetIndex = findSkipToTargetIndex(steps, activeStepIndex);
const isMobile = useMobile();
const stepHeaderRef = useRef<HTMLDivElement | null>(null);

useEffectOnUpdate(() => {
if (stepHeaderRef && stepHeaderRef.current) {
stepHeaderRef.current?.focus();
}
}, [activeStepIndex]);

const focusVisible = useFocusVisible();

const showSkipTo = allowSkipTo && skipToTargetIndex !== -1;
const skipToButtonText =
Expand All @@ -63,8 +74,10 @@ export default function WizardForm({
{i18nStrings.collapsedStepsLabel(activeStepIndex + 1, steps.length)}
</div>
<InternalHeader className={styles['form-header-component']} variant="h1" description={description} info={info}>
{title}
{isOptional && <i>{` - ${i18nStrings.optional}`}</i>}
<span className={styles['form-header-component-wrapper']} tabIndex={-1} ref={stepHeaderRef} {...focusVisible}>
{title}
{isOptional && <i>{` - ${i18nStrings.optional}`}</i>}
</span>
</InternalHeader>
</WizardFormHeader>
<InternalForm
Expand Down

0 comments on commit e42bba8

Please sign in to comment.