Skip to content

Commit 81ff8c8

Browse files
authored
fix(material/radio): set tabindex based on selected state (#18081)
Currently all radio buttons inside a radio group have a `tabindex` of 0, unless they're disabled, and we leave it up to the browser to focus the proper button based on its selected state when the user is tabbing. This works for the most part, but it breaks down with something like our focus trap which determines which element to focus based on its `tabindex` since all buttons have the same `tabindex`. These changes fix the issue by setting a `tabindex` of 0 only on the selected radio button. Fixes #17876.
1 parent dbb6dc0 commit 81ff8c8

File tree

6 files changed

+139
-4
lines changed

6 files changed

+139
-4
lines changed

src/material-experimental/mdc-radio/radio.html

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
[id]="inputId"
88
[checked]="checked"
99
[disabled]="disabled"
10-
[tabIndex]="tabIndex"
1110
[attr.name]="name"
1211
[attr.value]="value"
1312
[required]="required"

src/material-experimental/mdc-radio/radio.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe('MDC-based MatRadio', () => {
2626
TranscludingWrapper,
2727
RadioButtonWithPredefinedTabindex,
2828
RadioButtonWithPredefinedAriaAttributes,
29+
RadiosInsidePreCheckedRadioGroup,
2930
],
3031
});
3132

@@ -431,6 +432,43 @@ describe('MDC-based MatRadio', () => {
431432
),
432433
).toBe(true);
433434
});
435+
436+
it('should set the input tabindex based on the selected radio button', () => {
437+
const getTabIndexes = () => {
438+
return radioInputElements.map(element => parseInt(element.getAttribute('tabindex') || ''));
439+
};
440+
441+
expect(getTabIndexes()).toEqual([0, 0, 0]);
442+
443+
radioLabelElements[0].click();
444+
fixture.detectChanges();
445+
446+
expect(getTabIndexes()).toEqual([0, -1, -1]);
447+
448+
radioLabelElements[1].click();
449+
fixture.detectChanges();
450+
451+
expect(getTabIndexes()).toEqual([-1, 0, -1]);
452+
453+
radioLabelElements[2].click();
454+
fixture.detectChanges();
455+
456+
expect(getTabIndexes()).toEqual([-1, -1, 0]);
457+
});
458+
459+
it('should set the input tabindex correctly with a pre-checked radio button', () => {
460+
const precheckedFixture = TestBed.createComponent(RadiosInsidePreCheckedRadioGroup);
461+
precheckedFixture.detectChanges();
462+
463+
const radios: NodeListOf<HTMLElement> =
464+
precheckedFixture.nativeElement.querySelectorAll('mat-radio-button input');
465+
466+
expect(
467+
Array.from(radios).map(radio => {
468+
return radio.getAttribute('tabindex');
469+
}),
470+
).toEqual(['-1', '-1', '0']);
471+
});
434472
});
435473

436474
describe('group with ngModel', () => {
@@ -960,6 +998,17 @@ class RadiosInsideRadioGroup {
960998
color: string | null;
961999
}
9621000

1001+
@Component({
1002+
template: `
1003+
<mat-radio-group name="test-name">
1004+
<mat-radio-button value="fire">Charmander</mat-radio-button>
1005+
<mat-radio-button value="water">Squirtle</mat-radio-button>
1006+
<mat-radio-button value="leaf" checked>Bulbasaur</mat-radio-button>
1007+
</mat-radio-group>
1008+
`,
1009+
})
1010+
class RadiosInsidePreCheckedRadioGroup {}
1011+
9631012
@Component({
9641013
template: `
9651014
<mat-radio-button name="season" value="spring">Spring</mat-radio-button>

src/material/radio/radio.html

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
[id]="inputId"
1010
[checked]="checked"
1111
[disabled]="disabled"
12-
[tabIndex]="tabIndex"
1312
[attr.name]="name"
1413
[attr.value]="value"
1514
[required]="required"

src/material/radio/radio.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ describe('MatRadio', () => {
2222
TranscludingWrapper,
2323
RadioButtonWithPredefinedTabindex,
2424
RadioButtonWithPredefinedAriaAttributes,
25+
RadiosInsidePreCheckedRadioGroup,
2526
],
2627
});
2728

@@ -423,6 +424,43 @@ describe('MatRadio', () => {
423424
),
424425
).toBe(true);
425426
});
427+
428+
it('should set the input tabindex based on the selected radio button', () => {
429+
const getTabIndexes = () => {
430+
return radioInputElements.map(element => parseInt(element.getAttribute('tabindex') || ''));
431+
};
432+
433+
expect(getTabIndexes()).toEqual([0, 0, 0]);
434+
435+
radioLabelElements[0].click();
436+
fixture.detectChanges();
437+
438+
expect(getTabIndexes()).toEqual([0, -1, -1]);
439+
440+
radioLabelElements[1].click();
441+
fixture.detectChanges();
442+
443+
expect(getTabIndexes()).toEqual([-1, 0, -1]);
444+
445+
radioLabelElements[2].click();
446+
fixture.detectChanges();
447+
448+
expect(getTabIndexes()).toEqual([-1, -1, 0]);
449+
});
450+
451+
it('should set the input tabindex correctly with a pre-checked radio button', () => {
452+
const precheckedFixture = TestBed.createComponent(RadiosInsidePreCheckedRadioGroup);
453+
precheckedFixture.detectChanges();
454+
455+
const radios: NodeListOf<HTMLElement> =
456+
precheckedFixture.nativeElement.querySelectorAll('mat-radio-button input');
457+
458+
expect(
459+
Array.from(radios).map(radio => {
460+
return radio.getAttribute('tabindex');
461+
}),
462+
).toEqual(['-1', '-1', '0']);
463+
});
426464
});
427465

428466
describe('group with ngModel', () => {
@@ -943,6 +981,17 @@ class RadiosInsideRadioGroup {
943981
color: string | null;
944982
}
945983

984+
@Component({
985+
template: `
986+
<mat-radio-group name="test-name">
987+
<mat-radio-button value="fire">Charmander</mat-radio-button>
988+
<mat-radio-button value="water">Squirtle</mat-radio-button>
989+
<mat-radio-button value="leaf" checked>Bulbasaur</mat-radio-button>
990+
</mat-radio-group>
991+
`,
992+
})
993+
class RadiosInsidePreCheckedRadioGroup {}
994+
946995
@Component({
947996
template: `
948997
<mat-radio-button name="season" value="spring">Spring</mat-radio-button>

src/material/radio/radio.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
Component,
1919
ContentChildren,
2020
Directive,
21+
DoCheck,
2122
ElementRef,
2223
EventEmitter,
2324
forwardRef,
@@ -361,7 +362,7 @@ const _MatRadioButtonMixinBase = mixinDisableRipple(mixinTabIndex(MatRadioButton
361362
@Directive()
362363
export abstract class _MatRadioButtonBase
363364
extends _MatRadioButtonMixinBase
364-
implements OnInit, AfterViewInit, OnDestroy, CanDisableRipple, HasTabIndex
365+
implements OnInit, AfterViewInit, DoCheck, OnDestroy, CanDisableRipple, HasTabIndex
365366
{
366367
private _uniqueId: string = `mat-radio-${++nextUniqueId}`;
367368

@@ -500,6 +501,9 @@ export abstract class _MatRadioButtonBase
500501
/** Unregister function for _radioDispatcher */
501502
private _removeUniqueSelectionListener: () => void = () => {};
502503

504+
/** Previous value of the input's tabindex. */
505+
private _previousTabIndex: number | undefined;
506+
503507
/** The native `<input type=radio>` element */
504508
@ViewChild('input') _inputElement: ElementRef<HTMLInputElement>;
505509

@@ -568,7 +572,12 @@ export abstract class _MatRadioButtonBase
568572
}
569573
}
570574

575+
ngDoCheck(): void {
576+
this._updateTabIndex();
577+
}
578+
571579
ngAfterViewInit() {
580+
this._updateTabIndex();
572581
this._focusMonitor.monitor(this._elementRef, true).subscribe(focusOrigin => {
573582
if (!focusOrigin && this.radioGroup) {
574583
this.radioGroup._touch();
@@ -629,6 +638,33 @@ export abstract class _MatRadioButtonBase
629638
this._changeDetector.markForCheck();
630639
}
631640
}
641+
642+
/** Gets the tabindex for the underlying input element. */
643+
private _updateTabIndex() {
644+
const group = this.radioGroup;
645+
let value: number;
646+
647+
// Implement a roving tabindex if the button is inside a group. For most cases this isn't
648+
// necessary, because the browser handles the tab order for inputs inside a group automatically,
649+
// but we need an explicitly higher tabindex for the selected button in order for things like
650+
// the focus trap to pick it up correctly.
651+
if (!group || !group.selected || this.disabled) {
652+
value = this.tabIndex;
653+
} else {
654+
value = group.selected === this ? this.tabIndex : -1;
655+
}
656+
657+
if (value !== this._previousTabIndex) {
658+
// We have to set the tabindex directly on the DOM node, because it depends on
659+
// the selected state which is prone to "changed after checked errors".
660+
const input: HTMLInputElement | undefined = this._inputElement?.nativeElement;
661+
662+
if (input) {
663+
input.setAttribute('tabindex', value + '');
664+
this._previousTabIndex = value;
665+
}
666+
}
667+
}
632668
}
633669

634670
/**

tools/public_api_guard/material/radio.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { CanDisableRipple } from '@angular/material/core';
1212
import { ChangeDetectorRef } from '@angular/core';
1313
import { _Constructor } from '@angular/material/core';
1414
import { ControlValueAccessor } from '@angular/forms';
15+
import { DoCheck } from '@angular/core';
1516
import { ElementRef } from '@angular/core';
1617
import { EventEmitter } from '@angular/core';
1718
import { FocusMonitor } from '@angular/cdk/a11y';
@@ -48,7 +49,7 @@ export class MatRadioButton extends _MatRadioButtonBase {
4849
}
4950

5051
// @public
51-
export abstract class _MatRadioButtonBase extends _MatRadioButtonMixinBase implements OnInit, AfterViewInit, OnDestroy, CanDisableRipple, HasTabIndex {
52+
export abstract class _MatRadioButtonBase extends _MatRadioButtonMixinBase implements OnInit, AfterViewInit, DoCheck, OnDestroy, CanDisableRipple, HasTabIndex {
5253
constructor(radioGroup: _MatRadioGroupBase<_MatRadioButtonBase>, elementRef: ElementRef, _changeDetector: ChangeDetectorRef, _focusMonitor: FocusMonitor, _radioDispatcher: UniqueSelectionDispatcher, animationMode?: string, _providerOverride?: MatRadioDefaultOptions | undefined, tabIndex?: string);
5354
ariaDescribedby: string;
5455
ariaLabel: string;
@@ -75,6 +76,8 @@ export abstract class _MatRadioButtonBase extends _MatRadioButtonMixinBase imple
7576
// (undocumented)
7677
ngAfterViewInit(): void;
7778
// (undocumented)
79+
ngDoCheck(): void;
80+
// (undocumented)
7881
ngOnDestroy(): void;
7982
// (undocumented)
8083
ngOnInit(): void;

0 commit comments

Comments
 (0)