Skip to content

Commit 1b2b465

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

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
@@ -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: 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} from '@angular/material/core';
3739

@@ -106,8 +108,9 @@ export class MatButtonToggleChange {
106108
{provide: MAT_BUTTON_TOGGLE_GROUP, useExisting: MatButtonToggleGroup},
107109
],
108110
host: {
109-
'role': 'group',
110111
'class': 'mat-button-toggle-group',
112+
'(keydown)': '_keydown($event)',
113+
'[role]': "multiple ? 'group' : 'radiogroup'",
111114
'[attr.aria-disabled]': 'disabled',
112115
'[class.mat-button-toggle-vertical]': 'vertical',
113116
'[class.mat-button-toggle-group-appearance-standard]': 'appearance === "standard"',
@@ -211,6 +214,11 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
211214
this._markButtonsForCheck();
212215
}
213216

217+
/** The layout direction of the toggle button group. */
218+
get dir(): Direction {
219+
return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr';
220+
}
221+
214222
/** Event emitted when the group's value changes. */
215223
@Output() readonly change: EventEmitter<MatButtonToggleChange> =
216224
new EventEmitter<MatButtonToggleChange>();
@@ -220,6 +228,7 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
220228
@Optional()
221229
@Inject(MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS)
222230
defaultOptions?: MatButtonToggleDefaultOptions,
231+
@Optional() private _dir?: Directionality,
223232
) {
224233
this.appearance =
225234
defaultOptions && defaultOptions.appearance ? defaultOptions.appearance : 'standard';
@@ -231,6 +240,9 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
231240

232241
ngAfterContentInit() {
233242
this._selectionModel.select(...this._buttonToggles.filter(toggle => toggle.checked));
243+
if (!this.multiple) {
244+
this._initializeTabIndex();
245+
}
234246
}
235247

236248
/**
@@ -257,6 +269,49 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
257269
this.disabled = isDisabled;
258270
}
259271

272+
/** Handle keydown event calling to single-select button toggle. */
273+
protected _keydown(event: KeyboardEvent) {
274+
if (this.multiple || this.disabled) {
275+
return;
276+
}
277+
278+
const target = event.target as HTMLButtonElement;
279+
const buttonId = target.id;
280+
const index = this._buttonToggles.toArray().findIndex(toggle => {
281+
return toggle.buttonId === buttonId;
282+
});
283+
284+
let nextButton;
285+
switch (event.keyCode) {
286+
case SPACE:
287+
case ENTER:
288+
nextButton = this._buttonToggles.get(index);
289+
break;
290+
case UP_ARROW:
291+
nextButton = this._buttonToggles.get(this._getNextIndex(index, -1));
292+
break;
293+
case LEFT_ARROW:
294+
nextButton = this._buttonToggles.get(
295+
this._getNextIndex(index, this.dir === 'ltr' ? -1 : 1),
296+
);
297+
break;
298+
case DOWN_ARROW:
299+
nextButton = this._buttonToggles.get(this._getNextIndex(index, 1));
300+
break;
301+
case RIGHT_ARROW:
302+
nextButton = this._buttonToggles.get(
303+
this._getNextIndex(index, this.dir === 'ltr' ? 1 : -1),
304+
);
305+
break;
306+
default:
307+
return;
308+
}
309+
310+
event.preventDefault();
311+
nextButton?._onButtonClick();
312+
nextButton?.focus();
313+
}
314+
260315
/** Dispatch change event with current selection and group value. */
261316
_emitChangeEvent(toggle: MatButtonToggle): void {
262317
const event = new MatButtonToggleChange(toggle, this.value);
@@ -322,6 +377,31 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
322377
return toggle.value === this._rawValue;
323378
}
324379

380+
/** Initializes the tabindex attribute using the radio pattern. */
381+
private _initializeTabIndex() {
382+
this._buttonToggles.forEach(toggle => {
383+
toggle.tabIndex = -1;
384+
});
385+
if (this.selected) {
386+
(this.selected as MatButtonToggle).tabIndex = 0;
387+
} else if (this._buttonToggles.length > 0) {
388+
this._buttonToggles.get(0)!.tabIndex = 0;
389+
}
390+
this._markButtonsForCheck();
391+
}
392+
393+
/** Obtain the subsequent index to which the focus shifts. */
394+
private _getNextIndex(index: number, offset: number): number {
395+
let nextIndex = index + offset;
396+
if (nextIndex === this._buttonToggles.length) {
397+
nextIndex = 0;
398+
}
399+
if (nextIndex === -1) {
400+
nextIndex = this._buttonToggles.length - 1;
401+
}
402+
return nextIndex;
403+
}
404+
325405
/** Updates the selection state of the toggles in the group based on a value. */
326406
private _setSelectionByValue(value: any | any[]) {
327407
this._rawValue = value;
@@ -346,7 +426,13 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
346426
/** Clears the selected toggles. */
347427
private _clearSelection() {
348428
this._selectionModel.clear();
349-
this._buttonToggles.forEach(toggle => (toggle.checked = false));
429+
this._buttonToggles.forEach(toggle => {
430+
toggle.checked = false;
431+
// If the button toggle is in single select mode, initialize the tabIndex.
432+
if (!this.multiple) {
433+
toggle.tabIndex = -1;
434+
}
435+
});
350436
}
351437

352438
/** Selects a value if there's a toggle that corresponds to it. */
@@ -358,6 +444,10 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
358444
if (correspondingOption) {
359445
correspondingOption.checked = true;
360446
this._selectionModel.select(correspondingOption);
447+
if (!this.multiple) {
448+
// If the button toggle is in single select mode, reset the tabIndex.
449+
correspondingOption.tabIndex = 0;
450+
}
361451
}
362452
}
363453

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

440-
/** Tabindex for the toggle. */
441-
@Input() tabIndex: number | null;
530+
/** Tabindex of the toggle. */
531+
@Input()
532+
get tabIndex(): number | null {
533+
return this._tabIndex;
534+
}
535+
set tabIndex(value: number | null) {
536+
this._tabIndex = value;
537+
this._markForCheck();
538+
}
539+
private _tabIndex: number | null;
442540

443541
/** Whether ripples are disabled on the button toggle. */
444542
@Input({transform: booleanAttribute}) disableRipple: boolean;
@@ -541,7 +639,7 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
541639

542640
/** Checks the button toggle due to an interaction with the underlying native button. */
543641
_onButtonClick() {
544-
const newChecked = this._isSingleSelector() ? true : !this._checked;
642+
const newChecked = this.isSingleSelector() ? true : !this._checked;
545643

546644
if (newChecked !== this._checked) {
547645
this._checked = newChecked;
@@ -550,6 +648,19 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
550648
this.buttonToggleGroup._onTouched();
551649
}
552650
}
651+
652+
if (this.isSingleSelector()) {
653+
const focusable = this.buttonToggleGroup._buttonToggles.find(toggle => {
654+
return toggle.tabIndex === 0;
655+
});
656+
// Modify the tabindex attribute of the last focusable button toggle to -1.
657+
if (focusable) {
658+
focusable.tabIndex = -1;
659+
}
660+
// Modify the tabindex attribute of the presently selected button toggle to 0.
661+
this.tabIndex = 0;
662+
}
663+
553664
// Emit a change event when it's the single selector
554665
this.change.emit(new MatButtonToggleChange(this, this.value));
555666
}
@@ -567,14 +678,14 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
567678

568679
/** Gets the name that should be assigned to the inner DOM node. */
569680
_getButtonName(): string | null {
570-
if (this._isSingleSelector()) {
681+
if (this.isSingleSelector()) {
571682
return this.buttonToggleGroup.name;
572683
}
573684
return this.name || null;
574685
}
575686

576687
/** Whether the toggle is in single selection mode. */
577-
private _isSingleSelector(): boolean {
688+
isSingleSelector(): boolean {
578689
return this.buttonToggleGroup && !this.buttonToggleGroup.multiple;
579690
}
580691
}

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';
@@ -46,6 +48,7 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
4648
focus(options?: FocusOptions): void;
4749
_getButtonName(): string | null;
4850
id: string;
51+
isSingleSelector(): boolean;
4952
_markForCheck(): void;
5053
name: string;
5154
// (undocumented)
@@ -61,7 +64,8 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
6164
// (undocumented)
6265
ngOnInit(): void;
6366
_onButtonClick(): void;
64-
tabIndex: number | null;
67+
get tabIndex(): number | null;
68+
set tabIndex(value: number | null);
6569
value: any;
6670
// (undocumented)
6771
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>;
@@ -88,16 +92,18 @@ export interface MatButtonToggleDefaultOptions {
8892

8993
// @public
9094
export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, AfterContentInit {
91-
constructor(_changeDetector: ChangeDetectorRef, defaultOptions?: MatButtonToggleDefaultOptions);
95+
constructor(_changeDetector: ChangeDetectorRef, defaultOptions?: MatButtonToggleDefaultOptions, _dir?: Directionality | undefined);
9296
appearance: MatButtonToggleAppearance;
9397
_buttonToggles: QueryList<MatButtonToggle>;
9498
readonly change: EventEmitter<MatButtonToggleChange>;
9599
_controlValueAccessorChangeFn: (value: any) => void;
100+
get dir(): Direction;
96101
get disabled(): boolean;
97102
set disabled(value: boolean);
98103
_emitChangeEvent(toggle: MatButtonToggle): void;
99104
_isPrechecked(toggle: MatButtonToggle): boolean;
100105
_isSelected(toggle: MatButtonToggle): boolean;
106+
protected _keydown(event: KeyboardEvent): void;
101107
get multiple(): boolean;
102108
set multiple(value: boolean);
103109
get name(): string;
@@ -129,7 +135,7 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
129135
// (undocumented)
130136
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; }; }, { "valueChange": "valueChange"; "change": "change"; }, ["_buttonToggles"], never, true, never>;
131137
// (undocumented)
132-
static ɵfac: i0.ɵɵFactoryDeclaration<MatButtonToggleGroup, [null, { optional: true; }]>;
138+
static ɵfac: i0.ɵɵFactoryDeclaration<MatButtonToggleGroup, [null, { optional: true; }, { optional: true; }]>;
133139
}
134140

135141
// @public (undocumented)

0 commit comments

Comments
 (0)