Skip to content

Commit 2d3ea59

Browse files
committed
fix(button-toggle): use native button and aria-pressed for button-toggle
1 parent cb00e1b commit 2d3ea59

File tree

4 files changed

+77
-60
lines changed

4 files changed

+77
-60
lines changed
Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
1-
<label [attr.for]="inputId" class="mat-button-toggle-label" #label>
2-
<input #input class="mat-button-toggle-input cdk-visually-hidden"
3-
[type]="_type"
4-
[id]="inputId"
5-
[checked]="checked"
6-
[disabled]="disabled || null"
7-
[attr.name]="name"
8-
[attr.aria-label]="ariaLabel"
9-
[attr.aria-labelledby]="ariaLabelledby"
10-
(change)="_onInputChange($event)"
11-
(click)="_onInputClick($event)">
12-
1+
<button #button class="mat-button-toggle-button"
2+
type="button"
3+
[id]="buttonId"
4+
[attr.aria-pressed]="checked"
5+
[disabled]="disabled || null"
6+
[name]="name"
7+
[attr.aria-label]="ariaLabel"
8+
[attr.aria-labelledby]="ariaLabelledby"
9+
(click)="_onButtonClick($event)">
1310
<div class="mat-button-toggle-label-content">
1411
<ng-content></ng-content>
1512
</div>
16-
</label>
17-
<div class="mat-button-toggle-focus-overlay"></div>
13+
</button>
1814

15+
<div class="mat-button-toggle-focus-overlay"></div>
1916
<div class="mat-button-toggle-ripple" matRipple
20-
[matRippleTrigger]="label"
17+
[matRippleTrigger]="button"
2118
[matRippleDisabled]="this.disableRipple || this.disabled">
2219
</div>

src/lib/button-toggle/button-toggle.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,13 @@ $mat-button-toggle-border-radius: 2px !default;
100100
// Pointer events can be safely disabled because the ripple trigger element is the label element.
101101
pointer-events: none;
102102
}
103+
104+
.mat-button-toggle-button {
105+
border: 0;
106+
background: none;
107+
color: inherit;
108+
padding: inherit;
109+
margin: inherit;
110+
font: inherit;
111+
outline: none;
112+
}

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

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe('MatButtonToggle with forms', () => {
9393
buttonToggleDebugElements = fixture.debugElement.queryAll(By.directive(MatButtonToggle));
9494
buttonToggleInstances = buttonToggleDebugElements.map(debugEl => debugEl.componentInstance);
9595
buttonToggleLabels = buttonToggleDebugElements.map(
96-
debugEl => debugEl.query(By.css('label')).nativeElement);
96+
debugEl => debugEl.query(By.css('button')).nativeElement);
9797

9898
fixture.detectChanges();
9999
}));
@@ -243,7 +243,7 @@ describe('MatButtonToggle without forms', () => {
243243
buttonToggleNativeElements = buttonToggleDebugElements
244244
.map(debugEl => debugEl.nativeElement);
245245

246-
buttonToggleLabelElements = fixture.debugElement.queryAll(By.css('label'))
246+
buttonToggleLabelElements = fixture.debugElement.queryAll(By.css('button'))
247247
.map(debugEl => debugEl.nativeElement);
248248

249249
buttonToggleInstances = buttonToggleDebugElements.map(debugEl => debugEl.componentInstance);
@@ -338,7 +338,7 @@ describe('MatButtonToggle without forms', () => {
338338
buttonToggleLabelElements[0].click();
339339
fixture.detectChanges();
340340
tick();
341-
expect(changeSpy).toHaveBeenCalled();
341+
expect(changeSpy).toHaveBeenCalledTimes(1);
342342

343343
buttonToggleLabelElements[0].click();
344344
fixture.detectChanges();
@@ -446,7 +446,7 @@ describe('MatButtonToggle without forms', () => {
446446
buttonToggleDebugElements = fixture.debugElement.queryAll(By.directive(MatButtonToggle));
447447
buttonToggleNativeElements = buttonToggleDebugElements
448448
.map(debugEl => debugEl.nativeElement);
449-
buttonToggleLabelElements = fixture.debugElement.queryAll(By.css('label'))
449+
buttonToggleLabelElements = fixture.debugElement.queryAll(By.css('button'))
450450
.map(debugEl => debugEl.nativeElement);
451451
buttonToggleInstances = buttonToggleDebugElements.map(debugEl => debugEl.componentInstance);
452452
}));
@@ -462,7 +462,7 @@ describe('MatButtonToggle without forms', () => {
462462
it('should check a button toggle when clicked', () => {
463463
expect(buttonToggleInstances.every(buttonToggle => !buttonToggle.checked)).toBe(true);
464464

465-
let nativeCheckboxLabel = buttonToggleDebugElements[0].query(By.css('label')).nativeElement;
465+
let nativeCheckboxLabel = buttonToggleDebugElements[0].query(By.css('button')).nativeElement;
466466

467467
nativeCheckboxLabel.click();
468468

@@ -486,9 +486,9 @@ describe('MatButtonToggle without forms', () => {
486486
});
487487

488488
it('should check a button toggle upon interaction with underlying native checkbox', () => {
489-
let nativeCheckboxInput = buttonToggleDebugElements[0].query(By.css('input')).nativeElement;
489+
let nativeCheckboxButton = buttonToggleDebugElements[0].query(By.css('button')).nativeElement;
490490

491-
nativeCheckboxInput.click();
491+
nativeCheckboxButton.click();
492492
fixture.detectChanges();
493493

494494
expect(groupInstance.value).toEqual(['eggs']);
@@ -561,15 +561,19 @@ describe('MatButtonToggle without forms', () => {
561561
let buttonToggleNativeElement: HTMLElement;
562562
let buttonToggleLabelElement: HTMLLabelElement;
563563
let buttonToggleInstance: MatButtonToggle;
564+
let buttonToggleButtonElement: HTMLButtonElement;
564565

565566
beforeEach(fakeAsync(() => {
566567
fixture = TestBed.createComponent(StandaloneButtonToggle);
567568
fixture.detectChanges();
568569

569570
buttonToggleDebugElement = fixture.debugElement.query(By.directive(MatButtonToggle));
570571
buttonToggleNativeElement = buttonToggleDebugElement.nativeElement;
571-
buttonToggleLabelElement = fixture.debugElement.query(By.css('label')).nativeElement;
572+
buttonToggleLabelElement = fixture.debugElement
573+
.query(By.css('.mat-button-toggle-label-content')).nativeElement;
572574
buttonToggleInstance = buttonToggleDebugElement.componentInstance;
575+
buttonToggleButtonElement =
576+
buttonToggleNativeElement.querySelector('button')! as HTMLButtonElement;
573577
}));
574578

575579
it('should toggle when clicked', fakeAsync(() => {
@@ -608,66 +612,77 @@ describe('MatButtonToggle without forms', () => {
608612
}));
609613

610614
it('should focus on underlying input element when focus() is called', () => {
611-
let nativeRadioInput = buttonToggleDebugElement.query(By.css('input')).nativeElement;
612-
expect(document.activeElement).not.toBe(nativeRadioInput);
615+
let nativeButton = buttonToggleDebugElement.query(By.css('button')).nativeElement;
616+
expect(document.activeElement).not.toBe(nativeButton);
613617

614618
buttonToggleInstance.focus();
615619
fixture.detectChanges();
616620

617-
expect(document.activeElement).toBe(nativeRadioInput);
621+
expect(document.activeElement).toBe(nativeButton);
618622
});
619623

620624
it('should not assign a name to the underlying input if one is not passed in', () => {
621-
expect(buttonToggleNativeElement.querySelector('input')!.getAttribute('name')).toBeFalsy();
625+
expect(buttonToggleButtonElement.getAttribute('name')).toBeFalsy();
622626
});
623627

628+
it('should have correct aria-pressed attribute', () => {
629+
expect(buttonToggleButtonElement.getAttribute('aria-pressed'))
630+
.toBe('false');
631+
632+
buttonToggleLabelElement.click();
633+
634+
fixture.detectChanges();
635+
636+
expect(buttonToggleButtonElement.getAttribute('aria-pressed'))
637+
.toBe('true');
638+
});
624639
});
625640

626641
describe('aria-label handling ', () => {
627642
it('should not set the aria-label attribute if none is provided', () => {
628643
let fixture = TestBed.createComponent(StandaloneButtonToggle);
629644
let checkboxDebugElement = fixture.debugElement.query(By.directive(MatButtonToggle));
630645
let checkboxNativeElement = checkboxDebugElement.nativeElement;
631-
let inputElement = checkboxNativeElement.querySelector('input') as HTMLInputElement;
646+
let buttonElement = checkboxNativeElement.querySelector('button') as HTMLButtonElement;
632647

633648
fixture.detectChanges();
634-
expect(inputElement.hasAttribute('aria-label')).toBe(false);
649+
expect(buttonElement.hasAttribute('aria-label')).toBe(false);
635650
});
636651

637652
it('should use the provided aria-label', () => {
638653
let fixture = TestBed.createComponent(ButtonToggleWithAriaLabel);
639654
let checkboxDebugElement = fixture.debugElement.query(By.directive(MatButtonToggle));
640655
let checkboxNativeElement = checkboxDebugElement.nativeElement;
641-
let inputElement = checkboxNativeElement.querySelector('input') as HTMLInputElement;
656+
let buttonElement = checkboxNativeElement.querySelector('button') as HTMLButtonElement;
642657

643658
fixture.detectChanges();
644-
expect(inputElement.getAttribute('aria-label')).toBe('Super effective');
659+
expect(buttonElement.getAttribute('aria-label')).toBe('Super effective');
645660
});
646661
});
647662

648663
describe('with provided aria-labelledby ', () => {
649664
let checkboxDebugElement: DebugElement;
650665
let checkboxNativeElement: HTMLElement;
651-
let inputElement: HTMLInputElement;
666+
let buttonElement: HTMLButtonElement;
652667

653668
it('should use the provided aria-labelledby', () => {
654669
let fixture = TestBed.createComponent(ButtonToggleWithAriaLabelledby);
655670
checkboxDebugElement = fixture.debugElement.query(By.directive(MatButtonToggle));
656671
checkboxNativeElement = checkboxDebugElement.nativeElement;
657-
inputElement = checkboxNativeElement.querySelector('input') as HTMLInputElement;
672+
buttonElement = checkboxNativeElement.querySelector('button') as HTMLButtonElement;
658673

659674
fixture.detectChanges();
660-
expect(inputElement.getAttribute('aria-labelledby')).toBe('some-id');
675+
expect(buttonElement.getAttribute('aria-labelledby')).toBe('some-id');
661676
});
662677

663678
it('should not assign aria-labelledby if none is provided', () => {
664679
let fixture = TestBed.createComponent(StandaloneButtonToggle);
665680
checkboxDebugElement = fixture.debugElement.query(By.directive(MatButtonToggle));
666681
checkboxNativeElement = checkboxDebugElement.nativeElement;
667-
inputElement = checkboxNativeElement.querySelector('input') as HTMLInputElement;
682+
buttonElement = checkboxNativeElement.querySelector('button') as HTMLButtonElement;
668683

669684
fixture.detectChanges();
670-
expect(inputElement.getAttribute('aria-labelledby')).toBe(null);
685+
expect(buttonElement.getAttribute('aria-labelledby')).toBe(null);
671686
});
672687
});
673688

src/lib/button-toggle/button-toggle.ts

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export class MatButtonToggleChange {
8282
],
8383
inputs: ['disabled'],
8484
host: {
85-
'[attr.role]': 'multiple ? "group" : "radiogroup"',
85+
'role': 'group"',
8686
'class': 'mat-button-toggle-group',
8787
'[class.mat-button-toggle-vertical]': 'vertical'
8888
},
@@ -353,14 +353,17 @@ export class MatButtonToggle extends _MatButtonToggleMixinBase implements OnInit
353353
/** Type of the button toggle. Either 'radio' or 'checkbox'. */
354354
_type: ToggleType;
355355

356-
@ViewChild('input') _inputElement: ElementRef<HTMLInputElement>;
356+
@ViewChild('button') _buttonElement: ElementRef<HTMLButtonElement>;
357357

358358
/** The parent button toggle group (exclusive selection). Optional. */
359359
buttonToggleGroup: MatButtonToggleGroup;
360360

361-
/** Unique ID for the underlying `input` element. */
361+
/** @deprecated Use `buttonId` instead @deletion-target 7.0.0 */
362362
get inputId(): string { return `${this.id}-input`; }
363363

364+
/** Unique ID for the underlying `button` element. */
365+
get buttonId(): string { return `${this.id}-button`; }
366+
364367
/** The unique ID for this button toggle. */
365368
@Input() id: string;
366369

@@ -432,33 +435,25 @@ export class MatButtonToggle extends _MatButtonToggleMixinBase implements OnInit
432435

433436
/** Focuses the button. */
434437
focus(): void {
435-
this._inputElement.nativeElement.focus();
438+
this._buttonElement.nativeElement.focus();
436439
}
437440

438-
/** Checks the button toggle due to an interaction with the underlying native input. */
439-
_onInputChange(event: Event) {
441+
/** Checks the button toggle due to an interaction with the underlying native button. */
442+
_onButtonClick(event: Event) {
440443
event.stopPropagation();
441444

442-
this._checked = this._isSingleSelector ? true : !this._checked;
443-
444-
if (this.buttonToggleGroup) {
445-
this.buttonToggleGroup._syncButtonToggle(this, this._checked, true);
446-
this.buttonToggleGroup._onTouched();
447-
}
445+
const newChecked = this._isSingleSelector ? true : !this._checked;
448446

449-
// Emit a change event when the native input does.
450-
this.change.emit(new MatButtonToggleChange(this, this.value));
451-
}
447+
if (newChecked !== this._checked) {
448+
this._checked = newChecked;
449+
if (this.buttonToggleGroup) {
450+
this.buttonToggleGroup._syncButtonToggle(this, this._checked, true);
451+
this.buttonToggleGroup._onTouched();
452+
}
452453

453-
_onInputClick(event: Event) {
454-
// We have to stop propagation for click events on the visual hidden input element.
455-
// By default, when a user clicks on a label element, a generated click event will be
456-
// dispatched on the associated input element. Since we are using a label element as our
457-
// root container, the click event on the `slide-toggle` will be executed twice.
458-
// The real click event will bubble up, and the generated click event also tries to bubble up.
459-
// This will lead to multiple click events.
460-
// Preventing bubbling for the second event will solve that issue.
461-
event.stopPropagation();
454+
// Emit a change event when the native button does.
455+
this.change.emit(new MatButtonToggleChange(this, this.value));
456+
}
462457
}
463458

464459
/**

0 commit comments

Comments
 (0)