Skip to content

Commit 644acdd

Browse files
committed
fix(material/button-toggle): use radio pattern for single select Mat toggle button group
1 parent 2455a42 commit 644acdd

File tree

7 files changed

+83
-39
lines changed

7 files changed

+83
-39
lines changed

src/dev-app/button-toggle/button-toggle-demo.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<h1>Exclusive Selection</h1>
1010

1111
<section>
12-
<mat-button-toggle-group name="alignment" [vertical]="isVertical">
12+
<mat-button-toggle-group name="standard alignment" [vertical]="isVertical">
1313
<mat-button-toggle value="left" [disabled]="isDisabled">
1414
<mat-icon>format_align_left</mat-icon>
1515
</mat-button-toggle>
@@ -26,7 +26,7 @@ <h1>Exclusive Selection</h1>
2626
</section>
2727

2828
<section>
29-
<mat-button-toggle-group appearance="legacy" name="alignment" [vertical]="isVertical">
29+
<mat-button-toggle-group appearance="legacy" name="legacy alignment" [vertical]="isVertical">
3030
<mat-button-toggle value="left" [disabled]="isDisabled">
3131
<mat-icon>format_align_left</mat-icon>
3232
</mat-button-toggle>
Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
1-
<button #button class="mat-button-toggle-button mat-focus-indicator"
2-
type="button"
3-
[id]="buttonId"
4-
[attr.tabindex]="disabled ? -1 : tabIndex"
5-
[attr.aria-pressed]="checked"
6-
[disabled]="disabled || null"
7-
[attr.name]="_getButtonName()"
8-
[attr.aria-label]="ariaLabel"
9-
[attr.aria-labelledby]="ariaLabelledby"
10-
(click)="_onButtonClick()">
11-
<span class="mat-button-toggle-label-content">
12-
<ng-content></ng-content>
13-
</span>
14-
</button>
1+
<input #input class="mat-button-toggle-button mat-focus-indicator cdk-visually-hidden"
2+
[type]="type"
3+
[id]="buttonId"
4+
[attr.tabindex]="disabled ? -1 : tabIndex"
5+
[attr.aria-pressed]="_getAriaPressed()"
6+
[checked]="_getChecked()"
7+
[disabled]="disabled || null"
8+
[attr.name]="_getButtonName()"
9+
[attr.aria-label]="ariaLabel"
10+
[attr.aria-labelledby]="ariaLabelledby"
11+
(click)="_onButtonClick()"
12+
(change)="_onInteractionEvent($event)">
13+
<label class="mat-button-toggle-label-content" [for]="buttonId">
14+
<ng-content></ng-content>
15+
</label>
1516

1617
<span class="mat-button-toggle-focus-overlay"></span>
1718
<span class="mat-button-toggle-ripple" matRipple
18-
[matRippleTrigger]="button"
19+
[matRippleTrigger]="input"
1920
[matRippleDisabled]="this.disableRipple || this.disabled">
2021
</span>

src/material/button-toggle/button-toggle.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ $_standard-tokens: (
182182
@include vendor-prefixes.user-select(none);
183183
display: inline-block;
184184
padding: $legacy-padding;
185+
// Add the cursor effect on the label.
186+
cursor: pointer;
187+
// Center the content when needed.
188+
margin: auto;
185189

186190
@include token-utils.use-tokens($_legacy-tokens...) {
187191
@include token-utils.create-token-slot(line-height, height);
@@ -264,6 +268,9 @@ $_standard-tokens: (
264268
width: 100%; // Stretch the button in case the consumer set a custom width.
265269
cursor: pointer;
266270

271+
// Creates a new bounding box for the button and fill all available space.
272+
position: absolute;
273+
267274
.mat-button-toggle-disabled & {
268275
cursor: default;
269276
}

src/material/button-toggle/button-toggle.spec.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ describe('MatButtonToggle with forms', () => {
9797
buttonToggleDebugElements = fixture.debugElement.queryAll(By.directive(MatButtonToggle));
9898
buttonToggleInstances = buttonToggleDebugElements.map(debugEl => debugEl.componentInstance);
9999
innerButtons = buttonToggleDebugElements.map(
100-
debugEl => debugEl.query(By.css('button'))!.nativeElement,
100+
debugEl => debugEl.query(By.css('input'))!.nativeElement,
101101
);
102102

103103
fixture.detectChanges();
@@ -256,7 +256,7 @@ describe('MatButtonToggle with forms', () => {
256256
const fixture = TestBed.createComponent(ButtonToggleGroupWithIndirectDescendantToggles);
257257
fixture.detectChanges();
258258

259-
const button = fixture.nativeElement.querySelector('.mat-button-toggle button');
259+
const button = fixture.nativeElement.querySelector('.mat-button-toggle input');
260260
const groupDebugElement = fixture.debugElement.query(By.directive(MatButtonToggleGroup))!;
261261
const groupInstance =
262262
groupDebugElement.injector.get<MatButtonToggleGroup>(MatButtonToggleGroup);
@@ -359,7 +359,7 @@ describe('MatButtonToggle without forms', () => {
359359
buttonToggleNativeElements = buttonToggleDebugElements.map(debugEl => debugEl.nativeElement);
360360

361361
buttonToggleLabelElements = fixture.debugElement
362-
.queryAll(By.css('button'))
362+
.queryAll(By.css('input'))
363363
.map(debugEl => debugEl.nativeElement);
364364

365365
buttonToggleInstances = buttonToggleDebugElements.map(debugEl => debugEl.componentInstance);
@@ -401,7 +401,7 @@ describe('MatButtonToggle without forms', () => {
401401
});
402402

403403
it('should disable the underlying button when the group is disabled', () => {
404-
const buttons = buttonToggleNativeElements.map(toggle => toggle.querySelector('button')!);
404+
const buttons = buttonToggleNativeElements.map(toggle => toggle.querySelector('input')!);
405405

406406
expect(buttons.every(input => input.disabled)).toBe(false);
407407

@@ -595,7 +595,7 @@ describe('MatButtonToggle without forms', () => {
595595
buttonToggleDebugElements = fixture.debugElement.queryAll(By.directive(MatButtonToggle));
596596
buttonToggleNativeElements = buttonToggleDebugElements.map(debugEl => debugEl.nativeElement);
597597
buttonToggleLabelElements = fixture.debugElement
598-
.queryAll(By.css('button'))
598+
.queryAll(By.css('input'))
599599
.map(debugEl => debugEl.nativeElement);
600600
buttonToggleInstances = buttonToggleDebugElements.map(debugEl => debugEl.componentInstance);
601601
}));
@@ -612,7 +612,7 @@ describe('MatButtonToggle without forms', () => {
612612
expect(buttonToggleInstances.every(buttonToggle => !buttonToggle.checked)).toBe(true);
613613

614614
const nativeCheckboxLabel = buttonToggleDebugElements[0].query(
615-
By.css('button'),
615+
By.css('input'),
616616
)!.nativeElement;
617617

618618
nativeCheckboxLabel.click();
@@ -638,7 +638,7 @@ describe('MatButtonToggle without forms', () => {
638638

639639
it('should check a button toggle upon interaction with underlying native checkbox', () => {
640640
const nativeCheckboxButton = buttonToggleDebugElements[0].query(
641-
By.css('button'),
641+
By.css('input'),
642642
)!.nativeElement;
643643

644644
nativeCheckboxButton.click();
@@ -722,7 +722,7 @@ describe('MatButtonToggle without forms', () => {
722722
)!.nativeElement;
723723
buttonToggleInstance = buttonToggleDebugElement.componentInstance;
724724
buttonToggleButtonElement = buttonToggleNativeElement.querySelector(
725-
'button',
725+
'input',
726726
)! as HTMLButtonElement;
727727
}));
728728

@@ -761,7 +761,7 @@ describe('MatButtonToggle without forms', () => {
761761
}));
762762

763763
it('should focus on underlying input element when focus() is called', () => {
764-
const nativeButton = buttonToggleDebugElement.query(By.css('button'))!.nativeElement;
764+
const nativeButton = buttonToggleDebugElement.query(By.css('input'))!.nativeElement;
765765
expect(document.activeElement).not.toBe(nativeButton);
766766

767767
buttonToggleInstance.focus();
@@ -790,7 +790,7 @@ describe('MatButtonToggle without forms', () => {
790790
const fixture = TestBed.createComponent(StandaloneButtonToggle);
791791
const checkboxDebugElement = fixture.debugElement.query(By.directive(MatButtonToggle))!;
792792
const checkboxNativeElement = checkboxDebugElement.nativeElement;
793-
const buttonElement = checkboxNativeElement.querySelector('button') as HTMLButtonElement;
793+
const buttonElement = checkboxNativeElement.querySelector('input') as HTMLButtonElement;
794794

795795
fixture.detectChanges();
796796
expect(buttonElement.hasAttribute('aria-label')).toBe(false);
@@ -800,7 +800,7 @@ describe('MatButtonToggle without forms', () => {
800800
const fixture = TestBed.createComponent(ButtonToggleWithAriaLabel);
801801
const checkboxDebugElement = fixture.debugElement.query(By.directive(MatButtonToggle))!;
802802
const checkboxNativeElement = checkboxDebugElement.nativeElement;
803-
const buttonElement = checkboxNativeElement.querySelector('button') as HTMLButtonElement;
803+
const buttonElement = checkboxNativeElement.querySelector('input') as HTMLButtonElement;
804804

805805
fixture.detectChanges();
806806
expect(buttonElement.getAttribute('aria-label')).toBe('Super effective');
@@ -825,7 +825,7 @@ describe('MatButtonToggle without forms', () => {
825825
const fixture = TestBed.createComponent(ButtonToggleWithAriaLabelledby);
826826
checkboxDebugElement = fixture.debugElement.query(By.directive(MatButtonToggle))!;
827827
checkboxNativeElement = checkboxDebugElement.nativeElement;
828-
buttonElement = checkboxNativeElement.querySelector('button') as HTMLButtonElement;
828+
buttonElement = checkboxNativeElement.querySelector('input') as HTMLButtonElement;
829829

830830
fixture.detectChanges();
831831
expect(buttonElement.getAttribute('aria-labelledby')).toBe('some-id');
@@ -835,7 +835,7 @@ describe('MatButtonToggle without forms', () => {
835835
const fixture = TestBed.createComponent(StandaloneButtonToggle);
836836
checkboxDebugElement = fixture.debugElement.query(By.directive(MatButtonToggle))!;
837837
checkboxNativeElement = checkboxDebugElement.nativeElement;
838-
buttonElement = checkboxNativeElement.querySelector('button') as HTMLButtonElement;
838+
buttonElement = checkboxNativeElement.querySelector('input') as HTMLButtonElement;
839839

840840
fixture.detectChanges();
841841
expect(buttonElement.getAttribute('aria-labelledby')).toBe(null);
@@ -847,7 +847,7 @@ describe('MatButtonToggle without forms', () => {
847847
const fixture = TestBed.createComponent(ButtonToggleWithTabindex);
848848
fixture.detectChanges();
849849

850-
const button = fixture.nativeElement.querySelector('.mat-button-toggle button');
850+
const button = fixture.nativeElement.querySelector('.mat-button-toggle input');
851851

852852
expect(button.getAttribute('tabindex')).toBe('3');
853853
});
@@ -866,7 +866,7 @@ describe('MatButtonToggle without forms', () => {
866866
fixture.detectChanges();
867867

868868
const host = fixture.nativeElement.querySelector('.mat-button-toggle');
869-
const button = host.querySelector('button');
869+
const button = host.querySelector('input');
870870

871871
expect(document.activeElement).not.toBe(button);
872872

@@ -891,7 +891,7 @@ describe('MatButtonToggle without forms', () => {
891891
const hostNode: HTMLElement = fixture.nativeElement.querySelector('.mat-button-toggle');
892892

893893
expect(hostNode.hasAttribute('name')).toBe(false);
894-
expect(hostNode.querySelector('button')!.getAttribute('name')).toBe('custom-name');
894+
expect(hostNode.querySelector('input')!.getAttribute('name')).toBe('custom-name');
895895
});
896896

897897
it(

src/material/button-toggle/button-toggle.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@ export class MatButtonToggleChange {
106106
{provide: MAT_BUTTON_TOGGLE_GROUP, useExisting: MatButtonToggleGroup},
107107
],
108108
host: {
109-
'role': 'group',
110109
'class': 'mat-button-toggle-group',
110+
'[role]': "multiple ? 'group' : 'radiogroup'",
111111
'[attr.aria-disabled]': 'disabled',
112112
'[class.mat-button-toggle-vertical]': 'vertical',
113113
'[class.mat-button-toggle-group-appearance-standard]': 'appearance === "standard"',
@@ -417,8 +417,13 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
417417
*/
418418
@Input('aria-labelledby') ariaLabelledby: string | null = null;
419419

420+
/** Type of the button toggle. Either 'radio' or 'button'. */
421+
get type(): string {
422+
return this._isSingleSelector() ? 'radio' : 'button';
423+
}
424+
420425
/** Underlying native `button` element. */
421-
@ViewChild('button') _buttonElement: ElementRef<HTMLButtonElement>;
426+
@ViewChild('input') _inputElement: ElementRef<HTMLInputElement>;
422427

423428
/** The parent button toggle group (exclusive selection). Optional. */
424429
buttonToggleGroup: MatButtonToggleGroup;
@@ -536,7 +541,7 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
536541

537542
/** Focuses the button. */
538543
focus(options?: FocusOptions): void {
539-
this._buttonElement.nativeElement.focus(options);
544+
this._inputElement.nativeElement.focus(options);
540545
}
541546

542547
/** Checks the button toggle due to an interaction with the underlying native button. */
@@ -554,6 +559,15 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
554559
this.change.emit(new MatButtonToggleChange(this, this.value));
555560
}
556561

562+
/**
563+
* Stop propagation on the change event.
564+
* Otherwise the change event, from the input element, will bubble up and
565+
* emit its event object to the `change` output.
566+
*/
567+
_onInteractionEvent(event: Event) {
568+
event.stopPropagation();
569+
}
570+
557571
/**
558572
* Marks the button toggle as needing checking for change detection.
559573
* This method is exposed because the parent button toggle group will directly
@@ -573,6 +587,24 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
573587
return this.name || null;
574588
}
575589

590+
/** Get the aria-pressed attribute value. */
591+
_getAriaPressed(): boolean | null {
592+
// When the toggle button stands alone, or in multiple selection mode, use aria-pressed attribute.
593+
if (!this._isSingleSelector()) {
594+
return this.checked;
595+
}
596+
return null;
597+
}
598+
599+
/** Get the check property value. */
600+
_getChecked(): boolean | null {
601+
// When the toggle button is in single selection mode, use checked property.
602+
if (this._isSingleSelector()) {
603+
return this.checked;
604+
}
605+
return null;
606+
}
607+
576608
/** Whether the toggle is in single selection mode. */
577609
private _isSingleSelector(): boolean {
578610
return this.buttonToggleGroup && !this.buttonToggleGroup.multiple;

src/material/button-toggle/testing/button-toggle-harness.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ 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 pressed = (await this._button()).getAttribute('aria-pressed');
49+
const checked = (await this._button()).getProperty<boolean>('checked');
50+
return coerceBooleanProperty(await pressed) || coerceBooleanProperty(await checked);
5051
}
5152

5253
/** Gets a boolean promise indicating if the button toggle is disabled. */

tools/public_api_guard/material/button-toggle.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
3434
set appearance(value: MatButtonToggleAppearance);
3535
ariaLabel: string;
3636
ariaLabelledby: string | null;
37-
_buttonElement: ElementRef<HTMLButtonElement>;
3837
get buttonId(): string;
3938
buttonToggleGroup: MatButtonToggleGroup;
4039
readonly change: EventEmitter<MatButtonToggleChange>;
@@ -44,8 +43,10 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
4443
set disabled(value: boolean);
4544
disableRipple: boolean;
4645
focus(options?: FocusOptions): void;
46+
_getAriaPressed(): boolean | null;
4747
_getButtonName(): string | null;
4848
id: string;
49+
_inputElement: ElementRef<HTMLInputElement>;
4950
_markForCheck(): void;
5051
name: string;
5152
// (undocumented)
@@ -61,7 +62,9 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
6162
// (undocumented)
6263
ngOnInit(): void;
6364
_onButtonClick(): void;
65+
_onInteractionEvent(event: Event): void;
6466
tabIndex: number | null;
67+
get type(): string;
6568
value: any;
6669
// (undocumented)
6770
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>;

0 commit comments

Comments
 (0)