diff --git a/pages/wizard/non-linear-navigation.page.tsx b/pages/wizard/non-linear-navigation.page.tsx
new file mode 100644
index 0000000000..e8d5f76374
--- /dev/null
+++ b/pages/wizard/non-linear-navigation.page.tsx
@@ -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: (
+
+ ),
+ },
+ {
+ title: 'Step 2',
+ content: (
+
+ ),
+ },
+ {
+ title: 'Step 3',
+ content: (
+
+ ),
+ },
+ {
+ title: 'Step 4',
+ content: (
+
+ ),
+ },
+];
+
+export default function WizardPage() {
+ const [allowNonLinearNavigation, setAllowNonLinearNavigation] = useState(true);
+ const [activeStepIndex, setActiveStepIndex] = useState(0);
+ const [eventLog, setEventLog] = useState([]);
+
+ return (
+
+
+ setAllowNonLinearNavigation(e.detail.checked)}
+ >
+ Allow non-linear navigation (jump directly to any step)
+
+
+ alert('Cancelled!')}
+ onSubmit={() => alert('Created!')}
+ onNavigate={(event: NonCancelableCustomEvent) => {
+ setActiveStepIndex(event.detail.requestedStepIndex);
+ setEventLog(prev => [...prev, event.detail]);
+ }}
+ />
+
+
+ {eventLog.map((event, index) => (
+
+ {event.requestedStepIndex} - {event.reason}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
index b54c1b56ab..79413e8ff7 100644
--- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
+++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
@@ -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.
diff --git a/src/wizard/__tests__/wizard.test.tsx b/src/wizard/__tests__/wizard.test.tsx
index 74a219e31d..fc3b597673 100644
--- a/src/wizard/__tests__/wizard.test.tsx
+++ b/src/wizard/__tests__/wizard.test.tsx
@@ -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 }] });
@@ -777,6 +864,7 @@ describe('WizardStepList click and keyboard navigation', () => {
activeStepIndex: number;
farthestStepIndex: number;
allowSkipTo?: boolean;
+ allowNonLinearNavigation?: boolean;
onStepClick: jest.Mock;
onSkipToClick: jest.Mock;
}) {
@@ -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}
diff --git a/src/wizard/interfaces.ts b/src/wizard/interfaces.ts
index f52811c89c..4f9fa4a2bd 100644
--- a/src/wizard/interfaces.ts
+++ b/src/wizard/interfaces.ts
@@ -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).
*/
diff --git a/src/wizard/internal.tsx b/src/wizard/internal.tsx
index f0e75d143a..658c684a34 100644
--- a/src/wizard/internal.tsx
+++ b/src/wizard/internal.tsx
@@ -43,6 +43,7 @@ export default function InternalWizard({
submitButtonText,
isLoadingNextStep = false,
allowSkipTo = false,
+ allowNonLinearNavigation = false,
customPrimaryActions,
secondaryActions,
onCancel,
@@ -187,6 +188,7 @@ export default function InternalWizard({
activeStepIndex={actualActiveStepIndex}
farthestStepIndex={farthestStepIndex.current}
allowSkipTo={allowSkipTo}
+ allowNonLinearNavigation={allowNonLinearNavigation}
hidden={smallContainer}
i18nStrings={i18nStrings}
isLoadingNextStep={isLoadingNextStep}
@@ -200,6 +202,7 @@ export default function InternalWizard({
activeStepIndex={actualActiveStepIndex}
farthestStepIndex={farthestStepIndex.current}
allowSkipTo={allowSkipTo}
+ allowNonLinearNavigation={allowNonLinearNavigation}
i18nStrings={i18nStrings}
isLoadingNextStep={isLoadingNextStep}
onStepClick={onStepClick}
diff --git a/src/wizard/wizard-navigation.tsx b/src/wizard/wizard-navigation.tsx
index 7c73f0a60a..df2cbd38a8 100644
--- a/src/wizard/wizard-navigation.tsx
+++ b/src/wizard/wizard-navigation.tsx
@@ -19,6 +19,7 @@ interface NavigationProps {
activeStepIndex: number;
farthestStepIndex: number;
allowSkipTo: boolean;
+ allowNonLinearNavigation: boolean;
hidden: boolean;
i18nStrings: WizardProps.I18nStrings;
isLoadingNextStep: boolean;
@@ -40,6 +41,7 @@ export default function Navigation({
activeStepIndex,
farthestStepIndex,
allowSkipTo,
+ allowNonLinearNavigation,
hidden,
i18nStrings,
isLoadingNextStep,
@@ -59,6 +61,7 @@ export default function Navigation({
activeStepIndex={activeStepIndex}
farthestStepIndex={farthestStepIndex}
allowSkipTo={allowSkipTo}
+ allowNonLinearNavigation={allowNonLinearNavigation}
i18nStrings={i18nStrings}
isLoadingNextStep={isLoadingNextStep}
onStepClick={onStepClick}
@@ -74,7 +77,8 @@ export default function Navigation({
farthestStepIndex,
isLoadingNextStep,
allowSkipTo,
- steps
+ steps,
+ allowNonLinearNavigation
);
return (
void;
@@ -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;
@@ -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)) {
@@ -91,6 +97,7 @@ export default function WizardStepList({
activeStepIndex,
farthestStepIndex,
allowSkipTo,
+ allowNonLinearNavigation,
i18nStrings,
isLoadingNextStep,
onStepClick,
@@ -107,6 +114,7 @@ export default function WizardStepList({
activeStepIndex={activeStepIndex}
farthestStepIndex={farthestStepIndex}
allowSkipTo={allowSkipTo}
+ allowNonLinearNavigation={allowNonLinearNavigation}
i18nStrings={i18nStrings}
isLoadingNextStep={isLoadingNextStep}
onStepClick={onStepClick}
@@ -124,6 +132,7 @@ interface WizardStepListItemProps {
activeStepIndex: number;
farthestStepIndex: number;
allowSkipTo: boolean;
+ allowNonLinearNavigation: boolean;
i18nStrings: WizardProps.I18nStrings;
isLoadingNextStep: boolean;
onStepClick: (stepIndex: number) => void;
@@ -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}`;
diff --git a/src/wizard/wizard-step-navigation-expandable.tsx b/src/wizard/wizard-step-navigation-expandable.tsx
index 0c80807308..6d8b720f58 100644
--- a/src/wizard/wizard-step-navigation-expandable.tsx
+++ b/src/wizard/wizard-step-navigation-expandable.tsx
@@ -13,6 +13,7 @@ interface WizardStepNavigationExpandableProps {
activeStepIndex: number;
farthestStepIndex: number;
allowSkipTo: boolean;
+ allowNonLinearNavigation: boolean;
i18nStrings: WizardProps.I18nStrings;
isLoadingNextStep: boolean;
onStepClick: (stepIndex: number) => void;
@@ -26,6 +27,7 @@ export default function WizardStepNavigationExpandable({
activeStepIndex,
farthestStepIndex,
allowSkipTo,
+ allowNonLinearNavigation,
i18nStrings,
isLoadingNextStep,
onStepClick,
@@ -55,6 +57,7 @@ export default function WizardStepNavigationExpandable({
activeStepIndex={activeStepIndex}
farthestStepIndex={farthestStepIndex}
allowSkipTo={allowSkipTo}
+ allowNonLinearNavigation={allowNonLinearNavigation}
i18nStrings={i18nStrings}
isLoadingNextStep={isLoadingNextStep}
onStepClick={onStepClick}