Skip to content

Commit 0af3b61

Browse files
authoredJul 27, 2024··
feat(material/radio): add the ability to interact with disabled radio buttons (#29490)
Adds the `disabledInteractive` input that allows users to opt into being able to interact with a disabled radio button (e.g. focus or show a tooltip). Also fixes that we weren't setting `pointer-events: none` on the entire container when it's disabled.
1 parent 1aa8512 commit 0af3b61

File tree

10 files changed

+216
-61
lines changed

10 files changed

+216
-61
lines changed
 

‎src/dev-app/radio/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ ng_module(
1313
"//src/material/button",
1414
"//src/material/checkbox",
1515
"//src/material/radio",
16+
"//src/material/tooltip",
1617
"@npm//@angular/forms",
1718
],
1819
)

‎src/dev-app/radio/radio-demo.html

+18
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,21 @@ <h2>Dynamic Example with two-way data-binding</h2>
6363
</mat-radio-group>
6464
<p>Your favorite season is: {{favoriteSeason}}</p>
6565
</section>
66+
67+
<h1>Disabled interactive group</h1>
68+
<section class="demo-section">
69+
<mat-radio-group
70+
disabled
71+
[disabledInteractive]="disabledInteractive"
72+
[(ngModel)]="favoriteSeason">
73+
@for (season of seasonOptions; track season) {
74+
<mat-radio-button [value]="season" matTooltip="This is a tooltip" matTooltipPosition="above">
75+
{{season}}
76+
</mat-radio-button>
77+
}
78+
</mat-radio-group>
79+
80+
<div>
81+
<mat-checkbox [(ngModel)]="disabledInteractive">Disabled interactive</mat-checkbox>
82+
</div>
83+
</section>

‎src/dev-app/radio/radio-demo.ts

+15-6
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,34 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {CommonModule} from '@angular/common';
109
import {ChangeDetectionStrategy, Component} from '@angular/core';
10+
import {CommonModule} from '@angular/common';
1111
import {FormsModule} from '@angular/forms';
1212
import {MatButtonModule} from '@angular/material/button';
1313
import {MatCheckboxModule} from '@angular/material/checkbox';
1414
import {MatRadioModule} from '@angular/material/radio';
15+
import {MatTooltip} from '@angular/material/tooltip';
1516

1617
@Component({
1718
selector: 'radio-demo',
1819
templateUrl: 'radio-demo.html',
1920
styleUrl: 'radio-demo.css',
2021
standalone: true,
21-
imports: [CommonModule, MatRadioModule, FormsModule, MatButtonModule, MatCheckboxModule],
22+
imports: [
23+
CommonModule,
24+
MatRadioModule,
25+
FormsModule,
26+
MatButtonModule,
27+
MatCheckboxModule,
28+
MatTooltip,
29+
],
2230
changeDetection: ChangeDetectionStrategy.OnPush,
2331
})
2432
export class RadioDemo {
25-
isAlignEnd: boolean = false;
26-
isDisabled: boolean = false;
27-
isRequired: boolean = false;
28-
favoriteSeason: string = 'Autumn';
33+
isAlignEnd = false;
34+
isDisabled = false;
35+
isRequired = false;
36+
disabledInteractive = true;
37+
favoriteSeason = 'Autumn';
2938
seasonOptions = ['Winter', 'Spring', 'Summer', 'Autumn'];
3039
}

‎src/material/checkbox/checkbox.spec.ts

+13
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,19 @@ describe('MDC-based MatCheckbox', () => {
466466
expect(inputElement.disabled).toBe(false);
467467
}));
468468

469+
it('should not change the checked state if disabled and interactive', fakeAsync(() => {
470+
testComponent.isDisabled = testComponent.disabledInteractive = true;
471+
fixture.changeDetectorRef.markForCheck();
472+
fixture.detectChanges();
473+
474+
expect(inputElement.checked).toBe(false);
475+
476+
inputElement.click();
477+
fixture.detectChanges();
478+
479+
expect(inputElement.checked).toBe(false);
480+
}));
481+
469482
describe('ripple elements', () => {
470483
it('should show ripples on label mousedown', fakeAsync(() => {
471484
const rippleSelector = '.mat-ripple-element:not(.mat-checkbox-persistent-ripple)';

‎src/material/radio/_radio-common.scss

+21-3
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,27 @@ $_icon-size: 20px;
219219
}
220220
}
221221

222-
.mdc-radio--disabled {
223-
cursor: default;
224-
pointer-events: none;
222+
@if ($is-interactive) {
223+
&.mat-mdc-radio-disabled-interactive .mdc-radio--disabled {
224+
pointer-events: auto;
225+
226+
@include token-utils.use-tokens($tokens...) {
227+
.mdc-radio__native-control:not(:checked) + .mdc-radio__background .mdc-radio__outer-circle {
228+
@include token-utils.create-token-slot(border-color, disabled-unselected-icon-color);
229+
@include token-utils.create-token-slot(opacity, disabled-unselected-icon-opacity);
230+
}
231+
232+
&:hover .mdc-radio__native-control:checked + .mdc-radio__background,
233+
.mdc-radio__native-control:checked:focus + .mdc-radio__background,
234+
.mdc-radio__native-control + .mdc-radio__background {
235+
.mdc-radio__inner-circle,
236+
.mdc-radio__outer-circle {
237+
@include token-utils.create-token-slot(border-color, disabled-selected-icon-color);
238+
@include token-utils.create-token-slot(opacity, disabled-selected-icon-opacity);
239+
}
240+
}
241+
}
242+
}
225243
}
226244
}
227245

‎src/material/radio/radio.html

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
<input #input class="mdc-radio__native-control" type="radio"
66
[id]="inputId"
77
[checked]="checked"
8-
[disabled]="disabled"
8+
[disabled]="disabled && !disabledInteractive"
99
[attr.name]="name"
1010
[attr.value]="value"
1111
[required]="required"
1212
[attr.aria-label]="ariaLabel"
1313
[attr.aria-labelledby]="ariaLabelledby"
1414
[attr.aria-describedby]="ariaDescribedby"
15+
[attr.aria-disabled]="disabled && disabledInteractive ? 'true' : null"
1516
(change)="_onInputInteraction($event)">
1617
<div class="mdc-radio__background">
1718
<div class="mdc-radio__outer-circle"></div>

‎src/material/radio/radio.scss

+18-21
Original file line numberDiff line numberDiff line change
@@ -12,43 +12,31 @@
1212
.mdc-radio__background::before {
1313
@include token-utils.create-token-slot(background-color, ripple-color);
1414
}
15-
}
1615

17-
&.mat-mdc-radio-checked {
18-
@include token-utils.use-tokens(
19-
tokens-mat-radio.$prefix,
20-
tokens-mat-radio.get-token-slots()
21-
) {
16+
&.mat-mdc-radio-checked {
17+
.mat-ripple-element,
2218
.mdc-radio__background::before {
2319
@include token-utils.create-token-slot(background-color, checked-ripple-color);
2420
}
21+
}
2522

26-
.mat-ripple-element {
27-
@include token-utils.create-token-slot(background-color, checked-ripple-color);
23+
&.mat-mdc-radio-disabled-interactive .mdc-radio--disabled {
24+
.mat-ripple-element,
25+
.mdc-radio__background::before {
26+
@include token-utils.create-token-slot(background-color, ripple-color);
2827
}
2928
}
30-
}
3129

32-
.mat-internal-form-field {
33-
@include token-utils.use-tokens(
34-
tokens-mat-radio.$prefix,
35-
tokens-mat-radio.get-token-slots()
36-
) {
30+
.mat-internal-form-field {
3731
@include token-utils.create-token-slot(color, label-text-color);
3832
@include token-utils.create-token-slot(font-family, label-text-font);
3933
@include token-utils.create-token-slot(line-height, label-text-line-height);
4034
@include token-utils.create-token-slot(font-size, label-text-size);
4135
@include token-utils.create-token-slot(letter-spacing, label-text-tracking);
4236
@include token-utils.create-token-slot(font-weight, label-text-weight);
4337
}
44-
}
4538

46-
// MDC should set the disabled color on the label, but doesn't, so we do it here instead.
47-
.mdc-radio--disabled + label {
48-
@include token-utils.use-tokens(
49-
tokens-mat-radio.$prefix,
50-
tokens-mat-radio.get-token-slots()
51-
) {
39+
.mdc-radio--disabled + label {
5240
@include token-utils.create-token-slot(color, disabled-label-color);
5341
}
5442
}
@@ -84,6 +72,15 @@
8472
}
8573
}
8674

75+
.mat-mdc-radio-disabled {
76+
cursor: default;
77+
pointer-events: none;
78+
79+
&.mat-mdc-radio-disabled-interactive {
80+
pointer-events: auto;
81+
}
82+
}
83+
8784
// Element used to provide a larger tap target for users on touch devices.
8885
.mat-mdc-radio-touch-target {
8986
position: absolute;

‎src/material/radio/radio.spec.ts

+59-11
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,29 @@ describe('MDC-based MatRadio', () => {
132132
}
133133
});
134134

135+
it('should make all disabled buttons interactive if the group is marked as disabledInteractive', () => {
136+
testComponent.isGroupDisabledInteractive = true;
137+
fixture.changeDetectorRef.markForCheck();
138+
fixture.detectChanges();
139+
expect(radioInstances.every(radio => radio.disabledInteractive)).toBe(true);
140+
});
141+
142+
it('should prevent the click action when disabledInteractive and disabled', () => {
143+
testComponent.isGroupDisabled = true;
144+
testComponent.isGroupDisabledInteractive = true;
145+
fixture.changeDetectorRef.markForCheck();
146+
fixture.detectChanges();
147+
148+
// We can't monitor the `defaultPrevented` state on the
149+
// native `click` so we dispatch an extra one.
150+
const fakeEvent = dispatchFakeEvent(radioInputElements[0], 'click');
151+
radioInputElements[0].click();
152+
fixture.detectChanges();
153+
154+
expect(fakeEvent.defaultPrevented).toBe(true);
155+
expect(radioInstances[0].checked).toBe(false);
156+
});
157+
135158
it('should set required to each radio button when the group is required', () => {
136159
testComponent.isGroupRequired = true;
137160
fixture.changeDetectorRef.markForCheck();
@@ -675,6 +698,7 @@ describe('MDC-based MatRadio', () => {
675698
let fixture: ComponentFixture<DisableableRadioButton>;
676699
let radioInstance: MatRadioButton;
677700
let radioNativeElement: HTMLInputElement;
701+
let radioHost: HTMLElement;
678702
let testComponent: DisableableRadioButton;
679703

680704
beforeEach(() => {
@@ -683,8 +707,9 @@ describe('MDC-based MatRadio', () => {
683707

684708
testComponent = fixture.debugElement.componentInstance;
685709
const radioDebugElement = fixture.debugElement.query(By.directive(MatRadioButton))!;
710+
radioHost = radioDebugElement.nativeElement;
686711
radioInstance = radioDebugElement.injector.get<MatRadioButton>(MatRadioButton);
687-
radioNativeElement = radioDebugElement.nativeElement.querySelector('input');
712+
radioNativeElement = radioHost.querySelector('input')!;
688713
});
689714

690715
it('should toggle the disabled state', () => {
@@ -703,6 +728,24 @@ describe('MDC-based MatRadio', () => {
703728
expect(radioInstance.disabled).toBeFalsy();
704729
expect(radioNativeElement.disabled).toBeFalsy();
705730
});
731+
732+
it('should keep the button interactive if disabledInteractive is enabled', () => {
733+
testComponent.disabled = true;
734+
fixture.changeDetectorRef.markForCheck();
735+
fixture.detectChanges();
736+
737+
expect(radioNativeElement.disabled).toBe(true);
738+
expect(radioNativeElement.hasAttribute('aria-disabled')).toBe(false);
739+
expect(radioHost.classList).not.toContain('mat-mdc-radio-disabled-interactive');
740+
741+
testComponent.disabledInteractive = true;
742+
fixture.changeDetectorRef.markForCheck();
743+
fixture.detectChanges();
744+
745+
expect(radioNativeElement.disabled).toBe(false);
746+
expect(radioNativeElement.getAttribute('aria-disabled')).toBe('true');
747+
expect(radioHost.classList).toContain('mat-mdc-radio-disabled-interactive');
748+
});
706749
});
707750

708751
describe('as standalone', () => {
@@ -1031,11 +1074,13 @@ describe('MatRadioDefaultOverrides', () => {
10311074

10321075
@Component({
10331076
template: `
1034-
<mat-radio-group [disabled]="isGroupDisabled"
1035-
[labelPosition]="labelPos"
1036-
[required]="isGroupRequired"
1037-
[value]="groupValue"
1038-
name="test-name">
1077+
<mat-radio-group
1078+
[disabled]="isGroupDisabled"
1079+
[labelPosition]="labelPos"
1080+
[required]="isGroupRequired"
1081+
[value]="groupValue"
1082+
[disabledInteractive]="isGroupDisabledInteractive"
1083+
name="test-name">
10391084
@if (isFirstShown) {
10401085
<mat-radio-button value="fire" [disableRipple]="disableRipple" [disabled]="isFirstDisabled"
10411086
[color]="color">
@@ -1058,6 +1103,7 @@ class RadiosInsideRadioGroup {
10581103
isFirstDisabled = false;
10591104
isGroupDisabled = false;
10601105
isGroupRequired = false;
1106+
isGroupDisabledInteractive = false;
10611107
groupValue: string | null = null;
10621108
disableRipple = false;
10631109
color: string | null;
@@ -1130,16 +1176,18 @@ class RadioGroupWithNgModel {
11301176
}
11311177

11321178
@Component({
1133-
template: `<mat-radio-button>One</mat-radio-button>`,
1179+
template: `
1180+
<mat-radio-button
1181+
[disabled]="disabled"
1182+
[disabledInteractive]="disabledInteractive">One</mat-radio-button>`,
11341183
standalone: true,
11351184
imports: [MatRadioModule, FormsModule, ReactiveFormsModule, CommonModule],
11361185
})
11371186
class DisableableRadioButton {
1138-
@ViewChild(MatRadioButton) matRadioButton: MatRadioButton;
1187+
disabled = false;
1188+
disabledInteractive = false;
11391189

1140-
set disabled(value: boolean) {
1141-
this.matRadioButton.disabled = value;
1142-
}
1190+
@ViewChild(MatRadioButton) matRadioButton: MatRadioButton;
11431191
}
11441192

11451193
@Component({

0 commit comments

Comments
 (0)
Please sign in to comment.