Skip to content
Draft
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
88 changes: 88 additions & 0 deletions pages/wizard/non-linear-navigation.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';

import { Checkbox, NonCancelableCustomEvent, SpaceBetween } from '~components';
import Box from '~components/box';
import Wizard, { WizardProps } from '~components/wizard';

import { i18nStrings } from './common';

import styles from './styles.scss';

const steps: WizardProps.Step[] = [
{
title: 'Step 1',
content: (
<div className={styles['step-content']}>
<div id="content-text">Content 1</div>
</div>
),
},
{
title: 'Step 2',
content: (
<div className={styles['step-content']}>
<div id="content-text">Content 2</div>
</div>
),
},
{
title: 'Step 3',
content: (
<div className={styles['step-content']}>
<div id="content-text">Content 3</div>
</div>
),
},
{
title: 'Step 4',
content: (
<div className={styles['step-content']}>
<div id="content-text">Content 4</div>
</div>
),
},
];

export default function WizardPage() {
const [allowNonLinearNavigation, setAllowNonLinearNavigation] = useState(true);
const [activeStepIndex, setActiveStepIndex] = useState(0);
const [eventLog, setEventLog] = useState<WizardProps.NavigateDetail[]>([]);

return (
<Box margin="xxxl">
<SpaceBetween direction="vertical" size="m">
<Checkbox
id="allow-non-linear-navigation"
checked={allowNonLinearNavigation}
onChange={e => setAllowNonLinearNavigation(e.detail.checked)}
>
Allow non-linear navigation (jump directly to any step)
</Checkbox>

<Wizard
id="wizard"
steps={steps}
i18nStrings={i18nStrings}
activeStepIndex={activeStepIndex}
allowNonLinearNavigation={allowNonLinearNavigation}
onCancel={() => alert('Cancelled!')}
onSubmit={() => alert('Created!')}
onNavigate={(event: NonCancelableCustomEvent<WizardProps.NavigateDetail>) => {
setActiveStepIndex(event.detail.requestedStepIndex);
setEventLog(prev => [...prev, event.detail]);
}}
/>

<div id="event-log" style={{ display: 'flex', flexDirection: 'column' }}>
{eventLog.map((event, index) => (
<div key={index}>
{event.requestedStepIndex} - {event.reason}
</div>
))}
</div>
</SpaceBetween>
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -33902,6 +33902,23 @@ If you set it to a value that exceeds the maximum value (that is, the number of
"optional": true,
"type": "number",
},
{
"description": "When set to \`true\`, users can navigate directly to any step ahead of the
current one from the navigation pane, rather than only to previously visited
steps or optional steps that can be skipped over. This enables non-linear
navigation where every step is directly reachable.

Navigating to a not-yet-visited step fires \`onNavigate\` with a \`reason\` of \`skip\`.

This is an opt-in enhancement of the navigation pane and is independent of
\`allowSkipTo\` (which controls the in-form *skip-to* button). The two can be
combined.

Defaults to \`false\`.",
"name": "allowNonLinearNavigation",
"optional": true,
"type": "boolean",
},
{
"defaultValue": "false",
"description": "When set to \`false\`, the *skip-to* button is never shown.
Expand Down
89 changes: 89 additions & 0 deletions src/wizard/__tests__/wizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,93 @@ describe('Navigation', () => {
});
});

describe('Non-linear navigation', () => {
const requiredSteps = [
{ title: 'Step 1', isOptional: false, content: 'content 1' },
{ title: 'Step 2', isOptional: false, content: 'content 2' },
{ title: 'Step 3', isOptional: false, content: 'content 3' },
];

test('enables all forward steps in the navigation pane when allowNonLinearNavigation is set', () => {
const [wrapper] = renderWizard({
steps: requiredSteps,
i18nStrings: DEFAULT_I18N_SETS[0],
activeStepIndex: 0,
onNavigate: () => {},
allowNonLinearNavigation: true,
});
wrapper
.findMenuNavigationLinks()
.slice(1, 3)
.map(link => link.getElement())
.forEach(link => {
expect(link).not.toHaveClass(styles['navigation-link-disabled']);
expect(link).not.toHaveAttribute('aria-disabled');
expect(link).not.toHaveAttribute('aria-current');
});
expect(wrapper.findMenuNavigationLink(2, 'disabled')).toBeNull();
expect(wrapper.findMenuNavigationLink(3, 'disabled')).toBeNull();
});

test('forward required steps stay disabled by default (backward compatible)', () => {
const [wrapper] = renderWizard({
steps: requiredSteps,
i18nStrings: DEFAULT_I18N_SETS[0],
activeStepIndex: 0,
onNavigate: () => {},
});
expect(wrapper.findMenuNavigationLink(2, 'disabled')).not.toBeNull();
expect(wrapper.findMenuNavigationLink(3, 'disabled')).not.toBeNull();
});

test('fires onNavigate with reason "skip" when jumping to a not-yet-visited step', () => {
const onNavigate = jest.fn();
const [wrapper] = renderWizard({
steps: requiredSteps,
i18nStrings: DEFAULT_I18N_SETS[0],
activeStepIndex: 0,
onNavigate,
allowNonLinearNavigation: true,
});
// Jump directly from step 1 to the unvisited required step 3
wrapper.findMenuNavigationLink(3)!.click();
expect(onNavigate).toHaveBeenCalledTimes(1);
expect(onNavigate).toHaveBeenCalledWith(
expect.objectContaining({ detail: { requestedStepIndex: 2, reason: 'skip' } })
);
});

test('fires onNavigate with reason "step" when navigating back to a visited step', () => {
const onNavigate = jest.fn();
const [wrapper] = renderWizard({
steps: requiredSteps,
i18nStrings: DEFAULT_I18N_SETS[0],
activeStepIndex: 2,
onNavigate,
allowNonLinearNavigation: true,
});
// Step 1 has already been visited (farthest reached step 3)
wrapper.findMenuNavigationLink(1)!.click();
expect(onNavigate).toHaveBeenCalledTimes(1);
expect(onNavigate).toHaveBeenCalledWith(
expect.objectContaining({ detail: { requestedStepIndex: 0, reason: 'step' } })
);
});

test('forward steps are not navigable while the next step is loading', () => {
const [wrapper] = renderWizard({
steps: requiredSteps,
i18nStrings: DEFAULT_I18N_SETS[0],
activeStepIndex: 0,
onNavigate: () => {},
allowNonLinearNavigation: true,
isLoadingNextStep: true,
});
expect(wrapper.findMenuNavigationLink(2, 'disabled')).not.toBeNull();
expect(wrapper.findMenuNavigationLink(3, 'disabled')).not.toBeNull();
});
});

describe('Form', () => {
test('renders header', () => {
const [wrapper] = renderDefaultWizard({ steps: [{ title: 'test title', content: null }] });
Expand Down Expand Up @@ -777,6 +864,7 @@ describe('WizardStepList click and keyboard navigation', () => {
activeStepIndex: number;
farthestStepIndex: number;
allowSkipTo?: boolean;
allowNonLinearNavigation?: boolean;
onStepClick: jest.Mock;
onSkipToClick: jest.Mock;
}) {
Expand All @@ -785,6 +873,7 @@ describe('WizardStepList click and keyboard navigation', () => {
activeStepIndex={props.activeStepIndex}
farthestStepIndex={props.farthestStepIndex}
allowSkipTo={props.allowSkipTo ?? false}
allowNonLinearNavigation={props.allowNonLinearNavigation ?? false}
i18nStrings={defaultI18nStrings}
isLoadingNextStep={false}
onStepClick={props.onStepClick}
Expand Down
16 changes: 16 additions & 0 deletions src/wizard/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,22 @@ export interface WizardProps extends BaseComponentProps {
*/
allowSkipTo?: boolean;

/**
* When set to `true`, users can navigate directly to any step ahead of the
* current one from the navigation pane, rather than only to previously visited
* steps or optional steps that can be skipped over. This enables non-linear
* navigation where every step is directly reachable.
*
* Navigating to a not-yet-visited step fires `onNavigate` with a `reason` of `skip`.
*
* This is an opt-in enhancement of the navigation pane and is independent of
* `allowSkipTo` (which controls the in-form *skip-to* button). The two can be
* combined.
*
* Defaults to `false`.
*/
allowNonLinearNavigation?: boolean;

/**
* Specifies right-aligned custom primary actions for the wizard. Overwrites existing buttons (e.g. Cancel, Next, Finish).
*/
Expand Down
3 changes: 3 additions & 0 deletions src/wizard/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export default function InternalWizard({
submitButtonText,
isLoadingNextStep = false,
allowSkipTo = false,
allowNonLinearNavigation = false,
customPrimaryActions,
secondaryActions,
onCancel,
Expand Down Expand Up @@ -187,6 +188,7 @@ export default function InternalWizard({
activeStepIndex={actualActiveStepIndex}
farthestStepIndex={farthestStepIndex.current}
allowSkipTo={allowSkipTo}
allowNonLinearNavigation={allowNonLinearNavigation}
hidden={smallContainer}
i18nStrings={i18nStrings}
isLoadingNextStep={isLoadingNextStep}
Expand All @@ -200,6 +202,7 @@ export default function InternalWizard({
activeStepIndex={actualActiveStepIndex}
farthestStepIndex={farthestStepIndex.current}
allowSkipTo={allowSkipTo}
allowNonLinearNavigation={allowNonLinearNavigation}
i18nStrings={i18nStrings}
isLoadingNextStep={isLoadingNextStep}
onStepClick={onStepClick}
Expand Down
6 changes: 5 additions & 1 deletion src/wizard/wizard-navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface NavigationProps {
activeStepIndex: number;
farthestStepIndex: number;
allowSkipTo: boolean;
allowNonLinearNavigation: boolean;
hidden: boolean;
i18nStrings: WizardProps.I18nStrings;
isLoadingNextStep: boolean;
Expand All @@ -40,6 +41,7 @@ export default function Navigation({
activeStepIndex,
farthestStepIndex,
allowSkipTo,
allowNonLinearNavigation,
hidden,
i18nStrings,
isLoadingNextStep,
Expand All @@ -59,6 +61,7 @@ export default function Navigation({
activeStepIndex={activeStepIndex}
farthestStepIndex={farthestStepIndex}
allowSkipTo={allowSkipTo}
allowNonLinearNavigation={allowNonLinearNavigation}
i18nStrings={i18nStrings}
isLoadingNextStep={isLoadingNextStep}
onStepClick={onStepClick}
Expand All @@ -74,7 +77,8 @@ export default function Navigation({
farthestStepIndex,
isLoadingNextStep,
allowSkipTo,
steps
steps,
allowNonLinearNavigation
);
return (
<NavigationStepClassic
Expand Down
22 changes: 20 additions & 2 deletions src/wizard/wizard-step-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface WizardStepListProps {
activeStepIndex: number;
farthestStepIndex: number;
allowSkipTo: boolean;
allowNonLinearNavigation: boolean;
i18nStrings: WizardProps.I18nStrings;
isLoadingNextStep: boolean;
onStepClick: (stepIndex: number) => void;
Expand All @@ -37,7 +38,8 @@ export function getStepStatus(
farthestStepIndex: number,
isLoadingNextStep: boolean,
allowSkipTo: boolean,
steps: ReadonlyArray<{ isOptional?: boolean }>
steps: ReadonlyArray<{ isOptional?: boolean }>,
allowNonLinearNavigation = false
): StepStatus {
if (activeStepIndex === index) {
return StepStatusValues.Active;
Expand All @@ -48,6 +50,10 @@ export function getStepStatus(
if (farthestStepIndex >= index) {
return StepStatusValues.Visited;
}
if (allowNonLinearNavigation && index > activeStepIndex) {
// Non-linear navigation: every step ahead of the current one is directly reachable.
return StepStatusValues.Next;
}
if (allowSkipTo && index > activeStepIndex) {
// All steps between current and target are optional — can skip over them
if (canSkip(activeStepIndex + 1, index, steps)) {
Expand Down Expand Up @@ -91,6 +97,7 @@ export default function WizardStepList({
activeStepIndex,
farthestStepIndex,
allowSkipTo,
allowNonLinearNavigation,
i18nStrings,
isLoadingNextStep,
onStepClick,
Expand All @@ -107,6 +114,7 @@ export default function WizardStepList({
activeStepIndex={activeStepIndex}
farthestStepIndex={farthestStepIndex}
allowSkipTo={allowSkipTo}
allowNonLinearNavigation={allowNonLinearNavigation}
i18nStrings={i18nStrings}
isLoadingNextStep={isLoadingNextStep}
onStepClick={onStepClick}
Expand All @@ -124,6 +132,7 @@ interface WizardStepListItemProps {
activeStepIndex: number;
farthestStepIndex: number;
allowSkipTo: boolean;
allowNonLinearNavigation: boolean;
i18nStrings: WizardProps.I18nStrings;
isLoadingNextStep: boolean;
onStepClick: (stepIndex: number) => void;
Expand All @@ -137,13 +146,22 @@ function WizardStepListItem({
activeStepIndex,
farthestStepIndex,
allowSkipTo,
allowNonLinearNavigation,
i18nStrings,
isLoadingNextStep,
onStepClick,
onSkipToClick,
steps,
}: WizardStepListItemProps) {
const status = getStepStatus(index, activeStepIndex, farthestStepIndex, isLoadingNextStep, allowSkipTo, steps);
const status = getStepStatus(
index,
activeStepIndex,
farthestStepIndex,
isLoadingNextStep,
allowSkipTo,
steps,
allowNonLinearNavigation
);
const isClickable = status === StepStatusValues.Visited || status === StepStatusValues.Next;
const stepLabel = i18nStrings.stepNumberLabel?.(index + 1);
const fullStepLabel = `${stepLabel}: ${step.title}`;
Expand Down
Loading
Loading