What was expected
Consuming a CommandStepper (or StepperCommandDialog) as a keyboard/screen-reader-accessible wizard, and being able to hook step transitions regardless of how the person navigated.
What happened instead
Inspecting CommandStepperContent.js:
- The step-header list is a plain
<ol> of <button> elements (data-cratis-part="header") and the panels are plain <section> elements (data-cratis-part="panel") with only hidden toggling visibility. None of role="tablist", role="tab", aria-selected, aria-controls, role="tabpanel", or aria-labelledby are applied anywhere. A screen reader has no way to know these headers/panels are related, which one is current, or that navigating changes anything beyond page content silently swapping.
onChangeStep (documented as "Invoked when the active step changes") is only ever called from handleStepChange, which is wired to the header <button>'s onClick. handleNext/handlePrevious — the only navigation available in the default linear mode, since header clicks are disabled for unselected steps when linear is true — call onActiveStepChange directly and never invoke onChangeStep. In practice this means the one navigation method most wizards actually use (linear Next/Previous) never fires the step-changed callback at all.
Smallest reproduction
<CommandStepper command={SomeCommand} onChangeStep={(e) => console.log('step changed', e.index)}>
<StepperPanel header="One"><InputTextField .../></StepperPanel>
<StepperPanel header="Two"><InputTextField .../></StepperPanel>
</CommandStepper>
Click "Next" — onChangeStep never fires. Click a step header (only possible in non-linear mode, or on an already-visited step) — it fires.
Why it matters
This blocks any consumer from doing standard accessible-wizard behavior at the framework level: moving focus into the new step, updating a live-region announcement, or reacting to step changes for any other reason, for the default (linear) navigation path. A consuming application is currently forced to retrofit this by observing the DOM directly — reading each panel's hidden attribute via a MutationObserver and imperatively writing role/aria-* attributes onto the rendered data-cratis-part="header"/data-cratis-part="panel" elements — which only works because those internal data-cratis-part markers happen to be stable, and breaks the encapsulation CommandStepper is supposed to provide.
This also means every consumer of CommandStepper/StepperCommandDialog currently ships wizards with no ARIA tab semantics at all unless they each reinvent this same workaround.
Suggested direction
- Apply
role="tablist" to the step list, role="tab" + id + aria-controls + aria-selected to each header button, and role="tabpanel" + aria-labelledby to each panel, using ids CommandStepperContent already has available at render time.
- Fire
onChangeStep (or an equivalent) for every step transition, regardless of whether it was caused by a header click, Next, or Previous, so consumers have one reliable hook for step-transition side effects (focus management, analytics, live-region announcements).
Found while implementing Cratis/Ante#20 (onboarding accessibility). Happy to share the app-level workaround if useful as a reference.
What was expected
Consuming a
CommandStepper(orStepperCommandDialog) as a keyboard/screen-reader-accessible wizard, and being able to hook step transitions regardless of how the person navigated.What happened instead
Inspecting
CommandStepperContent.js:<ol>of<button>elements (data-cratis-part="header") and the panels are plain<section>elements (data-cratis-part="panel") with onlyhiddentoggling visibility. None ofrole="tablist",role="tab",aria-selected,aria-controls,role="tabpanel", oraria-labelledbyare applied anywhere. A screen reader has no way to know these headers/panels are related, which one is current, or that navigating changes anything beyond page content silently swapping.onChangeStep(documented as "Invoked when the active step changes") is only ever called fromhandleStepChange, which is wired to the header<button>'sonClick.handleNext/handlePrevious— the only navigation available in the defaultlinearmode, since header clicks are disabled for unselected steps whenlinearis true — callonActiveStepChangedirectly and never invokeonChangeStep. In practice this means the one navigation method most wizards actually use (linear Next/Previous) never fires the step-changed callback at all.Smallest reproduction
Click "Next" —
onChangeStepnever fires. Click a step header (only possible in non-linear mode, or on an already-visited step) — it fires.Why it matters
This blocks any consumer from doing standard accessible-wizard behavior at the framework level: moving focus into the new step, updating a live-region announcement, or reacting to step changes for any other reason, for the default (linear) navigation path. A consuming application is currently forced to retrofit this by observing the DOM directly — reading each panel's
hiddenattribute via aMutationObserverand imperatively writingrole/aria-*attributes onto the rendereddata-cratis-part="header"/data-cratis-part="panel"elements — which only works because those internaldata-cratis-partmarkers happen to be stable, and breaks the encapsulationCommandStepperis supposed to provide.This also means every consumer of
CommandStepper/StepperCommandDialogcurrently ships wizards with no ARIA tab semantics at all unless they each reinvent this same workaround.Suggested direction
role="tablist"to the step list,role="tab"+id+aria-controls+aria-selectedto each header button, androle="tabpanel"+aria-labelledbyto each panel, using idsCommandStepperContentalready has available at render time.onChangeStep(or an equivalent) for every step transition, regardless of whether it was caused by a header click, Next, or Previous, so consumers have one reliable hook for step-transition side effects (focus management, analytics, live-region announcements).Found while implementing
Cratis/Ante#20(onboarding accessibility). Happy to share the app-level workaround if useful as a reference.