diff --git a/src/cdk/stepper/step-header.ts b/src/cdk/stepper/step-header.ts index f0709e3f9878..26ebcbbcb0f7 100644 --- a/src/cdk/stepper/step-header.ts +++ b/src/cdk/stepper/step-header.ts @@ -12,7 +12,7 @@ import {FocusableOption} from '../a11y'; @Directive({ selector: '[cdkStepHeader]', host: { - 'role': 'tab', + 'role': 'button', }, }) export class CdkStepHeader implements FocusableOption { diff --git a/src/cdk/stepper/stepper.md b/src/cdk/stepper/stepper.md index 8eee8df85730..6052dbccb8ac 100644 --- a/src/cdk/stepper/stepper.md +++ b/src/cdk/stepper/stepper.md @@ -59,10 +59,10 @@ resetting it will call `reset` on the underlying form control which clears the v ### Accessibility Apart from the built-in keyboard support, the stepper doesn't apply any treatment. When implementing -your own component, it is recommended that the stepper is treated as a tabbed view for accessibility -purposes by giving it a `role="tablist"`. The header of step that can be clicked to select the step -should be given `role="tab"`, and the content that can be expanded upon selection should be given -`role="tabpanel"`. Furthermore, the step header should have an `aria-selected` attribute that -reflects its selected state. +your own component, it is recommended that the stepper is treated as a grouped view for accessibility +purposes by giving it a `role="group"`. The header of step that can be clicked to select the step +should be given `role="button"`, and the content that can be expanded upon selection should be given +`role="region"`. Furthermore, the step header should have an `aria-expanded` attribute that +reflects its expanded state. You can refer to the [Angular Material stepper](https://github.com/angular/components/tree/main/src/material/stepper) as an example of an accessible implementation. diff --git a/src/material/stepper/step-header.ts b/src/material/stepper/step-header.ts index c6f1815e0b5e..f2c56ea7d025 100644 --- a/src/material/stepper/step-header.ts +++ b/src/material/stepper/step-header.ts @@ -35,7 +35,7 @@ import {_CdkPrivateStyleLoader, _VisuallyHiddenLoader} from '@angular/cdk/privat host: { 'class': 'mat-step-header', '[class]': '"mat-" + (color || "primary")', - 'role': 'tab', + 'role': 'button', }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/material/stepper/stepper.html b/src/material/stepper/stepper.html index a75e48522600..ea234279a9b1 100644 --- a/src/material/stepper/stepper.html +++ b/src/material/stepper/stepper.html @@ -10,7 +10,7 @@ @switch (orientation) { @case ('horizontal') { -
+
@for (step of steps; track step) { - -
-
-
- +
+ @for (step of steps; track step) { +
+ +
+
+
+ +
-
- } + } +
} } @@ -74,13 +76,15 @@ (keydown)="_onKeydown($event)" [tabIndex]="_getFocusIndex() === i ? 0 : -1" [id]="_getStepLabelId(i)" - [attr.aria-posinset]="i + 1" - [attr.aria-setsize]="steps.length" [attr.aria-controls]="_getStepContentId(i)" - [attr.aria-selected]="selectedIndex == i" + [attr.aria-expanded]="orientation === 'vertical' ? selectedIndex == i : undefined" + [attr.aria-current]="selectedIndex == i ? 'step' : undefined" + [attr.aria-pressed]="selectedIndex == i" [attr.aria-label]="step.ariaLabel || null" [attr.aria-labelledby]="(!step.ariaLabel && step.ariaLabelledby) ? step.ariaLabelledby : null" [attr.aria-disabled]="_stepIsNavigable(i, step) ? null : true" + [attr.aria-owns]="_getStepContentId(i)" + [attr.mat-step-index]="i" [index]="i" [state]="_getIndicatorType(i, step.state)" [label]="step.stepLabel || step.label" @@ -91,4 +95,4 @@ [iconOverrides]="_iconOverrides" [disableRipple]="disableRipple || !_stepIsNavigable(i, step)" [color]="step.color || color"/> - + \ No newline at end of file diff --git a/src/material/stepper/stepper.md b/src/material/stepper/stepper.md index b8c9f1602175..593ab14f6780 100644 --- a/src/material/stepper/stepper.md +++ b/src/material/stepper/stepper.md @@ -228,10 +228,10 @@ export class MyApp {} ### Accessibility -The stepper is treated as a tabbed view for accessibility purposes, so it is given -`role="tablist"` by default. The header of step that can be clicked to select the step -is given `role="tab"`, and the content that can be expanded upon selection is given -`role="tabpanel"`. `aria-selected` attribute of step header is automatically set based on +The stepper is treated as a grouped view for general accessibility purposes, so it is given +`role="group"` by default. The header of step that can be clicked to select the step +is given `role="button"`, and the content that can be expanded upon selection is given +`role="region"`. `aria-expanded` attribute of step header is automatically set based on step selection change. The stepper and each step should be given a meaningful label via `aria-label` or `aria-labelledby`. diff --git a/src/material/stepper/stepper.spec.ts b/src/material/stepper/stepper.spec.ts index 287f9dcb2327..57af46738d3e 100644 --- a/src/material/stepper/stepper.spec.ts +++ b/src/material/stepper/stepper.spec.ts @@ -132,28 +132,31 @@ describe('MatStepper', () => { expect(stepperComponent.selected instanceof MatStep).toBe(true); }); - it('should set the "tablist" role on stepper', () => { - const stepperEl = fixture.debugElement.query(By.css('mat-stepper'))!.nativeElement; - expect(stepperEl.getAttribute('role')).toBe('tablist'); + it('should set the "group" role on the stepper', () => { + const stepperWrapper = fixture.debugElement.query(By.css('.mat-stepper-wrapper')); + expect(stepperWrapper).toBeTruthy(); + + const stepperEl = stepperWrapper!.nativeElement; + expect(stepperEl.getAttribute('role')).toBe('group'); }); it('should display the correct label', () => { const stepperComponent = fixture.debugElement.query( By.directive(MatStepper), )!.componentInstance; - let selectedLabel = fixture.nativeElement.querySelector('[aria-selected="true"]'); + let selectedLabel = fixture.nativeElement.querySelector('[aria-expanded="true"]'); expect(selectedLabel.textContent).toMatch('Step 1'); stepperComponent.selectedIndex = 2; fixture.detectChanges(); - selectedLabel = fixture.nativeElement.querySelector('[aria-selected="true"]'); + selectedLabel = fixture.nativeElement.querySelector('[aria-expanded="true"]'); expect(selectedLabel.textContent).toMatch('Step 3'); fixture.componentInstance.inputLabel.set('New Label'); fixture.detectChanges(); - selectedLabel = fixture.nativeElement.querySelector('[aria-selected="true"]'); + selectedLabel = fixture.nativeElement.querySelector('[aria-expanded="true"]'); expect(selectedLabel.textContent).toMatch('New Label'); }); @@ -381,15 +384,6 @@ describe('MatStepper', () => { animationDoneSubscription.unsubscribe(); }); - it('should set the correct aria-posinset and aria-setsize', () => { - const headers = Array.from( - fixture.nativeElement.querySelectorAll('.mat-step-header'), - ); - - expect(headers.map(header => header.getAttribute('aria-posinset'))).toEqual(['1', '2', '3']); - expect(headers.every(header => header.getAttribute('aria-setsize') === '3')).toBe(true); - }); - it('should adjust the index when removing a step before the current one', () => { const stepperComponent: MatStepper = fixture.debugElement.query( By.css('mat-stepper'), diff --git a/src/material/stepper/stepper.ts b/src/material/stepper/stepper.ts index fe5834bde0a0..0698b2398ba6 100644 --- a/src/material/stepper/stepper.ts +++ b/src/material/stepper/stepper.ts @@ -131,7 +131,6 @@ export class MatStep extends CdkStep implements ErrorStateMatcher, AfterContentI '[class.mat-stepper-animating]': '_isAnimating()', '[style.--mat-stepper-animation-duration]': '_getAnimationDuration()', '[attr.aria-orientation]': 'orientation', - 'role': 'tablist', }, providers: [{provide: CdkStepper, useExisting: MatStepper}], encapsulation: ViewEncapsulation.None, diff --git a/src/material/stepper/testing/step-harness-filters.ts b/src/material/stepper/testing/step-harness-filters.ts index b4e5fc3bf26e..ead2e99659dd 100644 --- a/src/material/stepper/testing/step-harness-filters.ts +++ b/src/material/stepper/testing/step-harness-filters.ts @@ -17,7 +17,7 @@ export enum StepperOrientation { export interface StepHarnessFilters extends BaseHarnessFilters { /** Only find instances whose label matches the given value. */ label?: string | RegExp; - /** Only find steps with the given selected state. */ + /** Only find steps with the given pressed/expanded for Stepper state. */ selected?: boolean; /** Only find completed steps. */ completed?: boolean; diff --git a/src/material/stepper/testing/step-harness.ts b/src/material/stepper/testing/step-harness.ts index f3af257d6667..e74b874ee0ca 100644 --- a/src/material/stepper/testing/step-harness.ts +++ b/src/material/stepper/testing/step-harness.ts @@ -61,10 +61,10 @@ export class MatStepHarness extends ContentContainerComponentHarness { return (await this.host()).getAttribute('aria-labelledby'); } - /** Whether the step is selected. */ + /** Whether the step of Stepper is pressed/expanded. */ async isSelected(): Promise { const host = await this.host(); - return (await host.getAttribute('aria-selected')) === 'true'; + return (await host.getAttribute('aria-pressed')) === 'true'; } /** Whether the step has been filled out. */ @@ -92,7 +92,7 @@ export class MatStepHarness extends ContentContainerComponentHarness { } /** - * Selects the given step by clicking on the label. The step may not be selected + * Selects the given step by clicking on the label. The step may not be selected/expanded * if the stepper doesn't allow it (e.g. if there are validation errors). */ async select(): Promise { diff --git a/src/material/stepper/testing/stepper-harness.spec.ts b/src/material/stepper/testing/stepper-harness.spec.ts index 0627dcd39abb..2115ebd2c4c0 100644 --- a/src/material/stepper/testing/stepper-harness.spec.ts +++ b/src/material/stepper/testing/stepper-harness.spec.ts @@ -67,7 +67,7 @@ describe('MatStepperHarness', () => { expect(await parallel(() => steps.map(step => step.getLabel()))).toEqual(['Two', 'Four']); }); - it('should be able to select a particular step that matches a filter', async () => { + it('should be able to select a particular step that matches a filter on a vertical stepper', async () => { const stepper = await loader.getHarness(MatStepperHarness.with({selector: '#one-stepper'})); const steps = await stepper.getSteps(); @@ -88,6 +88,25 @@ describe('MatStepperHarness', () => { ]); }); + it('should be able to select a particular step that matches a filter on a horizontal stepper', async () => { + const stepper = await loader.getHarness(MatStepperHarness.with({selector: '#two-stepper'})); + const steps = await stepper.getSteps(); + + expect(await parallel(() => steps.map(step => step.isSelected()))).toEqual([ + true, + false, + false, + ]); + + await stepper.selectStep({label: 'Three'}); + + expect(await parallel(() => steps.map(step => step.isSelected()))).toEqual([ + false, + false, + true, + ]); + }); + it('should be able to get the text-based label of a step', async () => { const stepper = await loader.getHarness(MatStepperHarness.with({selector: '#one-stepper'})); const steps = await stepper.getSteps(); @@ -132,7 +151,7 @@ describe('MatStepperHarness', () => { ]); }); - it('should get the selected state of a step', async () => { + it('should get the expanded state of a step in a vertical stepper', async () => { const stepper = await loader.getHarness(MatStepperHarness.with({selector: '#one-stepper'})); const steps = await stepper.getSteps(); @@ -144,7 +163,18 @@ describe('MatStepperHarness', () => { ]); }); - it('should be able to select a step', async () => { + it('should get the selected state of a step in a horizontal stepper', async () => { + const stepper = await loader.getHarness(MatStepperHarness.with({selector: '#two-stepper'})); + const steps = await stepper.getSteps(); + + expect(await parallel(() => steps.map(step => step.isSelected()))).toEqual([ + true, + false, + false, + ]); + }); + + it('should be able to select a step in a vertical stepper', async () => { const stepper = await loader.getHarness(MatStepperHarness.with({selector: '#one-stepper'})); const steps = await stepper.getSteps(); @@ -165,6 +195,25 @@ describe('MatStepperHarness', () => { ]); }); + it('should be able to select a step in a horizontal stepper', async () => { + const stepper = await loader.getHarness(MatStepperHarness.with({selector: '#two-stepper'})); + const steps = await stepper.getSteps(); + + expect(await parallel(() => steps.map(step => step.isSelected()))).toEqual([ + true, + false, + false, + ]); + + await steps[2].select(); + + expect(await parallel(() => steps.map(step => step.isSelected()))).toEqual([ + false, + false, + true, + ]); + }); + it('should get whether a step is optional', async () => { const stepper = await loader.getHarness(MatStepperHarness.with({selector: '#two-stepper'})); const steps = await stepper.getSteps(); @@ -183,7 +232,7 @@ describe('MatStepperHarness', () => { expect(await previousButton.getText()).toBe('Previous'); }); - it('should go forward when pressing the next button', async () => { + it('should go forward when pressing the next button in a vertical stepper', async () => { const stepper = await loader.getHarness(MatStepperHarness.with({selector: '#one-stepper'})); const steps = await stepper.getSteps(); const secondStep = steps[1]; @@ -208,7 +257,30 @@ describe('MatStepperHarness', () => { ]); }); - it('should go backward when pressing the previous button', async () => { + it('should go forward when pressing the next button in a horizontal stepper', async () => { + const stepper = await loader.getHarness(MatStepperHarness.with({selector: '#two-stepper'})); + const steps = await stepper.getSteps(); + const secondStep = steps[1]; + const nextButton = await secondStep.getHarness(MatStepperNextHarness); + + await secondStep.select(); + + expect(await parallel(() => steps.map(step => step.isSelected()))).toEqual([ + false, + true, + false, + ]); + + await nextButton.click(); + + expect(await parallel(() => steps.map(step => step.isSelected()))).toEqual([ + false, + false, + true, + ]); + }); + + it('should go backward when pressing the previous button of a vertical stepper', async () => { const stepper = await loader.getHarness(MatStepperHarness.with({selector: '#one-stepper'})); const steps = await stepper.getSteps(); const secondStep = steps[1]; @@ -233,6 +305,29 @@ describe('MatStepperHarness', () => { ]); }); + it('should go backward when pressing the previous button of a horizontal stepper', async () => { + const stepper = await loader.getHarness(MatStepperHarness.with({selector: '#two-stepper'})); + const steps = await stepper.getSteps(); + const secondStep = steps[1]; + const previousButton = await secondStep.getHarness(MatStepperPreviousHarness); + + await secondStep.select(); + + expect(await parallel(() => steps.map(step => step.isSelected()))).toEqual([ + false, + true, + false, + ]); + + await previousButton.click(); + + expect(await parallel(() => steps.map(step => step.isSelected()))).toEqual([ + true, + false, + false, + ]); + }); + it('should get whether a step has errors', async () => { const stepper = await loader.getHarness(MatStepperHarness.with({selector: '#three-stepper'})); const steps = await stepper.getSteps(); @@ -279,12 +374,16 @@ describe('MatStepperHarness', () => { One + Two + + Three +