Skip to content

feat(ui): add Stepper component to @automattic/ui - #111036

Merged
bogiii merged 137 commits into
trunkfrom
add/stepper-component
Jun 11, 2026
Merged

bogiii merged 137 commits into
trunkfrom
add/stepper-component

Conversation

@bogiii

@bogiii bogiii commented May 26, 2026 •

Copy link
Copy Markdown
Contributor

Part of DOTPROD-58

Proposed Changes

  • Adds a new Stepper component family to packages/ui (@automattic/ui), built on @base-ui/react v1.5
  • Tier 2 primitives: Stepper.Root, Stepper.List, Stepper.Step, Stepper.Trigger, Stepper.Panel, Stepper.Indicator, Stepper.Title, Stepper.Description
  • Tier 1 convenience wrappers: VerticalStepper (accordion-style with animated expand/collapse) and HorizontalStepper (tab-style), both exported from the package root
  • Exports Stepper, VerticalStepper, HorizontalStepper, and types (StepperProps, StepperRootProps, StepperRef, StepProps, StepStatus, IndicatorVariant) from @automattic/ui
Screenshot 2026-05-29 at 12 42 17 Screenshot 2026-05-29 at 12 42 30 Screenshot 2026-05-29 at 12 42 58

Why are these changes being made?

The design system lacked a Stepper component for guiding users through multi-step flows (onboarding, wizards, checkout sequences). This implementation provides both a flexible primitive layer for bespoke layouts and ready-to-use wrappers for the common vertical and horizontal patterns, all with full accessibility support (ARIA, keyboard navigation, screen reader labels) baked in.

Testing Instructions

  • Import VerticalStepper or HorizontalStepper from @automattic/ui and render a multi-step flow
  • Verify keyboard navigation: arrow keys move between steps (horizontal), Enter/Space expands steps (vertical)
  • Verify linear prop restricts navigation to completed steps only
  • Test screen reader labels via the formatStepLabel prop and the built-in visually-hidden indicator text
  • Run unit tests: yarn test-packages --testPathPattern="packages/ui/src/stepper"
  • View Storybook stories for both Tier 1 and Tier 2 components

Pre-merge Checklist

  • Has the general commit checklist been followed? (PCYsg-hS-p2)
  • Have you written new tests for your changes?
  • Have you tested the feature in Simple (P9HQHe-k8-p2), Atomic (P9HQHe-jW-p2), and self-hosted Jetpack sites (PCYsg-g6b-p2)?
  • Have you checked for TypeScript, React or other console errors?
  • For UI changes, have you tested the affected components in dark mode?
  • Have you tested accessibility for your changes? Ensure the feature remains usable with various user agents (e.g., browsers), interfaces (e.g., keyboard navigation), and assistive technologies (e.g., screen readers) (PCYsg-S3g-p2).
  • Have you used memoizing on expensive computations? More info in Memoizing with create-selector and Using memoizing selectors and Our Approach to Data
  • Have we added the "[Status] String Freeze" label as soon as any new strings were ready for translation (p4TIVU-5Jq-p2)?
    • For UI changes, have we tested the change in various languages (for example, ES, PT, FR, or DE)? The length of text and words vary significantly between languages.
  • For changes affecting Jetpack: Have we added the "[Status] Needs Privacy Updates" label if this pull request changes what data or activity we track or use (p4TIVU-aUh-p2)?

@matticbot

matticbot commented May 26, 2026 •

Copy link
Copy Markdown
Contributor

This PR modifies the release build for the following Calypso Apps:

For info about this notification, see here: PCYsg-OT6-p2

  • agents-manager
  • help-center
  • notifications
  • odyssey-stats
  • wpcom-block-editor

To test WordPress.com changes, run install-plugin.sh $pluginSlug add/stepper-component on your sandbox.

@bogiii bogiii self-assigned this May 26, 2026
@bogiii
bogiii force-pushed the add/stepper-component branch from 63b3db1 to 52654fe Compare May 27, 2026 10:02
@github-actions

Copy link
Copy Markdown

Looks like one of the E2E tests has failed.

You can fix them following these steps:

  1. Check out this branch locally:
    gh pr checkout 111036
  2. Start Claude Code in the repo:
    claude
  3. Run the /fix-e2e-tests skill, passing this PR number:
    /fix-e2e-tests 111036
    

@bogiii
bogiii force-pushed the add/stepper-component branch from 0deab44 to 9f9b8e1 Compare May 27, 2026 15:32
@bogiii bogiii mentioned this pull request May 28, 2026
3 of 10 tasks
@bogiii
bogiii force-pushed the add/stepper-component branch from 8d2cbab to 4213efd Compare May 29, 2026 10:34
@bogiii
bogiii requested a review from mirka May 29, 2026 11:07
@matticbot matticbot added the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label May 29, 2026
@bogiii
bogiii requested review from a team and ciampo May 29, 2026 11:07
@bogiii
bogiii marked this pull request as ready for review May 29, 2026 11:07
Comment thread packages/ui/src/horizontal-stepper/style.module.scss Outdated
@@ -0,0 +1,77 @@
// packages/ui/src/horizontal-stepper/style.module.scss
@use '@wordpress/base-styles/breakpoints';
@use '@wordpress/base-styles/variables';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'd try to replace these all with @wordpress/theme style tokens.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Breakpoints are required here because the container query threshold needs a static length value at parse time. CSS custom properties var(--wpds-*) are not valid in container query conditions and would be silently ignored by the browser.

A reasonable compromise would be hardcoding 599px (which is $break-small - 1) directly and dropping the @use dependency entirely.

What do you think?

Comment thread packages/ui/src/horizontal-stepper/style.module.scss
background: none;
border: none;
padding: variables.$grid-unit-15;
cursor: pointer;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You can use --wpds-cursor-control for pointer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the suggestion, but the AI reviewer found an issue.

There's a known SPA-leak issue with this token in Calypso. Once @wordpress/theme/design-tokens.css is loaded anywhere in the session (which happens on several Reader pages), it sets --wpds-cursor-control: pointer on :root, and that value persists across page navigations. @wordpress/components Button picks it up globally and resolves to cursor: default even on pages that never imported the token directly. There's already a workaround in client/reader/user-profile/views/achievements/achievements-settings/style.scss with a comment documenting exactly this. Using the token in the stepper would spread that fragility further. Keeping cursor: pointer as a plain value is safer until the token scoping issue is resolved upstream.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is that just a hypothetical issue, or in practice, it's an issue if it leaks? Sounds like the component and UI button can use the same token (and value) without issues?

cc @mirka otherwise might be good material for prepping buttons to be stable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I haven't tested it, but found a comment in the codebase documenting exactly this issue: client/reader/user-profile/views/achievements/achievements-settings/style.scss has a cursor: pointer override with a note explaining that the token leaks across SPA navigations. So it appears to be a real, known issue.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks! It's also not a biggie to keep static so I'm mostly glad it surfaced the issue needing fixing. :-)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

it sets --wpds-cursor-control: pointer on :root, and that value persists across page navigations. @wordpress/components Button picks it up globally and resolves to cursor: default even on pages that never imported the token directly.

I'm confused to how Button would resolve to cursor: default if the theme styles set the token to --wpds-cursor-control: pointer ? (and the hardcoded fallback on Button is also pointer )?

From an initial look, I don't understand how this bug could happen.

I haven't tested it [...] it appears to be a real, known issue.

We should push back against the AI reviewer untested claim, and investigate further:

  • If it's a real issue, let's open a dedicated GtiHub issue to track it, explain it, and follow-up to fix it (we definitely don't want to propagate hacks/style overrides across calypso)
  • if it's not, we can undo the temporary fix in client/reader/user-profile/views/achievements/achievements-settings/style.scss and use the token in this PR

Also fine to keep the current hardcoded pointer, but this follow-up work should happen sooner rather than later.

Comment thread packages/ui/src/horizontal-stepper/style.module.scss Outdated
Comment thread packages/ui/src/horizontal-stepper/style.module.scss Outdated
Comment thread packages/ui/src/horizontal-stepper/style.module.scss Outdated
Comment thread packages/ui/src/horizontal-stepper/style.module.scss Outdated
Comment thread packages/ui/src/horizontal-stepper/style.module.scss Outdated
Comment thread packages/ui/src/stepper/style.module.scss Outdated
Comment thread packages/ui/src/stepper/style.module.scss
Comment thread packages/ui/src/stepper/style.module.scss Outdated
Comment thread packages/ui/src/stepper/style.module.scss
Comment thread packages/ui/src/stepper/style.module.scss Outdated
Comment thread packages/ui/src/stepper/style.module.scss Outdated
Comment thread packages/ui/src/stepper/style.module.scss Outdated
@bogiii
bogiii marked this pull request as ready for review June 9, 2026 23:43
@bogiii
bogiii requested review from ciampo and jameskoster June 9, 2026 23:43
bogiii and others added 3 commits June 10, 2026 12:15
- Merge duplicated orientation guard in Stepper.List and move render-phase
  warning() calls into useEffect (Concurrent Mode safety) in List and Panel
- Use imported ReactNode type in step.tsx for consistency
- Replace `valueProp as string` cast with `valueProp ?? internalValue`
- Export StepperRootProps from the package root as the PR description claims
- Add missing coverage: horizontal Panel missing-value warning, VerticalStepper
  linear mode, and keepMounted passthrough

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The forwardRef wrappers already type and forward the ref; the explicit
ref prop in the props type only forced an Omit workaround in
StepperRootProps and leaked into the rest-prop spread type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@ciampo ciampo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you, @bogiii , for addressing most of the feedback that I left!

I understand that we'd prefer to merge this PR sooner rather than later.

I'm happy to pre-approve and allow this iteration, but we will definitely need to continue work on the component in follow-up PRs in the upcoming days. All of the unresolved comments in this PR will need to be worked on.

I also wanted to highlight the current state of the Stepper code when it comes to theming: it currently consumes WPDS tokens, which means that the component won't work as expected when enabling Calypso dark mode. After chatting with @aduth , we think that one of the most important follow-ups here should be to add a compat layer (extending client/lib/color-scheme/dark-theme.scss) to remap WPDS tokens to Calypso dark mode values, which will fix the issue at the right layer (calypso app).

Separately, we will likely need to revert the proprietary Dark Mode code, refactoring to @wordpress/theme — but that's unrelated from this PR.

Comment on lines +8 to +9
--a8c-ui-stepper-indicator-size: 18px;
--a8c-ui-stepper-indicator-size-sm: 16px;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it really necessary to have two different sizes for the indicator? Is this a sign that we should tweak the icons/layout to be able to use only one size in the CSS?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I agree, but this follows the Figma design.

The dashed circle for upcoming steps is intentionally 2px wider than the solid circle used for current and completed steps. The margin: 1px compensates for the size difference so all indicator centers stay vertically aligned across states.

Screenshot 2026-06-10 at 23 40 45

cc @nuriapenya

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We could keep the size fixed to 18px and account for these differences directly in the SVG?

Comment thread packages/ui/src/stepper/style.module.scss
Comment on lines +15 to +27
const halfCircleIcon = (
<SVG
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
style={ { transform: 'rotate(180deg)' } }
>
<Path d="M0 5a5 5 0 0 1 10 0H0z" fill="currentColor" />
</SVG>
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Probably fine for this iteration, but I'd like to get feedback from @jameskoster @jasmussen @fcoveram to see if we can follow-up and only use standard @wordpress/icons icons

Comment thread packages/ui/src/stepper/indicator.tsx Outdated
if ( status === 'completed' ) {
indicator = <Icon icon={ check } size={ 14 } />;
} else if ( status === 'error' ) {
indicator = <span aria-hidden="true">!</span>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same here — implementing this custom solution could work as a temporary, initial solution, but I feel like in the long term we should uniform to a more DS-like implementation. cc @jasmussen @jameskoster @fcoveram

Comment thread packages/ui/src/stepper/root.tsx
Comment thread packages/ui/src/stepper/use-step-registration.ts
Comment thread packages/ui/src/stepper/types.ts
Comment on lines +124 to +141
&:hover:not( [aria-disabled='true'] ) {
.title {
color: var( --wpds-color-fg-interactive-brand );
}

// Number-variant current step has a solid filled circle — skip border change
// to avoid an accent ring appearing against the dark background on hover.
.indicator:not( .is-current[data-indicator-variant='number'] ) {
border-color: var( --wpds-color-fg-interactive-brand );
}

// Pull icon color for completed and current steps (SVG/dome use currentColor).
// Number-variant current steps keep their original number color on hover.
.indicator.is-completed,
.indicator.is-current:not( [data-indicator-variant='number'] ) {
color: var( --wpds-color-fg-interactive-brand );
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Mixing and matching the tone (ie. neutral in the resting state, and brand in the hover state) should be avoided when using the tokens. The tokens themselves already offer an active variant that should be used for "active" states, such as hover/focus/active. The same applies for disabled states (there is a disabled variant of the interactive tokens).

Another mismatch that I noticed is the mixing of content and interactive tokens: we should use interactive tokens only for UI elements that are effectively interactive (such as links and buttons), and otherwise use content tokens.

In other words, we should use the tokens according to their semantic meaning — the resulting "look and feel" will automatically follow the WPDS aesthetic.

To summarize, we should pick one family of tokens for the interactive trigger / indicator / title, and avoid mixing and matching different families of DS tokens. We could probably go for fg-interactive-neutral{, -active, -disabled} and reserve fg-content-* for genuinely static, non interactive content.

cc @nuriapenya @jameskoster @jasmussen @fcoveram

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Agree on the content / interactive concern.

The neutral / brand point speaks to a system issue; --wpds-color-fg-interactive-neutral and --wpds-color-fg-interactive-neutral-active map to identical values, as tracked in WordPress/gutenberg#77153. So when backgrounds aren't utilised, switching foreground tone is one of very few reliable ways to achieve a visible hover style. Until we find a solution for this issue maybe it's okay to switch tone on hover? It shouldn't be harmful...?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@jameskoster I'm a bit concerned about allowing mixing and matching tones, as it can easily get out of control and produce inconsistent behaviour across UIs.

As a thoguht exercise, I guess Stepper should behave essentially like @wordpress/ui Tabs or at most like a minimal neutral Button?

Of course, we can still discuss whether neutral UIs should use the brand color for their active state, but that information should be probably baked in the token, at not at the consumer's discretion?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Update:

I spoke with @jameskoster and we agreed on the following:

We see two possible solutions:

  • Use existing @wordpress/ui components, in this case Button, which already come with extra affordances (background color changes). This would likely need a light re-design to fit Button better, but it would remove the need for custom markup/styles in Stepper
  • Keep the custom UI + WPDS tokens, but add extra visual affordances for hover/focus states, eg making font weight bolder, slightly scale (animate) the icon

Comment thread packages/ui/src/stepper/style.module.scss
@bogiii

bogiii commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Thank you all for the reviews.

I'll merge this PR to unlock shipping the experiment, but I'll continue handling and polishing this component in the follow-up PRs.

@bogiii
bogiii merged commit 2afc14c into trunk Jun 11, 2026
48 checks passed
@bogiii
bogiii deleted the add/stepper-component branch June 11, 2026 08:39
@github-actions github-actions Bot removed the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Jun 11, 2026
@a8ci18n

a8ci18n commented Jun 11, 2026

Copy link
Copy Markdown

This Pull Request is now available for translation here: https://translate.wordpress.com/deliverables/33822996

Some locales (Hebrew, Japanese) have been temporarily machine-translated due to translator availability. All other translations are usually ready within a few days. Untranslated and machine-translated strings will be sent for translation next Monday and are expected to be completed by the following Friday.

Thank you @bogiii for including a screenshot in the description! This is really helpful for our translators.

Comment thread packages/ui/CHANGELOG.md
@ciampo

ciampo commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

I'll merge this PR to unlock shipping the experiment, but I'll continue handling and polishing this component in the follow-up PRs.

Sounds good, @bogiii ! Is there a place to track the progress for all follow-up tasks?

bogiii added a commit that referenced this pull request Jun 11, 2026
The duplicate-value warning in Stepper.Root was dead code: registration
de-duped steps before the warning loop could ever see a duplicate. Move
the warning into useStepRegistration where it can actually fire, and
return a no-op deregister for rejected duplicates so unmounting one no
longer removes the original step registration.

Follow-up to #111036 review feedback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
bogiii added a commit that referenced this pull request Jun 12, 2026
The union-keys comparison in updateStep was needed when registration meta carried ReactNode snapshots; meta is now flat scalars, so a plain shallow compare (with a key-count check for removed fields) is enough. Also drop children/className from StepperPanelProps since ComponentProps<'div'> already provides them.

Follow-up to #111036 review feedback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@bogiii

bogiii commented Jun 12, 2026 •

Copy link
Copy Markdown
Contributor Author

Sounds good, @bogiii ! Is there a place to track the progress for all follow-up tasks?

I'll handle the remaining feedback in small, easy-to-review follow-up PRs. For tracking, I'll reply under each open feedback thread in this PR with a link to the follow-up PR that addresses it, so each thread can be resolved once it's handled.

How does it sound to you, @ciampo?

Follow-up feedback checklist:

bogiii added a commit that referenced this pull request Jun 15, 2026
* ui: Remove dead Stepper title color and derive bullet margin from size tokens

Follow-up to review feedback on #111036:
- The base `.title` color never applied; it is always overridden by the
  higher-specificity `[data-indicator-variant] .title` rules since the
  root always renders `data-indicator-variant`.
- Replace the magic `margin: 1px` on bullet indicators with a calc()
  derived from the indicator size variables, so the centre alignment
  stays correct if the sizes ever change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ui: Extract Stepper bullet offset into a shared custom property

Replace the duplicated margin calc on current/completed bullet indicators
with a single --a8c-ui-stepper-indicator-offset token.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
bogiii added a commit that referenced this pull request Jun 16, 2026
…111567)

* ui: Stepper warns about duplicate step values at registration time

The duplicate-value warning in Stepper.Root was dead code: registration
de-duped steps before the warning loop could ever see a duplicate. Move
the warning into useStepRegistration where it can actually fire, and
return a no-op deregister for rejected duplicates so unmounting one no
longer removes the original step registration.

Follow-up to #111036 review feedback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ui: Add changelog entry for Stepper duplicate-value warning fix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ui: Drop changelog entry for unreleased Stepper fix

The Stepper component is still unreleased, so a bug fix to it needs no changelog entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ui: Ref-count Stepper registrations so transient duplicates never drop a step

A rejected duplicate previously got a no-op deregister, so if the originally-accepted instance unmounted while the duplicate stayed mounted, the value lost its registration entirely. Track a per-value mount count and remove the entry only when the last mounted instance deregisters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ui: Simplify Stepper updateStep compare and StepperPanelProps

The union-keys comparison in updateStep was needed when registration meta carried ReactNode snapshots; meta is now flat scalars, so a plain shallow compare (with a key-count check for removed fields) is enough. Also drop children/className from StepperPanelProps since ComponentProps<'div'> already provides them.

Follow-up to #111036 review feedback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Address PR review feedback (#111567)

- Document the stale-metadata limitation when duplicate step values overlap
- Rename use-step-registration test to .ts (no JSX)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ui: Key Stepper registration by per-mount id to fix stale duplicate metadata

Instead of keying steps by value, each Stepper.Step now registers under a
unique useId. The public steps list is derived as one entry per value from
the first still-mounted instance, so a duplicate value can no longer override
or leave stale metadata on the surviving step.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ui: Pin Stepper step slot so a surviving duplicate keeps its position

Order derived steps by each value's stable first-appearance slot instead of
its current array position, so when an earlier duplicate instance unmounts the
surviving instance keeps the value's original slot rather than jumping to the
end. Adds tests for the slot invariant, the duplicate warning, and strengthens
the surviving-metadata assertion to cover both mounted and unmounted states.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ui: Store Stepper slot in state so steps derivation stays pure

Fold the per-value slot into the instances state and sort on it, so the
steps memo no longer reads the slotOrder ref mid-render — keeping the
derivation pure under concurrent rendering.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
@ciampo

ciampo commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Let's also not forget the cursor: pointer conversation and the agreed follow-ups #111036 (comment)

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.

9 participants