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 (#28548)
  • Loading branch information
clamli committed Mar 28, 2024
1 parent 816ab8d commit 49901c6
Show file tree
Hide file tree
Showing 5 changed files with 155 additions and 14 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
18 changes: 18 additions & 0 deletions src/material/button-toggle/button-toggle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,24 @@ describe('MatButtonToggle without forms', () => {
buttonToggleInstances = buttonToggleDebugElements.map(debugEl => debugEl.componentInstance);
});

it('should initialize the tab index correctly', () => {
buttonToggleLabelElements.forEach((buttonToggle, index) => {
if (index === 0) {
expect(buttonToggle.getAttribute('tabindex')).toBe('0');
} else {
expect(buttonToggle.getAttribute('tabindex')).toBe('-1');
}
});
});

it('should update the tab index correctly', () => {
buttonToggleLabelElements[1].click();
fixture.detectChanges();

expect(buttonToggleLabelElements[0].getAttribute('tabindex')).toBe('-1');
expect(buttonToggleLabelElements[1].getAttribute('tabindex')).toBe('0');
});

it('should set individual button toggle names based on the group name', () => {
expect(groupInstance.name).toBeTruthy();
for (let buttonToggle of buttonToggleLabelElements) {
Expand Down
125 changes: 118 additions & 7 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 All @@ -32,6 +33,7 @@ import {
AfterViewInit,
booleanAttribute,
} from '@angular/core';
import {Direction, Directionality} from '@angular/cdk/bidi';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
import {MatRipple, MatPseudoCheckbox} from '@angular/material/core';

Expand Down Expand Up @@ -121,8 +123,9 @@ export class MatButtonToggleChange {
{provide: MAT_BUTTON_TOGGLE_GROUP, useExisting: MatButtonToggleGroup},
],
host: {
'role': 'group',
'class': 'mat-button-toggle-group',
'(keydown)': '_keydown($event)',
'[attr.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 @@ -226,6 +229,11 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
this._markButtonsForCheck();
}

/** The layout direction of the toggle button group. */
get dir(): Direction {
return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr';
}

/** Event emitted when the group's value changes. */
@Output() readonly change: EventEmitter<MatButtonToggleChange> =
new EventEmitter<MatButtonToggleChange>();
Expand Down Expand Up @@ -257,6 +265,7 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
@Optional()
@Inject(MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS)
defaultOptions?: MatButtonToggleDefaultOptions,
@Optional() private _dir?: Directionality,
) {
this.appearance =
defaultOptions && defaultOptions.appearance ? defaultOptions.appearance : 'standard';
Expand All @@ -270,6 +279,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 @@ -296,6 +308,49 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
this.disabled = isDisabled;
}

/** Handle keydown event calling to single-select button toggle. */
protected _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:
nextButton = this._buttonToggles.get(this._getNextIndex(index, -1));
break;
case LEFT_ARROW:
nextButton = this._buttonToggles.get(
this._getNextIndex(index, this.dir === 'ltr' ? -1 : 1),
);
break;
case DOWN_ARROW:
nextButton = this._buttonToggles.get(this._getNextIndex(index, 1));
break;
case RIGHT_ARROW:
nextButton = this._buttonToggles.get(
this._getNextIndex(index, this.dir === 'ltr' ? 1 : -1),
);
break;
default:
return;
}

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

/** Dispatch change event with current selection and group value. */
_emitChangeEvent(toggle: MatButtonToggle): void {
const event = new MatButtonToggleChange(toggle, this.value);
Expand Down Expand Up @@ -361,6 +416,31 @@ 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;
}
this._markButtonsForCheck();
}

/** Obtain the subsequent index to which the focus shifts. */
private _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;
}

/** Updates the selection state of the toggles in the group based on a value. */
private _setSelectionByValue(value: any | any[]) {
this._rawValue = value;
Expand All @@ -385,7 +465,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 @@ -397,6 +483,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 @@ -476,8 +566,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;
/** Tabindex of the toggle. */
@Input()
get tabIndex(): number | null {
return this._tabIndex;
}
set tabIndex(value: number | null) {
this._tabIndex = value;
this._markForCheck();
}
private _tabIndex: number | null;

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

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

if (newChecked !== this._checked) {
this._checked = newChecked;
Expand All @@ -589,6 +687,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 @@ -606,14 +717,14 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {

/** Gets the name that should be assigned to the inner DOM node. */
_getButtonName(): string | null {
if (this._isSingleSelector()) {
if (this.isSingleSelector()) {
return this.buttonToggleGroup.name;
}
return this.name || null;
}

/** 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
12 changes: 9 additions & 3 deletions tools/public_api_guard/material/button-toggle.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { AfterContentInit } from '@angular/core';
import { AfterViewInit } from '@angular/core';
import { ChangeDetectorRef } from '@angular/core';
import { ControlValueAccessor } from '@angular/forms';
import { Direction } from '@angular/cdk/bidi';
import { Directionality } from '@angular/cdk/bidi';
import { ElementRef } from '@angular/core';
import { EventEmitter } from '@angular/core';
import { FocusMonitor } from '@angular/cdk/a11y';
Expand Down Expand Up @@ -49,6 +51,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 @@ -64,7 +67,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 @@ -93,11 +97,12 @@ export interface MatButtonToggleDefaultOptions {

// @public
export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, AfterContentInit {
constructor(_changeDetector: ChangeDetectorRef, defaultOptions?: MatButtonToggleDefaultOptions);
constructor(_changeDetector: ChangeDetectorRef, defaultOptions?: MatButtonToggleDefaultOptions, _dir?: Directionality | undefined);
appearance: MatButtonToggleAppearance;
_buttonToggles: QueryList<MatButtonToggle>;
readonly change: EventEmitter<MatButtonToggleChange>;
_controlValueAccessorChangeFn: (value: any) => void;
get dir(): Direction;
get disabled(): boolean;
set disabled(value: boolean);
_emitChangeEvent(toggle: MatButtonToggle): void;
Expand All @@ -107,6 +112,7 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
set hideSingleSelectionIndicator(value: boolean);
_isPrechecked(toggle: MatButtonToggle): boolean;
_isSelected(toggle: MatButtonToggle): boolean;
protected _keydown(event: KeyboardEvent): void;
get multiple(): boolean;
set multiple(value: boolean);
get name(): string;
Expand Down Expand Up @@ -142,7 +148,7 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
// (undocumented)
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>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<MatButtonToggleGroup, [null, { optional: true; }]>;
static ɵfac: i0.ɵɵFactoryDeclaration<MatButtonToggleGroup, [null, { optional: true; }, { optional: true; }]>;
}

// @public (undocumented)
Expand Down

0 comments on commit 49901c6

Please sign in to comment.