Skip to content

Commit

Permalink
fix(material/radio): set tabindex based on selected state (#18081)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
crisbeto authored Mar 27, 2022
1 parent dbb6dc0 commit 81ff8c8
Show file tree
Hide file tree
Showing 6 changed files with 139 additions and 4 deletions.
1 change: 0 additions & 1 deletion src/material-experimental/mdc-radio/radio.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
[id]="inputId"
[checked]="checked"
[disabled]="disabled"
[tabIndex]="tabIndex"
[attr.name]="name"
[attr.value]="value"
[required]="required"
Expand Down
49 changes: 49 additions & 0 deletions src/material-experimental/mdc-radio/radio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('MDC-based MatRadio', () => {
TranscludingWrapper,
RadioButtonWithPredefinedTabindex,
RadioButtonWithPredefinedAriaAttributes,
RadiosInsidePreCheckedRadioGroup,
],
});

Expand Down Expand Up @@ -431,6 +432,43 @@ describe('MDC-based MatRadio', () => {
),
).toBe(true);
});

it('should set the input tabindex based on the selected radio button', () => {
const getTabIndexes = () => {
return radioInputElements.map(element => parseInt(element.getAttribute('tabindex') || ''));
};

expect(getTabIndexes()).toEqual([0, 0, 0]);

radioLabelElements[0].click();
fixture.detectChanges();

expect(getTabIndexes()).toEqual([0, -1, -1]);

radioLabelElements[1].click();
fixture.detectChanges();

expect(getTabIndexes()).toEqual([-1, 0, -1]);

radioLabelElements[2].click();
fixture.detectChanges();

expect(getTabIndexes()).toEqual([-1, -1, 0]);
});

it('should set the input tabindex correctly with a pre-checked radio button', () => {
const precheckedFixture = TestBed.createComponent(RadiosInsidePreCheckedRadioGroup);
precheckedFixture.detectChanges();

const radios: NodeListOf<HTMLElement> =
precheckedFixture.nativeElement.querySelectorAll('mat-radio-button input');

expect(
Array.from(radios).map(radio => {
return radio.getAttribute('tabindex');
}),
).toEqual(['-1', '-1', '0']);
});
});

describe('group with ngModel', () => {
Expand Down Expand Up @@ -960,6 +998,17 @@ class RadiosInsideRadioGroup {
color: string | null;
}

@Component({
template: `
<mat-radio-group name="test-name">
<mat-radio-button value="fire">Charmander</mat-radio-button>
<mat-radio-button value="water">Squirtle</mat-radio-button>
<mat-radio-button value="leaf" checked>Bulbasaur</mat-radio-button>
</mat-radio-group>
`,
})
class RadiosInsidePreCheckedRadioGroup {}

@Component({
template: `
<mat-radio-button name="season" value="spring">Spring</mat-radio-button>
Expand Down
1 change: 0 additions & 1 deletion src/material/radio/radio.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
[id]="inputId"
[checked]="checked"
[disabled]="disabled"
[tabIndex]="tabIndex"
[attr.name]="name"
[attr.value]="value"
[required]="required"
Expand Down
49 changes: 49 additions & 0 deletions src/material/radio/radio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('MatRadio', () => {
TranscludingWrapper,
RadioButtonWithPredefinedTabindex,
RadioButtonWithPredefinedAriaAttributes,
RadiosInsidePreCheckedRadioGroup,
],
});

Expand Down Expand Up @@ -423,6 +424,43 @@ describe('MatRadio', () => {
),
).toBe(true);
});

it('should set the input tabindex based on the selected radio button', () => {
const getTabIndexes = () => {
return radioInputElements.map(element => parseInt(element.getAttribute('tabindex') || ''));
};

expect(getTabIndexes()).toEqual([0, 0, 0]);

radioLabelElements[0].click();
fixture.detectChanges();

expect(getTabIndexes()).toEqual([0, -1, -1]);

radioLabelElements[1].click();
fixture.detectChanges();

expect(getTabIndexes()).toEqual([-1, 0, -1]);

radioLabelElements[2].click();
fixture.detectChanges();

expect(getTabIndexes()).toEqual([-1, -1, 0]);
});

it('should set the input tabindex correctly with a pre-checked radio button', () => {
const precheckedFixture = TestBed.createComponent(RadiosInsidePreCheckedRadioGroup);
precheckedFixture.detectChanges();

const radios: NodeListOf<HTMLElement> =
precheckedFixture.nativeElement.querySelectorAll('mat-radio-button input');

expect(
Array.from(radios).map(radio => {
return radio.getAttribute('tabindex');
}),
).toEqual(['-1', '-1', '0']);
});
});

describe('group with ngModel', () => {
Expand Down Expand Up @@ -943,6 +981,17 @@ class RadiosInsideRadioGroup {
color: string | null;
}

@Component({
template: `
<mat-radio-group name="test-name">
<mat-radio-button value="fire">Charmander</mat-radio-button>
<mat-radio-button value="water">Squirtle</mat-radio-button>
<mat-radio-button value="leaf" checked>Bulbasaur</mat-radio-button>
</mat-radio-group>
`,
})
class RadiosInsidePreCheckedRadioGroup {}

@Component({
template: `
<mat-radio-button name="season" value="spring">Spring</mat-radio-button>
Expand Down
38 changes: 37 additions & 1 deletion src/material/radio/radio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
Component,
ContentChildren,
Directive,
DoCheck,
ElementRef,
EventEmitter,
forwardRef,
Expand Down Expand Up @@ -361,7 +362,7 @@ const _MatRadioButtonMixinBase = mixinDisableRipple(mixinTabIndex(MatRadioButton
@Directive()
export abstract class _MatRadioButtonBase
extends _MatRadioButtonMixinBase
implements OnInit, AfterViewInit, OnDestroy, CanDisableRipple, HasTabIndex
implements OnInit, AfterViewInit, DoCheck, OnDestroy, CanDisableRipple, HasTabIndex
{
private _uniqueId: string = `mat-radio-${++nextUniqueId}`;

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

/** Previous value of the input's tabindex. */
private _previousTabIndex: number | undefined;

/** The native `<input type=radio>` element */
@ViewChild('input') _inputElement: ElementRef<HTMLInputElement>;

Expand Down Expand Up @@ -568,7 +572,12 @@ export abstract class _MatRadioButtonBase
}
}

ngDoCheck(): void {
this._updateTabIndex();
}

ngAfterViewInit() {
this._updateTabIndex();
this._focusMonitor.monitor(this._elementRef, true).subscribe(focusOrigin => {
if (!focusOrigin && this.radioGroup) {
this.radioGroup._touch();
Expand Down Expand Up @@ -629,6 +638,33 @@ export abstract class _MatRadioButtonBase
this._changeDetector.markForCheck();
}
}

/** Gets the tabindex for the underlying input element. */
private _updateTabIndex() {
const group = this.radioGroup;
let value: number;

// Implement a roving tabindex if the button is inside a group. For most cases this isn't
// necessary, because the browser handles the tab order for inputs inside a group automatically,
// but we need an explicitly higher tabindex for the selected button in order for things like
// the focus trap to pick it up correctly.
if (!group || !group.selected || this.disabled) {
value = this.tabIndex;
} else {
value = group.selected === this ? this.tabIndex : -1;
}

if (value !== this._previousTabIndex) {
// We have to set the tabindex directly on the DOM node, because it depends on
// the selected state which is prone to "changed after checked errors".
const input: HTMLInputElement | undefined = this._inputElement?.nativeElement;

if (input) {
input.setAttribute('tabindex', value + '');
this._previousTabIndex = value;
}
}
}
}

/**
Expand Down
5 changes: 4 additions & 1 deletion tools/public_api_guard/material/radio.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { CanDisableRipple } from '@angular/material/core';
import { ChangeDetectorRef } from '@angular/core';
import { _Constructor } from '@angular/material/core';
import { ControlValueAccessor } from '@angular/forms';
import { DoCheck } from '@angular/core';
import { ElementRef } from '@angular/core';
import { EventEmitter } from '@angular/core';
import { FocusMonitor } from '@angular/cdk/a11y';
Expand Down Expand Up @@ -48,7 +49,7 @@ export class MatRadioButton extends _MatRadioButtonBase {
}

// @public
export abstract class _MatRadioButtonBase extends _MatRadioButtonMixinBase implements OnInit, AfterViewInit, OnDestroy, CanDisableRipple, HasTabIndex {
export abstract class _MatRadioButtonBase extends _MatRadioButtonMixinBase implements OnInit, AfterViewInit, DoCheck, OnDestroy, CanDisableRipple, HasTabIndex {
constructor(radioGroup: _MatRadioGroupBase<_MatRadioButtonBase>, elementRef: ElementRef, _changeDetector: ChangeDetectorRef, _focusMonitor: FocusMonitor, _radioDispatcher: UniqueSelectionDispatcher, animationMode?: string, _providerOverride?: MatRadioDefaultOptions | undefined, tabIndex?: string);
ariaDescribedby: string;
ariaLabel: string;
Expand All @@ -75,6 +76,8 @@ export abstract class _MatRadioButtonBase extends _MatRadioButtonMixinBase imple
// (undocumented)
ngAfterViewInit(): void;
// (undocumented)
ngDoCheck(): void;
// (undocumented)
ngOnDestroy(): void;
// (undocumented)
ngOnInit(): void;
Expand Down

0 comments on commit 81ff8c8

Please sign in to comment.