Skip to content

Commit

Permalink
feat(material/slide-toggle): add the ability to interact with disable…
Browse files Browse the repository at this point in the history
…d toggle (angular#29502)

Adds the `disabledInteractive` input which allows users to interact with slide toggles that are disabled.
  • Loading branch information
crisbeto authored Jul 29, 2024
1 parent c9078d1 commit 4292e1b
Show file tree
Hide file tree
Showing 8 changed files with 124 additions and 46 deletions.
1 change: 1 addition & 0 deletions src/dev-app/slide-toggle/slide-toggle-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<mat-slide-toggle color="primary" [(ngModel)]="firstToggle">Default Slide Toggle</mat-slide-toggle>
<mat-slide-toggle [(ngModel)]="firstToggle" disabled>Disabled Slide Toggle</mat-slide-toggle>
<mat-slide-toggle [disabled]="firstToggle">Disable Bound</mat-slide-toggle>
<mat-slide-toggle disabled disabledInteractive [(ngModel)]="firstToggle">Disabled Interactive Toggle</mat-slide-toggle>
<mat-slide-toggle hideIcon [(ngModel)]="firstToggle">No icon</mat-slide-toggle>

<p>With label before the slide toggle.</p>
Expand Down
4 changes: 2 additions & 2 deletions src/dev-app/slide-toggle/slide-toggle-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.`);
Expand Down
5 changes: 4 additions & 1 deletion src/material/slide-toggle/slide-toggle-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@ 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`. */
export const MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS = new InjectionToken<MatSlideToggleDefaultOptions>(
'mat-slide-toggle-default-options',
{
providedIn: 'root',
factory: () => ({disableToggleValue: false, hideIcon: false}),
factory: () => ({disableToggleValue: false, hideIcon: false, disabledInteractive: false}),
},
);
6 changes: 4 additions & 2 deletions src/material/slide-toggle/slide-toggle.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@
[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"
[attr.aria-labelledby]="_getAriaLabelledBy()"
[attr.aria-describedby]="ariaDescribedby"
[attr.aria-required]="required || null"
[attr.aria-checked]="checked"
[attr.aria-disabled]="disabledInteractive && disabledInteractive ? 'true' : null"
(click)="_handleClick()"
#switch>
<span class="mdc-switch__track"></span>
Expand Down
48 changes: 36 additions & 12 deletions src/material/slide-toggle/slide-toggle.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
}
}
Expand All @@ -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);
}
}
Expand All @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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);
}

Expand All @@ -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);
}
}
Expand Down
84 changes: 61 additions & 23 deletions src/material/slide-toggle/slide-toggle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -828,33 +862,36 @@ describe('MDC-based MatSlideToggle with forms', () => {

@Component({
template: `
<mat-slide-toggle [dir]="direction" [required]="isRequired"
[disabled]="isDisabled"
[color]="slideColor"
[id]="slideId"
[checked]="slideChecked"
[name]="slideName"
[aria-label]="slideLabel"
[aria-labelledby]="slideLabelledBy"
[aria-describedby]="slideAriaDescribedBy"
[tabIndex]="slideTabindex"
[labelPosition]="labelPosition"
[disableRipple]="disableRipple"
[hideIcon]="hideIcon"
(toggleChange)="onSlideToggleChange()"
(dragChange)="onSlideDragChange()"
(change)="onSlideChange($event)"
(click)="onSlideClick($event)">
<mat-slide-toggle
[dir]="direction"
[required]="isRequired"
[disabled]="isDisabled"
[color]="slideColor"
[id]="slideId"
[checked]="slideChecked"
[name]="slideName"
[aria-label]="slideLabel"
[aria-labelledby]="slideLabelledBy"
[aria-describedby]="slideAriaDescribedBy"
[tabIndex]="slideTabindex"
[labelPosition]="labelPosition"
[disableRipple]="disableRipple"
[hideIcon]="hideIcon"
[disabledInteractive]="disabledInteractive"
(toggleChange)="onSlideToggleChange()"
(dragChange)="onSlideDragChange()"
(change)="onSlideChange($event)"
(click)="onSlideClick($event)">
<span>Test Slide Toggle</span>
</mat-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;
Expand All @@ -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);
Expand Down
16 changes: 11 additions & 5 deletions src/material/slide-toggle/slide-toggle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MatSlideToggleChange>();

Expand Down Expand Up @@ -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';
}

Expand Down Expand Up @@ -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));
}
}
}

Expand Down
6 changes: 5 additions & 1 deletion tools/public_api_guard/material/slide-toggle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -99,7 +102,7 @@ export class MatSlideToggle implements OnDestroy, AfterContentInit, OnChanges, C
validate(control: AbstractControl<boolean>): ValidationErrors | null;
writeValue(value: any): void;
// (undocumented)
static ɵcmp: i0.ɵɵComponentDeclaration<MatSlideToggle, "mat-slide-toggle", ["matSlideToggle"], { "name": { "alias": "name"; "required": false; }; "id": { "alias": "id"; "required": false; }; "labelPosition": { "alias": "labelPosition"; "required": false; }; "ariaLabel": { "alias": "aria-label"; "required": false; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; }; "ariaDescribedby": { "alias": "aria-describedby"; "required": false; }; "required": { "alias": "required"; "required": false; }; "color": { "alias": "color"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "disableRipple": { "alias": "disableRipple"; "required": false; }; "tabIndex": { "alias": "tabIndex"; "required": false; }; "checked": { "alias": "checked"; "required": false; }; "hideIcon": { "alias": "hideIcon"; "required": false; }; }, { "change": "change"; "toggleChange": "toggleChange"; }, never, ["*"], true, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MatSlideToggle, "mat-slide-toggle", ["matSlideToggle"], { "name": { "alias": "name"; "required": false; }; "id": { "alias": "id"; "required": false; }; "labelPosition": { "alias": "labelPosition"; "required": false; }; "ariaLabel": { "alias": "aria-label"; "required": false; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; }; "ariaDescribedby": { "alias": "aria-describedby"; "required": false; }; "required": { "alias": "required"; "required": false; }; "color": { "alias": "color"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "disableRipple": { "alias": "disableRipple"; "required": false; }; "tabIndex": { "alias": "tabIndex"; "required": false; }; "checked": { "alias": "checked"; "required": false; }; "hideIcon": { "alias": "hideIcon"; "required": false; }; "disabledInteractive": { "alias": "disabledInteractive"; "required": false; }; }, { "change": "change"; "toggleChange": "toggleChange"; }, never, ["*"], true, never>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<MatSlideToggle, [null, null, null, { attribute: "tabindex"; }, null, { optional: true; }]>;
}
Expand All @@ -116,6 +119,7 @@ export class MatSlideToggleChange {
// @public
export interface MatSlideToggleDefaultOptions {
color?: ThemePalette;
disabledInteractive?: boolean;
disableToggleValue?: boolean;
hideIcon?: boolean;
}
Expand Down

0 comments on commit 4292e1b

Please sign in to comment.