Skip to content

feat: Add expandable step navigation for narrow viewports in Wizard Component#4200

Open
Who-is-PS wants to merge 38 commits intomainfrom
dev-v3-philosr-wizard-a11y
Open

feat: Add expandable step navigation for narrow viewports in Wizard Component#4200
Who-is-PS wants to merge 38 commits intomainfrom
dev-v3-philosr-wizard-a11y

Conversation

@Who-is-PS
Copy link
Member

@Who-is-PS Who-is-PS commented Jan 22, 2026

Description

Adds an expandable step navigation for the Wizard component that appears on narrow viewports (< 688px).

Changes

  • New WizardStepNavigationExpandable component: Displays step navigation inside an expandable section when the sidebar is hidden on narrow viewports
  • New WizardStepList component: Shared step list rendering with grid-based layout matching the desktop sidebar visual style (circles, connecting lines, step labels)
  • Refactored shared utilities: getStepStatus, handleStepNavigation, canSkip functions extracted for reuse between desktop and expandable navigation
  • Accessibility enhancements: <nav> landmark wrapper with headerAriaLabel prop support for screen readers
  • Expandable section state management: Navigation stays open during step transitions, with controlled state lifted to InternalWizard
Bildschirmaufnahme.2026-02-04.um.08.52.46.mov

Related links, issue #, if available: AWSUI-19175

How has this been tested?

Review checklist

The following items are to be evaluated by the author(s) and the reviewer(s).

Correctness

  • Changes include appropriate documentation updates.
  • Changes are backward-compatible if not indicated, see CONTRIBUTING.md.
  • Changes do not include unsupported browser features, see CONTRIBUTING.md.
  • Changes were manually tested for accessibility, see accessibility guidelines.

Security

Testing

  • Changes are covered with new/existing unit tests?
  • Changes are covered with new/existing integration tests?

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@codecov
Copy link

codecov bot commented Jan 30, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.25%. Comparing base (ea2b35b) to head (6379285).
⚠️ Report is 20 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4200      +/-   ##
==========================================
+ Coverage   97.18%   97.25%   +0.07%     
==========================================
  Files         883      888       +5     
  Lines       25866    26050     +184     
  Branches     9344     9448     +104     
==========================================
+ Hits        25137    25336     +199     
+ Misses        723      708      -15     
  Partials        6        6              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.


// Collapsed steps container should exist at narrow viewport
// Note: Using attribute selector because CSS class names are hashed in the build output
// Using isExisting() because ExpandableSection header has screenreader-only styling when collapsed
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, that doesn't sound right to me. Are you sure you can't use isDisplayed() here? The expandable section header is always visible, so it doesn't use screenreader-only styling.

And instead of relying on [class*=...], you can use the exact selectors directly from the style file. Check the unit tests for how we import it; it's find to do in the integ tests too.

const collapsedSteps = wizardWrapper.find(styles['collapsed-steps']).findExpandableSection().toSelector();

Same for the sidebar navigation.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - using isDisplayed() and importing styles directly from styles.selectors.js

Comment on lines 20 to 37
// Helper to mock mobile viewport for useMobile() hook
const mockMobileViewport = () => {
const originalMatchMedia = window.matchMedia;
window.matchMedia = jest.fn().mockImplementation(query => ({
matches: query.includes('max-width'),
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
return () => {
window.matchMedia = originalMatchMedia;
};
};

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to just mock useMobile entirely rather than the underlying implementation. It's also simpler to manage since you just need to change true/false.

jest.mock('../../../lib/components/internal/hooks/use-mobile', () => ({
  ...jest.requireActual('../../../lib/components/internal/hooks/use-mobile'),
  useMobile: jest.fn().mockReturnValue(true),
}));

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - now mocking useContainerBreakpoints directly instead of window.matchMedia.

const ref = useMergeRefs(breakpointsRef, __internalRootRef);

const smallContainer = breakpoint === 'default';
const isMobile = useMobile();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a change in implementation; did we talk about doing this? useMobile is a global hook, so it returns true/false depending on the width of the viewport. useContainerBreakpoints is a container hook, so it changes depends on the width of the wizard itself.

For example, take a look at the playground page for wizard. It switches to mobile even before the page goes to mobile size because the wizard is in a small container.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was raised as a bug during bug bash. When resizing the viewport, the Wizard collapses to mobile steps first, then a few pixels later AppLayout switches to mobile - creating a jarring staggered effect.

This happens because Wizard uses useContainerBreakpoints() (triggers earlier as container shrinks) while AppLayout uses useMobile() (triggers at viewport breakpoint).

Which behavior should we prioritize?

  • (A) Keep container-based: Preserves narrow-container collapse, but Wizard collapses before AppLayout
  • (B) Switch to viewport-based: Syncs with AppLayout collapse timing, but loses narrow-container collapse

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's so important for the responsive logic to smoothly transition from big to small screen and never go up again — the idea behind container queries is so that it switches depending on both the screen size and if panels are toggled, which is more of a common action.

I would prefer not making the change in the PR and independently checking with a designer — there's a reason we have container queries in a number of components, especially when the available width on a page can change so frequently (especially now, with left panels, right panels, bottom panels, etc)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've reverted the change back to useContainerBreakpoints. The container-responsive behavior is preserved - Wizard will collapse based on its own container width, not the viewport.

I'll mark this bug bash issue as "works as designed" and note that it needs designer input if we want to revisit the transition timing between AppLayout and Wizard.


<span className={clsx(styles.number, styles['navigation-link-label'])}>
{i18nStrings.stepNumberLabel?.(index + 1)}
{step.isOptional && <i id={optionalDescriptionId}>{` - ${i18nStrings.optional}`}</i>}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants