Skip to content

Commit

Permalink
fix(material/button-toggle): use radio pattern for single select Mat …
Browse files Browse the repository at this point in the history
…toggle button group
  • Loading branch information
clamli committed Feb 8, 2024
1 parent 8fab892 commit 0a923f3
Show file tree
Hide file tree
Showing 4 changed files with 115 additions and 10 deletions.
4 changes: 3 additions & 1 deletion src/material/button-toggle/button-toggle.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<button #button class="mat-button-toggle-button mat-focus-indicator"
type="button"
[id]="buttonId"
[attr.role]="_isSingleSelector() ? 'radio' : 'button'"
[attr.tabindex]="disabled ? -1 : tabIndex"
[attr.aria-pressed]="checked"
[attr.aria-pressed]="!_isSingleSelector() ? checked : null"
[attr.aria-checked]="_isSingleSelector() ? checked : null"
[disabled]="disabled || null"
[attr.name]="_getButtonName()"
[attr.aria-label]="ariaLabel"
Expand Down
105 changes: 100 additions & 5 deletions src/material/button-toggle/button-toggle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import {FocusMonitor} from '@angular/cdk/a11y';
import {SelectionModel} from '@angular/cdk/collections';
import {DOWN_ARROW, LEFT_ARROW, RIGHT_ARROW, UP_ARROW, SPACE, ENTER} from '@angular/cdk/keycodes';
import {
AfterContentInit,
Attribute,
Expand Down Expand Up @@ -106,8 +107,9 @@ export class MatButtonToggleChange {
{provide: MAT_BUTTON_TOGGLE_GROUP, useExisting: MatButtonToggleGroup},
],
host: {
'role': 'group',
'class': 'mat-button-toggle-group',
'(keydown)': '_keydown($event)',
'[role]': "multiple ? 'group' : 'radiogroup'",
'[attr.aria-disabled]': 'disabled',
'[class.mat-button-toggle-vertical]': 'vertical',
'[class.mat-button-toggle-group-appearance-standard]': 'appearance === "standard"',
Expand Down Expand Up @@ -231,6 +233,9 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After

ngAfterContentInit() {
this._selectionModel.select(...this._buttonToggles.filter(toggle => toggle.checked));
if (!this.multiple) {
this._initializeTabIndex();
}
}

/**
Expand All @@ -257,6 +262,53 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
this.disabled = isDisabled;
}

/** Handle keydown event calling to single-select button toggle. */
_keydown(event: KeyboardEvent) {
if (this.multiple || this.disabled) {
return;
}

const target = event.target as HTMLButtonElement;
const buttonId = target.id;
const index = this._buttonToggles.toArray().findIndex(toggle => {
return toggle.buttonId === buttonId;
});

let nextButton;
switch (event.keyCode) {
case SPACE:
case ENTER:
nextButton = this._buttonToggles.get(index);
break;
case UP_ARROW:
case LEFT_ARROW:
nextButton = this._buttonToggles.get(this._getNextIndex(index, -1));
break;
case DOWN_ARROW:
case RIGHT_ARROW:
nextButton = this._buttonToggles.get(this._getNextIndex(index, 1));
break;
default:
return;
}

event.preventDefault();
nextButton?._onButtonClick();
nextButton?.focus();
}

/** Obtain the subsequent index to which the focus shifts. */
_getNextIndex(index: number, offset: number): number {
let nextIndex = index + offset;
if (nextIndex === this._buttonToggles.length) {
nextIndex = 0;
}
if (nextIndex === -1) {
nextIndex = this._buttonToggles.length - 1;
}
return nextIndex;
}

/** Dispatch change event with current selection and group value. */
_emitChangeEvent(toggle: MatButtonToggle): void {
const event = new MatButtonToggleChange(toggle, this.value);
Expand Down Expand Up @@ -322,6 +374,18 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
return toggle.value === this._rawValue;
}

/** Initializes the tabindex attribute using the radio pattern. */
private _initializeTabIndex() {
this._buttonToggles.forEach(toggle => {
toggle.tabIndex = -1;
});
if (this.selected) {
(this.selected as MatButtonToggle).tabIndex = 0;
} else if (this._buttonToggles.length > 0) {
this._buttonToggles.get(0)!.tabIndex = 0;
}
}

/** Updates the selection state of the toggles in the group based on a value. */
private _setSelectionByValue(value: any | any[]) {
this._rawValue = value;
Expand All @@ -346,7 +410,13 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
/** Clears the selected toggles. */
private _clearSelection() {
this._selectionModel.clear();
this._buttonToggles.forEach(toggle => (toggle.checked = false));
this._buttonToggles.forEach(toggle => {
toggle.checked = false;
// If the button toggle is in single select mode, initialize the tabIndex.
if (!this.multiple) {
toggle.tabIndex = -1;
}
});
}

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

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

/** Tabindex for the toggle. */
@Input() tabIndex: number | null;
/** The tabindex of the button. */
@Input()
get tabIndex(): number | null {
return this._tabIndex;
}
set tabIndex(value: number | null) {
this._tabIndex = value;
this._changeDetectorRef.markForCheck();
}
private _tabIndex: number | null;

/** Whether ripples are disabled on the button toggle. */
@Input({transform: booleanAttribute}) disableRipple: boolean;
Expand Down Expand Up @@ -550,6 +632,19 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
this.buttonToggleGroup._onTouched();
}
}

if (this._isSingleSelector()) {
const focusable = this.buttonToggleGroup._buttonToggles.find(toggle => {
return toggle.tabIndex === 0;
});
// Modify the tabindex attribute of the last focusable button toggle to -1.
if (focusable) {
focusable.tabIndex = -1;
}
// Modify the tabindex attribute of the presently selected button toggle to 0.
this.tabIndex = 0;
}

// Emit a change event when it's the single selector
this.change.emit(new MatButtonToggleChange(this, this.value));
}
Expand All @@ -574,7 +669,7 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
}

/** Whether the toggle is in single selection mode. */
private _isSingleSelector(): boolean {
_isSingleSelector(): boolean {
return this.buttonToggleGroup && !this.buttonToggleGroup.multiple;
}
}
10 changes: 7 additions & 3 deletions src/material/button-toggle/testing/button-toggle-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

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

/** Gets a boolean promise indicating if the button toggle is checked. */
async isChecked(): Promise<boolean> {
const checked = (await this._button()).getAttribute('aria-pressed');
return coerceBooleanProperty(await checked);
const button = await this._button();
const [checked, pressed] = await parallel(() => [
button.getAttribute('aria-checked'),
button.getAttribute('aria-pressed'),
]);
return coerceBooleanProperty(checked) || coerceBooleanProperty(pressed);
}

/** Gets a boolean promise indicating if the button toggle is disabled. */
Expand Down
6 changes: 5 additions & 1 deletion tools/public_api_guard/material/button-toggle.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
focus(options?: FocusOptions): void;
_getButtonName(): string | null;
id: string;
_isSingleSelector(): boolean;
_markForCheck(): void;
name: string;
// (undocumented)
Expand All @@ -61,7 +62,8 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
// (undocumented)
ngOnInit(): void;
_onButtonClick(): void;
tabIndex: number | null;
get tabIndex(): number | null;
set tabIndex(value: number | null);
value: any;
// (undocumented)
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>;
Expand Down Expand Up @@ -96,8 +98,10 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
get disabled(): boolean;
set disabled(value: boolean);
_emitChangeEvent(toggle: MatButtonToggle): void;
_getNextIndex(index: number, offset: number): number;
_isPrechecked(toggle: MatButtonToggle): boolean;
_isSelected(toggle: MatButtonToggle): boolean;
_keydown(event: KeyboardEvent): void;
get multiple(): boolean;
set multiple(value: boolean);
get name(): string;
Expand Down

0 comments on commit 0a923f3

Please sign in to comment.