Conversation
Netlify deploy preview
Bundle size report
|
There was a problem hiding this comment.
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.
packages/mui-material/src/Stepper/utils/useRovingTabIndexFocus.test.tsx
Outdated
Show resolved
Hide resolved
packages/mui-material/src/Stepper/utils/useRovingTabIndexFocus.ts
Outdated
Show resolved
Hide resolved
2efcaec to
a84bfad
Compare
There was a problem hiding this comment.
@silviuaavram Tested it initially with NVDA screen reader. Nice improvements.
- 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. - Is it expected that
Tabkey 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),Tabmoves between steps, but in this PR it jumps directly to the "Next" button. - If
useStepperContextis intended to be a public API, I think we should document how to use it with a custom stepper demo.
|
Hey @ZeeshanTamboli thanks for your input!
|
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.
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?
I read this |
packages/mui-material/src/Stepper/utils/useRovingTabIndexFocus.ts
Outdated
Show resolved
Hide resolved
d598657 to
be10951
Compare
1f0ac0d to
353af74
Compare
2c39c92 to
179c988
Compare
c95b272 to
917952a
Compare
29fb61a to
f10f8b7
Compare
There was a problem hiding this comment.
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.
| ref(node); | ||
| } else if (ref) { | ||
| ref.current = node; | ||
| } | ||
| }); |
There was a problem hiding this comment.
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.
| @@ -3,5 +3,4 @@ export { default } from './Stepper'; | |||
| export { default as stepperClasses } from './stepperClasses'; | |||
| export * from './stepperClasses'; | |||
|
|
|||
There was a problem hiding this comment.
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.
| export { default as StepperContext } from './StepperContext'; |
| className={clsx(classes.root, className)} | ||
| ref={ref} | ||
| {...(isTabList && { | ||
| role: 'tablist', | ||
| 'aria-orientation': orientation, |
There was a problem hiding this comment.
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.
| const { focusNext, getContainerProps, getItemProps } = useRovingTabIndex({ | ||
| focusableIndex: activeItemIndex, |
There was a problem hiding this comment.
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).
| 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, |
| getItemProps: ( | ||
| index: number, | ||
| ref?: React.RefObject<HTMLElement>, | ||
| ) => { | ||
| ref: (element: HTMLElement | null) => void; |
There was a problem hiding this comment.
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.
| if ( | ||
| focusableIndexProp !== undefined && | ||
| focusableIndexProp !== previousFocusableIndexPropRef.current | ||
| ) { | ||
| previousFocusableIndexPropRef.current = focusableIndexProp; |
There was a problem hiding this comment.
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.
Fixes #43689.
Fixes #32826.
Stepper:
Technical:
focusNextfrom the hook, for MenuList to use it for character key focus, in order to keep the single source of truth for the tabindex.UX for roving tabindex: