diff --git a/docs/pages/material-ui/api/step-button.json b/docs/pages/material-ui/api/step-button.json index c17afb77ca85a7..d9600c36f2505c 100644 --- a/docs/pages/material-ui/api/step-button.json +++ b/docs/pages/material-ui/api/step-button.json @@ -2,6 +2,13 @@ "props": { "children": { "type": { "name": "node" } }, "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, + "getAriaLabel": { + "type": { "name": "func" }, + "signature": { + "type": "function(index: number, totalSteps: number) => string", + "describedArgs": ["index", "totalSteps"] + } + }, "icon": { "type": { "name": "node" } }, "optional": { "type": { "name": "node" } }, "sx": { diff --git a/docs/pages/material-ui/api/step.json b/docs/pages/material-ui/api/step.json index cdab6d8edc0bac..22ecd06ea6cfa5 100644 --- a/docs/pages/material-ui/api/step.json +++ b/docs/pages/material-ui/api/step.json @@ -7,6 +7,13 @@ "component": { "type": { "name": "elementType" } }, "disabled": { "type": { "name": "bool" } }, "expanded": { "type": { "name": "bool" }, "default": "false" }, + "getAriaLabel": { + "type": { "name": "func" }, + "signature": { + "type": "function(index: number, totalSteps: number) => string", + "describedArgs": ["index", "totalSteps"] + } + }, "index": { "type": { "name": "custom", "description": "integer" } }, "last": { "type": { "name": "bool" } }, "sx": { diff --git a/docs/pages/material-ui/api/stepper.json b/docs/pages/material-ui/api/stepper.json index 2f33724a4fb497..390140656d3aaf 100644 --- a/docs/pages/material-ui/api/stepper.json +++ b/docs/pages/material-ui/api/stepper.json @@ -6,6 +6,13 @@ "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, "component": { "type": { "name": "elementType" } }, "connector": { "type": { "name": "element" }, "default": "" }, + "getAriaLabel": { + "type": { "name": "func" }, + "signature": { + "type": "function(totalSteps: number) => string", + "describedArgs": ["totalSteps"] + } + }, "nonLinear": { "type": { "name": "bool" }, "default": "false" }, "orientation": { "type": { "name": "enum", "description": "'horizontal'
| 'vertical'" }, diff --git a/docs/translations/api-docs/step-button/step-button.json b/docs/translations/api-docs/step-button/step-button.json index 7c794c6a98f746..d2a6cd8c676346 100644 --- a/docs/translations/api-docs/step-button/step-button.json +++ b/docs/translations/api-docs/step-button/step-button.json @@ -5,6 +5,13 @@ "description": "Can be a StepLabel or a node to place inside StepLabel as children." }, "classes": { "description": "Override or extend the styles applied to the component." }, + "getAriaLabel": { + "description": "Accepts a function which returns a string value that provides a user-friendly name for the step button. This is important for screen reader users.", + "typeDescriptions": { + "index": { "name": "index", "description": "The step's index." }, + "totalSteps": { "name": "totalSteps", "description": "The total number of steps." } + } + }, "icon": { "description": "The icon displayed by the step label." }, "optional": { "description": "The optional node to display." }, "sx": { diff --git a/docs/translations/api-docs/step/step.json b/docs/translations/api-docs/step/step.json index 865c4708542f57..32ee2782cfdbe6 100644 --- a/docs/translations/api-docs/step/step.json +++ b/docs/translations/api-docs/step/step.json @@ -14,6 +14,13 @@ "description": "If true, the step is disabled, will also disable the button if StepButton is a child of Step. Is passed to child components." }, "expanded": { "description": "Expand the step." }, + "getAriaLabel": { + "description": "Accepts a function which returns a string value that provides a user-friendly name for the step. This is important for screen reader users.", + "typeDescriptions": { + "index": { "name": "index", "description": "The step's index." }, + "totalSteps": { "name": "totalSteps", "description": "The total number of steps." } + } + }, "index": { "description": "The position of the step. The prop defaults to the value inherited from the parent Stepper component." }, diff --git a/docs/translations/api-docs/stepper/stepper.json b/docs/translations/api-docs/stepper/stepper.json index 1cad97543c78f7..c447fe71c0fa7a 100644 --- a/docs/translations/api-docs/stepper/stepper.json +++ b/docs/translations/api-docs/stepper/stepper.json @@ -13,6 +13,12 @@ "description": "The component used for the root node. Either a string to use a HTML element or a component." }, "connector": { "description": "An element to be placed between each step." }, + "getAriaLabel": { + "description": "Accepts a function which returns a string value that provides a user-friendly name for the stepper navigation. This is important for screen reader users when the stepper contains interactive steps.", + "typeDescriptions": { + "totalSteps": { "name": "totalSteps", "description": "The total number of steps." } + } + }, "nonLinear": { "description": "If set the Stepper will not assist in controlling steps for linear flow." }, diff --git a/packages/mui-material/src/Step/Step.d.ts b/packages/mui-material/src/Step/Step.d.ts index ccb6f1044ff242..f0a97ca3c5237a 100644 --- a/packages/mui-material/src/Step/Step.d.ts +++ b/packages/mui-material/src/Step/Step.d.ts @@ -31,6 +31,14 @@ export interface StepOwnProps { * @default false */ expanded?: boolean; + /** + * Accepts a function which returns a string value that provides a user-friendly name for the step. + * This is important for screen reader users. + * @param {number} index The step's index. + * @param {number} totalSteps The total number of steps. + * @returns {string} + */ + getAriaLabel?: (index: number, totalSteps: number) => string; /** * The position of the step. * The prop defaults to the value inherited from the parent Stepper component. diff --git a/packages/mui-material/src/Step/Step.js b/packages/mui-material/src/Step/Step.js index 048efdbb8a03a4..2d523d66bd362f 100644 --- a/packages/mui-material/src/Step/Step.js +++ b/packages/mui-material/src/Step/Step.js @@ -62,13 +62,20 @@ const Step = React.forwardRef(function Step(inProps, ref) { completed: completedProp, disabled: disabledProp, expanded = false, + getAriaLabel, index, last, ...other } = props; - const { activeStep, connector, alternativeLabel, orientation, nonLinear } = - React.useContext(StepperContext); + const { + activeStep, + connector, + alternativeLabel, + orientation, + nonLinear, + totalSteps = 0, + } = React.useContext(StepperContext); let [active = false, completed = false, disabled = false] = [ activeProp, @@ -85,8 +92,8 @@ const Step = React.forwardRef(function Step(inProps, ref) { } const contextValue = React.useMemo( - () => ({ index, last, expanded, icon: index + 1, active, completed, disabled }), - [index, last, expanded, active, completed, disabled], + () => ({ index, last, expanded, icon: index + 1, active, completed, disabled, totalSteps }), + [index, last, expanded, active, completed, disabled, totalSteps], ); const ownerState = { @@ -102,12 +109,17 @@ const Step = React.forwardRef(function Step(inProps, ref) { const classes = useUtilityClasses(ownerState); + // Only add aria-label if user explicitly provides getAriaLabel + // Otherwise, rely on visually hidden text in StepLabel to avoid redundancy + const ariaLabel = getAriaLabel ? getAriaLabel(index, totalSteps) : undefined; + const newChildren = ( {connector && alternativeLabel && index !== 0 ? connector : null} @@ -169,6 +181,14 @@ Step.propTypes /* remove-proptypes */ = { * @default false */ expanded: PropTypes.bool, + /** + * Accepts a function which returns a string value that provides a user-friendly name for the step. + * This is important for screen reader users. + * @param {number} index The step's index. + * @param {number} totalSteps The total number of steps. + * @returns {string} + */ + getAriaLabel: PropTypes.func, /** * The position of the step. * The prop defaults to the value inherited from the parent Stepper component. diff --git a/packages/mui-material/src/Step/Step.test.js b/packages/mui-material/src/Step/Step.test.js index d9e2c5a098c6bc..8e99a6e9836314 100644 --- a/packages/mui-material/src/Step/Step.test.js +++ b/packages/mui-material/src/Step/Step.test.js @@ -121,4 +121,45 @@ describe('', () => { expect(stepLabels[1]).to.have.class(stepLabelClasses.disabled); }); }); + + describe('accessibility', () => { + it('should not have aria-label by default (relies on StepLabel visually hidden text)', () => { + const { container } = render( + + + Step 1 + + + Step 2 + + + Step 3 + + , + ); + + const steps = container.querySelectorAll(`.${classes.root}`); + // No aria-label by default to avoid redundancy with visually hidden text + expect(steps[0]).not.to.have.attribute('aria-label'); + expect(steps[1]).not.to.have.attribute('aria-label'); + expect(steps[2]).not.to.have.attribute('aria-label'); + }); + + it('should use custom getAriaLabel when provided', () => { + const { container } = render( + + `Item ${index + 1} of ${totalSteps}`}> + First + + `Item ${index + 1} of ${totalSteps}`}> + Second + + , + ); + + const steps = container.querySelectorAll(`.${classes.root}`); + expect(steps[0]).to.have.attribute('aria-label', 'Item 1 of 2'); + expect(steps[1]).to.have.attribute('aria-label', 'Item 2 of 2'); + }); + }); }); diff --git a/packages/mui-material/src/Step/StepContext.ts b/packages/mui-material/src/Step/StepContext.ts index a48e883b22fd62..114b1885f5b278 100644 --- a/packages/mui-material/src/Step/StepContext.ts +++ b/packages/mui-material/src/Step/StepContext.ts @@ -9,6 +9,7 @@ export interface StepContextType { active: boolean; completed: boolean; disabled: boolean; + totalSteps: number; } /** diff --git a/packages/mui-material/src/StepButton/StepButton.d.ts b/packages/mui-material/src/StepButton/StepButton.d.ts index 0dae1f9975af1b..fb2559509ce63e 100644 --- a/packages/mui-material/src/StepButton/StepButton.d.ts +++ b/packages/mui-material/src/StepButton/StepButton.d.ts @@ -14,6 +14,14 @@ export interface StepButtonOwnProps { * Override or extend the styles applied to the component. */ classes?: Partial; + /** + * Accepts a function which returns a string value that provides a user-friendly name for the step button. + * This is important for screen reader users. + * @param {number} index The step's index. + * @param {number} totalSteps The total number of steps. + * @returns {string} + */ + getAriaLabel?: (index: number, totalSteps: number) => string; /** * The icon displayed by the step label. */ diff --git a/packages/mui-material/src/StepButton/StepButton.js b/packages/mui-material/src/StepButton/StepButton.js index 86b75c33b6a9f7..70f8a177009981 100644 --- a/packages/mui-material/src/StepButton/StepButton.js +++ b/packages/mui-material/src/StepButton/StepButton.js @@ -57,9 +57,10 @@ const StepButtonRoot = styled(ButtonBase, { const StepButton = React.forwardRef(function StepButton(inProps, ref) { const props = useDefaultProps({ props: inProps, name: 'MuiStepButton' }); - const { children, className, icon, optional, ...other } = props; + const { children, className, getAriaLabel, icon, optional, ...other } = props; - const { disabled, active } = React.useContext(StepContext); + const stepContext = React.useContext(StepContext); + const { disabled, active, index, totalSteps = 0 } = stepContext; const { orientation } = React.useContext(StepperContext); const ownerState = { ...props, orientation }; @@ -77,6 +78,14 @@ const StepButton = React.forwardRef(function StepButton(inProps, ref) { {children} ); + // Add aria-label with step position + let ariaLabel; + if (getAriaLabel) { + ariaLabel = getAriaLabel(index, totalSteps); + } else if (totalSteps > 0 && index !== undefined) { + ariaLabel = `Step ${index + 1} of ${totalSteps}`; + } + return ( {child} @@ -110,6 +120,14 @@ StepButton.propTypes /* remove-proptypes */ = { * @ignore */ className: PropTypes.string, + /** + * Accepts a function which returns a string value that provides a user-friendly name for the step button. + * This is important for screen reader users. + * @param {number} index The step's index. + * @param {number} totalSteps The total number of steps. + * @returns {string} + */ + getAriaLabel: PropTypes.func, /** * The icon displayed by the step label. */ @@ -128,4 +146,6 @@ StepButton.propTypes /* remove-proptypes */ = { ]), }; +StepButton.muiName = 'StepButton'; + export default StepButton; diff --git a/packages/mui-material/src/StepButton/StepButton.test.js b/packages/mui-material/src/StepButton/StepButton.test.js index 71d9e8b29af0a5..d6c6f15fc98eeb 100644 --- a/packages/mui-material/src/StepButton/StepButton.test.js +++ b/packages/mui-material/src/StepButton/StepButton.test.js @@ -3,6 +3,7 @@ import { spy } from 'sinon'; import { createRenderer, screen, fireEvent, supportsTouch } from '@mui/internal-test-utils'; import StepButton, { stepButtonClasses as classes } from '@mui/material/StepButton'; import Step from '@mui/material/Step'; +import Stepper from '@mui/material/Stepper'; import StepLabel, { stepLabelClasses } from '@mui/material/StepLabel'; import ButtonBase from '@mui/material/ButtonBase'; import describeConformance from '../../test/describeConformance'; @@ -143,4 +144,69 @@ describe('', () => { expect(screen.getByRole('button')).not.to.equal(null); }); + + describe('accessibility', () => { + it('should have aria-label with step position', () => { + render( + + + Step 1 + + + Step 2 + + + Step 3 + + , + ); + + const buttons = screen.getAllByRole('button'); + expect(buttons[0]).to.have.attribute('aria-label', 'Step 1 of 3'); + expect(buttons[1]).to.have.attribute('aria-label', 'Step 2 of 3'); + expect(buttons[2]).to.have.attribute('aria-label', 'Step 3 of 3'); + }); + + it('should use custom getAriaLabel', () => { + render( + + + `Go to step ${index + 1} of ${totalSteps}`} + > + First + + + + `Go to step ${index + 1} of ${totalSteps}`} + > + Second + + + , + ); + + const buttons = screen.getAllByRole('button'); + expect(buttons[0]).to.have.attribute('aria-label', 'Go to step 1 of 2'); + expect(buttons[1]).to.have.attribute('aria-label', 'Go to step 2 of 2'); + }); + + it('should have aria-current="step" on active button', () => { + render( + + + Step 1 + + + Step 2 + + , + ); + + const buttons = screen.getAllByRole('button'); + expect(buttons[0]).not.to.have.attribute('aria-current'); + expect(buttons[1]).to.have.attribute('aria-current', 'step'); + }); + }); }); diff --git a/packages/mui-material/src/Stepper/Stepper.d.ts b/packages/mui-material/src/Stepper/Stepper.d.ts index 5fc6837acaf0b0..2f783bff2f7843 100644 --- a/packages/mui-material/src/Stepper/Stepper.d.ts +++ b/packages/mui-material/src/Stepper/Stepper.d.ts @@ -20,6 +20,13 @@ export interface StepperOwnProps extends Pick string; /** * Two or more `` components. */ diff --git a/packages/mui-material/src/Stepper/Stepper.js b/packages/mui-material/src/Stepper/Stepper.js index d80c29df95ede0..d9814e42ca92a4 100644 --- a/packages/mui-material/src/Stepper/Stepper.js +++ b/packages/mui-material/src/Stepper/Stepper.js @@ -63,6 +63,7 @@ const Stepper = React.forwardRef(function Stepper(inProps, ref) { const { activeStep = 0, alternativeLabel = false, + getAriaLabel, children, className, component = 'div', @@ -83,6 +84,19 @@ const Stepper = React.forwardRef(function Stepper(inProps, ref) { const classes = useUtilityClasses(ownerState); const childrenArray = React.Children.toArray(children).filter(Boolean); + const totalSteps = childrenArray.length; + + // Detect if stepper contains interactive steps (StepButton) + const hasInteractiveSteps = React.useMemo(() => { + return childrenArray.some((step) => { + const child = step.props?.children; + if (!child) { + return false; + } + return React.Children.toArray(child).some((c) => c?.type?.muiName === 'StepButton'); + }); + }, [childrenArray]); + const steps = childrenArray.map((step, index) => { return React.cloneElement(step, { index, @@ -91,10 +105,18 @@ const Stepper = React.forwardRef(function Stepper(inProps, ref) { }); }); const contextValue = React.useMemo( - () => ({ activeStep, alternativeLabel, connector, nonLinear, orientation }), - [activeStep, alternativeLabel, connector, nonLinear, orientation], + () => ({ activeStep, alternativeLabel, connector, nonLinear, orientation, totalSteps }), + [activeStep, alternativeLabel, connector, nonLinear, orientation, totalSteps], ); + // Add navigation attributes for interactive steppers + const navigationProps = hasInteractiveSteps + ? { + role: 'navigation', + 'aria-label': getAriaLabel ? getAriaLabel(totalSteps) : `Stepper with ${totalSteps} steps`, + } + : {}; + return ( {steps} @@ -149,6 +172,13 @@ Stepper.propTypes /* remove-proptypes */ = { * @default */ connector: PropTypes.element, + /** + * Accepts a function which returns a string value that provides a user-friendly name for the stepper navigation. + * This is important for screen reader users when the stepper contains interactive steps. + * @param {number} totalSteps The total number of steps. + * @returns {string} + */ + getAriaLabel: PropTypes.func, /** * If set the `Stepper` will not assist in controlling steps for linear flow. * @default false diff --git a/packages/mui-material/src/Stepper/Stepper.test.tsx b/packages/mui-material/src/Stepper/Stepper.test.tsx index 4b80013ca2a22c..d87742d2b68f4c 100644 --- a/packages/mui-material/src/Stepper/Stepper.test.tsx +++ b/packages/mui-material/src/Stepper/Stepper.test.tsx @@ -1,6 +1,7 @@ import { expect } from 'chai'; import { createRenderer, screen } from '@mui/internal-test-utils'; import Step, { StepProps, stepClasses } from '@mui/material/Step'; +import StepButton from '@mui/material/StepButton'; import StepLabel from '@mui/material/StepLabel'; import StepConnector, { stepConnectorClasses } from '@mui/material/StepConnector'; import StepContent, { stepContentClasses } from '@mui/material/StepContent'; @@ -270,4 +271,59 @@ describe('', () => { const stepper = container.querySelector(`.${classes.root}`); expect(stepper).to.have.class(classes.nonLinear); }); + + describe('accessibility', () => { + it('should have navigation role and aria-label for interactive steppers', () => { + const { container } = render( + + + Step 1 + + + Step 2 + + + Step 3 + + , + ); + + const stepper = container.querySelector(`.${classes.root}`); + expect(stepper).to.have.attribute('role', 'navigation'); + expect(stepper).to.have.attribute('aria-label', 'Stepper with 3 steps'); + }); + + it('should not have navigation role for non-interactive steppers', () => { + const { container } = render( + + + Step 1 + + + Step 2 + + , + ); + + const stepper = container.querySelector(`.${classes.root}`); + expect(stepper).not.to.have.attribute('role'); + expect(stepper).not.to.have.attribute('aria-label'); + }); + + it('should use custom getAriaLabel for interactive steppers', () => { + const { container } = render( + `Checkout with ${totalSteps} steps`}> + + Cart + + + Payment + + , + ); + + const stepper = container.querySelector(`.${classes.root}`); + expect(stepper).to.have.attribute('aria-label', 'Checkout with 2 steps'); + }); + }); }); diff --git a/packages/mui-material/src/Stepper/StepperContext.ts b/packages/mui-material/src/Stepper/StepperContext.ts index 1bdbb3608a9783..c6ea6d116d5e0c 100644 --- a/packages/mui-material/src/Stepper/StepperContext.ts +++ b/packages/mui-material/src/Stepper/StepperContext.ts @@ -7,6 +7,7 @@ export interface StepperContextType { connector: React.ReactNode; nonLinear: boolean; orientation: 'horizontal' | 'vertical'; + totalSteps: number; } /**