Skip to content

Commit 5588c4f

Browse files
committed
fix(material/button-toggle): use radio pattern for single select Mat toggle button group
1 parent 8fab892 commit 5588c4f

File tree

5 files changed

+133
-10
lines changed

5 files changed

+133
-10
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
@@ -365,6 +365,24 @@ describe('MatButtonToggle without forms', () => {
365365
buttonToggleInstances = buttonToggleDebugElements.map(debugEl => debugEl.componentInstance);
366366
});
367367

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

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

Lines changed: 101 additions & 5 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,
@@ -106,8 +107,9 @@ export class MatButtonToggleChange {
106107
{provide: MAT_BUTTON_TOGGLE_GROUP, useExisting: MatButtonToggleGroup},
107108
],
108109
host: {
109-
'role': 'group',
110110
'class': 'mat-button-toggle-group',
111+
'(keydown)': '_keydown($event)',
112+
'[role]': "multiple ? 'group' : 'radiogroup'",
111113
'[attr.aria-disabled]': 'disabled',
112114
'[class.mat-button-toggle-vertical]': 'vertical',
113115
'[class.mat-button-toggle-group-appearance-standard]': 'appearance === "standard"',
@@ -231,6 +233,9 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
231233

232234
ngAfterContentInit() {
233235
this._selectionModel.select(...this._buttonToggles.filter(toggle => toggle.checked));
236+
if (!this.multiple) {
237+
this._initializeTabIndex();
238+
}
234239
}
235240

236241
/**
@@ -257,6 +262,41 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
257262
this.disabled = isDisabled;
258263
}
259264

265+
/** Handle keydown event calling to single-select button toggle. */
266+
protected _keydown(event: KeyboardEvent) {
267+
if (this.multiple || this.disabled) {
268+
return;
269+
}
270+
271+
const target = event.target as HTMLButtonElement;
272+
const buttonId = target.id;
273+
const index = this._buttonToggles.toArray().findIndex(toggle => {
274+
return toggle.buttonId === buttonId;
275+
});
276+
277+
let nextButton;
278+
switch (event.keyCode) {
279+
case SPACE:
280+
case ENTER:
281+
nextButton = this._buttonToggles.get(index);
282+
break;
283+
case UP_ARROW:
284+
case LEFT_ARROW:
285+
nextButton = this._buttonToggles.get(this._getNextIndex(index, -1));
286+
break;
287+
case DOWN_ARROW:
288+
case RIGHT_ARROW:
289+
nextButton = this._buttonToggles.get(this._getNextIndex(index, 1));
290+
break;
291+
default:
292+
return;
293+
}
294+
295+
event.preventDefault();
296+
nextButton?._onButtonClick();
297+
nextButton?.focus();
298+
}
299+
260300
/** Dispatch change event with current selection and group value. */
261301
_emitChangeEvent(toggle: MatButtonToggle): void {
262302
const event = new MatButtonToggleChange(toggle, this.value);
@@ -322,6 +362,31 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
322362
return toggle.value === this._rawValue;
323363
}
324364

365+
/** Initializes the tabindex attribute using the radio pattern. */
366+
private _initializeTabIndex() {
367+
this._buttonToggles.forEach(toggle => {
368+
toggle.tabIndex = -1;
369+
});
370+
if (this.selected) {
371+
(this.selected as MatButtonToggle).tabIndex = 0;
372+
} else if (this._buttonToggles.length > 0) {
373+
this._buttonToggles.get(0)!.tabIndex = 0;
374+
}
375+
this._markButtonsForCheck();
376+
}
377+
378+
/** Obtain the subsequent index to which the focus shifts. */
379+
private _getNextIndex(index: number, offset: number): number {
380+
let nextIndex = index + offset;
381+
if (nextIndex === this._buttonToggles.length) {
382+
nextIndex = 0;
383+
}
384+
if (nextIndex === -1) {
385+
nextIndex = this._buttonToggles.length - 1;
386+
}
387+
return nextIndex;
388+
}
389+
325390
/** Updates the selection state of the toggles in the group based on a value. */
326391
private _setSelectionByValue(value: any | any[]) {
327392
this._rawValue = value;
@@ -346,7 +411,13 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
346411
/** Clears the selected toggles. */
347412
private _clearSelection() {
348413
this._selectionModel.clear();
349-
this._buttonToggles.forEach(toggle => (toggle.checked = false));
414+
this._buttonToggles.forEach(toggle => {
415+
toggle.checked = false;
416+
// If the button toggle is in single select mode, initialize the tabIndex.
417+
if (!this.multiple) {
418+
toggle.tabIndex = -1;
419+
}
420+
});
350421
}
351422

352423
/** Selects a value if there's a toggle that corresponds to it. */
@@ -358,6 +429,10 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
358429
if (correspondingOption) {
359430
correspondingOption.checked = true;
360431
this._selectionModel.select(correspondingOption);
432+
if (!this.multiple) {
433+
// If the button toggle is in single select mode, reset the tabIndex.
434+
correspondingOption.tabIndex = 0;
435+
}
361436
}
362437
}
363438

@@ -437,8 +512,16 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
437512
/** MatButtonToggleGroup reads this to assign its own value. */
438513
@Input() value: any;
439514

440-
/** Tabindex for the toggle. */
441-
@Input() tabIndex: number | null;
515+
/** Tabindex of the toggle. */
516+
@Input()
517+
get tabIndex(): number | null {
518+
return this._tabIndex;
519+
}
520+
set tabIndex(value: number | null) {
521+
this._tabIndex = value;
522+
this._markForCheck();
523+
}
524+
private _tabIndex: number | null;
442525

443526
/** Whether ripples are disabled on the button toggle. */
444527
@Input({transform: booleanAttribute}) disableRipple: boolean;
@@ -550,6 +633,19 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
550633
this.buttonToggleGroup._onTouched();
551634
}
552635
}
636+
637+
if (this._isSingleSelector()) {
638+
const focusable = this.buttonToggleGroup._buttonToggles.find(toggle => {
639+
return toggle.tabIndex === 0;
640+
});
641+
// Modify the tabindex attribute of the last focusable button toggle to -1.
642+
if (focusable) {
643+
focusable.tabIndex = -1;
644+
}
645+
// Modify the tabindex attribute of the presently selected button toggle to 0.
646+
this.tabIndex = 0;
647+
}
648+
553649
// Emit a change event when it's the single selector
554650
this.change.emit(new MatButtonToggleChange(this, this.value));
555651
}
@@ -574,7 +670,7 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
574670
}
575671

576672
/** Whether the toggle is in single selection mode. */
577-
private _isSingleSelector(): boolean {
673+
_isSingleSelector(): boolean {
578674
return this.buttonToggleGroup && !this.buttonToggleGroup.multiple;
579675
}
580676
}

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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
4646
focus(options?: FocusOptions): void;
4747
_getButtonName(): string | null;
4848
id: string;
49+
_isSingleSelector(): boolean;
4950
_markForCheck(): void;
5051
name: string;
5152
// (undocumented)
@@ -61,7 +62,8 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
6162
// (undocumented)
6263
ngOnInit(): void;
6364
_onButtonClick(): void;
64-
tabIndex: number | null;
65+
get tabIndex(): number | null;
66+
set tabIndex(value: number | null);
6567
value: any;
6668
// (undocumented)
6769
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>;
@@ -98,6 +100,7 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
98100
_emitChangeEvent(toggle: MatButtonToggle): void;
99101
_isPrechecked(toggle: MatButtonToggle): boolean;
100102
_isSelected(toggle: MatButtonToggle): boolean;
103+
protected _keydown(event: KeyboardEvent): void;
101104
get multiple(): boolean;
102105
set multiple(value: boolean);
103106
get name(): string;

0 commit comments

Comments
 (0)