Skip to content

Commit 0a923f3

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

File tree

4 files changed

+115
-10
lines changed

4 files changed

+115
-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.ts

Lines changed: 100 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,53 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
257262
this.disabled = isDisabled;
258263
}
259264

265+
/** Handle keydown event calling to single-select button toggle. */
266+
_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+
300+
/** Obtain the subsequent index to which the focus shifts. */
301+
_getNextIndex(index: number, offset: number): number {
302+
let nextIndex = index + offset;
303+
if (nextIndex === this._buttonToggles.length) {
304+
nextIndex = 0;
305+
}
306+
if (nextIndex === -1) {
307+
nextIndex = this._buttonToggles.length - 1;
308+
}
309+
return nextIndex;
310+
}
311+
260312
/** Dispatch change event with current selection and group value. */
261313
_emitChangeEvent(toggle: MatButtonToggle): void {
262314
const event = new MatButtonToggleChange(toggle, this.value);
@@ -322,6 +374,18 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
322374
return toggle.value === this._rawValue;
323375
}
324376

377+
/** Initializes the tabindex attribute using the radio pattern. */
378+
private _initializeTabIndex() {
379+
this._buttonToggles.forEach(toggle => {
380+
toggle.tabIndex = -1;
381+
});
382+
if (this.selected) {
383+
(this.selected as MatButtonToggle).tabIndex = 0;
384+
} else if (this._buttonToggles.length > 0) {
385+
this._buttonToggles.get(0)!.tabIndex = 0;
386+
}
387+
}
388+
325389
/** Updates the selection state of the toggles in the group based on a value. */
326390
private _setSelectionByValue(value: any | any[]) {
327391
this._rawValue = value;
@@ -346,7 +410,13 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
346410
/** Clears the selected toggles. */
347411
private _clearSelection() {
348412
this._selectionModel.clear();
349-
this._buttonToggles.forEach(toggle => (toggle.checked = false));
413+
this._buttonToggles.forEach(toggle => {
414+
toggle.checked = false;
415+
// If the button toggle is in single select mode, initialize the tabIndex.
416+
if (!this.multiple) {
417+
toggle.tabIndex = -1;
418+
}
419+
});
350420
}
351421

352422
/** Selects a value if there's a toggle that corresponds to it. */
@@ -358,6 +428,10 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
358428
if (correspondingOption) {
359429
correspondingOption.checked = true;
360430
this._selectionModel.select(correspondingOption);
431+
if (!this.multiple) {
432+
// If the button toggle is in single select mode, reset the tabIndex.
433+
correspondingOption.tabIndex = 0;
434+
}
361435
}
362436
}
363437

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

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

443525
/** Whether ripples are disabled on the button toggle. */
444526
@Input({transform: booleanAttribute}) disableRipple: boolean;
@@ -550,6 +632,19 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
550632
this.buttonToggleGroup._onTouched();
551633
}
552634
}
635+
636+
if (this._isSingleSelector()) {
637+
const focusable = this.buttonToggleGroup._buttonToggles.find(toggle => {
638+
return toggle.tabIndex === 0;
639+
});
640+
// Modify the tabindex attribute of the last focusable button toggle to -1.
641+
if (focusable) {
642+
focusable.tabIndex = -1;
643+
}
644+
// Modify the tabindex attribute of the presently selected button toggle to 0.
645+
this.tabIndex = 0;
646+
}
647+
553648
// Emit a change event when it's the single selector
554649
this.change.emit(new MatButtonToggleChange(this, this.value));
555650
}
@@ -574,7 +669,7 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
574669
}
575670

576671
/** Whether the toggle is in single selection mode. */
577-
private _isSingleSelector(): boolean {
672+
_isSingleSelector(): boolean {
578673
return this.buttonToggleGroup && !this.buttonToggleGroup.multiple;
579674
}
580675
}

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: 5 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>;
@@ -96,8 +98,10 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
9698
get disabled(): boolean;
9799
set disabled(value: boolean);
98100
_emitChangeEvent(toggle: MatButtonToggle): void;
101+
_getNextIndex(index: number, offset: number): number;
99102
_isPrechecked(toggle: MatButtonToggle): boolean;
100103
_isSelected(toggle: MatButtonToggle): boolean;
104+
_keydown(event: KeyboardEvent): void;
101105
get multiple(): boolean;
102106
set multiple(value: boolean);
103107
get name(): string;

0 commit comments

Comments
 (0)