diff --git a/src/wizard/__integ__/wizard.test.ts b/src/wizard/__integ__/wizard.test.ts
index ad8aaafabf..7f52953ca8 100644
--- a/src/wizard/__integ__/wizard.test.ts
+++ b/src/wizard/__integ__/wizard.test.ts
@@ -5,6 +5,8 @@ import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
import createWrapper from '../../../lib/components/test-utils/selectors';
+import styles from '../../../lib/components/wizard/styles.selectors.js';
+
const wizardWrapper = createWrapper().findWizard();
class WizardPageObject extends BasePageObject {
@@ -94,6 +96,27 @@ describe('Wizard keyboard navigation', () => {
});
});
+describe('Wizard narrow viewport navigation', () => {
+ test(
+ 'shows expandable step navigation at narrow viewport',
+ useBrowser(async browser => {
+ const page = new WizardPageObject(browser);
+ // Set narrow viewport first using page object with object syntax
+ await page.setWindowSize({ width: 320, height: 600 });
+ await browser.url('/#/light/wizard/simple?visualRefresh=true');
+ await page.waitForVisible(wizardWrapper.findPrimaryButton().toSelector());
+
+ // Collapsed steps container should be displayed at narrow viewport
+ const collapsedSteps = wizardWrapper.find(`.${styles['collapsed-steps']}`).findExpandableSection().toSelector();
+ await expect(page.isDisplayed(collapsedSteps)).resolves.toBe(true);
+
+ // Sidebar navigation should be hidden at narrow viewport
+ const navigation = wizardWrapper.findByClassName(styles.navigation).toSelector();
+ await expect(page.isDisplayed(navigation)).resolves.toBe(false);
+ })
+ );
+});
+
describe('Wizard scroll to top upon navigation', () => {
test(
'in window',
diff --git a/src/wizard/__tests__/wizard.test.tsx b/src/wizard/__tests__/wizard.test.tsx
index 2e2c7ddbbc..6f321f8caa 100644
--- a/src/wizard/__tests__/wizard.test.tsx
+++ b/src/wizard/__tests__/wizard.test.tsx
@@ -1,18 +1,29 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
-import { render } from '@testing-library/react';
+import { fireEvent, render } from '@testing-library/react';
import Button from '../../../lib/components/button';
import TestI18nProvider from '../../../lib/components/i18n/testing';
+import { useContainerBreakpoints } from '../../../lib/components/internal/hooks/container-queries';
import createWrapper from '../../../lib/components/test-utils/dom';
import WizardWrapper from '../../../lib/components/test-utils/dom/wizard';
import Wizard, { WizardProps } from '../../../lib/components/wizard';
+import WizardStepList, {
+ handleStepNavigation,
+ StepStatusValues,
+} from '../../../lib/components/wizard/wizard-step-list';
import { DEFAULT_I18N_SETS, DEFAULT_STEPS } from './common';
import liveRegionStyles from '../../../lib/components/live-region/test-classes/styles.css.js';
import styles from '../../../lib/components/wizard/styles.selectors.js';
+jest.mock('../../../lib/components/internal/hooks/container-queries', () => ({
+ ...jest.requireActual('../../../lib/components/internal/hooks/container-queries'),
+ useContainerBreakpoints: jest.fn().mockReturnValue(['xs', { current: null }]),
+}));
+const mockedUseContainerBreakpoints = useContainerBreakpoints as jest.Mock;
+
declare global {
interface Window {
AWSC?: any;
@@ -66,7 +77,9 @@ describe('i18nStrings', () => {
i18nStrings.navigationAriaLabel
);
- wrapper.findAllByClassName(styles['navigation-link-label']).forEach((label, index) => {
+ // Only test labels from the desktop navigation (not the expandable collapsed-steps navigation)
+ const desktopNav = wrapper.findByClassName(styles.navigation);
+ desktopNav!.findAllByClassName(styles['navigation-link-label']).forEach((label, index) => {
const expectedTitle = i18nStrings.stepNumberLabel!(index + 1);
const expectedLabel = DEFAULT_STEPS[index].isOptional
? `${expectedTitle} - ${i18nStrings.optional}`
@@ -79,10 +92,6 @@ describe('i18nStrings', () => {
// navigate to next step
wrapper.findPrimaryButton().click();
- const expectedCollapsedSteps = `${i18nStrings.collapsedStepsLabel!(2, DEFAULT_STEPS.length)}`;
- expect(wrapper.findByClassName(styles['collapsed-steps'])!.getElement()).toHaveTextContent(
- expectedCollapsedSteps
- );
const expectedFormTitle = `${DEFAULT_STEPS[1].title} - ${i18nStrings.optional}`;
expect(wrapper.findHeader()!.getElement()).toHaveTextContent(expectedFormTitle);
@@ -619,6 +628,89 @@ describe('Custom primary actions', () => {
});
});
+describe('handleStepNavigation', () => {
+ test('calls onStepClick for visited steps', () => {
+ const onStepClick = jest.fn();
+ const onSkipToClick = jest.fn();
+ handleStepNavigation(2, StepStatusValues.Visited, onStepClick, onSkipToClick);
+ expect(onStepClick).toHaveBeenCalledWith(2);
+ expect(onSkipToClick).not.toHaveBeenCalled();
+ });
+
+ test('calls onSkipToClick for next steps', () => {
+ const onStepClick = jest.fn();
+ const onSkipToClick = jest.fn();
+ handleStepNavigation(3, StepStatusValues.Next, onStepClick, onSkipToClick);
+ expect(onSkipToClick).toHaveBeenCalledWith(3);
+ expect(onStepClick).not.toHaveBeenCalled();
+ });
+});
+
+describe('Small container navigation', () => {
+ beforeEach(() => {
+ mockedUseContainerBreakpoints.mockReturnValue(['default', { current: null }]);
+ });
+
+ afterEach(() => {
+ mockedUseContainerBreakpoints.mockReturnValue(['xs', { current: null }]);
+ });
+
+ test('renders collapsed steps label in small container', () => {
+ const { container } = render(
+
+ );
+ const wrapper = createWrapper(container).findWizard()!;
+ const expectedCollapsedSteps = `${DEFAULT_I18N_SETS[0].collapsedStepsLabel!(2, DEFAULT_STEPS.length)}`;
+ expect(wrapper.findByClassName(styles['collapsed-steps'])!.getElement()).toHaveTextContent(expectedCollapsedSteps);
+ });
+
+ test('expandable section can be toggled in small container', () => {
+ const { container } = render(
+
+ );
+ const wrapper = createWrapper(container);
+
+ // Find the expandable section using the generic wrapper
+ const expandableSection = wrapper.findExpandableSection();
+ expect(expandableSection).not.toBeNull();
+
+ // Toggle the expandable section - this exercises the onChange handler in WizardStepNavigationExpandable
+ expandableSection!.findHeader()!.click();
+
+ // Toggle again to exercise the handler again
+ expandableSection!.findHeader()!.click();
+ });
+
+ test('renders i18n collapsed steps label in small container', () => {
+ const { container } = render(
+
+
+
+ );
+ const wrapper = createWrapper(container).findWizard()!;
+ expect(wrapper.getElement()).toHaveTextContent('Custom step 1 of 3');
+ });
+});
+
describe('i18n', () => {
test('supports rendering static strings using i18n provider', () => {
const { container } = render(
@@ -651,7 +743,6 @@ describe('i18n', () => {
expect(wrapper.find('li:nth-child(1)')!.getElement()).toHaveTextContent(
'Custom step 1 - Custom optional' + 'Step 1'
);
- expect(wrapper.getElement()).toHaveTextContent('Custom step 1 of 3');
expect(wrapper.findCancelButton().getElement()).toHaveTextContent('Custom cancel');
expect(wrapper.findPrimaryButton().getElement()).toHaveTextContent('Custom next');
expect(wrapper.findSkipToButton()!.getElement()).toHaveTextContent('Custom skip to Step 3');
@@ -660,3 +751,226 @@ describe('i18n', () => {
expect(wrapper.findPreviousButton()!.getElement()).toHaveTextContent('Custom previous');
});
});
+
+describe('WizardStepList click and keyboard navigation', () => {
+ const testSteps = [
+ { title: 'Step 1', content: 'content 1' },
+ { title: 'Step 2', content: 'content 2', isOptional: true },
+ { title: 'Step 3', content: 'content 3', isOptional: true },
+ ];
+
+ const defaultI18nStrings = {
+ stepNumberLabel: (stepNumber: number) => `Step ${stepNumber}`,
+ optional: 'optional',
+ };
+
+ function renderStepList(props: {
+ activeStepIndex: number;
+ farthestStepIndex: number;
+ allowSkipTo?: boolean;
+ onStepClick: jest.Mock;
+ onSkipToClick: jest.Mock;
+ }) {
+ return render(
+
+ );
+ }
+
+ test('navigates to visited step on Enter keydown', () => {
+ const onStepClick = jest.fn();
+ const onSkipToClick = jest.fn();
+ const { container } = renderStepList({
+ activeStepIndex: 2,
+ farthestStepIndex: 2,
+ onStepClick,
+ onSkipToClick,
+ });
+
+ // First step is visited (index 0, farthestStepIndex is 2)
+ const firstStepLink = container.querySelector('a[role="button"]') as HTMLElement;
+ fireEvent.keyDown(firstStepLink, { key: 'Enter' });
+
+ expect(onStepClick).toHaveBeenCalledWith(0);
+ expect(onSkipToClick).not.toHaveBeenCalled();
+ });
+
+ test('navigates to visited step on Space keyup', () => {
+ const onStepClick = jest.fn();
+ const onSkipToClick = jest.fn();
+ const { container } = renderStepList({
+ activeStepIndex: 2,
+ farthestStepIndex: 2,
+ onStepClick,
+ onSkipToClick,
+ });
+
+ const firstStepLink = container.querySelector('a[role="button"]') as HTMLElement;
+ fireEvent.keyDown(firstStepLink, { key: ' ' });
+ fireEvent.keyUp(firstStepLink, { key: ' ' });
+
+ expect(onStepClick).toHaveBeenCalledWith(0);
+ expect(onSkipToClick).not.toHaveBeenCalled();
+ });
+
+ test('does not navigate on Space keydown only (requires keyup)', () => {
+ const onStepClick = jest.fn();
+ const onSkipToClick = jest.fn();
+ const { container } = renderStepList({
+ activeStepIndex: 2,
+ farthestStepIndex: 2,
+ onStepClick,
+ onSkipToClick,
+ });
+
+ const firstStepLink = container.querySelector('a[role="button"]') as HTMLElement;
+ fireEvent.keyDown(firstStepLink, { key: ' ' });
+
+ // Only keydown, no keyup - should not trigger navigation
+ expect(onStepClick).not.toHaveBeenCalled();
+ expect(onSkipToClick).not.toHaveBeenCalled();
+ });
+
+ test('calls preventDefault on Enter keydown', () => {
+ const onStepClick = jest.fn();
+ const onSkipToClick = jest.fn();
+ const { container } = renderStepList({
+ activeStepIndex: 2,
+ farthestStepIndex: 2,
+ onStepClick,
+ onSkipToClick,
+ });
+
+ const firstStepLink = container.querySelector('a[role="button"]') as HTMLElement;
+ const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
+ const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
+
+ firstStepLink.dispatchEvent(event);
+
+ expect(preventDefaultSpy).toHaveBeenCalled();
+ });
+
+ test('calls preventDefault on Space keydown', () => {
+ const onStepClick = jest.fn();
+ const onSkipToClick = jest.fn();
+ const { container } = renderStepList({
+ activeStepIndex: 2,
+ farthestStepIndex: 2,
+ onStepClick,
+ onSkipToClick,
+ });
+
+ const firstStepLink = container.querySelector('a[role="button"]') as HTMLElement;
+ const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true });
+ const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
+
+ firstStepLink.dispatchEvent(event);
+
+ expect(preventDefaultSpy).toHaveBeenCalled();
+ });
+
+ test('skip-to navigation via Enter key', () => {
+ const onStepClick = jest.fn();
+ const onSkipToClick = jest.fn();
+ const { container } = renderStepList({
+ activeStepIndex: 0,
+ farthestStepIndex: 0,
+ allowSkipTo: true,
+ onStepClick,
+ onSkipToClick,
+ });
+
+ // Third step should be "next" (skippable) since steps 2 and 3 are optional
+ const links = container.querySelectorAll('a[role="button"]');
+ const thirdStepLink = links[2] as HTMLElement;
+
+ fireEvent.keyDown(thirdStepLink, { key: 'Enter' });
+
+ expect(onSkipToClick).toHaveBeenCalledWith(2);
+ expect(onStepClick).not.toHaveBeenCalled();
+ });
+
+ test('skip-to navigation via Space key', () => {
+ const onStepClick = jest.fn();
+ const onSkipToClick = jest.fn();
+ const { container } = renderStepList({
+ activeStepIndex: 0,
+ farthestStepIndex: 0,
+ allowSkipTo: true,
+ onStepClick,
+ onSkipToClick,
+ });
+
+ const links = container.querySelectorAll('a[role="button"]');
+ const thirdStepLink = links[2] as HTMLElement;
+
+ fireEvent.keyDown(thirdStepLink, { key: ' ' });
+ fireEvent.keyUp(thirdStepLink, { key: ' ' });
+
+ expect(onSkipToClick).toHaveBeenCalledWith(2);
+ expect(onStepClick).not.toHaveBeenCalled();
+ });
+
+ test('navigates to visited step on click', () => {
+ const onStepClick = jest.fn();
+ const onSkipToClick = jest.fn();
+ const { container } = renderStepList({
+ activeStepIndex: 2,
+ farthestStepIndex: 2,
+ onStepClick,
+ onSkipToClick,
+ });
+
+ const firstStepLink = container.querySelector('a[role="button"]') as HTMLElement;
+ fireEvent.click(firstStepLink);
+
+ expect(onStepClick).toHaveBeenCalledWith(0);
+ expect(onSkipToClick).not.toHaveBeenCalled();
+ });
+
+ test('calls preventDefault on click', () => {
+ const onStepClick = jest.fn();
+ const onSkipToClick = jest.fn();
+ const { container } = renderStepList({
+ activeStepIndex: 2,
+ farthestStepIndex: 2,
+ onStepClick,
+ onSkipToClick,
+ });
+
+ const firstStepLink = container.querySelector('a[role="button"]') as HTMLElement;
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true });
+ const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
+
+ firstStepLink.dispatchEvent(event);
+
+ expect(preventDefaultSpy).toHaveBeenCalled();
+ });
+
+ test('skip-to navigation via click', () => {
+ const onStepClick = jest.fn();
+ const onSkipToClick = jest.fn();
+ const { container } = renderStepList({
+ activeStepIndex: 0,
+ farthestStepIndex: 0,
+ allowSkipTo: true,
+ onStepClick,
+ onSkipToClick,
+ });
+
+ const links = container.querySelectorAll('a[role="button"]');
+ const thirdStepLink = links[2] as HTMLElement;
+ fireEvent.click(thirdStepLink);
+
+ expect(onSkipToClick).toHaveBeenCalledWith(2);
+ expect(onStepClick).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/wizard/internal.tsx b/src/wizard/internal.tsx
index ad0709ae55..f0e75d143a 100644
--- a/src/wizard/internal.tsx
+++ b/src/wizard/internal.tsx
@@ -1,6 +1,6 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
-import React, { useRef } from 'react';
+import React, { useRef, useState } from 'react';
import clsx from 'clsx';
import { useMergeRefs, warnOnce } from '@cloudscape-design/component-toolkit/internal';
@@ -27,6 +27,7 @@ import { GeneratedAnalyticsMetadataWizardComponent } from './analytics-metadata/
import { WizardProps } from './interfaces';
import WizardForm, { STEP_NAME_SELECTOR } from './wizard-form';
import WizardNavigation from './wizard-navigation';
+import WizardStepNavigationExpandable from './wizard-step-navigation-expandable';
import analyticsSelectors from './analytics-metadata/styles.css.js';
import styles from './styles.css.js';
@@ -55,7 +56,6 @@ export default function InternalWizard({
const [breakpoint, breakpointsRef] = useContainerBreakpoints(['xs']);
const ref = useMergeRefs(breakpointsRef, __internalRootRef);
-
const smallContainer = breakpoint === 'default';
const [activeStepIndex, setActiveStepIndex] = useControllable(controlledActiveStepIndex, onNavigate, 0, {
@@ -73,6 +73,8 @@ export default function InternalWizard({
const isVisualRefresh = useVisualRefresh();
const isLastStep = actualActiveStepIndex >= steps.length - 1;
+ const [isStepNavigationExpanded, setIsStepNavigationExpanded] = useState(false);
+
const navigationEvent = (requestedStepIndex: number, reason: WizardProps.NavigationReason) => {
if (funnelInteractionId) {
const stepName = getTextFromSelector(STEP_NAME_SELECTOR);
@@ -192,12 +194,27 @@ export default function InternalWizard({
onSkipToClick={onSkipToClick}
steps={steps}
/>
+ {smallContainer && (
+
+
+
+ )}
.form-header {
grid-column: 1 / span 2;
+ grid-row: 2;
}
> .form-header > .form-header-content {
@@ -252,6 +259,7 @@
> .form-component {
grid-column: 1 / span 2;
+ grid-row: 3;
}
}
}
@@ -261,13 +269,22 @@
}
.collapsed-steps {
- color: awsui.$color-text-heading-secondary;
- font-weight: styles.$font-weight-bold;
- padding-block-start: awsui.$space-scaled-xxs;
- &-hidden {
- display: none;
+ grid-column: 1 / span 2;
+ grid-row: 1;
+}
+
+/* Override fixed sidebar width for expandable navigation */
+/* stylelint-disable selector-max-type */
+.navigation.refresh.collapsed-steps-navigation {
+ grid-column: unset;
+ grid-row: unset;
+ padding-block-start: 0;
+
+ > ul.refresh {
+ inline-size: 100%;
}
}
+/* stylelint-enable selector-max-type */
.form-header-component {
&-wrapper {
diff --git a/src/wizard/wizard-form.tsx b/src/wizard/wizard-form.tsx
index 1db4b301e5..3f807914be 100644
--- a/src/wizard/wizard-form.tsx
+++ b/src/wizard/wizard-form.tsx
@@ -2,7 +2,6 @@
// SPDX-License-Identifier: Apache-2.0
import React, { MutableRefObject, useEffect, useRef } from 'react';
-import clsx from 'clsx';
import { useComponentMetadata, useMergeRefs, useUniqueId } from '@cloudscape-design/component-toolkit/internal';
import { AnalyticsMetadata } from '@cloudscape-design/component-toolkit/internal/base-component/metrics/interfaces';
@@ -31,7 +30,6 @@ import styles from './styles.css.js';
interface WizardFormProps extends InternalBaseComponentProps {
steps: ReadonlyArray;
activeStepIndex: number;
- showCollapsedSteps: boolean;
i18nStrings: WizardProps.I18nStrings;
submitButtonText?: string;
isPrimaryLoading: boolean;
@@ -75,7 +73,6 @@ function WizardForm({
stepHeaderRef,
steps,
activeStepIndex,
- showCollapsedSteps,
i18nStrings,
submitButtonText,
isPrimaryLoading,
@@ -131,9 +128,6 @@ function WizardForm({
return (
<>
-
- {i18nStrings.collapsedStepsLabel?.(activeStepIndex + 1, steps.length)}
-
void;
onSkipToClick: (stepIndex: number) => void;
- status: string;
+ status: StepStatus;
step: WizardProps.Step;
}
-enum Statuses {
- Active = 'active',
- Unvisited = 'unvisited',
- Visited = 'visited',
- Next = 'next',
-}
-
export default function Navigation({
activeStepIndex,
farthestStepIndex,
@@ -52,152 +48,63 @@ export default function Navigation({
steps,
}: NavigationProps) {
const isVisualRefresh = useVisualRefresh();
+
return (
);
-
- function getStatus(index: number) {
- if (activeStepIndex === index) {
- return Statuses.Active;
- }
- if (isLoadingNextStep) {
- return Statuses.Unvisited;
- }
- if (farthestStepIndex >= index) {
- return Statuses.Visited;
- }
- if (allowSkipTo && canSkip(activeStepIndex + 1, index)) {
- return Statuses.Next;
- }
- return Statuses.Unvisited;
- }
-
- function canSkip(fromIndex: number, toIndex: number) {
- let index = fromIndex;
- do {
- if (!steps[index].isOptional) {
- return false;
- }
- index++;
- } while (index < toIndex);
- return true;
- }
-}
-
-function NavigationStepVisualRefresh({
- i18nStrings,
- index,
- onStepClick,
- onSkipToClick,
- status,
- step,
-}: NavigationStepProps) {
- function handleStepInteraction() {
- if (status === Statuses.Visited) {
- onStepClick(index);
- }
- if (status === Statuses.Next) {
- onSkipToClick(index);
- }
- }
-
- const state = {
- active: 'active',
- unvisited: 'disabled',
- visited: 'enabled',
- next: 'enabled',
- }[status];
-
- const linkClassName = clsx(styles['navigation-link'], {
- [styles['navigation-link-active']]: status === Statuses.Active,
- [styles['navigation-link-disabled']]: status === Statuses.Unvisited,
- });
-
- return (
-
-
-
-
- {i18nStrings.stepNumberLabel && i18nStrings.stepNumberLabel(index + 1)}
- {step.isOptional && {` - ${i18nStrings.optional}`}}
-
-
- {
- event.preventDefault();
- handleStepInteraction();
- }}
- onKeyDown={event => {
- if (event.key === ' ' || event.key === 'Enter') {
- event.preventDefault();
- }
- // Enter activates the button on key down instead of key up.
- if (event.key === 'Enter') {
- handleStepInteraction();
- }
- }}
- onKeyUp={event => {
- // Emulate button behavior, which also fires on space.
- if (event.key === ' ') {
- handleStepInteraction();
- }
- }}
- role="button"
- tabIndex={status === Statuses.Visited || status === Statuses.Next ? 0 : undefined}
- {...(status === Statuses.Unvisited
- ? {}
- : getNavigationActionDetail(index, 'step', true, `.${analyticsSelectors['step-title']}`))}
- >
-
-
- {step.title}
-
-
- );
}
function NavigationStepClassic({ i18nStrings, index, onStepClick, onSkipToClick, status, step }: NavigationStepProps) {
const spanClassName = clsx(
styles['navigation-link'],
- status === Statuses.Active ? styles['navigation-link-active'] : styles['navigation-link-disabled']
+ status === StepStatusValues.Active ? styles['navigation-link-active'] : styles['navigation-link-disabled']
);
+ const optionalDescriptionId = useUniqueId('wizard-step-optional-');
return (
@@ -208,29 +115,31 @@ function NavigationStepClassic({ i18nStrings, index, onStepClick, onSkipToClick,
margin={{ bottom: 'xxs' }}
>
{i18nStrings.stepNumberLabel && i18nStrings.stepNumberLabel(index + 1)}
- {step.isOptional && {` - ${i18nStrings.optional}`}}
+ {step.isOptional && {` - ${i18nStrings.optional}`}}
- {status === Statuses.Visited || status === Statuses.Next ? (
+ {status === StepStatusValues.Visited || status === StepStatusValues.Next ? (
{
evt.preventDefault();
- if (status === Statuses.Visited) {
+ if (status === StepStatusValues.Visited) {
onStepClick(index);
} else {
onSkipToClick(index);
}
}}
variant="primary"
+ nativeAttributes={step.isOptional ? { 'aria-describedby': optionalDescriptionId } : undefined}
>
{step.title}
) : (
{step.title}
diff --git a/src/wizard/wizard-step-list.tsx b/src/wizard/wizard-step-list.tsx
new file mode 100644
index 0000000000..ccde68bf17
--- /dev/null
+++ b/src/wizard/wizard-step-list.tsx
@@ -0,0 +1,209 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React from 'react';
+import clsx from 'clsx';
+
+import { useUniqueId } from '@cloudscape-design/component-toolkit/internal';
+
+import { getNavigationActionDetail } from './analytics-metadata/utils';
+import { WizardProps } from './interfaces';
+
+import analyticsSelectors from './analytics-metadata/styles.css.js';
+import styles from './styles.css.js';
+
+export const StepStatusValues = {
+ Active: 'active',
+ Visited: 'visited',
+ Unvisited: 'unvisited',
+ Next: 'next',
+} as const;
+
+export type StepStatus = (typeof StepStatusValues)[keyof typeof StepStatusValues];
+
+export interface WizardStepListProps {
+ activeStepIndex: number;
+ farthestStepIndex: number;
+ allowSkipTo: boolean;
+ i18nStrings: WizardProps.I18nStrings;
+ isLoadingNextStep: boolean;
+ onStepClick: (stepIndex: number) => void;
+ onSkipToClick: (stepIndex: number) => void;
+ steps: ReadonlyArray
;
+}
+
+export function getStepStatus(
+ index: number,
+ activeStepIndex: number,
+ farthestStepIndex: number,
+ isLoadingNextStep: boolean,
+ allowSkipTo: boolean,
+ steps: ReadonlyArray<{ isOptional?: boolean }>
+): StepStatus {
+ if (activeStepIndex === index) {
+ return StepStatusValues.Active;
+ }
+ if (isLoadingNextStep) {
+ return StepStatusValues.Unvisited;
+ }
+ if (farthestStepIndex >= index) {
+ return StepStatusValues.Visited;
+ }
+ if (allowSkipTo && index > activeStepIndex) {
+ // Can we skip to this step? (all steps between current and this one are optional)
+ if (canSkip(activeStepIndex + 1, index, steps)) {
+ return StepStatusValues.Next;
+ }
+ // Immediate next step is also navigable if it's optional
+ if (index === activeStepIndex + 1 && steps[index]?.isOptional) {
+ return StepStatusValues.Next;
+ }
+ }
+ return StepStatusValues.Unvisited;
+}
+
+export function canSkip(fromIndex: number, toIndex: number, steps: ReadonlyArray<{ isOptional?: boolean }>): boolean {
+ // Can't skip if there are no steps to skip over
+ if (fromIndex >= toIndex) {
+ return false;
+ }
+ for (let i = fromIndex; i < toIndex; i++) {
+ if (!steps[i].isOptional) {
+ return false;
+ }
+ }
+ return true;
+}
+
+export function handleStepNavigation(
+ stepIndex: number,
+ status: StepStatus,
+ onStepClick: (index: number) => void,
+ onSkipToClick: (index: number) => void
+): void {
+ if (status === StepStatusValues.Visited) {
+ onStepClick(stepIndex);
+ } else if (status === StepStatusValues.Next) {
+ onSkipToClick(stepIndex);
+ }
+}
+
+export default function WizardStepList({
+ activeStepIndex,
+ farthestStepIndex,
+ allowSkipTo,
+ i18nStrings,
+ isLoadingNextStep,
+ onStepClick,
+ onSkipToClick,
+ steps,
+}: WizardStepListProps) {
+ return (
+
+ {steps.map((step, index) => (
+
+ ))}
+
+ );
+}
+
+interface WizardStepListItemProps {
+ index: number;
+ step: WizardProps.Step;
+ activeStepIndex: number;
+ farthestStepIndex: number;
+ allowSkipTo: boolean;
+ i18nStrings: WizardProps.I18nStrings;
+ isLoadingNextStep: boolean;
+ onStepClick: (stepIndex: number) => void;
+ onSkipToClick: (stepIndex: number) => void;
+ steps: ReadonlyArray;
+}
+
+function WizardStepListItem({
+ index,
+ step,
+ activeStepIndex,
+ farthestStepIndex,
+ allowSkipTo,
+ i18nStrings,
+ isLoadingNextStep,
+ onStepClick,
+ onSkipToClick,
+ steps,
+}: WizardStepListItemProps) {
+ const status = getStepStatus(index, activeStepIndex, farthestStepIndex, isLoadingNextStep, allowSkipTo, steps);
+ const isClickable = status === StepStatusValues.Visited || status === StepStatusValues.Next;
+ const stepLabel = i18nStrings.stepNumberLabel?.(index + 1);
+ const fullStepLabel = `${stepLabel}: ${step.title}`;
+ const optionalDescriptionId = useUniqueId('wizard-step-optional-');
+ const state = {
+ active: 'active',
+ unvisited: 'disabled',
+ visited: 'enabled',
+ next: 'enabled',
+ }[status];
+
+ const handleInteraction = () => handleStepNavigation(index, status, onStepClick, onSkipToClick);
+
+ return (
+
+
+
+
+ {i18nStrings.stepNumberLabel?.(index + 1)}
+ {step.isOptional && {` - ${i18nStrings.optional}`}}
+
+
+ {
+ event.preventDefault();
+ handleInteraction();
+ }}
+ onKeyDown={event => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ }
+ // Enter activates the button on key down instead of key up.
+ if (event.key === 'Enter') {
+ handleInteraction();
+ }
+ }}
+ onKeyUp={event => {
+ // Emulate button behavior, which also fires on space.
+ if (event.key === ' ') {
+ handleInteraction();
+ }
+ }}
+ role="button"
+ tabIndex={isClickable ? 0 : undefined}
+ aria-label={fullStepLabel}
+ aria-describedby={step.isOptional ? optionalDescriptionId : undefined}
+ {...(status === StepStatusValues.Unvisited
+ ? {}
+ : getNavigationActionDetail(index, 'step', true, `.${analyticsSelectors['step-title']}`))}
+ >
+
+
+ {step.title}
+
+
+ );
+}
diff --git a/src/wizard/wizard-step-navigation-expandable.tsx b/src/wizard/wizard-step-navigation-expandable.tsx
new file mode 100644
index 0000000000..0c80807308
--- /dev/null
+++ b/src/wizard/wizard-step-navigation-expandable.tsx
@@ -0,0 +1,67 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React from 'react';
+import clsx from 'clsx';
+
+import InternalExpandableSection from '../expandable-section/internal';
+import { WizardProps } from './interfaces';
+import WizardStepList from './wizard-step-list';
+
+import styles from './styles.css.js';
+
+interface WizardStepNavigationExpandableProps {
+ activeStepIndex: number;
+ farthestStepIndex: number;
+ allowSkipTo: boolean;
+ i18nStrings: WizardProps.I18nStrings;
+ isLoadingNextStep: boolean;
+ onStepClick: (stepIndex: number) => void;
+ onSkipToClick: (stepIndex: number) => void;
+ steps: ReadonlyArray;
+ expanded: boolean;
+ onExpandChange: (expanded: boolean) => void;
+}
+
+export default function WizardStepNavigationExpandable({
+ activeStepIndex,
+ farthestStepIndex,
+ allowSkipTo,
+ i18nStrings,
+ isLoadingNextStep,
+ onStepClick,
+ onSkipToClick,
+ steps,
+ expanded,
+ onExpandChange,
+}: WizardStepNavigationExpandableProps) {
+ const collapsedStepsLabel = i18nStrings.collapsedStepsLabel?.(activeStepIndex + 1, steps.length);
+ const headerAriaLabel = collapsedStepsLabel
+ ? `${collapsedStepsLabel} - ${i18nStrings.navigationAriaLabel ?? 'Steps'}`
+ : undefined;
+
+ return (
+ onExpandChange(detail.expanded)}
+ >
+
+
+ );
+}