From 09765b224a0762dcef6a01c7181f0d7805235599 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 13:43:09 +0000 Subject: [PATCH 1/2] feat: add non-linear navigation to Wizard --- pages/wizard/non-linear-navigation.page.tsx | 88 ++++++++++++++++++ src/wizard/__tests__/wizard.test.tsx | 89 +++++++++++++++++++ src/wizard/interfaces.ts | 16 ++++ src/wizard/internal.tsx | 3 + src/wizard/wizard-navigation.tsx | 6 +- src/wizard/wizard-step-list.tsx | 22 ++++- .../wizard-step-navigation-expandable.tsx | 3 + 7 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 pages/wizard/non-linear-navigation.page.tsx 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: ( +
+
Content 1
+
+ ), + }, + { + title: 'Step 2', + content: ( +
+
Content 2
+
+ ), + }, + { + title: 'Step 3', + content: ( +
+
Content 3
+
+ ), + }, + { + title: 'Step 4', + content: ( +
+
Content 4
+
+ ), + }, +]; + +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/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} From dfddcb571254850a4aeddb91764085ba00d580ea Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 15:59:06 +0000 Subject: [PATCH 2/2] test: regenerate documenter snapshot for wizard non-linear navigation --- .../__snapshots__/documenter.test.ts.snap | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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.