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']}`))} - > -
  • - ); } 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']}`))} + > +
  • + ); +} 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)} + > + + + ); +}