Skip to content

CommandStepper/StepperCommandDialog lack ARIA tab semantics and a step-changed callback for Next/Previous #249

Description

@einari

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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions