From 4292e1b3a05492e62413f3a62e082f2b8b012026 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 29 Jul 2024 17:43:34 +0200 Subject: [PATCH] feat(material/slide-toggle): add the ability to interact with disabled toggle (#29502) Adds the `disabledInteractive` input which allows users to interact with slide toggles that are disabled. --- .../slide-toggle/slide-toggle-demo.html | 1 + src/dev-app/slide-toggle/slide-toggle-demo.ts | 4 +- .../slide-toggle/slide-toggle-config.ts | 5 +- src/material/slide-toggle/slide-toggle.html | 6 +- src/material/slide-toggle/slide-toggle.scss | 48 ++++++++--- .../slide-toggle/slide-toggle.spec.ts | 84 ++++++++++++++----- src/material/slide-toggle/slide-toggle.ts | 16 ++-- .../public_api_guard/material/slide-toggle.md | 6 +- 8 files changed, 124 insertions(+), 46 deletions(-) diff --git a/src/dev-app/slide-toggle/slide-toggle-demo.html b/src/dev-app/slide-toggle/slide-toggle-demo.html index 7c63ab9c059b..b9e67a0bae61 100644 --- a/src/dev-app/slide-toggle/slide-toggle-demo.html +++ b/src/dev-app/slide-toggle/slide-toggle-demo.html @@ -2,6 +2,7 @@ Default Slide Toggle Disabled Slide Toggle Disable Bound + Disabled Interactive Toggle No icon

With label before the slide toggle.

diff --git a/src/dev-app/slide-toggle/slide-toggle-demo.ts b/src/dev-app/slide-toggle/slide-toggle-demo.ts index a09f11e02d78..321f543085ef 100644 --- a/src/dev-app/slide-toggle/slide-toggle-demo.ts +++ b/src/dev-app/slide-toggle/slide-toggle-demo.ts @@ -20,8 +20,8 @@ import {MatSlideToggleModule} from '@angular/material/slide-toggle'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SlideToggleDemo { - firstToggle: boolean = false; - formToggle: boolean = false; + firstToggle = false; + formToggle = false; onFormSubmit() { alert(`You submitted the form. Value: ${this.formToggle}.`); diff --git a/src/material/slide-toggle/slide-toggle-config.ts b/src/material/slide-toggle/slide-toggle-config.ts index 4b1936e834b9..f2150a3d32a4 100644 --- a/src/material/slide-toggle/slide-toggle-config.ts +++ b/src/material/slide-toggle/slide-toggle-config.ts @@ -24,6 +24,9 @@ export interface MatSlideToggleDefaultOptions { /** Whether to hide the icon inside the slide toggle. */ hideIcon?: boolean; + + /** Whether disabled slide toggles should remain interactive. */ + disabledInteractive?: boolean; } /** Injection token to be used to override the default options for `mat-slide-toggle`. */ @@ -31,6 +34,6 @@ export const MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS = new InjectionToken ({disableToggleValue: false, hideIcon: false}), + factory: () => ({disableToggleValue: false, hideIcon: false, disabledInteractive: false}), }, ); diff --git a/src/material/slide-toggle/slide-toggle.html b/src/material/slide-toggle/slide-toggle.html index 969598d17454..a48e753e1138 100644 --- a/src/material/slide-toggle/slide-toggle.html +++ b/src/material/slide-toggle/slide-toggle.html @@ -7,8 +7,9 @@ [class.mdc-switch--unselected]="!checked" [class.mdc-switch--checked]="checked" [class.mdc-switch--disabled]="disabled" - [tabIndex]="disabled ? -1 : tabIndex" - [disabled]="disabled" + [class.mat-mdc-slide-toggle-disabled-interactive]="disabledInteractive" + [tabIndex]="disabled && !disabledInteractive ? -1 : tabIndex" + [disabled]="disabled && !disabledInteractive" [attr.id]="buttonId" [attr.name]="name" [attr.aria-label]="ariaLabel" @@ -16,6 +17,7 @@ [attr.aria-describedby]="ariaDescribedby" [attr.aria-required]="required || null" [attr.aria-checked]="checked" + [attr.aria-disabled]="disabledInteractive && disabledInteractive ? 'true' : null" (click)="_handleClick()" #switch> diff --git a/src/material/slide-toggle/slide-toggle.scss b/src/material/slide-toggle/slide-toggle.scss index b499bdb7cf09..2ee4027d2520 100644 --- a/src/material/slide-toggle/slide-toggle.scss +++ b/src/material/slide-toggle/slide-toggle.scss @@ -6,6 +6,7 @@ $_mdc-slots: (tokens-mdc-switch.$prefix, tokens-mdc-switch.get-token-slots()); $_mat-slots: (tokens-mat-switch.$prefix, tokens-mat-switch.get-token-slots()); +$_interactive-disabled-selector: '.mat-mdc-slide-toggle-disabled-interactive.mdc-switch--disabled'; .mdc-switch { align-items: center; @@ -20,11 +21,15 @@ $_mat-slots: (tokens-mat-switch.$prefix, tokens-mat-switch.get-token-slots()); padding: 0; position: relative; - &:disabled { + &.mdc-switch--disabled { cursor: default; pointer-events: none; } + &.mat-mdc-slide-toggle-disabled-interactive { + pointer-events: auto; + } + @include token-utils.use-tokens($_mdc-slots...) { @include token-utils.create-token-slot(width, track-width); } @@ -39,7 +44,7 @@ $_mat-slots: (tokens-mat-switch.$prefix, tokens-mat-switch.get-token-slots()); @include token-utils.create-token-slot(height, track-height); @include token-utils.create-token-slot(border-radius, track-shape); - .mdc-switch:disabled & { + .mdc-switch--disabled.mdc-switch & { @include token-utils.create-token-slot(opacity, disabled-track-opacity); } } @@ -117,7 +122,10 @@ $_mat-slots: (tokens-mat-switch.$prefix, tokens-mat-switch.get-token-slots()); @include token-utils.create-token-slot(background, unselected-pressed-track-color); } - .mdc-switch:disabled & { + #{$_interactive-disabled-selector}:hover:not(:focus):not(:active) &, + #{$_interactive-disabled-selector}:focus:not(:active) &, + #{$_interactive-disabled-selector}:active &, + .mdc-switch.mdc-switch--disabled & { @include token-utils.create-token-slot(background, disabled-unselected-track-color); } } @@ -161,7 +169,10 @@ $_mat-slots: (tokens-mat-switch.$prefix, tokens-mat-switch.get-token-slots()); @include token-utils.create-token-slot(background, selected-pressed-track-color); } - .mdc-switch:disabled & { + #{$_interactive-disabled-selector}:hover:not(:focus):not(:active) &, + #{$_interactive-disabled-selector}:focus:not(:active) &, + #{$_interactive-disabled-selector}:active &, + .mdc-switch.mdc-switch--disabled & { @include token-utils.create-token-slot(background, disabled-selected-track-color); } } @@ -310,7 +321,10 @@ $_mat-slots: (tokens-mat-switch.$prefix, tokens-mat-switch.get-token-slots()); @include token-utils.create-token-slot(background, selected-pressed-handle-color); } - .mdc-switch--selected:disabled & { + #{$_interactive-disabled-selector}.mdc-switch--selected:hover:not(:focus):not(:active) &, + #{$_interactive-disabled-selector}.mdc-switch--selected:focus:not(:active) &, + #{$_interactive-disabled-selector}.mdc-switch--selected:active &, + .mdc-switch--selected.mdc-switch--disabled & { @include token-utils.create-token-slot(background, disabled-selected-handle-color); } @@ -330,7 +344,7 @@ $_mat-slots: (tokens-mat-switch.$prefix, tokens-mat-switch.get-token-slots()); @include token-utils.create-token-slot(background, unselected-pressed-handle-color); } - .mdc-switch--unselected:disabled & { + .mdc-switch--unselected.mdc-switch--disabled & { @include token-utils.create-token-slot(background, disabled-unselected-handle-color); } } @@ -354,7 +368,10 @@ $_mat-slots: (tokens-mat-switch.$prefix, tokens-mat-switch.get-token-slots()); @include token-utils.create-token-slot(box-shadow, handle-elevation-shadow); } - .mdc-switch:disabled & { + #{$_interactive-disabled-selector}:hover:not(:focus):not(:active) &, + #{$_interactive-disabled-selector}:focus:not(:active) &, + #{$_interactive-disabled-selector}:active &, + .mdc-switch.mdc-switch--disabled & { @include token-utils.create-token-slot(box-shadow, disabled-handle-elevation-shadow); } } @@ -376,10 +393,14 @@ $_mat-slots: (tokens-mat-switch.$prefix, tokens-mat-switch.get-token-slots()); content: ''; opacity: 0; - .mdc-switch:disabled & { + .mdc-switch--disabled & { display: none; } + .mat-mdc-slide-toggle-disabled-interactive & { + display: block; + } + .mdc-switch:hover & { opacity: 0.04; transition: 75ms opacity cubic-bezier(0, 0, 0.2, 1); @@ -391,6 +412,9 @@ $_mat-slots: (tokens-mat-switch.$prefix, tokens-mat-switch.get-token-slots()); } @include token-utils.use-tokens($_mdc-slots...) { + #{$_interactive-disabled-selector}:enabled:focus &, + #{$_interactive-disabled-selector}:enabled:active &, + #{$_interactive-disabled-selector}:enabled:hover:not(:focus) &, .mdc-switch--unselected:enabled:hover:not(:focus) & { @include token-utils.create-token-slot(background, unselected-hover-state-layer-color); } @@ -429,11 +453,11 @@ $_mat-slots: (tokens-mat-switch.$prefix, tokens-mat-switch.get-token-slots()); z-index: 1; @include token-utils.use-tokens($_mdc-slots...) { - .mdc-switch--unselected:disabled & { + .mdc-switch--disabled.mdc-switch--unselected & { @include token-utils.create-token-slot(opacity, disabled-unselected-icon-opacity); } - .mdc-switch--selected:disabled & { + .mdc-switch--disabled.mdc-switch--selected & { @include token-utils.create-token-slot(opacity, disabled-selected-icon-opacity); } } @@ -456,7 +480,7 @@ $_mat-slots: (tokens-mat-switch.$prefix, tokens-mat-switch.get-token-slots()); @include token-utils.create-token-slot(fill, unselected-icon-color); } - .mdc-switch--unselected:disabled & { + .mdc-switch--unselected.mdc-switch--disabled & { @include token-utils.create-token-slot(fill, disabled-unselected-icon-color); } @@ -466,7 +490,7 @@ $_mat-slots: (tokens-mat-switch.$prefix, tokens-mat-switch.get-token-slots()); @include token-utils.create-token-slot(fill, selected-icon-color); } - .mdc-switch--selected:disabled & { + .mdc-switch--selected.mdc-switch--disabled & { @include token-utils.create-token-slot(fill, disabled-selected-icon-color); } } diff --git a/src/material/slide-toggle/slide-toggle.spec.ts b/src/material/slide-toggle/slide-toggle.spec.ts index 546d6e656934..1cf3b83a6973 100644 --- a/src/material/slide-toggle/slide-toggle.spec.ts +++ b/src/material/slide-toggle/slide-toggle.spec.ts @@ -379,6 +379,40 @@ describe('MDC-based MatSlideToggle without forms', () => { expect(slideToggleElement.querySelector('.mdc-switch__icons')).toBeFalsy(); })); + + it('should be able to mark a slide toggle as interactive while it is disabled', fakeAsync(() => { + testComponent.isDisabled = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(buttonElement.disabled).toBe(true); + expect(buttonElement.hasAttribute('aria-disabled')).toBe(false); + expect(buttonElement.getAttribute('tabindex')).toBe('-1'); + expect(buttonElement.classList).not.toContain('mat-mdc-slide-toggle-disabled-interactive'); + + testComponent.disabledInteractive = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(buttonElement.disabled).toBe(false); + expect(buttonElement.getAttribute('aria-disabled')).toBe('true'); + expect(buttonElement.getAttribute('tabindex')).toBe('0'); + expect(buttonElement.classList).toContain('mat-mdc-slide-toggle-disabled-interactive'); + })); + + it('should not change its state when clicked while disabled and interactive', fakeAsync(() => { + expect(slideToggle.checked).toBe(false); + + testComponent.isDisabled = testComponent.disabledInteractive = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + buttonElement.click(); + fixture.detectChanges(); + tick(); + + expect(slideToggle.checked).toBe(false); + })); }); describe('custom template', () => { @@ -828,33 +862,36 @@ describe('MDC-based MatSlideToggle with forms', () => { @Component({ template: ` - + Test Slide Toggle `, standalone: true, imports: [MatSlideToggleModule, BidiModule], }) class SlideToggleBasic { - isDisabled: boolean = false; - isRequired: boolean = false; - disableRipple: boolean = false; - slideChecked: boolean = false; + isDisabled = false; + isRequired = false; + disableRipple = false; + slideChecked = false; slideColor: string; slideId: string | null; slideName: string | null; @@ -864,10 +901,11 @@ class SlideToggleBasic { slideTabindex: number; lastEvent: MatSlideToggleChange; labelPosition: string; - toggleTriggered: number = 0; - dragTriggered: number = 0; + toggleTriggered = 0; + dragTriggered = 0; direction: Direction = 'ltr'; hideIcon = false; + disabledInteractive = false; onSlideClick: (event?: Event) => void = () => {}; onSlideChange = (event: MatSlideToggleChange) => (this.lastEvent = event); diff --git a/src/material/slide-toggle/slide-toggle.ts b/src/material/slide-toggle/slide-toggle.ts index 2e4501a8b3b6..28837bcf85c9 100644 --- a/src/material/slide-toggle/slide-toggle.ts +++ b/src/material/slide-toggle/slide-toggle.ts @@ -187,6 +187,9 @@ export class MatSlideToggle /** Whether to hide the icon inside of the slide toggle. */ @Input({transform: booleanAttribute}) hideIcon: boolean; + /** Whether the slide toggle should remain interactive when it is disabled. */ + @Input({transform: booleanAttribute}) disabledInteractive: boolean; + /** An event will be dispatched each time the slide-toggle changes its value. */ @Output() readonly change = new EventEmitter(); @@ -215,6 +218,7 @@ export class MatSlideToggle this._noopAnimations = animationMode === 'NoopAnimations'; this.id = this._uniqueId = `mat-mdc-slide-toggle-${++nextUniqueId}`; this.hideIcon = defaults.hideIcon ?? false; + this.disabledInteractive = defaults.disabledInteractive ?? false; this._labelId = this._uniqueId + '-label'; } @@ -295,12 +299,14 @@ export class MatSlideToggle /** Method being called whenever the underlying button is clicked. */ _handleClick() { - this.toggleChange.emit(); + if (!this.disabled) { + this.toggleChange.emit(); - if (!this.defaults.disableToggleValue) { - this.checked = !this.checked; - this._onChange(this.checked); - this.change.emit(new MatSlideToggleChange(this, this.checked)); + if (!this.defaults.disableToggleValue) { + this.checked = !this.checked; + this._onChange(this.checked); + this.change.emit(new MatSlideToggleChange(this, this.checked)); + } } } diff --git a/tools/public_api_guard/material/slide-toggle.md b/tools/public_api_guard/material/slide-toggle.md index faf590458716..1e14c8840e4a 100644 --- a/tools/public_api_guard/material/slide-toggle.md +++ b/tools/public_api_guard/material/slide-toggle.md @@ -53,6 +53,7 @@ export class MatSlideToggle implements OnDestroy, AfterContentInit, OnChanges, C // (undocumented) defaults: MatSlideToggleDefaultOptions; disabled: boolean; + disabledInteractive: boolean; disableRipple: boolean; protected _emitChangeEvent(): void; focus(): void; @@ -73,6 +74,8 @@ export class MatSlideToggle implements OnDestroy, AfterContentInit, OnChanges, C // (undocumented) static ngAcceptInputType_disabled: unknown; // (undocumented) + static ngAcceptInputType_disabledInteractive: unknown; + // (undocumented) static ngAcceptInputType_disableRipple: unknown; // (undocumented) static ngAcceptInputType_hideIcon: unknown; @@ -99,7 +102,7 @@ export class MatSlideToggle implements OnDestroy, AfterContentInit, OnChanges, C validate(control: AbstractControl): ValidationErrors | null; writeValue(value: any): void; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -116,6 +119,7 @@ export class MatSlideToggleChange { // @public export interface MatSlideToggleDefaultOptions { color?: ThemePalette; + disabledInteractive?: boolean; disableToggleValue?: boolean; hideIcon?: boolean; }