Skip to content

Commit a62e659

Browse files
authored
fix(material/radio): avoid making the touch target a focus stop (#33558)
Some screen readers detect which elements have `click` listeners and make them a focus stop, even if they're `aria-hidden`. These changes rework the radio button so it doesn't have to rely on a `click` listener on the touch target by wrapping the `input` in the `label` instead.
1 parent 1305fe0 commit a62e659

7 files changed

Lines changed: 30 additions & 45 deletions

File tree

‎goldens/material/core/index.api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ export const MATERIAL_ANIMATIONS: InjectionToken<AnimationsConfig>;
162162
export class _MatInternalFormField {
163163
labelPosition: 'before' | 'after';
164164
// (undocumented)
165-
static ɵcmp: i0.ɵɵComponentDeclaration<_MatInternalFormField, "div[mat-internal-form-field]", never, { "labelPosition": { "alias": "labelPosition"; "required": true; }; }, {}, never, ["*"], true, never>;
165+
static ɵcmp: i0.ɵɵComponentDeclaration<_MatInternalFormField, "[mat-internal-form-field]", never, { "labelPosition": { "alias": "labelPosition"; "required": true; }; }, {}, never, ["*"], true, never>;
166166
// (undocumented)
167167
static ɵfac: i0.ɵɵFactoryDeclaration<_MatInternalFormField, never>;
168168
}

‎goldens/material/radio/index.api.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ export class MatRadioButton implements OnInit, AfterViewInit, DoCheck, OnDestroy
7777
ngOnInit(): void;
7878
_noopAnimations: boolean;
7979
_onInputInteraction(event: Event): void;
80-
_onTouchTargetClick(event: Event): void;
8180
radioGroup: MatRadioGroup;
8281
get required(): boolean;
8382
set required(value: boolean);

‎src/material/core/internal-form-field/internal-form-field.scss‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@
66
align-items: center;
77
vertical-align: middle;
88

9-
& > label {
9+
& > label,
10+
& > .mat-internal-form-field-label {
1011
margin-left: 0;
1112
margin-right: auto;
1213
padding-left: 4px;
1314
padding-right: 0;
1415
order: 0;
1516
}
1617

17-
[dir='rtl'] & > label {
18+
[dir='rtl'] & > label,
19+
[dir='rtl'] & > .mat-internal-form-field-label {
1820
margin-left: auto;
1921
margin-right: 0;
2022
padding-left: 0;
@@ -23,15 +25,17 @@
2325
}
2426

2527
.mdc-form-field--align-end {
26-
& > label {
28+
& > label,
29+
& > .mat-internal-form-field-label {
2730
margin-left: auto;
2831
margin-right: 0;
2932
padding-left: 0;
3033
padding-right: 4px;
3134
order: -1;
3235
}
3336

34-
[dir='rtl'] .mdc-form-field--align-end & label {
37+
[dir='rtl'] .mdc-form-field--align-end & label,
38+
[dir='rtl'] .mdc-form-field--align-end & .mat-internal-form-field-label {
3539
margin-left: 0;
3640
margin-right: auto;
3741
padding-left: 4px;

‎src/material/core/internal-form-field/internal-form-field.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ import {Component, Input, ViewEncapsulation} from '@angular/core';
1414
* @docs-private
1515
*/
1616
@Component({
17-
// Use a `div` selector to match the old markup closer.
18-
selector: 'div[mat-internal-form-field]',
17+
selector: '[mat-internal-form-field]',
1918
template: '<ng-content></ng-content>',
2019
styleUrl: 'internal-form-field.css',
2120
encapsulation: ViewEncapsulation.None,

‎src/material/radio/radio.html‎

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
<div mat-internal-form-field [labelPosition]="labelPosition" #formField>
2-
<div class="mdc-radio" [class.mdc-radio--disabled]="disabled">
1+
<label mat-internal-form-field [labelPosition]="labelPosition" [for]="inputId" #formField>
2+
<span class="mdc-radio" [class.mdc-radio--disabled]="disabled">
33
<!-- Render this element first so the input is on top. -->
4-
<div
5-
class="mat-mdc-radio-touch-target"
6-
(click)="_onTouchTargetClick($event)"
7-
aria-hidden="true"></div>
4+
<span class="mat-mdc-radio-touch-target"></span>
85
<!--
96
Note that we set `aria-invalid="false"` on the input, because otherwise some screen readers
107
will read out "required, invalid data" for each radio button that hasn't been checked.
@@ -25,19 +22,19 @@
2522
[attr.aria-describedby]="ariaDescribedby"
2623
[attr.aria-disabled]="disabled && disabledInteractive ? 'true' : null"
2724
(change)="_onInputInteraction($event)">
28-
<div class="mdc-radio__background" aria-hidden="true">
29-
<div class="mdc-radio__outer-circle"></div>
30-
<div class="mdc-radio__inner-circle"></div>
31-
</div>
32-
<div mat-ripple class="mat-radio-ripple mat-focus-indicator"
25+
<span class="mdc-radio__background" aria-hidden="true">
26+
<span class="mdc-radio__outer-circle"></span>
27+
<span class="mdc-radio__inner-circle"></span>
28+
</span>
29+
<span mat-ripple class="mat-radio-ripple mat-focus-indicator"
3330
[matRippleTrigger]="_rippleTrigger.nativeElement"
3431
[matRippleDisabled]="_isRippleDisabled()"
3532
[matRippleCentered]="true"
3633
aria-hidden="true">
37-
<div class="mat-ripple-element mat-radio-persistent-ripple"></div>
38-
</div>
39-
</div>
40-
<label class="mdc-label" [for]="inputId">
34+
<span class="mat-ripple-element mat-radio-persistent-ripple"></span>
35+
</span>
36+
</span>
37+
<span class="mat-internal-form-field-label mdc-label">
4138
<ng-content></ng-content>
42-
</label>
43-
</div>
39+
</span>
40+
</label>

‎src/material/radio/radio.scss‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,10 @@ $_is-external-build: true;
1212
@include radio-common.radio-structure(true);
1313
@include radio-common.radio-noop-animations();
1414

15-
// Clicking the label toggles the radio, but MDC does not include any styles that inform the
16-
// user of this. Therefore we add the pointer cursor on top of MDC's styles.
17-
label {
18-
cursor: pointer;
19-
20-
// TODO(crisbeto): disable internally due to a large amount of breakages.
21-
// Prevent the label from taking up space when it's empty.
22-
@if ($_is-external-build) {
15+
// TODO(crisbeto): disable internally due to a large amount of breakages.
16+
// Prevent the label from taking up space when it's empty.
17+
@if ($_is-external-build) {
18+
.mat-internal-form-field-label {
2319
&:empty {
2420
display: none;
2521
}
@@ -51,9 +47,10 @@ $_is-external-build: true;
5147
font-size: token-utils.slot(radio-label-text-size, $fallbacks);
5248
letter-spacing: token-utils.slot(radio-label-text-tracking, $fallbacks);
5349
font-weight: token-utils.slot(radio-label-text-weight, $fallbacks);
50+
cursor: pointer;
5451
}
5552

56-
.mdc-radio--disabled + label {
53+
.mdc-radio--disabled + .mat-internal-form-field-label {
5754
color: token-utils.slot(radio-disabled-label-color, $fallbacks);
5855
}
5956

‎src/material/radio/radio.ts‎

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -709,17 +709,6 @@ export class MatRadioButton implements OnInit, AfterViewInit, DoCheck, OnDestroy
709709
}
710710
}
711711

712-
/** Triggered when the user clicks on the touch target. */
713-
_onTouchTargetClick(event: Event) {
714-
this._onInputInteraction(event);
715-
716-
if (!this.disabled || this.disabledInteractive) {
717-
// Normally the input should be focused already, but if the click
718-
// comes from the touch target, then we might have to focus it ourselves.
719-
this._inputElement?.nativeElement.focus();
720-
}
721-
}
722-
723712
/** Sets the disabled state and marks for check if a change occurred. */
724713
protected _setDisabled(value: boolean) {
725714
if (this._disabled !== value) {

0 commit comments

Comments
 (0)