Skip to content

Commit 49901c6

Browse files
authored
fix(material/button-toggle): use radio pattern for single select Mat toggle button group (#28548)
1 parent 816ab8d commit 49901c6

File tree

5 files changed

+155
-14
lines changed

5 files changed

+155
-14
lines changed

src/material/button-toggle/button-toggle.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
<button #button class="mat-button-toggle-button mat-focus-indicator"
22
type="button"
33
[id]="buttonId"
4+
[attr.role]="isSingleSelector() ? 'radio' : 'button'"
45
[attr.tabindex]="disabled ? -1 : tabIndex"
5-
[attr.aria-pressed]="checked"
6+
[attr.aria-pressed]="!isSingleSelector() ? checked : null"
7+
[attr.aria-checked]="isSingleSelector() ? checked : null"
68
[disabled]="disabled || null"
79
[attr.name]="_getButtonName()"
810
[attr.aria-label]="ariaLabel"

src/material/button-toggle/button-toggle.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,24 @@ describe('MatButtonToggle without forms', () => {
366366
buttonToggleInstances = buttonToggleDebugElements.map(debugEl => debugEl.componentInstance);
367367
});
368368

369+
it('should initialize the tab index correctly', () => {
370+
buttonToggleLabelElements.forEach((buttonToggle, index) => {
371+
if (index === 0) {
372+
expect(buttonToggle.getAttribute('tabindex')).toBe('0');
373+
} else {
374+
expect(buttonToggle.getAttribute('tabindex')).toBe('-1');
375+
}
376+
});
377+
});
378+
379+
it('should update the tab index correctly', () => {
380+
buttonToggleLabelElements[1].click();
381+
fixture.detectChanges();
382+
383+
expect(buttonToggleLabelElements[0].getAttribute('tabindex')).toBe('-1');
384+
expect(buttonToggleLabelElements[1].getAttribute('tabindex')).toBe('0');
385+
});
386+
369387
it('should set individual button toggle names based on the group name', () => {
370388
expect(groupInstance.name).toBeTruthy();
371389
for (let buttonToggle of buttonToggleLabelElements) {

src/material/button-toggle/button-toggle.ts

Lines changed: 118 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {FocusMonitor} from '@angular/cdk/a11y';
1010
import {SelectionModel} from '@angular/cdk/collections';
11+
import {DOWN_ARROW, LEFT_ARROW, RIGHT_ARROW, UP_ARROW, SPACE, ENTER} from '@angular/cdk/keycodes';
1112
import {
1213
AfterContentInit,
1314
Attribute,
@@ -32,6 +33,7 @@ import {
3233
AfterViewInit,
3334
booleanAttribute,
3435
} from '@angular/core';
36+
import {Direction, Directionality} from '@angular/cdk/bidi';
3537
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
3638
import {MatRipple, MatPseudoCheckbox} from '@angular/material/core';
3739

@@ -121,8 +123,9 @@ export class MatButtonToggleChange {
121123
{provide: MAT_BUTTON_TOGGLE_GROUP, useExisting: MatButtonToggleGroup},
122124
],
123125
host: {
124-
'role': 'group',
125126
'class': 'mat-button-toggle-group',
127+
'(keydown)': '_keydown($event)',
128+
'[attr.role]': "multiple ? 'group' : 'radiogroup'",
126129
'[attr.aria-disabled]': 'disabled',
127130
'[class.mat-button-toggle-vertical]': 'vertical',
128131
'[class.mat-button-toggle-group-appearance-standard]': 'appearance === "standard"',
@@ -226,6 +229,11 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
226229
this._markButtonsForCheck();
227230
}
228231

232+
/** The layout direction of the toggle button group. */
233+
get dir(): Direction {
234+
return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr';
235+
}
236+
229237
/** Event emitted when the group's value changes. */
230238
@Output() readonly change: EventEmitter<MatButtonToggleChange> =
231239
new EventEmitter<MatButtonToggleChange>();
@@ -257,6 +265,7 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
257265
@Optional()
258266
@Inject(MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS)
259267
defaultOptions?: MatButtonToggleDefaultOptions,
268+
@Optional() private _dir?: Directionality,
260269
) {
261270
this.appearance =
262271
defaultOptions && defaultOptions.appearance ? defaultOptions.appearance : 'standard';
@@ -270,6 +279,9 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
270279

271280
ngAfterContentInit() {
272281
this._selectionModel.select(...this._buttonToggles.filter(toggle => toggle.checked));
282+
if (!this.multiple) {
283+
this._initializeTabIndex();
284+
}
273285
}
274286

275287
/**
@@ -296,6 +308,49 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
296308
this.disabled = isDisabled;
297309
}
298310

311+
/** Handle keydown event calling to single-select button toggle. */
312+
protected _keydown(event: KeyboardEvent) {
313+
if (this.multiple || this.disabled) {
314+
return;
315+
}
316+
317+
const target = event.target as HTMLButtonElement;
318+
const buttonId = target.id;
319+
const index = this._buttonToggles.toArray().findIndex(toggle => {
320+
return toggle.buttonId === buttonId;
321+
});
322+
323+
let nextButton;
324+
switch (event.keyCode) {
325+
case SPACE:
326+
case ENTER:
327+
nextButton = this._buttonToggles.get(index);
328+
break;
329+
case UP_ARROW:
330+
nextButton = this._buttonToggles.get(this._getNextIndex(index, -1));
331+
break;
332+
case LEFT_ARROW:
333+
nextButton = this._buttonToggles.get(
334+
this._getNextIndex(index, this.dir === 'ltr' ? -1 : 1),
335+
);
336+
break;
337+
case DOWN_ARROW:
338+
nextButton = this._buttonToggles.get(this._getNextIndex(index, 1));
339+
break;
340+
case RIGHT_ARROW:
341+
nextButton = this._buttonToggles.get(
342+
this._getNextIndex(index, this.dir === 'ltr' ? 1 : -1),
343+
);
344+
break;
345+
default:
346+
return;
347+
}
348+
349+
event.preventDefault();
350+
nextButton?._onButtonClick();
351+
nextButton?.focus();
352+
}
353+
299354
/** Dispatch change event with current selection and group value. */
300355
_emitChangeEvent(toggle: MatButtonToggle): void {
301356
const event = new MatButtonToggleChange(toggle, this.value);
@@ -361,6 +416,31 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
361416
return toggle.value === this._rawValue;
362417
}
363418

419+
/** Initializes the tabindex attribute using the radio pattern. */
420+
private _initializeTabIndex() {
421+
this._buttonToggles.forEach(toggle => {
422+
toggle.tabIndex = -1;
423+
});
424+
if (this.selected) {
425+
(this.selected as MatButtonToggle).tabIndex = 0;
426+
} else if (this._buttonToggles.length > 0) {
427+
this._buttonToggles.get(0)!.tabIndex = 0;
428+
}
429+
this._markButtonsForCheck();
430+
}
431+
432+
/** Obtain the subsequent index to which the focus shifts. */
433+
private _getNextIndex(index: number, offset: number): number {
434+
let nextIndex = index + offset;
435+
if (nextIndex === this._buttonToggles.length) {
436+
nextIndex = 0;
437+
}
438+
if (nextIndex === -1) {
439+
nextIndex = this._buttonToggles.length - 1;
440+
}
441+
return nextIndex;
442+
}
443+
364444
/** Updates the selection state of the toggles in the group based on a value. */
365445
private _setSelectionByValue(value: any | any[]) {
366446
this._rawValue = value;
@@ -385,7 +465,13 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
385465
/** Clears the selected toggles. */
386466
private _clearSelection() {
387467
this._selectionModel.clear();
388-
this._buttonToggles.forEach(toggle => (toggle.checked = false));
468+
this._buttonToggles.forEach(toggle => {
469+
toggle.checked = false;
470+
// If the button toggle is in single select mode, initialize the tabIndex.
471+
if (!this.multiple) {
472+
toggle.tabIndex = -1;
473+
}
474+
});
389475
}
390476

391477
/** Selects a value if there's a toggle that corresponds to it. */
@@ -397,6 +483,10 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
397483
if (correspondingOption) {
398484
correspondingOption.checked = true;
399485
this._selectionModel.select(correspondingOption);
486+
if (!this.multiple) {
487+
// If the button toggle is in single select mode, reset the tabIndex.
488+
correspondingOption.tabIndex = 0;
489+
}
400490
}
401491
}
402492

@@ -476,8 +566,16 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
476566
/** MatButtonToggleGroup reads this to assign its own value. */
477567
@Input() value: any;
478568

479-
/** Tabindex for the toggle. */
480-
@Input() tabIndex: number | null;
569+
/** Tabindex of the toggle. */
570+
@Input()
571+
get tabIndex(): number | null {
572+
return this._tabIndex;
573+
}
574+
set tabIndex(value: number | null) {
575+
this._tabIndex = value;
576+
this._markForCheck();
577+
}
578+
private _tabIndex: number | null;
481579

482580
/** Whether ripples are disabled on the button toggle. */
483581
@Input({transform: booleanAttribute}) disableRipple: boolean;
@@ -580,7 +678,7 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
580678

581679
/** Checks the button toggle due to an interaction with the underlying native button. */
582680
_onButtonClick() {
583-
const newChecked = this._isSingleSelector() ? true : !this._checked;
681+
const newChecked = this.isSingleSelector() ? true : !this._checked;
584682

585683
if (newChecked !== this._checked) {
586684
this._checked = newChecked;
@@ -589,6 +687,19 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
589687
this.buttonToggleGroup._onTouched();
590688
}
591689
}
690+
691+
if (this.isSingleSelector()) {
692+
const focusable = this.buttonToggleGroup._buttonToggles.find(toggle => {
693+
return toggle.tabIndex === 0;
694+
});
695+
// Modify the tabindex attribute of the last focusable button toggle to -1.
696+
if (focusable) {
697+
focusable.tabIndex = -1;
698+
}
699+
// Modify the tabindex attribute of the presently selected button toggle to 0.
700+
this.tabIndex = 0;
701+
}
702+
592703
// Emit a change event when it's the single selector
593704
this.change.emit(new MatButtonToggleChange(this, this.value));
594705
}
@@ -606,14 +717,14 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
606717

607718
/** Gets the name that should be assigned to the inner DOM node. */
608719
_getButtonName(): string | null {
609-
if (this._isSingleSelector()) {
720+
if (this.isSingleSelector()) {
610721
return this.buttonToggleGroup.name;
611722
}
612723
return this.name || null;
613724
}
614725

615726
/** Whether the toggle is in single selection mode. */
616-
private _isSingleSelector(): boolean {
727+
isSingleSelector(): boolean {
617728
return this.buttonToggleGroup && !this.buttonToggleGroup.multiple;
618729
}
619730
}

src/material/button-toggle/testing/button-toggle-harness.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing';
9+
import {ComponentHarness, HarnessPredicate, parallel} from '@angular/cdk/testing';
1010
import {coerceBooleanProperty} from '@angular/cdk/coercion';
1111
import {MatButtonToggleAppearance} from '@angular/material/button-toggle';
1212
import {ButtonToggleHarnessFilters} from './button-toggle-harness-filters';
@@ -45,8 +45,12 @@ export class MatButtonToggleHarness extends ComponentHarness {
4545

4646
/** Gets a boolean promise indicating if the button toggle is checked. */
4747
async isChecked(): Promise<boolean> {
48-
const checked = (await this._button()).getAttribute('aria-pressed');
49-
return coerceBooleanProperty(await checked);
48+
const button = await this._button();
49+
const [checked, pressed] = await parallel(() => [
50+
button.getAttribute('aria-checked'),
51+
button.getAttribute('aria-pressed'),
52+
]);
53+
return coerceBooleanProperty(checked) || coerceBooleanProperty(pressed);
5054
}
5155

5256
/** Gets a boolean promise indicating if the button toggle is disabled. */

tools/public_api_guard/material/button-toggle.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { AfterContentInit } from '@angular/core';
88
import { AfterViewInit } from '@angular/core';
99
import { ChangeDetectorRef } from '@angular/core';
1010
import { ControlValueAccessor } from '@angular/forms';
11+
import { Direction } from '@angular/cdk/bidi';
12+
import { Directionality } from '@angular/cdk/bidi';
1113
import { ElementRef } from '@angular/core';
1214
import { EventEmitter } from '@angular/core';
1315
import { FocusMonitor } from '@angular/cdk/a11y';
@@ -49,6 +51,7 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
4951
focus(options?: FocusOptions): void;
5052
_getButtonName(): string | null;
5153
id: string;
54+
isSingleSelector(): boolean;
5255
_markForCheck(): void;
5356
name: string;
5457
// (undocumented)
@@ -64,7 +67,8 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
6467
// (undocumented)
6568
ngOnInit(): void;
6669
_onButtonClick(): void;
67-
tabIndex: number | null;
70+
get tabIndex(): number | null;
71+
set tabIndex(value: number | null);
6872
value: any;
6973
// (undocumented)
7074
static ɵcmp: i0.ɵɵComponentDeclaration<MatButtonToggle, "mat-button-toggle", ["matButtonToggle"], { "ariaLabel": { "alias": "aria-label"; "required": false; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; }; "id": { "alias": "id"; "required": false; }; "name": { "alias": "name"; "required": false; }; "value": { "alias": "value"; "required": false; }; "tabIndex": { "alias": "tabIndex"; "required": false; }; "disableRipple": { "alias": "disableRipple"; "required": false; }; "appearance": { "alias": "appearance"; "required": false; }; "checked": { "alias": "checked"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; }, { "change": "change"; }, never, ["*"], true, never>;
@@ -93,11 +97,12 @@ export interface MatButtonToggleDefaultOptions {
9397

9498
// @public
9599
export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, AfterContentInit {
96-
constructor(_changeDetector: ChangeDetectorRef, defaultOptions?: MatButtonToggleDefaultOptions);
100+
constructor(_changeDetector: ChangeDetectorRef, defaultOptions?: MatButtonToggleDefaultOptions, _dir?: Directionality | undefined);
97101
appearance: MatButtonToggleAppearance;
98102
_buttonToggles: QueryList<MatButtonToggle>;
99103
readonly change: EventEmitter<MatButtonToggleChange>;
100104
_controlValueAccessorChangeFn: (value: any) => void;
105+
get dir(): Direction;
101106
get disabled(): boolean;
102107
set disabled(value: boolean);
103108
_emitChangeEvent(toggle: MatButtonToggle): void;
@@ -107,6 +112,7 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
107112
set hideSingleSelectionIndicator(value: boolean);
108113
_isPrechecked(toggle: MatButtonToggle): boolean;
109114
_isSelected(toggle: MatButtonToggle): boolean;
115+
protected _keydown(event: KeyboardEvent): void;
110116
get multiple(): boolean;
111117
set multiple(value: boolean);
112118
get name(): string;
@@ -142,7 +148,7 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
142148
// (undocumented)
143149
static ɵdir: i0.ɵɵDirectiveDeclaration<MatButtonToggleGroup, "mat-button-toggle-group", ["matButtonToggleGroup"], { "appearance": { "alias": "appearance"; "required": false; }; "name": { "alias": "name"; "required": false; }; "vertical": { "alias": "vertical"; "required": false; }; "value": { "alias": "value"; "required": false; }; "multiple": { "alias": "multiple"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "hideSingleSelectionIndicator": { "alias": "hideSingleSelectionIndicator"; "required": false; }; "hideMultipleSelectionIndicator": { "alias": "hideMultipleSelectionIndicator"; "required": false; }; }, { "valueChange": "valueChange"; "change": "change"; }, ["_buttonToggles"], never, true, never>;
144150
// (undocumented)
145-
static ɵfac: i0.ɵɵFactoryDeclaration<MatButtonToggleGroup, [null, { optional: true; }]>;
151+
static ɵfac: i0.ɵɵFactoryDeclaration<MatButtonToggleGroup, [null, { optional: true; }, { optional: true; }]>;
146152
}
147153

148154
// @public (undocumented)

0 commit comments

Comments
 (0)