diff --git a/src/dev-app/slide-toggle/BUILD.bazel b/src/dev-app/slide-toggle/BUILD.bazel index dc184702b6bb..7adb91bf3ed7 100644 --- a/src/dev-app/slide-toggle/BUILD.bazel +++ b/src/dev-app/slide-toggle/BUILD.bazel @@ -13,6 +13,7 @@ ng_project( "//:node_modules/@angular/core", "//:node_modules/@angular/forms", "//src/material/button", + "//src/material/icon", "//src/material/slide-toggle", ], ) diff --git a/src/dev-app/slide-toggle/slide-toggle-demo.html b/src/dev-app/slide-toggle/slide-toggle-demo.html index 59b711ac3e9f..d1c86e9a1395 100644 --- a/src/dev-app/slide-toggle/slide-toggle-demo.html +++ b/src/dev-app/slide-toggle/slide-toggle-demo.html @@ -1,16 +1,71 @@
- Default Slide Toggle + Default Slide Toggle Disabled Slide Toggle Disable Bound - Disabled Interactive Toggle + Disabled Interactive Toggle + No icon (While Unchecked) + No icon (While Checked) No icon - + + light_mode + dark_mode + Custom Icons + + + light_mode + dark_mode + Disabled Custom Icons + + + light_mode + dark_mode + lock + lock_open + Disabled Custom Disabled Icons +

With label before the slide toggle.

- Default Slide Toggle - Disabled Slide Toggle + Default Slide Toggle + Disabled Slide Toggle Disable Bound - No icon + No icon (While Unchecked) + No icon (While Checked) + No icon + + light_mode + dark_mode + Custom Icons + + + light_mode + dark_mode + Disabled Custom Icons + + + light_mode + dark_mode + lock + lock_open + Disabled Custom Disabled Icons +

Example where the slide toggle is required inside of a form.

diff --git a/src/dev-app/slide-toggle/slide-toggle-demo.ts b/src/dev-app/slide-toggle/slide-toggle-demo.ts index 068d26a30e18..f2f6d889eef0 100644 --- a/src/dev-app/slide-toggle/slide-toggle-demo.ts +++ b/src/dev-app/slide-toggle/slide-toggle-demo.ts @@ -10,12 +10,13 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MatButtonModule} from '@angular/material/button'; import {MatSlideToggleModule} from '@angular/material/slide-toggle'; +import {MatIconModule} from '@angular/material/icon'; @Component({ selector: 'slide-toggle-demo', templateUrl: 'slide-toggle-demo.html', styleUrl: 'slide-toggle-demo.css', - imports: [FormsModule, MatButtonModule, MatSlideToggleModule], + imports: [FormsModule, MatButtonModule, MatSlideToggleModule, MatIconModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SlideToggleDemo { diff --git a/src/material/slide-toggle/slide-toggle-config.ts b/src/material/slide-toggle/slide-toggle-config.ts index e78afce93700..fadd76d882a3 100644 --- a/src/material/slide-toggle/slide-toggle-config.ts +++ b/src/material/slide-toggle/slide-toggle-config.ts @@ -23,7 +23,7 @@ export interface MatSlideToggleDefaultOptions { color?: ThemePalette; /** Whether to hide the icon inside the slide toggle. */ - hideIcon?: boolean; + hideIcon?: 'both' | 'checked' | 'unchecked' | 'none'; /** Whether disabled slide toggles should remain interactive. */ disabledInteractive?: boolean; @@ -34,6 +34,6 @@ export const MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS = new InjectionToken ({disableToggleValue: false, hideIcon: false, disabledInteractive: false}), + factory: () => ({disableToggleValue: false, hideIcon: 'none', disabledInteractive: false}), }, ); diff --git a/src/material/slide-toggle/slide-toggle.html b/src/material/slide-toggle/slide-toggle.html index 6e1c74ce2ad4..8af522db005e 100644 --- a/src/material/slide-toggle/slide-toggle.html +++ b/src/material/slide-toggle/slide-toggle.html @@ -19,7 +19,8 @@ [attr.aria-checked]="checked" [attr.aria-disabled]="disabled && disabledInteractive ? 'true' : null" (click)="_handleClick()" - #switch> + #switch + >
@@ -28,26 +29,21 @@ - + [matRippleCentered]="true" + > - @if (!hideIcon) { - - - - + + @if (hideIcon !== "both") { + } @@ -56,8 +52,45 @@ + -->
+ + + + + + + + + @if (disabled) { + + + + } @else { + + } + + + + + + + + + + @if (disabled) { + + + + } @else { + + } + + diff --git a/src/material/slide-toggle/slide-toggle.scss b/src/material/slide-toggle/slide-toggle.scss index babf986b0f8d..edf5a57de447 100644 --- a/src/material/slide-toggle/slide-toggle.scss +++ b/src/material/slide-toggle/slide-toggle.scss @@ -8,6 +8,13 @@ $_interactive-disabled-selector: '.mat-mdc-slide-toggle-disabled-interactive.mdc $fallbacks: m3-slide-toggle.get-tokens(); +[matCheckedIcon], +[matUncheckedIcon], +[matCheckedDisabledIcon], +[matUncheckedDisabledIcon] { + display: none; +} + .mdc-switch { align-items: center; background: none; @@ -66,9 +73,13 @@ $fallbacks: m3-slide-toggle.get-tokens(); .mdc-switch--disabled & { border-width: token-utils.slot( - slide-toggle-disabled-unselected-track-outline-width, $fallbacks); + slide-toggle-disabled-unselected-track-outline-width, + $fallbacks + ); border-color: token-utils.slot( - slide-toggle-disabled-unselected-track-outline-color, $fallbacks); + slide-toggle-disabled-unselected-track-outline-color, + $fallbacks + ); } } @@ -223,7 +234,9 @@ $fallbacks: m3-slide-toggle.get-tokens(); &:has(.mdc-switch__icons) { margin: token-utils.slot( - slide-toggle-unselected-with-icon-handle-horizontal-margin, $fallbacks); + slide-toggle-unselected-with-icon-handle-horizontal-margin, + $fallbacks + ); } } @@ -234,7 +247,9 @@ $fallbacks: m3-slide-toggle.get-tokens(); &:has(.mdc-switch__icons) { margin: token-utils.slot( - slide-toggle-selected-with-icon-handle-horizontal-margin, $fallbacks); + slide-toggle-selected-with-icon-handle-horizontal-margin, + $fallbacks + ); } } @@ -275,7 +290,8 @@ $fallbacks: m3-slide-toggle.get-tokens(); left: 0; position: absolute; top: 0; - transition: background-color 75ms 0ms cubic-bezier(0.4, 0, 0.2, 1), + transition: + background-color 75ms 0ms cubic-bezier(0.4, 0, 0.2, 1), border-color 75ms 0ms cubic-bezier(0.4, 0, 0.2, 1); z-index: -1; @@ -435,39 +451,50 @@ $fallbacks: m3-slide-toggle.get-tokens(); } } -.mdc-switch__icon { - bottom: 0; - left: 0; - margin: auto; +.mdc-switch__icons > [matUncheckedIcon], +.mdc-switch__icons > [matCheckedIcon], +.mdc-switch__icons > [matUncheckedDisabledIcon], +.mdc-switch__icons > [matCheckedDisabledIcon] { + margin: 0; position: absolute; - right: 0; - top: 0; + top: 50%; + right: 50%; + transform: translate(50%, -50%); opacity: 0; + font-size: 16px; + display: block; + transition: opacity 30ms 0ms cubic-bezier(0.4, 0, 1, 1); .mdc-switch--unselected & { width: token-utils.slot(slide-toggle-unselected-icon-size, $fallbacks); height: token-utils.slot(slide-toggle-unselected-icon-size, $fallbacks); fill: token-utils.slot(slide-toggle-unselected-icon-color, $fallbacks); + color: token-utils.slot(slide-toggle-unselected-icon-color, $fallbacks); } .mdc-switch--unselected.mdc-switch--disabled & { fill: token-utils.slot(slide-toggle-disabled-unselected-icon-color, $fallbacks); + color: token-utils.slot(slide-toggle-disabled-unselected-icon-color, $fallbacks); } .mdc-switch--selected & { width: token-utils.slot(slide-toggle-selected-icon-size, $fallbacks); height: token-utils.slot(slide-toggle-selected-icon-size, $fallbacks); fill: token-utils.slot(slide-toggle-selected-icon-color, $fallbacks); + color: token-utils.slot(slide-toggle-selected-icon-color, $fallbacks); } .mdc-switch--selected.mdc-switch--disabled & { fill: token-utils.slot(slide-toggle-disabled-selected-icon-color, $fallbacks); + color: token-utils.slot(slide-toggle-disabled-selected-icon-color, $fallbacks); } } -.mdc-switch--selected .mdc-switch__icon--on, -.mdc-switch--unselected .mdc-switch__icon--off { +.mdc-switch--selected .mdc-switch__icons [matCheckedIcon], +.mdc-switch--unselected .mdc-switch__icons [matUncheckedIcon], +.mdc-switch--disabled.mdc-switch--selected .mdc-switch__icons [matCheckedDisabledIcon], +.mdc-switch--disabled.mdc-switch--unselected .mdc-switch__icons [matUncheckedDisabledIcon] { opacity: 1; transition: opacity 45ms 30ms cubic-bezier(0, 0, 0.2, 1); } diff --git a/src/material/slide-toggle/slide-toggle.spec.ts b/src/material/slide-toggle/slide-toggle.spec.ts index 7ef13fc56555..86a15edc65b9 100644 --- a/src/material/slide-toggle/slide-toggle.spec.ts +++ b/src/material/slide-toggle/slide-toggle.spec.ts @@ -26,6 +26,7 @@ describe('MatSlideToggle without forms', () => { SlideToggleWithTabindexAttr, SlideToggleWithoutLabel, SlideToggleProjectedLabel, + SlideToggleProjectedLabelAndCustomIcons, TextBindingComponent, SlideToggleWithStaticAriaAttributes, ], @@ -366,7 +367,7 @@ describe('MatSlideToggle without forms', () => { expect(rippleElement.classList).toContain('mat-focus-indicator'); })); - it('should be able to hide the icon', fakeAsync(() => { + it('should be able to hide both icons through true, "", and "both"', fakeAsync(() => { expect(slideToggleElement.querySelector('.mdc-switch__icons')).toBeTruthy(); testComponent.hideIcon = true; @@ -374,6 +375,51 @@ describe('MatSlideToggle without forms', () => { fixture.detectChanges(); expect(slideToggleElement.querySelector('.mdc-switch__icons')).toBeFalsy(); + + testComponent.hideIcon = ''; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(slideToggleElement.querySelector('.mdc-switch__icons')).toBeFalsy(); + + testComponent.hideIcon = 'both'; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + expect(slideToggleElement.querySelector('.mdc-switch__icons')).toBeFalsy(); + })); + + it('should be able to hide the on icon through "checked"', fakeAsync(() => { + expect(slideToggleElement.querySelector('.mdc-switch__icons')).toBeTruthy(); + + testComponent.hideIcon = 'checked'; + testComponent.slideChecked = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(slideToggleElement.querySelector('.mdc-switch__icons')).toBeFalsy(); + + testComponent.slideChecked = false; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(slideToggleElement.querySelector('.mdc-switch__icons')).toBeTruthy(); + })); + + it('should be able to hide the off icon through "unchecked"', fakeAsync(() => { + expect(slideToggleElement.querySelector('.mdc-switch__icons')).toBeTruthy(); + + testComponent.hideIcon = 'unchecked'; + testComponent.slideChecked = false; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(slideToggleElement.querySelector('.mdc-switch__icons')).toBeFalsy(); + + testComponent.slideChecked = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(slideToggleElement.querySelector('.mdc-switch__icons')).toBeTruthy(); })); it('should be able to mark a slide toggle as interactive while it is disabled', fakeAsync(() => { @@ -536,6 +582,86 @@ describe('MatSlideToggle without forms', () => { expect(host.hasAttribute('aria-label')).toBe(false); expect(host.hasAttribute('aria-labelledby')).toBe(false); }); + + it('should render default icons if no custom icons are present', () => { + const fixture = TestBed.createComponent(SlideToggleBasic); + const slideToggleDebug = fixture.debugElement.query(By.directive(MatSlideToggle))!; + const slideToggle = slideToggleDebug.componentInstance; + const slideToggleElement = slideToggleDebug.nativeElement; + fixture.detectChanges(); + + const offIconElement = slideToggleElement + .querySelector('.mdc-switch__icons') + ?.querySelector(`[matUncheckedIcon]`); + expect(offIconElement).toBeTruthy(); + + slideToggle.checked = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + const onIconElement = slideToggleElement + .querySelector('.mdc-switch__icons') + ?.querySelector(`[matCheckedIcon]`); + expect(onIconElement).toBeTruthy(); + }); + + it('should render custom icons if hideIcon is not present', fakeAsync(() => { + const fixture = TestBed.createComponent(SlideToggleProjectedLabelAndCustomIcons); + const slideToggleDebug = fixture.debugElement.query(By.directive(MatSlideToggle))!; + const slideToggle = slideToggleDebug.componentInstance; + const slideToggleElement = slideToggleDebug.nativeElement; + slideToggle.hideIcon = 'both'; + slideToggle.checked = true; + slideToggle.disabled = false; + fixture.detectChanges(); + + const icons = { + '[matCheckedIcon]': { + checked: true, + disabled: false, + text: 'light_mode', + hide: ['checked', 'both'], + }, + '[matUncheckedIcon]': { + checked: false, + disabled: false, + text: 'dark_mode', + hide: ['unchecked', 'both'], + }, + '[matCheckedDisabledIcon]': { + checked: true, + disabled: true, + text: 'lock', + hide: ['checked', 'both'], + }, + '[matUncheckedDisabledIcon]': { + checked: false, + disabled: true, + text: 'lock_open', + hide: ['unchecked', 'both'], + }, + }; + for (const hide of ['checked', 'unchecked', 'both', 'none']) { + slideToggle.hideIcon = hide; + for (const [icon, state] of Object.entries(icons)) { + slideToggle.checked = state.checked; + slideToggle.disabled = state.disabled; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + const iconElement = slideToggleElement.querySelector(`${icon}`); + const otherIcons = Object.keys(icons) + .filter(key => key !== icon) + .map(key => slideToggleElement.querySelector(`${key}`)); + state.hide.includes(hide) + ? expect(iconElement).toBeFalsy() + : expect(iconElement).toBeTruthy(); + if (iconElement) { + expect(iconElement.textContent?.trim()).toBe(state.text); + } + otherIcons.forEach(otherIcon => expect(otherIcon).toBeFalsy()); + } + } + })); }); describe('MatSlideToggle with forms', () => { @@ -900,7 +1026,7 @@ class SlideToggleBasic { toggleTriggered = 0; dragTriggered = 0; direction: Direction = 'ltr'; - hideIcon = false; + hideIcon: '' | 'both' | 'checked' | 'unchecked' | 'none' | boolean = 'none'; disabledInteractive = false; onSlideClick: (event?: Event) => void = () => {}; @@ -990,6 +1116,15 @@ class TextBindingComponent { text: string = 'Some text'; } +@Component({ + template: `light_mode + dark_mode + lock + lock_open + `, + imports: [MatSlideToggleModule, BidiModule], +}) +class SlideToggleProjectedLabelAndCustomIcons {} @Component({ template: ` diff --git a/src/material/slide-toggle/slide-toggle.ts b/src/material/slide-toggle/slide-toggle.ts index 2b0df39012c4..9ea368892132 100644 --- a/src/material/slide-toggle/slide-toggle.ts +++ b/src/material/slide-toggle/slide-toggle.ts @@ -46,6 +46,7 @@ import { MatRipple, } from '../core'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; +import {NgTemplateOutlet} from '@angular/common'; /** Change event object emitted by a slide toggle. */ export class MatSlideToggleChange { @@ -89,7 +90,7 @@ export class MatSlideToggleChange { multi: true, }, ], - imports: [MatRipple, _MatInternalFormField], + imports: [MatRipple, _MatInternalFormField, NgTemplateOutlet], }) export class MatSlideToggle implements OnDestroy, AfterContentInit, OnChanges, ControlValueAccessor, Validator @@ -184,7 +185,7 @@ export class MatSlideToggle } /** Whether to hide the icon inside of the slide toggle. */ - @Input({transform: booleanAttribute}) hideIcon: boolean; + @Input({transform: _defaultBoth}) hideIcon: 'both' | 'checked' | 'unchecked' | 'none' = 'none'; /** Whether the slide toggle should remain interactive when it is disabled. */ @Input({transform: booleanAttribute}) disabledInteractive: boolean; @@ -214,7 +215,8 @@ export class MatSlideToggle this.tabIndex = tabIndex == null ? 0 : parseInt(tabIndex) || 0; this.color = defaults.color || 'accent'; this.id = this._uniqueId = inject(_IdGenerator).getId('mat-mdc-slide-toggle-'); - this.hideIcon = defaults.hideIcon ?? false; + + this.hideIcon = defaults.hideIcon ?? 'none'; this.disabledInteractive = defaults.disabledInteractive ?? false; this._labelId = this._uniqueId + '-label'; } @@ -317,3 +319,14 @@ export class MatSlideToggle return this.ariaLabel ? null : this._labelId; } } +function _defaultBoth( + value: 'both' | 'checked' | 'unchecked' | '' | boolean | undefined, +): 'both' | 'checked' | 'unchecked' | 'none' { + if (value === '' || value === true) { + return 'both'; + } + if (value === undefined || value === false) { + return 'none'; + } + return value as 'checked' | 'unchecked' | 'none'; +} diff --git a/src/universal-app/kitchen-sink/kitchen-sink.html b/src/universal-app/kitchen-sink/kitchen-sink.html index 7b97a7860eca..61a42997d9a4 100644 --- a/src/universal-app/kitchen-sink/kitchen-sink.html +++ b/src/universal-app/kitchen-sink/kitchen-sink.html @@ -113,9 +113,9 @@

Disabled datepicker

Timepicker

Pick a time - - - + + +

Dialog

@@ -292,6 +292,16 @@

Slide-toggle

with a label + + light_mode + dark_mode + with a custom icon + + lock + lock_open + with a custom disabled icon

Slider