Skip to content

feat(progress-indicator): add path type and shade variant - #3244

Open
Niznikr wants to merge 4 commits into
masterfrom
feat/progress-indicator-path-shade
Open

Niznikr wants to merge 4 commits into
masterfrom
feat/progress-indicator-path-shade

Conversation

@Niznikr

@Niznikr Niznikr commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Additional description

Adds type="path" and variant="shade" to ProgressIndicator, matching type and variant on lightning-progress-indicator. I checked the behavior against the progressIndicator and progressStep source in lightning-base-components, and the markup against the SLDS Path blueprint.

variant="shade" adds slds-progress_shade to the progress bar, the same as LWC. modal still adds the shade class and keeps its absolute tooltip positioning, so modal markup doesn't change.

type="path" renders the steps as SLDS Path stages:

  • A listbox of a[role=option] stages, with slds-is-complete, slds-is-current, slds-is-incomplete, and slds-is-active classes and the check icon.
  • Assistive text "Stage Complete" or "Current Stage:" before each stage's title, and a visible "Stage: {name}" label that names the listbox. These come from new assistiveText.completedStage and assistiveText.currentStage keys and new labels.stageNameBefore and labels.stageNameAfter keys; step.assistiveText still overrides per stage. The labels carry their own spacing (the default is 'Stage: '), so translations can put the name anywhere.
  • The stage name sits in div.slds-grid.slds-path__action, as in the SLDS blueprint (LWC uses a bare slds-grid). Measured in Chromium, the class centers the name in a full-width row at narrow widths and adds the blueprint's 16px gap at medium widths, where it otherwise touches the track. The cost is a 24px margin at the end of the track at ≥64em, where the name is hidden; the blueprint fills that space with its Mark Complete button.
  • Keyboard behavior matches LWC. The path is a single tab stop. Left/Up and Right/Down move focus and the active stage, and stop at the first and last stages. When selectedStep changes, the tab stop moves back to it. Clicking a stage focuses it, so it takes the tab stop in Safari too, where clicks don't focus links.
  • aria-selected follows the active stage (the one the user has moved to), not the current stage. The SLDS Path accessibility docs (ui/components/path/docs.mdx in the SLDS repo) specify this: "aria-selected="true" is used to describe the currently active slds-path__link Tab, not the Stage the Path is currently set to", and the arrow keys "move focus and selection". LWC does the same: lightning-progress-step sets both aria-selected and tabindex from state.active. slds-is-current, the "Current Stage:" text, and the listbox name stay on the step the app selected.
  • onStepClick and onStepFocus receive the same data shape as the base type. Clicks call preventDefault(), because the link is href="#". Enter activates a stage through the link's native click; Space does nothing. That matches LWC, which has no activation handling on path stages, and SLDS's keyboard spec, which only covers the arrow keys.
  • If selectedStep isn't in steps, no stage is current, the first stage holds the tab stop, and the listbox name is just "Stage:". The base type also falls back to the first step.

Differences from LWC

  • Completed stages come from completedSteps, as they do for the base type, not from each stage's position before the current step. A stage that is both completed and selected renders as current.
  • Not implemented: Home/End keys, right-to-left layout (slds-is-rtl), and stage coaching. LWC has no coaching or Home/End either.

Base-only props. errorSteps, disabledSteps, orientation, tooltipIsOpenSteps, tooltipPosition, and non-base variant values don't apply to the path. Combining them with type="path" logs a dev-only warning via a new check-props.js. Empty step arrays don't count as passed, so props shared between both types don't warn. The check runs again when one of those props changes, but not when a step array is only a new array with the same contents. Their JSDoc now says "type="base" only". I didn't narrow the props into a discriminated union, because the breaking-change taxonomy counts type narrowing as breaking.

Other changes

  • A roving-tabindex hook, private to progress indicator (components/progress-indicator/private/use-roving-tab-index.ts). package.json exports ./utilities/*, so putting it in utilities/hooks/ would make it public API with one consumer. It can move there once a second component shows what the shared API needs. getItemProps(index, { onFocus, onKeyDown }) calls the caller's handlers before its own, and the item ref callbacks are stable across renders.
  • Bug fix in utilities/warning/incompatible-props.js: when given a list of incompatible values, it only checked the last one, because a forEach overwrote the match flag on each iteration. Popover, the only other caller, passes single values, so its warnings don't change. The new path warnings depend on the fix (variant is checked against modal, setup-assistant, and shade). This repo squash-merges, so the changelog entry for the fix belongs under "Fixed" in the release notes.
  • vitest.config.ts: the unit project now also runs utilities/**/__tests__/*.test.*, for the new incompatibleProps test. No existing file matched that pattern.

Not a breaking change: type and the new assistiveText and labels keys are optional, and the defaults render the same markup as before. Existing story snapshots are unchanged; the snapshot diff only adds the three new stories. Nothing is added to utilities/hooks.

Commits: style(progress-indicator) is formatting only. The pre-commit hook formats whole files, and three of them weren't formatted on master. The feat commit has the functional diff, and the later commits address review feedback.

Tests and stories

  • jsdom (progress-indicator.test.jsx): the shade class, plus for the path:
    • markup, status classes, titles, assistive text and overrides, and custom labels
    • a stage that's both completed and selected (renders as current), and a selectedStep that isn't in steps
    • tab stops and aria-selected, arrow-key navigation and the end stops, the tab stop resetting when selectedStep changes, and click moving the tab stop
    • callback data
    • dev warnings: each base-only step prop; every variant value, including the first and middle items of the list; no warning for empty arrays or compatible values; warning after a rerender into an incompatible combination; no repeat warning when only an array's identity changes
  • jsdom (new utilities/warning/__tests__/incompatible-props.test.js): the first, middle, and last items of a value list, for both props. With master's incompatible-props.js, the first and middle cases fail; with the fix, all pass.
  • Browser (new progress-indicator.browser.test.jsx), with the SLDS 2 Lightning Blue stylesheet and real icon sprites loaded:
    • accessible names such as "Current Stage: Unqualified" (jsdom joins the inline spans without spaces)
    • real Tab / Shift+Tab and arrow-key focus
    • arrowing away from the current stage, tabbing out, and back: focus returns to the moved-to stage, which keeps aria-selected, while the current stage and listbox name stay put
    • the stage name's layout: a full-width row below the stages at 400px, hidden at 1280px
  • New ShadeVariant, Path, and InteractivePath stories, plus a type control and shade in the variant control.
  • Docs site: new __examples__/path.jsx and a "Path" site-stories entry in component.json.
  • Lint: 0 errors, and one fewer warning than master.

CONTRIBUTOR checklist (do not remove)

Please complete for every pull request.

Green gate — all of these must pass locally (CI runs the same):

  • npm run lint passes with 0 errors (npm run lint:fix to auto-fix). Warnings are the tracked gradual-migration backlog; do not add new errors.
  • npm run typecheck is clean (tsc --noEmit).
  • npm test -- --run passes — this runs both Vitest projects (jsdom unit + real-browser browser) and the story HTML snapshots. If you changed rendered markup on purpose, update snapshots with npm test -- --run -u and review the diff.
  • npm run build succeeds (type-check + Vite library build + .d.ts + entry shims).

Behavior & docs:

  • First-time contributors: sign the CLA (a bot will comment with instructions).
  • Added/updated Vitest + React Testing Library tests for the change (template: components/button/__tests__). New props and bug fixes should get a regression test. Tests that genuinely need a real browser (focus traversal, layout/measurement, canvas) go in a *.browser.test.jsx file (the browser project); everything else is jsdom.
  • Reviewed the component's Storybook stories: npm run storybook → http://localhost:6007/. Confirm the story actually renders and behaves (deprecated Storyshots/Karma are gone; there is no separate browser-tests server).
  • Story/example code uses the component's real prop API (e.g. render-props like onRenderContent / onRenderVisualPicker, icon props, events), not assumptions — a common source of "component looks broken" reports has been stories authored against the wrong API. Cross-check against components/<name>/__examples__.
  • Storybook Accessibility (a11y) panel shows no new violations for the affected stories.
  • If you added or changed a type, remember runtime PropTypes were removed in the TS migration — types are the contract. Ship accurate .d.ts (they're generated by the build); JS consumers rely on them via editor type-acquisition.

REVIEWER checklist (do not remove)

  • CI is green (GitHub Actions CI workflow: lint, typecheck, test, build). Chromatic visual review, if configured, has been checked.
  • Change has adequate test coverage; bug fixes include a regression test.
  • Reviewed the affected Storybook stories and their a11y panels.
  • Rendered markup still conforms to SLDS — check the story HTML snapshot diff (components/__tests__/__snapshots__/story-snapshots.test.jsx.snap).
  • Public API / package surface: if exports changed, the deep-import contract still holds (@salesforce/design-system-react/components/<name> and nested paths resolve for ESM, CJS, and types). Removing/renaming a public component or export is a breaking change → feat!:/BREAKING CHANGE: and a major version bump.

Niznikr and others added 2 commits October 14, 2026 10:39
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Match lightning-progress-indicator:

- `type="path"` renders the steps as SLDS Path stages: a listbox of
  `a[role=option]` stages with complete/current/incomplete status,
  "Stage Complete" / "Current Stage:" assistive text, and a visible
  "Stage: {name}" label. The stages are a single tab stop; the arrow
  keys move between them, and the tab stop returns to the selected
  step when `selectedStep` changes. New `assistiveText.completedStage`,
  `assistiveText.currentStage`, and `labels.stageNameBefore` keys.
- `variant="shade"` adds `slds-progress_shade` to the base type.
- Dev-only warnings when `type="path"` is combined with base-only props
  (`errorSteps`, `disabledSteps`, `orientation="vertical"`, tooltip
  props, non-base `variant`).
- New `useRovingTabIndex` hook in `utilities/hooks`.
- Fix `incompatibleProps` only matching the last value in a values list.

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

@showerbee showerbee left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for this. It's well scoped, the description is thorough, and keeping type separate from variant matches LWC. The non-breaking reasoning holds up too. I checked out the branch, ran the tests (all pass, CI green), and probed some edge cases. Details are in the inline comments. Here's the summary:

Should fix before merge

  1. The regression test for the incompatibleProps fix doesn't exercise the fix. I restored master's incompatible-props.js and the progress-indicator and popover tests still pass (44/44). See the inline comment.
  2. The stage-name wrapper is missing slds-path__action, which the SLDS Path blueprint uses and which carries the responsive layout in the SLDS 2 CSS.
  3. aria-selected follows focus, not the selected step. After the arrow keys and a Tab out, assistive tech hears a different "selected" stage than the one shown as current, and the listbox label disagrees with both.
  4. useRovingTabIndex becomes public API as soon as this ships, because package.json exports ./utilities/*. I'd like to settle its API (or keep it private) before release.

Should consider: false-positive warnings when disabledSteps or errorSteps is [], warnings that only run on mount, the listbox name when selectedStep isn't in steps, and Enter/Space behavior on role="option".

Not in the diff, so noting it here:

  • Docs site: component.json site-stories and __examples__/ weren't updated, so the path type only appears in Storybook. Consider adding __examples__/path.jsx and a site-stories entry.
  • Accessibility review: component.json shows the last accessibility review was in 2018. This adds a whole new interaction model (a listbox with roving focus), so I'd like an accessibility reviewer to approve before merge.
  • Commits: consider moving the incompatibleProps fix into its own fix(warning): commit so it shows up as a fix in the changelog, not only inside the feat commit.
  • Missing tests: a stage that's both completed and selected (the description says it renders as current, which is worth pinning), and the narrow-width stage-name layout.

I'd treat items 1–3 as blocking. Item 4 is for maintainers to discuss.

renderProgressIndicator({
...pathProps,
errorSteps: [steps[2]],
variant: 'shade',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This test doesn't guard the incompatibleProps fix. 'shade' is the last value in ['modal', 'setup-assistant', 'shade'], and the old forEach bug only got the last value right, so the old code passes too. I checked by restoring master's utilities/warning/incompatible-props.js: every progress-indicator and popover test still passes.

Suggestions:

  • Use variant: 'modal' or 'setup-assistant' here.
  • Better: add a direct unit test for incompatibleProps with a list of several values, covering both the first and the middle item. The fix is in a shared utility, so its test shouldn't depend on one component's prop list.

let propOneConditionMet = false;
let propTwoConditionMet = false;

if (props[propOneName] && props[propTwoName]) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a truthiness check, so [] counts as "passed." type="path" with disabledSteps={[]} or errorSteps={[]} logs both warnings. I verified this. Passing empty arrays is common when a consumer shares one set of props between both types.

I wouldn't change this shared check. Instead, check-props.js could pass undefined for empty arrays, or check .length for the step-array props.

checkProps = function checkPropsFunction(COMPONENT, props) {
const comment = 'Only `type="base"` supports this prop.';
const baseOnlyProps = [
['disabledSteps', null],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Related to the comment on incompatible-props.js: disabledSteps, errorSteps, and tooltipIsOpenSteps with null values warn on any truthy value, including []. Consider normalizing empty arrays to undefined before calling incompatibleProps.

const allSteps = checkSteps(propSteps) ? propSteps : defaultSteps;

// Check props in development
useEffect(() => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two small points:

  • This only runs on mount, so rendering type="base" variant="modal" and then re-rendering as type="path" logs nothing (verified). That matches the repo's existing check-props pattern, so it's a nit, but listing the checked props as dependencies would be cheap.
  • selectedStep and steps (lines 227–228) are passed in, but check-props.js never reads them.

{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
{...itemProps}
aria-selected={index === activeIndex}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

aria-selected follows the roving activeIndex (focus), but slds-is-current, the "Current Stage:" assistive text, and the listbox label (Stage: {currentStep}) all follow isSelected.

Repro (verified): selected stage is 2, focus it, press ArrowRight twice, then Tab out. The result:

  • aria-selected="true" on stage 4
  • slds-is-current and "Current Stage:" on stage 2
  • the listbox's name is "Stage: <stage 2>"
  • no onStepClick fires, so app state doesn't change either

A screen reader user hears stage 4 as selected, which contradicts everything else. If this is a deliberate match with LWC's progressStep, please cite that code in the PR description and add a test that pins this behavior. If it isn't, aria-selected should probably follow isSelected, with slds-is-active and tabIndex following focus.

</div>
<div className="slds-grid">
<span id={stageNameId} className="slds-path__stage-name">
{labels.stageNameBefore} {currentStep?.label}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two points:

  • If selectedStep isn't in steps, this renders "Stage: ", so the listbox's accessible name is just "Stage:". In that case the tab stop also falls back to index 0 (verified). It's an edge case, but worth a fallback or at least a test.
  • The hard-coded space between stageNameBefore and the label doesn't suit every locale. A format string, or letting stageNameBefore include its own spacing, would be easier to translate.

Comment thread utilities/hooks/use-roving-tab-index.ts Outdated
/** Number of items in the group */
count: number;
/** Index of the item that holds the tab stop at first, and again whenever this value changes */
defaultIndex?: number;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

package.json exports ./utilities/*, so this hook becomes public API (@salesforce/design-system-react/utilities/hooks) as soon as this ships. Under the breaking-change rules, renaming it later is breaking. So I'd push on the API now:

  • Naming: in React, default* means "initial value, then uncontrolled." This value resets the active index whenever it changes, which is neither uncontrolled nor fully controlled. Consider selectedIndex or syncIndex, or a fully controlled activeIndex with onActiveIndexChange.
  • Scope: with one consumer, there's no disabled-item skipping, no Home/End, no wrap option, no RTL, and both arrow axes are handled regardless of orientation. Tabs, tree, carousel indicators, and pill-container already have their own keyboard handling. I'd rather keep this in progress-indicator/private/ until a second consumer shows what the shared API should be. If it stays shared, it needs its own unit tests; today it's only tested through Path.

Comment thread utilities/hooks/use-roving-tab-index.ts Outdated
export interface RovingTabIndexItemProps<T extends HTMLElement> {
ref: (node: T | null) => void;
tabIndex: 0 | -1;
onFocus: () => void;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The prop getter doesn't compose handlers. onFocus takes no event, and callers have to override it and remember to call itemProps.onFocus(), as path.tsx does. If a caller spreads getItemProps(index) after its own onKeyDown or onFocus, the caller's handler is silently replaced.

The usual pattern (as in downshift) is getItemProps(index, { onFocus, onKeyDown, ref }), which calls the caller's handler and then the hook's, with the event passed through.

Comment thread utilities/hooks/use-roving-tab-index.ts Outdated
};

const getItemProps = (index: number): RovingTabIndexItemProps<T> => ({
ref: (node) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: this creates a new ref callback for every item on every render, so React detaches and re-attaches every item ref (null, then the node) on each render. It's harmless, but a ref callback cached per index (or a stable callback that reads index from a data attribute) avoids the churn.

describe('SLDSProgressIndicator path (browser)', () => {
const renderPath = () =>
render(
<IconSettings iconPath="/assets/icons">

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two points:

  • iconPath="/assets/icons" probably causes 404 requests for the sprite in browser mode. It's harmless but noisy.
  • Worth adding a browser test for the arrow-then-Tab-out case: arrow to a stage that isn't current, Tab out, Shift+Tab back, and assert which stage has focus and aria-selected. That pins down whichever behavior we settle on in the aria-selected thread on path.tsx.

Niznikr and others added 2 commits October 15, 2026 14:02
The regression test for the incompatibleProps fix only checked the last
value in the list, which the old code also got right. Test the first,
middle, and last items for both props directly, and run
utilities/**/__tests__ in the unit project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Wrap the stage name in `slds-path__action`, as in the SLDS blueprint,
  so it lays out correctly at narrow and medium widths
- Move useRovingTabIndex to progress-indicator/private so it doesn't
  become public API through `./utilities/*`; rename `defaultIndex` to
  `selectedIndex`, compose caller handlers, and keep item ref callbacks
  stable
- Focus a stage on click so it takes the tab stop in Safari
- Let `labels.stageNameBefore` carry its own spacing and add
  `labels.stageNameAfter`, instead of a hard-coded space
- Don't warn for empty step arrays, and re-run the dev checks when the
  checked props change
- Add tests for the variant list's first and middle values, a stage
  that is both completed and selected, a selectedStep missing from
  steps, and aria-selected after arrowing and tabbing away (which
  follows the SLDS Path spec); load real icon sprites in browser tests
- Add a Path example to the docs site

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Niznikr
Niznikr requested a review from showerbee October 15, 2026 18:26

@showerbee showerbee left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

All major issues look addressed- looks good!

This branch has not been deployed

No deployments
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