Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(material/button-toggle): use radio pattern for single select Mat toggle button group #28548

Merged
merged 1 commit into from
Mar 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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:
clamli marked this conversation as resolved.
Show resolved Hide resolved
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),
clamli marked this conversation as resolved.
Show resolved Hide resolved
);
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
Loading