Skip to content

[Stepper] Improve accessibility#47687

Open
silviuaavram wants to merge 72 commits intomui:masterfrom
silviuaavram:feat/stepper-accessibility
Open

[Stepper] Improve accessibility#47687
silviuaavram wants to merge 72 commits intomui:masterfrom
silviuaavram:feat/stepper-accessibility

Conversation

@silviuaavram
Copy link
Member

@silviuaavram silviuaavram commented Jan 27, 2026

Fixes #43689.
Fixes #32826.

Stepper:

  • improve markup for stepper and step, from divs to ol and li.
  • implement roving tabindex focus for the step buttons.
  • step buttons also receive the role of tab, aria-posinset, aria-setsize and aria-selected instead of aria-current.
  • stepper that has step buttons will have the role of tablist and aria-orientation
  • step that has step buttons will have the role of presentation.

Technical:

  • roving tabindex has a separate hook that keeps track of the current focusable item (by index).
  • it exports 2 prop getters, one for container, one for item, and a focusNext function
  • one source of truth for tabindex values -> the hook.
  • update the Tabs and MenuList to also use the hook instead of their own logic
  • exported focusNext from the hook, for MenuList to use it for character key focus, in order to keep the single source of truth for the tabindex.
  • using refs instead of parsing DOM directly for getting the focusable siblings

UX for roving tabindex:

  • tabindex now follows focus. before, it stayed on the selected item, even as you moved away with arrow keys. this created issues:
    • if you tabbed from and back, the previously focused item was forgotten, you would've ended up on the selected item
    • if you moved selection from selected item with arrows, then tabbed / shift tabbed, you could have ended up with the selected item focused, which should not have happened

Copilot AI review requested due to automatic review settings January 27, 2026 08:27
@mui-bot
Copy link

mui-bot commented Jan 27, 2026

Netlify deploy preview

Bundle size report

Bundle Parsed size Gzip size
@mui/material 🔺+1.6KB(+0.31%) 🔺+668B(+0.44%)
@mui/lab 0B(0.00%) 0B(0.00%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 🔺+1.94KB(+15.09%) 🔺+753B(+14.95%)

Details of bundle changes

Generated by 🚫 dangerJS against f10f8b7

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request improves the accessibility of the Stepper component by implementing semantic HTML markup and keyboard navigation following ARIA best practices.

Changes:

  • Replaced div elements with semantic ol/li elements for Stepper and Step components
  • Implemented roving tabindex focus management with arrow key navigation for step buttons
  • Added ARIA attributes (aria-posinset, aria-setsize, aria-orientation) to improve screen reader support
  • Refactored StepperContext to export only useStepperContext hook and StepperContextProvider, removing the default export

Reviewed changes

Copilot reviewed 13 out of 15 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
packages/mui-material/src/Stepper/utils/useRovingTabIndexFocus.ts New hook implementing circular keyboard navigation with arrow keys, skipping disabled steps
packages/mui-material/src/Stepper/utils/useRovingTabIndexFocus.test.tsx Comprehensive unit tests for the roving tabindex hook
packages/mui-material/src/Stepper/index.js Removed default export of StepperContext, keeping named exports
packages/mui-material/src/Stepper/index.d.ts Updated TypeScript exports to match JavaScript changes
packages/mui-material/src/Stepper/StepperContext.ts Added new context properties for focus management, marked as @internal, exported StepperContextProvider
packages/mui-material/src/Stepper/Stepper.test.tsx Updated test to expect HTMLOListElement instead of HTMLDivElement
packages/mui-material/src/Stepper/Stepper.js Changed root element from div to ol, integrated roving tabindex, added aria-orientation
packages/mui-material/src/StepLabel/StepLabel.js Updated to use useStepperContext hook instead of direct context import
packages/mui-material/src/StepContent/StepContent.js Updated to use useStepperContext hook instead of direct context import
packages/mui-material/src/StepConnector/StepConnector.js Updated to use useStepperContext hook instead of direct context import
packages/mui-material/src/StepButton/StepButton.test.js Added StepperContextProvider wrapper to all test cases
packages/mui-material/src/StepButton/StepButton.js Integrated roving tabindex, added ARIA attributes, implemented keyboard and click handlers
packages/mui-material/src/StepButton/StepButton.d.ts Added TypeScript definitions for onClick and onKeyDown props
packages/mui-material/src/Step/Step.test.js Updated tests for li element and added StepperContextProvider wrappers
packages/mui-material/src/Step/Step.js Changed root element from div to li, updated to use useStepperContext hook

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@silviuaavram silviuaavram marked this pull request as draft January 27, 2026 08:41
@silviuaavram silviuaavram force-pushed the feat/stepper-accessibility branch from 2efcaec to a84bfad Compare January 27, 2026 17:44
@silviuaavram silviuaavram marked this pull request as ready for review January 27, 2026 17:56
@zannager zannager added the scope: stepper Changes related to the stepper. label Jan 29, 2026
@zannager zannager requested a review from mj12albert January 29, 2026 16:15
Copy link
Member

@ZeeshanTamboli ZeeshanTamboli left a comment

Choose a reason for hiding this comment

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

@silviuaavram Tested it initially with NVDA screen reader. Nice improvements.

  1. With NVDA enabled, the Left/Right arrow keys don't work on the non-linear stepper:
    https://deploy-preview-47687--material-ui.netlify.app/material-ui/react-stepper/#non-linear and nothing is announced as well. When NVDA is off, arrow-key navigation works as expected.
  2. Is it expected that Tab key should move focus to the next step? Since this isn't a tablist, I'm not sure. On the docs site (https://mui.com/material-ui/react-stepper/#non-linear), Tab moves between steps, but in this PR it jumps directly to the "Next" button.
  3. If useStepperContext is intended to be a public API, I think we should document how to use it with a custom stepper demo.

@ZeeshanTamboli ZeeshanTamboli added accessibility a11y type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. labels Jan 31, 2026
@silviuaavram
Copy link
Member Author

silviuaavram commented Feb 2, 2026

Hey @ZeeshanTamboli thanks for your input!

  1. I don't know what to say, especially that I don't have a windows setup at the moment. For example, does this stepper work with NVDA?
  2. I expect Tab to only work on one step at a time. Focusing other steppers with keyboard should work with arrows instead.
  3. useStepperContext should not be public, as StepperContext wasn't public either, right? The hook is meant be used internally, instead of doing useContext(StepperContext).

@ZeeshanTamboli
Copy link
Member

Hey @ZeeshanTamboli thanks for your input!

  1. I don't know what to say, especially that I don't have a windows setup at the moment. For example, does this stepper work with NVDA?

Yes, it works. I am able to navigate with left/right arrow keys with NVDA enabled in this demo but not in this PR's demo.

  1. I expect Tab to only work on one step at a time. Focusing other steppers with keyboard should work with arrows instead.

Ok, so you mean, pressing Tab should focus only on the last focused step in the Stepper and not move to the next step like in production?

  1. useStepperContext should not be public, as StepperContext wasn't public either, right? The hook is meant be used internally, instead of doing useContext(StepperContext).

I read this only export useStepperContext in order to control the usage better. in the PR description, so I thought it is exported.

@silviuaavram silviuaavram force-pushed the feat/stepper-accessibility branch from d598657 to be10951 Compare February 10, 2026 13:38
@silviuaavram silviuaavram marked this pull request as draft February 18, 2026 09:10
@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Feb 19, 2026
@silviuaavram silviuaavram force-pushed the feat/stepper-accessibility branch from 1f0ac0d to 353af74 Compare February 19, 2026 14:50
@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Feb 19, 2026
@silviuaavram silviuaavram force-pushed the feat/stepper-accessibility branch 5 times, most recently from 2c39c92 to 179c988 Compare February 26, 2026 09:50
@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Feb 26, 2026
@silviuaavram silviuaavram marked this pull request as ready for review February 26, 2026 11:54
@silviuaavram silviuaavram force-pushed the feat/stepper-accessibility branch from c95b272 to 917952a Compare February 26, 2026 11:56
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 30 out of 32 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +276 to +280
ref(node);
} else if (ref) {
ref.current = node;
}
});
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

handleRefs assigns to ref.current, but React.RefObject has a readonly current in the React type definitions (e.g. refs created via createRef()), which will cause a TypeScript error here. Use a safe ref setter helper (like MUI's setRef/useForkRef pattern) or narrow to MutableRefObject before assignment to keep this hook type-safe.

Copilot uses AI. Check for mistakes.
@@ -3,5 +3,4 @@ export { default } from './Stepper';
export { default as stepperClasses } from './stepperClasses';
export * from './stepperClasses';

Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

StepperContext was previously re-exported from the Stepper entrypoint, but this change removes the named export (export { default as StepperContext } ...) while keeping only export *. Since export * does not re-export the default, this is a breaking public API change for anyone importing { StepperContext } from @mui/material/Stepper. If the removal is intentional for v9, it should be called out in the v9 migration notes; otherwise consider keeping the named export for compatibility.

Suggested change
export { default as StepperContext } from './StepperContext';

Copilot uses AI. Check for mistakes.
Comment on lines 155 to +159
className={clsx(classes.root, className)}
ref={ref}
{...(isTabList && {
role: 'tablist',
'aria-orientation': orientation,
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

StepperRoot receives ref={ref} but when isTabList is true the spread of rovingTabIndexContainerProps includes its own ref, which will override the forwarded ref. This breaks ref forwarding for <Stepper /> in the tablist case. Merge refs (e.g. via useForkRef) so only one ref is ultimately passed to StepperRoot.

Copilot uses AI. Check for mistakes.
Comment on lines +159 to +160
const { focusNext, getContainerProps, getItemProps } = useRovingTabIndex({
focusableIndex: activeItemIndex,
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

activeItemIndex is computed as the raw child index (including muiSkipListHighlight items like Divider/ListSubheader), but getItemProps() is called with a separate focusableIndex counter that skips those items. Passing focusableIndex: activeItemIndex into useRovingTabIndex can desync the roving state from the registered elements, leading to incorrect initial tabbable item when there are skipped/presentation children before the active item. Compute and pass a focusable-item index that uses the same skipping rules as the render loop (or maintain a mapping from child index to focusable index).

Suggested change
const { focusNext, getContainerProps, getItemProps } = useRovingTabIndex({
focusableIndex: activeItemIndex,
// Map the raw child index `activeItemIndex` to the corresponding focusable index
// used by useRovingTabIndex (which skips muiSkipListHighlight items).
let focusableActiveItemIndex = -1;
if (activeItemIndex !== -1) {
let currentFocusableIndex = 0;
React.Children.forEach(children, (child, index) => {
if (
React.isValidElement(child) &&
!child.props.muiSkipListHighlight &&
!child.type.muiSkipListHighlight
) {
if (index === activeItemIndex && !child.props.disabled) {
focusableActiveItemIndex = currentFocusableIndex;
}
currentFocusableIndex += 1;
}
});
}
const { focusNext, getContainerProps, getItemProps } = useRovingTabIndex({
focusableIndex: focusableActiveItemIndex,

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +21
getItemProps: (
index: number,
ref?: React.RefObject<HTMLElement>,
) => {
ref: (element: HTMLElement | null) => void;
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

The exported UseRovingTabIndexReturn.getItemProps type only accepts ref?: React.RefObject<HTMLElement>, but the implementation supports general React.Ref<HTMLElement> and callers may supply callback refs (e.g. child.ref). Widen the type to React.Ref<HTMLElement> to match usage and avoid TypeScript errors for callback refs.

Copilot uses AI. Check for mistakes.
Comment on lines +62 to +66
if (
focusableIndexProp !== undefined &&
focusableIndexProp !== previousFocusableIndexPropRef.current
) {
previousFocusableIndexPropRef.current = focusableIndexProp;
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

The hook calls setFocusableIndex during render when focusableIndexProp changes. Updating state during render can trigger React warnings and is not concurrent/StrictMode-friendly. Sync the controlled focusableIndexProp via useEffect (or the existing useControlled utility) instead of performing a state update inside the render path.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

accessibility a11y breaking change Introduces changes that are not backward compatible. scope: stepper Changes related to the stepper. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Stepper] Lacks accessibility Accessibility | Narrator is not announcing the Stepper headings as "selected"

8 participants