From b389b046474da5fbe3866f3ac25fd9308c0275ea Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 26 Jul 2024 13:21:16 +0200 Subject: [PATCH] feat(material/radio): add the ability to interact with disabled radio buttons 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. --- src/dev-app/radio/BUILD.bazel | 1 + src/dev-app/radio/radio-demo.html | 18 ++++++ src/dev-app/radio/radio-demo.ts | 21 +++++-- src/material/checkbox/checkbox.spec.ts | 13 +++++ src/material/radio/_radio-common.scss | 24 +++++++- src/material/radio/radio.html | 3 +- src/material/radio/radio.scss | 39 ++++++------- src/material/radio/radio.spec.ts | 70 +++++++++++++++++++---- src/material/radio/radio.ts | 71 +++++++++++++++++++----- tools/public_api_guard/material/radio.md | 17 ++++-- 10 files changed, 216 insertions(+), 61 deletions(-) diff --git a/src/dev-app/radio/BUILD.bazel b/src/dev-app/radio/BUILD.bazel index 426623bcaa94..e820d20228fb 100644 --- a/src/dev-app/radio/BUILD.bazel +++ b/src/dev-app/radio/BUILD.bazel @@ -13,6 +13,7 @@ ng_module( "//src/material/button", "//src/material/checkbox", "//src/material/radio", + "//src/material/tooltip", "@npm//@angular/forms", ], ) diff --git a/src/dev-app/radio/radio-demo.html b/src/dev-app/radio/radio-demo.html index 35a4def520db..fb007822906b 100644 --- a/src/dev-app/radio/radio-demo.html +++ b/src/dev-app/radio/radio-demo.html @@ -63,3 +63,21 @@

Dynamic Example with two-way data-binding

Your favorite season is: {{favoriteSeason}}

+ +

Disabled interactive group

+
+ + @for (season of seasonOptions; track season) { + + {{season}} + + } + + +
+ Disabled interactive +
+
diff --git a/src/dev-app/radio/radio-demo.ts b/src/dev-app/radio/radio-demo.ts index ed450d3af4f2..b907aeebd344 100644 --- a/src/dev-app/radio/radio-demo.ts +++ b/src/dev-app/radio/radio-demo.ts @@ -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']; } diff --git a/src/material/checkbox/checkbox.spec.ts b/src/material/checkbox/checkbox.spec.ts index 7d858e856b1b..dc751b271d7f 100644 --- a/src/material/checkbox/checkbox.spec.ts +++ b/src/material/checkbox/checkbox.spec.ts @@ -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)'; diff --git a/src/material/radio/_radio-common.scss b/src/material/radio/_radio-common.scss index eeacd0a814a6..3367577e21e1 100644 --- a/src/material/radio/_radio-common.scss +++ b/src/material/radio/_radio-common.scss @@ -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); + } + } + } + } } } diff --git a/src/material/radio/radio.html b/src/material/radio/radio.html index d86ba521e6ec..b369b5bf663e 100644 --- a/src/material/radio/radio.html +++ b/src/material/radio/radio.html @@ -5,13 +5,14 @@
diff --git a/src/material/radio/radio.scss b/src/material/radio/radio.scss index 1880b6677610..f2ce51c3181d 100644 --- a/src/material/radio/radio.scss +++ b/src/material/radio/radio.scss @@ -12,28 +12,22 @@ .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); @@ -41,14 +35,8 @@ @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); } } @@ -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; diff --git a/src/material/radio/radio.spec.ts b/src/material/radio/radio.spec.ts index ff7b8ec891e9..55db22baf8aa 100644 --- a/src/material/radio/radio.spec.ts +++ b/src/material/radio/radio.spec.ts @@ -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(); @@ -675,6 +698,7 @@ describe('MDC-based MatRadio', () => { let fixture: ComponentFixture; let radioInstance: MatRadioButton; let radioNativeElement: HTMLInputElement; + let radioHost: HTMLElement; let testComponent: DisableableRadioButton; beforeEach(() => { @@ -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); - radioNativeElement = radioDebugElement.nativeElement.querySelector('input'); + radioNativeElement = radioHost.querySelector('input')!; }); it('should toggle the disabled state', () => { @@ -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', () => { @@ -1031,11 +1074,13 @@ describe('MatRadioDefaultOverrides', () => { @Component({ template: ` - + @if (isFirstShown) { @@ -1058,6 +1103,7 @@ class RadiosInsideRadioGroup { isFirstDisabled = false; isGroupDisabled = false; isGroupRequired = false; + isGroupDisabledInteractive = false; groupValue: string | null = null; disableRipple = false; color: string | null; @@ -1130,16 +1176,18 @@ class RadioGroupWithNgModel { } @Component({ - template: `One`, + template: ` + One`, 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({ diff --git a/src/material/radio/radio.ts b/src/material/radio/radio.ts index 7a178900b730..a913374f8bc3 100644 --- a/src/material/radio/radio.ts +++ b/src/material/radio/radio.ts @@ -25,6 +25,7 @@ import { InjectionToken, Injector, Input, + NgZone, OnDestroy, OnInit, Optional, @@ -82,6 +83,9 @@ export interface MatRadioDefaultOptions { * https://material.angular.io/guide/theming#using-component-color-variants. */ color: ThemePalette; + + /** Whether disabled radio buttons should be interactive. */ + disabledInteractive?: boolean; } export const MAT_RADIO_DEFAULT_OPTIONS = new InjectionToken( @@ -95,6 +99,7 @@ export const MAT_RADIO_DEFAULT_OPTIONS = new InjectionToken { + this._inputElement.nativeElement.addEventListener('click', this._onInputClick); + }); } ngOnDestroy() { + this._inputElement.nativeElement.removeEventListener('click', this._onInputClick); this._focusMonitor.stopMonitoring(this._elementRef); this._removeUniqueSelectionListener(); } @@ -645,17 +685,6 @@ export class MatRadioButton implements OnInit, AfterViewInit, DoCheck, OnDestroy return this.disableRipple || this.disabled; } - _onInputClick(event: Event) { - // We have to stop propagation for click events on the visual hidden input element. - // By default, when a user clicks on a label element, a generated click event will be - // dispatched on the associated input element. Since we are using a label element as our - // root container, the click event on the `radio-button` will be executed twice. - // The real click event will bubble up, and the generated click event also tries to bubble up. - // This will lead to multiple click events. - // Preventing bubbling for the second event will solve that issue. - event.stopPropagation(); - } - /** Triggered when the radio button receives an interaction from the user. */ _onInputInteraction(event: Event) { // We always have to stop propagation on the change event. @@ -681,7 +710,7 @@ export class MatRadioButton implements OnInit, AfterViewInit, DoCheck, OnDestroy _onTouchTargetClick(event: Event) { this._onInputInteraction(event); - if (!this.disabled) { + if (!this.disabled || this.disabledInteractive) { // Normally the input should be focused already, but if the click // comes from the touch target, then we might have to focus it ourselves. this._inputElement.nativeElement.focus(); @@ -696,6 +725,20 @@ export class MatRadioButton implements OnInit, AfterViewInit, DoCheck, OnDestroy } } + /** Called when the input is clicked. */ + private _onInputClick = (event: Event) => { + // If the input is disabled while interactive, we need to prevent the + // selection from happening in this event handler. Note that even though + // this happens on `click` events, the logic applies when the user is + // navigating with the keyboard as well. An alternative way of doing + // this is by resetting the `checked` state in the `change` callback but + // it isn't optimal, because it can allow a pre-checked disabled button + // to be un-checked. This approach seems to cover everything. + if (this.disabled && this.disabledInteractive) { + event.preventDefault(); + } + }; + /** Gets the tabindex for the underlying input element. */ private _updateTabIndex() { const group = this.radioGroup; diff --git a/tools/public_api_guard/material/radio.md b/tools/public_api_guard/material/radio.md index f7083a61585a..3abddd72015d 100644 --- a/tools/public_api_guard/material/radio.md +++ b/tools/public_api_guard/material/radio.md @@ -37,7 +37,7 @@ export const MAT_RADIO_GROUP_CONTROL_VALUE_ACCESSOR: any; // @public (undocumented) export class MatRadioButton implements OnInit, AfterViewInit, DoCheck, OnDestroy { - constructor(radioGroup: MatRadioGroup, _elementRef: ElementRef, _changeDetector: ChangeDetectorRef, _focusMonitor: FocusMonitor, _radioDispatcher: UniqueSelectionDispatcher, animationMode?: string, _providerOverride?: MatRadioDefaultOptions | undefined, tabIndex?: string); + constructor(radioGroup: MatRadioGroup, _elementRef: ElementRef, _changeDetector: ChangeDetectorRef, _focusMonitor: FocusMonitor, _radioDispatcher: UniqueSelectionDispatcher, animationMode?: string, _defaultOptions?: MatRadioDefaultOptions | undefined, tabIndex?: string); ariaDescribedby: string; ariaLabel: string; ariaLabelledby: string; @@ -48,6 +48,8 @@ export class MatRadioButton implements OnInit, AfterViewInit, DoCheck, OnDestroy set color(newValue: ThemePalette); get disabled(): boolean; set disabled(value: boolean); + get disabledInteractive(): boolean; + set disabledInteractive(value: boolean); disableRipple: boolean; // (undocumented) protected _elementRef: ElementRef; @@ -66,6 +68,8 @@ export class MatRadioButton implements OnInit, AfterViewInit, DoCheck, OnDestroy // (undocumented) static ngAcceptInputType_disabled: unknown; // (undocumented) + static ngAcceptInputType_disabledInteractive: unknown; + // (undocumented) static ngAcceptInputType_disableRipple: unknown; // (undocumented) static ngAcceptInputType_required: unknown; @@ -80,8 +84,6 @@ export class MatRadioButton implements OnInit, AfterViewInit, DoCheck, OnDestroy // (undocumented) ngOnInit(): void; _noopAnimations: boolean; - // (undocumented) - _onInputClick(event: Event): void; _onInputInteraction(event: Event): void; _onTouchTargetClick(event: Event): void; radioGroup: MatRadioGroup; @@ -93,7 +95,7 @@ export class MatRadioButton implements OnInit, AfterViewInit, DoCheck, OnDestroy get value(): any; set value(value: any); // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -110,6 +112,7 @@ export class MatRadioChange { // @public (undocumented) export interface MatRadioDefaultOptions { color: ThemePalette; + disabledInteractive?: boolean; } // @public @@ -122,6 +125,8 @@ export class MatRadioGroup implements AfterContentInit, OnDestroy, ControlValueA _controlValueAccessorChangeFn: (value: any) => void; get disabled(): boolean; set disabled(value: boolean); + get disabledInteractive(): boolean; + set disabledInteractive(value: boolean); _emitChangeEvent(): void; get labelPosition(): 'before' | 'after'; set labelPosition(v: 'before' | 'after'); @@ -132,6 +137,8 @@ export class MatRadioGroup implements AfterContentInit, OnDestroy, ControlValueA // (undocumented) static ngAcceptInputType_disabled: unknown; // (undocumented) + static ngAcceptInputType_disabledInteractive: unknown; + // (undocumented) static ngAcceptInputType_required: unknown; ngAfterContentInit(): void; // (undocumented) @@ -150,7 +157,7 @@ export class MatRadioGroup implements AfterContentInit, OnDestroy, ControlValueA set value(newValue: any); writeValue(value: any): void; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; }