Skip to content

Commit

Permalink
feat(material/radio): add the ability to interact with disabled radio…
Browse files Browse the repository at this point in the history
… 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.
  • Loading branch information
crisbeto committed Jul 27, 2024
1 parent 1aa8512 commit 0af3b61
Show file tree
Hide file tree
Showing 10 changed files with 216 additions and 61 deletions.
1 change: 1 addition & 0 deletions src/dev-app/radio/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ ng_module(
"//src/material/button",
"//src/material/checkbox",
"//src/material/radio",
"//src/material/tooltip",
"@npm//@angular/forms",
],
)
Expand Down
18 changes: 18 additions & 0 deletions src/dev-app/radio/radio-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,21 @@ <h2>Dynamic Example with two-way data-binding</h2>
</mat-radio-group>
<p>Your favorite season is: {{favoriteSeason}}</p>
</section>

<h1>Disabled interactive group</h1>
<section class="demo-section">
<mat-radio-group
disabled
[disabledInteractive]="disabledInteractive"
[(ngModel)]="favoriteSeason">
@for (season of seasonOptions; track season) {
<mat-radio-button [value]="season" matTooltip="This is a tooltip" matTooltipPosition="above">
{{season}}
</mat-radio-button>
}
</mat-radio-group>

<div>
<mat-checkbox [(ngModel)]="disabledInteractive">Disabled interactive</mat-checkbox>
</div>
</section>
21 changes: 15 additions & 6 deletions src/dev-app/radio/radio-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,34 @@
* found in the LICENSE file at https://angular.io/license
*/

import {CommonModule} from '@angular/common';
import {ChangeDetectionStrategy, Component} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
import {MatCheckboxModule} from '@angular/material/checkbox';
import {MatRadioModule} from '@angular/material/radio';
import {MatTooltip} from '@angular/material/tooltip';

@Component({
selector: 'radio-demo',
templateUrl: 'radio-demo.html',
styleUrl: 'radio-demo.css',
standalone: true,
imports: [CommonModule, MatRadioModule, FormsModule, MatButtonModule, MatCheckboxModule],
imports: [
CommonModule,
MatRadioModule,
FormsModule,
MatButtonModule,
MatCheckboxModule,
MatTooltip,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RadioDemo {
isAlignEnd: boolean = false;
isDisabled: boolean = false;
isRequired: boolean = false;
favoriteSeason: string = 'Autumn';
isAlignEnd = false;
isDisabled = false;
isRequired = false;
disabledInteractive = true;
favoriteSeason = 'Autumn';
seasonOptions = ['Winter', 'Spring', 'Summer', 'Autumn'];
}
13 changes: 13 additions & 0 deletions src/material/checkbox/checkbox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,19 @@ describe('MDC-based MatCheckbox', () => {
expect(inputElement.disabled).toBe(false);
}));

it('should not change the checked state if disabled and interactive', fakeAsync(() => {
testComponent.isDisabled = testComponent.disabledInteractive = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

expect(inputElement.checked).toBe(false);

inputElement.click();
fixture.detectChanges();

expect(inputElement.checked).toBe(false);
}));

describe('ripple elements', () => {
it('should show ripples on label mousedown', fakeAsync(() => {
const rippleSelector = '.mat-ripple-element:not(.mat-checkbox-persistent-ripple)';
Expand Down
24 changes: 21 additions & 3 deletions src/material/radio/_radio-common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,27 @@ $_icon-size: 20px;
}
}

.mdc-radio--disabled {
cursor: default;
pointer-events: none;
@if ($is-interactive) {
&.mat-mdc-radio-disabled-interactive .mdc-radio--disabled {
pointer-events: auto;

@include token-utils.use-tokens($tokens...) {
.mdc-radio__native-control:not(:checked) + .mdc-radio__background .mdc-radio__outer-circle {
@include token-utils.create-token-slot(border-color, disabled-unselected-icon-color);
@include token-utils.create-token-slot(opacity, disabled-unselected-icon-opacity);
}

&:hover .mdc-radio__native-control:checked + .mdc-radio__background,
.mdc-radio__native-control:checked:focus + .mdc-radio__background,
.mdc-radio__native-control + .mdc-radio__background {
.mdc-radio__inner-circle,
.mdc-radio__outer-circle {
@include token-utils.create-token-slot(border-color, disabled-selected-icon-color);
@include token-utils.create-token-slot(opacity, disabled-selected-icon-opacity);
}
}
}
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/material/radio/radio.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
<input #input class="mdc-radio__native-control" type="radio"
[id]="inputId"
[checked]="checked"
[disabled]="disabled"
[disabled]="disabled && !disabledInteractive"
[attr.name]="name"
[attr.value]="value"
[required]="required"
[attr.aria-label]="ariaLabel"
[attr.aria-labelledby]="ariaLabelledby"
[attr.aria-describedby]="ariaDescribedby"
[attr.aria-disabled]="disabled && disabledInteractive ? 'true' : null"
(change)="_onInputInteraction($event)">
<div class="mdc-radio__background">
<div class="mdc-radio__outer-circle"></div>
Expand Down
39 changes: 18 additions & 21 deletions src/material/radio/radio.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,43 +12,31 @@
.mdc-radio__background::before {
@include token-utils.create-token-slot(background-color, ripple-color);
}
}

&.mat-mdc-radio-checked {
@include token-utils.use-tokens(
tokens-mat-radio.$prefix,
tokens-mat-radio.get-token-slots()
) {
&.mat-mdc-radio-checked {
.mat-ripple-element,
.mdc-radio__background::before {
@include token-utils.create-token-slot(background-color, checked-ripple-color);
}
}

.mat-ripple-element {
@include token-utils.create-token-slot(background-color, checked-ripple-color);
&.mat-mdc-radio-disabled-interactive .mdc-radio--disabled {
.mat-ripple-element,
.mdc-radio__background::before {
@include token-utils.create-token-slot(background-color, ripple-color);
}
}
}

.mat-internal-form-field {
@include token-utils.use-tokens(
tokens-mat-radio.$prefix,
tokens-mat-radio.get-token-slots()
) {
.mat-internal-form-field {
@include token-utils.create-token-slot(color, label-text-color);
@include token-utils.create-token-slot(font-family, label-text-font);
@include token-utils.create-token-slot(line-height, label-text-line-height);
@include token-utils.create-token-slot(font-size, label-text-size);
@include token-utils.create-token-slot(letter-spacing, label-text-tracking);
@include token-utils.create-token-slot(font-weight, label-text-weight);
}
}

// MDC should set the disabled color on the label, but doesn't, so we do it here instead.
.mdc-radio--disabled + label {
@include token-utils.use-tokens(
tokens-mat-radio.$prefix,
tokens-mat-radio.get-token-slots()
) {
.mdc-radio--disabled + label {
@include token-utils.create-token-slot(color, disabled-label-color);
}
}
Expand Down Expand Up @@ -84,6 +72,15 @@
}
}

.mat-mdc-radio-disabled {
cursor: default;
pointer-events: none;

&.mat-mdc-radio-disabled-interactive {
pointer-events: auto;
}
}

// Element used to provide a larger tap target for users on touch devices.
.mat-mdc-radio-touch-target {
position: absolute;
Expand Down
70 changes: 59 additions & 11 deletions src/material/radio/radio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,29 @@ describe('MDC-based MatRadio', () => {
}
});

it('should make all disabled buttons interactive if the group is marked as disabledInteractive', () => {
testComponent.isGroupDisabledInteractive = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
expect(radioInstances.every(radio => radio.disabledInteractive)).toBe(true);
});

it('should prevent the click action when disabledInteractive and disabled', () => {
testComponent.isGroupDisabled = true;
testComponent.isGroupDisabledInteractive = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

// We can't monitor the `defaultPrevented` state on the
// native `click` so we dispatch an extra one.
const fakeEvent = dispatchFakeEvent(radioInputElements[0], 'click');
radioInputElements[0].click();
fixture.detectChanges();

expect(fakeEvent.defaultPrevented).toBe(true);
expect(radioInstances[0].checked).toBe(false);
});

it('should set required to each radio button when the group is required', () => {
testComponent.isGroupRequired = true;
fixture.changeDetectorRef.markForCheck();
Expand Down Expand Up @@ -675,6 +698,7 @@ describe('MDC-based MatRadio', () => {
let fixture: ComponentFixture<DisableableRadioButton>;
let radioInstance: MatRadioButton;
let radioNativeElement: HTMLInputElement;
let radioHost: HTMLElement;
let testComponent: DisableableRadioButton;

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

testComponent = fixture.debugElement.componentInstance;
const radioDebugElement = fixture.debugElement.query(By.directive(MatRadioButton))!;
radioHost = radioDebugElement.nativeElement;
radioInstance = radioDebugElement.injector.get<MatRadioButton>(MatRadioButton);
radioNativeElement = radioDebugElement.nativeElement.querySelector('input');
radioNativeElement = radioHost.querySelector('input')!;
});

it('should toggle the disabled state', () => {
Expand All @@ -703,6 +728,24 @@ describe('MDC-based MatRadio', () => {
expect(radioInstance.disabled).toBeFalsy();
expect(radioNativeElement.disabled).toBeFalsy();
});

it('should keep the button interactive if disabledInteractive is enabled', () => {
testComponent.disabled = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

expect(radioNativeElement.disabled).toBe(true);
expect(radioNativeElement.hasAttribute('aria-disabled')).toBe(false);
expect(radioHost.classList).not.toContain('mat-mdc-radio-disabled-interactive');

testComponent.disabledInteractive = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

expect(radioNativeElement.disabled).toBe(false);
expect(radioNativeElement.getAttribute('aria-disabled')).toBe('true');
expect(radioHost.classList).toContain('mat-mdc-radio-disabled-interactive');
});
});

describe('as standalone', () => {
Expand Down Expand Up @@ -1031,11 +1074,13 @@ describe('MatRadioDefaultOverrides', () => {

@Component({
template: `
<mat-radio-group [disabled]="isGroupDisabled"
[labelPosition]="labelPos"
[required]="isGroupRequired"
[value]="groupValue"
name="test-name">
<mat-radio-group
[disabled]="isGroupDisabled"
[labelPosition]="labelPos"
[required]="isGroupRequired"
[value]="groupValue"
[disabledInteractive]="isGroupDisabledInteractive"
name="test-name">
@if (isFirstShown) {
<mat-radio-button value="fire" [disableRipple]="disableRipple" [disabled]="isFirstDisabled"
[color]="color">
Expand All @@ -1058,6 +1103,7 @@ class RadiosInsideRadioGroup {
isFirstDisabled = false;
isGroupDisabled = false;
isGroupRequired = false;
isGroupDisabledInteractive = false;
groupValue: string | null = null;
disableRipple = false;
color: string | null;
Expand Down Expand Up @@ -1130,16 +1176,18 @@ class RadioGroupWithNgModel {
}

@Component({
template: `<mat-radio-button>One</mat-radio-button>`,
template: `
<mat-radio-button
[disabled]="disabled"
[disabledInteractive]="disabledInteractive">One</mat-radio-button>`,
standalone: true,
imports: [MatRadioModule, FormsModule, ReactiveFormsModule, CommonModule],
})
class DisableableRadioButton {
@ViewChild(MatRadioButton) matRadioButton: MatRadioButton;
disabled = false;
disabledInteractive = false;

set disabled(value: boolean) {
this.matRadioButton.disabled = value;
}
@ViewChild(MatRadioButton) matRadioButton: MatRadioButton;
}

@Component({
Expand Down
Loading

0 comments on commit 0af3b61

Please sign in to comment.