From b730ba856c4c38affe641cf7cafe61c02b5da381 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 25 Apr 2019 23:11:59 +0200 Subject: [PATCH] prototype(slide-toggle): create prototype menu based on MDC Web (#15893) Creates a prototype of `mat-slide-toggle` that uses MDC web. --- e2e/components/mdc-slide-toggle-e2e.spec.ts | 76 +- .../mdc-slide-toggle-demo-module.ts | 2 + .../mdc-slide-toggle-demo.html | 31 +- .../mdc-slide-toggle-demo.scss | 10 +- .../mdc-slide-toggle/mdc-slide-toggle-demo.ts | 5 + .../mdc-slide-toggle-e2e.html | 3 +- .../mdc-slide-toggle/mdc-slide-toggle-e2e.ts | 4 +- .../mdc-slide-toggle/BUILD.bazel | 12 + .../mdc-slide-toggle/README.md | 94 ++- .../mdc-slide-toggle/_mdc-slide-toggle.scss | 61 +- .../mdc-slide-toggle/module.ts | 4 +- .../mdc-slide-toggle/public-api.ts | 1 + .../mdc-slide-toggle/slide-toggle-config.ts | 28 + .../mdc-slide-toggle/slide-toggle.html | 38 +- .../mdc-slide-toggle/slide-toggle.scss | 44 +- .../mdc-slide-toggle/slide-toggle.spec.ts | 787 +++++++++++++++++- .../mdc-slide-toggle/slide-toggle.ts | 300 ++++++- .../kitchen-sink-mdc/kitchen-sink-mdc.html | 6 +- 18 files changed, 1485 insertions(+), 21 deletions(-) create mode 100644 src/material-experimental/mdc-slide-toggle/slide-toggle-config.ts diff --git a/e2e/components/mdc-slide-toggle-e2e.spec.ts b/e2e/components/mdc-slide-toggle-e2e.spec.ts index f80d2210c57..6d0817f5af2 100644 --- a/e2e/components/mdc-slide-toggle-e2e.spec.ts +++ b/e2e/components/mdc-slide-toggle-e2e.spec.ts @@ -1 +1,75 @@ -// TODO: copy tests from existing mat-slide-toggle, update as necessary to fix. +import {browser, element, by, Key} from 'protractor'; +import {expectToExist} from '../util/index'; + + +describe('slide-toggle', () => { + const getInput = () => element(by.css('#normal-slide-toggle input')); + const getNormalToggle = () => element(by.css('#normal-slide-toggle')); + + beforeEach(async () => await browser.get('mdc-slide-toggle')); + + it('should render a slide-toggle', async () => { + await expectToExist('mat-slide-toggle'); + }); + + it('should change the checked state on click', async () => { + const inputEl = getInput(); + + expect(await inputEl.getAttribute('checked')) + .toBeFalsy('Expect slide-toggle to be unchecked'); + + await getNormalToggle().click(); + + expect(await inputEl.getAttribute('checked')) + .toBeTruthy('Expect slide-toggle to be checked'); + }); + + it('should change the checked state on click', async () => { + const inputEl = getInput(); + + expect(await inputEl.getAttribute('checked')) + .toBeFalsy('Expect slide-toggle to be unchecked'); + + await getNormalToggle().click(); + + expect(await inputEl.getAttribute('checked')) + .toBeTruthy('Expect slide-toggle to be checked'); + }); + + it('should not change the checked state on click when disabled', async () => { + const inputEl = getInput(); + + expect(await inputEl.getAttribute('checked')) + .toBeFalsy('Expect slide-toggle to be unchecked'); + + await element(by.css('#disabled-slide-toggle')).click(); + + expect(await inputEl.getAttribute('checked')) + .toBeFalsy('Expect slide-toggle to be unchecked'); + }); + + it('should move the thumb on state change', async () => { + const slideToggleEl = getNormalToggle(); + const thumbEl = element(by.css('#normal-slide-toggle .mdc-switch__thumb-underlay')); + const previousPosition = await thumbEl.getLocation(); + + await slideToggleEl.click(); + + const position = await thumbEl.getLocation(); + + expect(position.x).not.toBe(previousPosition.x); + }); + + it('should toggle the slide-toggle on space key', async () => { + const inputEl = getInput(); + + expect(await inputEl.getAttribute('checked')) + .toBeFalsy('Expect slide-toggle to be unchecked'); + + await inputEl.sendKeys(Key.SPACE); + + expect(await inputEl.getAttribute('checked')) + .toBeTruthy('Expect slide-toggle to be checked'); + }); + +}); diff --git a/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo-module.ts b/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo-module.ts index ca9c0b9dc0f..1b1a39a956e 100644 --- a/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo-module.ts +++ b/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo-module.ts @@ -9,12 +9,14 @@ import {NgModule} from '@angular/core'; import {MatSlideToggleModule} from '@angular/material-experimental/mdc-slide-toggle'; import {RouterModule} from '@angular/router'; +import {FormsModule} from '@angular/forms'; import {MdcSlideToggleDemo} from './mdc-slide-toggle-demo'; @NgModule({ imports: [ MatSlideToggleModule, RouterModule.forChild([{path: '', component: MdcSlideToggleDemo}]), + FormsModule, ], declarations: [MdcSlideToggleDemo], }) diff --git a/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo.html b/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo.html index af5734fc032..105158669f2 100644 --- a/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo.html +++ b/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo.html @@ -1,2 +1,29 @@ - -Not yet implemented. +
+ + + Default Slide Toggle + + + + Disabled Slide Toggle + + + + Disable Bound + + +

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

+ +
+ + + Slide Toggle + + +

+ +

+ +
+ +
diff --git a/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo.scss b/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo.scss index 670f248f709..18cfedd33d1 100644 --- a/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo.scss +++ b/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo.scss @@ -1 +1,9 @@ -// TODO: copy in demo styles from existing mat-slide-toggle demo. +.demo-slide-toggle { + display: flex; + flex-direction: column; + align-items: flex-start; + + mat-slide-toggle { + margin: 6px 0; + } +} diff --git a/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo.ts b/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo.ts index 5891e30f243..18d99d1e0c2 100644 --- a/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo.ts +++ b/src/dev-app/mdc-slide-toggle/mdc-slide-toggle-demo.ts @@ -15,4 +15,9 @@ import {Component} from '@angular/core'; styleUrls: ['mdc-slide-toggle-demo.css'], }) export class MdcSlideToggleDemo { + firstToggle: boolean; + + onFormSubmit() { + alert(`You submitted the form.`); + } } diff --git a/src/e2e-app/mdc-slide-toggle/mdc-slide-toggle-e2e.html b/src/e2e-app/mdc-slide-toggle/mdc-slide-toggle-e2e.html index 9d3d9f8d528..23eac9f2c5a 100644 --- a/src/e2e-app/mdc-slide-toggle/mdc-slide-toggle-e2e.html +++ b/src/e2e-app/mdc-slide-toggle/mdc-slide-toggle-e2e.html @@ -1 +1,2 @@ - +Slide Toggle +Disabled Slide Toggle diff --git a/src/e2e-app/mdc-slide-toggle/mdc-slide-toggle-e2e.ts b/src/e2e-app/mdc-slide-toggle/mdc-slide-toggle-e2e.ts index f0ac13d3e2f..9284cd286ac 100644 --- a/src/e2e-app/mdc-slide-toggle/mdc-slide-toggle-e2e.ts +++ b/src/e2e-app/mdc-slide-toggle/mdc-slide-toggle-e2e.ts @@ -13,6 +13,4 @@ import {Component} from '@angular/core'; selector: 'mdc-slide-toggle-e2e', templateUrl: 'mdc-slide-toggle-e2e.html', }) -export class MdcSlideToggleE2e { - // TODO: copy implementation from existing mat-slide-toggle e2e page. -} +export class MdcSlideToggleE2e {} diff --git a/src/material-experimental/mdc-slide-toggle/BUILD.bazel b/src/material-experimental/mdc-slide-toggle/BUILD.bazel index 29aa0050db9..2c39465fa8a 100644 --- a/src/material-experimental/mdc-slide-toggle/BUILD.bazel +++ b/src/material-experimental/mdc-slide-toggle/BUILD.bazel @@ -10,6 +10,10 @@ ng_module( module_name = "@angular/material-experimental/mdc-slide-toggle", assets = [":slide_toggle_scss"] + glob(["**/*.html"]), deps = [ + "@npm//@angular/common", + "@npm//@angular/core", + "@npm//@angular/forms", + "@npm//@angular/animations", "@npm//material-components-web", ] + CDK_TARGETS + MATERIAL_TARGETS, ) @@ -20,10 +24,18 @@ sass_library( deps = [ "//src/material/core:core_scss_lib", "//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib", + "//src/material-experimental/mdc-helpers:mdc_scss_deps_lib", ], ) sass_binary( name = "slide_toggle_scss", src = "slide-toggle.scss", + include_paths = [ + "external/npm/node_modules", + ], + deps = [ + ":slide_toggle_scss_lib", + "//src/material-experimental/mdc-helpers:mdc_scss_deps_lib", + ] ) diff --git a/src/material-experimental/mdc-slide-toggle/README.md b/src/material-experimental/mdc-slide-toggle/README.md index 1ab1acdd7fa..e740302528c 100644 --- a/src/material-experimental/mdc-slide-toggle/README.md +++ b/src/material-experimental/mdc-slide-toggle/README.md @@ -1 +1,93 @@ -This is a placeholder for the MDC-based implementation of slide toggle. +This is prototype of an alternate version of `` built on top of +[MDC Web](https://github.com/material-components/material-components-web). It demonstrates how +Angular Material could use MDC Web under the hood while still exposing the same API Angular users as +the existing ``. This component is experimental and should not be used in production. + +## How to use +Assuming your application is already up and running using Angular Material, you can add this +component by following these steps: + +1. Install Angular Material Experimental & MDC WEB: + + ```bash + npm i material-components-web @angular/material-experimental + ``` + +2. In your `angular.json`, make sure `node_modules/` is listed as a Sass include path. This is + needed for the Sass compiler to be able to find the MDC Web Sass files. + + ```json + ... + "styles": [ + "src/styles.scss" + ], + "stylePreprocessorOptions": { + "includePaths": [ + "node_modules/" + ] + }, + ... + ``` + +3. Import the experimental `MatSlideToggleModule` and add it to the module that declares your + component: + + ```ts + import {MatSlideToggleModule} from '@angular/material-experimental/mdc-slide-toggle'; + + @NgModule({ + declarations: [MyComponent], + imports: [MatSlideToggleModule], + }) + export class MyModule {} + ``` + +4. Add use `` in your component's template, just like you would the normal + ``: + + ```html + Toggle me + ``` + +5. Add the theme and typography mixins to your Sass. (There is currently no pre-built CSS option for + the experimental ``): + + ```scss + @import '~@angular/material/theming'; + @import '~@angular/material-experimental/mdc-slide-toggle'; + + $my-primary: mat-palette($mat-indigo); + $my-accent: mat-palette($mat-pink, A200, A100, A400); + $my-theme: mat-light-theme($my-primary, $my-accent); + + @include mat-slide-toggle-theme-mdc($my-theme); + @include mat-slide-toggle-typography-mdc(); + ``` + +## API differences +The experimental slide toggle API closely matches the +[API of the standard slide toggle](https://material.angular.io/components/slide-toggle/api). +`@angular/material-experimental/mdc-slide-toggle` exports symbols with the same name and public +interface as all of the symbols found under `@angular/material/slide-toggle`, except for the +following differences: + +* The MDC-based `mat-slide-toggle` drops the dependency on Hammer.js and as a result doesn't support +dragging gestures. +* As a result of dragging gestures not being supported, the `dragChange` event won't emit. + +## Replacing the standard slide toggle in an existing app +Because the experimental API mirrors the API for the standard slide toggle, it can easily be swapped +in by just changing the import paths. There is currently no schematic for this, but you can run the +following string replace across your TypeScript files: + +```bash +grep -lr --include="*.ts" --exclude-dir="node_modules" \ + --exclude="*.d.ts" "['\"]@angular/material/slide-toggle['\"]" | xargs sed -i \ + "s/['\"]@angular\/material\/slide-toggle['\"]/'@angular\/material-experimental\/mdc-slide-toggle'/g" +``` + +CSS styles and tests that depend on implementation details of mat-slide-toggle (such as getting +elements from the template by class name) will need to be manually updated. + +There are some small visual differences between this slide and the standard mat-slide. This +slide has a slightly larger ripple and different spacing between the label and the toggle. diff --git a/src/material-experimental/mdc-slide-toggle/_mdc-slide-toggle.scss b/src/material-experimental/mdc-slide-toggle/_mdc-slide-toggle.scss index e90515bcca0..3eea1af678e 100644 --- a/src/material-experimental/mdc-slide-toggle/_mdc-slide-toggle.scss +++ b/src/material-experimental/mdc-slide-toggle/_mdc-slide-toggle.scss @@ -1,13 +1,70 @@ +@import '@material/switch/mixins'; +@import '@material/switch/variables'; +@import '@material/form-field/mixins'; +@import '@material/ripple/variables'; +@import '@material/theme/functions'; @import '../mdc-helpers/mdc-helpers'; @mixin mat-slide-toggle-theme-mdc($theme) { + $primary: mat-color(map-get($theme, primary)); + $accent: mat-color(map-get($theme, accent)); + $warn: mat-color(map-get($theme, warn)); + + // Save original values of MDC global variables. We need to save these so we can restore the + // variables to their original values and prevent unintended side effects from using this mixin. + $orig-mdc-switch-baseline-theme-color: $mdc-switch-baseline-theme-color; + @include mat-using-mdc-theme($theme) { - // TODO: MDC theme styles here. + $mdc-switch-baseline-theme-color: primary !global; + + @include mdc-switch-without-ripple($query: $mat-theme-styles-query); + @include mdc-form-field-core-styles($query: $mat-theme-styles-query); + + // MDC's switch doesn't support a `color` property. We add support + // for it by adding a CSS class for accent and warn style. + .mat-mdc-slide-toggle { + .mdc-switch__thumb-underlay::before, .mat-ripple-element { + background: $mdc-switch-toggled-off-ripple-color; + } + + &.mat-accent { + $mdc-switch-baseline-theme-color: secondary !global; + @include mdc-switch-without-ripple($query: $mat-theme-styles-query); + } + + &.mat-warn { + $mdc-switch-baseline-theme-color: error !global; + @include mdc-switch-without-ripple($query: $mat-theme-styles-query); + } + } + + // The ripple color matches the palette only when it's checked. + .mat-mdc-slide-toggle-checked { + .mdc-switch__thumb-underlay::before, .mat-ripple-element { + background: $primary; + } + + &.mat-accent { + .mdc-switch__thumb-underlay::before, .mat-ripple-element { + background: $accent; + } + } + + &.mat-warn { + .mdc-switch__thumb-underlay::before, .mat-ripple-element { + background: $warn; + } + } + } } + + // Restore original values of MDC global variables. + $mdc-switch-baseline-theme-color: $orig-mdc-switch-baseline-theme-color !global; } @mixin mat-slide-toggle-typography-mdc($config) { @include mat-using-mdc-typography($config) { - // TODO: MDC typography styles here. + @include mdc-switch-without-ripple($query: $mat-typography-styles-query); + @include mdc-form-field-core-styles($query: $mat-typography-styles-query); } } diff --git a/src/material-experimental/mdc-slide-toggle/module.ts b/src/material-experimental/mdc-slide-toggle/module.ts index 240364437fe..b1e4ad5737c 100644 --- a/src/material-experimental/mdc-slide-toggle/module.ts +++ b/src/material-experimental/mdc-slide-toggle/module.ts @@ -8,11 +8,11 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; -import {MatCommonModule} from '@angular/material/core'; +import {MatCommonModule, MatRippleModule} from '@angular/material/core'; import {MatSlideToggle} from './slide-toggle'; @NgModule({ - imports: [MatCommonModule, CommonModule], + imports: [MatCommonModule, MatRippleModule, CommonModule], exports: [MatSlideToggle, MatCommonModule], declarations: [MatSlideToggle], }) diff --git a/src/material-experimental/mdc-slide-toggle/public-api.ts b/src/material-experimental/mdc-slide-toggle/public-api.ts index 3f2133d6121..8c10fb2b799 100644 --- a/src/material-experimental/mdc-slide-toggle/public-api.ts +++ b/src/material-experimental/mdc-slide-toggle/public-api.ts @@ -7,4 +7,5 @@ */ export * from './slide-toggle'; +export * from './slide-toggle-config'; export * from './module'; diff --git a/src/material-experimental/mdc-slide-toggle/slide-toggle-config.ts b/src/material-experimental/mdc-slide-toggle/slide-toggle-config.ts new file mode 100644 index 00000000000..e83bba485c9 --- /dev/null +++ b/src/material-experimental/mdc-slide-toggle/slide-toggle-config.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {InjectionToken} from '@angular/core'; + + +/** Default `mat-slide-toggle` options that can be overridden. */ +export interface MatSlideToggleDefaultOptions { + /** Whether toggle action triggers value changes in slide toggle. */ + disableToggleValue?: boolean; + /** + * Whether drag action triggers value changes in slide toggle. + * @deprecated No longer being used. + * @breaking-change 9.0.0. + */ + disableDragValue?: boolean; +} + +/** Injection token to be used to override the default options for `mat-slide-toggle`. */ +export const MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS = + new InjectionToken('mat-slide-toggle-default-options', { + providedIn: 'root', + factory: () => ({disableToggleValue: false, disableDragValue: false}) + }); diff --git a/src/material-experimental/mdc-slide-toggle/slide-toggle.html b/src/material-experimental/mdc-slide-toggle/slide-toggle.html index 9c75ddb6f47..b617042356d 100644 --- a/src/material-experimental/mdc-slide-toggle/slide-toggle.html +++ b/src/material-experimental/mdc-slide-toggle/slide-toggle.html @@ -1 +1,37 @@ - +
+
+
+
+
+
+ +
+
+
+ + +
diff --git a/src/material-experimental/mdc-slide-toggle/slide-toggle.scss b/src/material-experimental/mdc-slide-toggle/slide-toggle.scss index f61450904c9..c79e938a012 100644 --- a/src/material-experimental/mdc-slide-toggle/slide-toggle.scss +++ b/src/material-experimental/mdc-slide-toggle/slide-toggle.scss @@ -1 +1,43 @@ -// TODO: MDC core styles here. +@import '@material/switch/functions'; +@import '@material/switch/mixins'; +@import '@material/form-field/mixins'; +@import '@material/ripple/variables'; +@import '../mdc-helpers/mdc-helpers'; +@import '../../material/core/style/layout-common'; + +@include mdc-switch-without-ripple($query: $mat-base-styles-query); +@include mdc-form-field-core-styles($query: $mat-base-styles-query); + +.mat-mdc-slide-toggle { + display: inline-block; + + // The ripple needs extra specificity so the base ripple styling doesn't override its `position`. + .mat-mdc-slide-toggle-ripple, .mdc-switch__thumb-underlay::before { + @include mat-fill; + } + + // The MDC switch styles related to the hover state are intertwined with the MDC ripple styles. + // We currently don't use the MDC ripple due to size concerns, therefore we need to add some + // additional styles to restore the hover state. + .mdc-switch__thumb-underlay::before { + border-radius: 50%; + content: ''; + opacity: 0; + } + + .mdc-switch:hover .mdc-switch__thumb-underlay::before { + opacity: map-get($mdc-ripple-dark-ink-opacities, hover); + transition: mdc-switch-transition-enter(opacity, 0, 75ms); + } + + // Needs a little more specificity so the :hover styles don't override it. + &.mat-mdc-slide-toggle-focused .mdc-switch .mdc-switch__thumb-underlay::before { + opacity: map-get($mdc-ripple-dark-ink-opacities, focus); + } + + // We use an Angular Material ripple rather than an MDC ripple due to size concerns, so we need to + // style it appropriately. + .mat-ripple-element { + opacity: map-get($mdc-ripple-dark-ink-opacities, press); + } +} diff --git a/src/material-experimental/mdc-slide-toggle/slide-toggle.spec.ts b/src/material-experimental/mdc-slide-toggle/slide-toggle.spec.ts index f80d2210c57..8dfa7bc6662 100644 --- a/src/material-experimental/mdc-slide-toggle/slide-toggle.spec.ts +++ b/src/material-experimental/mdc-slide-toggle/slide-toggle.spec.ts @@ -1 +1,786 @@ -// TODO: copy tests from existing mat-slide-toggle, update as necessary to fix. +import {BidiModule, Direction} from '@angular/cdk/bidi'; +import {dispatchFakeEvent} from '@angular/cdk/testing'; +import {Component} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + flush, + flushMicrotasks, + TestBed, + tick, +} from '@angular/core/testing'; +import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms'; +import {By} from '@angular/platform-browser'; +import {MatSlideToggle, MatSlideToggleChange, MatSlideToggleModule} from './index'; +import {MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS} from './slide-toggle-config'; + +describe('MatSlideToggle without forms', () => { + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [MatSlideToggleModule, BidiModule], + declarations: [ + SlideToggleBasic, + SlideToggleWithTabindexAttr, + SlideToggleWithoutLabel, + SlideToggleProjectedLabel, + TextBindingComponent, + ] + }); + + TestBed.compileComponents(); + })); + + describe('basic behavior', () => { + let fixture: ComponentFixture; + + let testComponent: SlideToggleBasic; + let slideToggle: MatSlideToggle; + let slideToggleElement: HTMLElement; + let labelElement: HTMLLabelElement; + let inputElement: HTMLInputElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(SlideToggleBasic); + + // Enable jasmine spies on event functions, which may trigger at initialization + // of the slide-toggle component. + spyOn(fixture.debugElement.componentInstance, 'onSlideChange').and.callThrough(); + spyOn(fixture.debugElement.componentInstance, 'onSlideClick').and.callThrough(); + + // Initialize the slide-toggle component, by triggering the first change detection cycle. + fixture.detectChanges(); + + const slideToggleDebug = fixture.debugElement.query(By.css('mat-slide-toggle')); + + testComponent = fixture.debugElement.componentInstance; + slideToggle = slideToggleDebug.componentInstance; + slideToggleElement = slideToggleDebug.nativeElement; + inputElement = fixture.debugElement.query(By.css('input')).nativeElement; + labelElement = fixture.debugElement.query(By.css('label')).nativeElement; + })); + + it('should apply class based on color attribute', () => { + testComponent.slideColor = 'primary'; + fixture.detectChanges(); + + expect(slideToggleElement.classList).toContain('mat-primary'); + + testComponent.slideColor = 'accent'; + fixture.detectChanges(); + + expect(slideToggleElement.classList).toContain('mat-accent'); + }); + + it('should correctly update the disabled property', () => { + expect(inputElement.disabled).toBeFalsy(); + + testComponent.isDisabled = true; + fixture.detectChanges(); + + expect(inputElement.disabled).toBeTruthy(); + }); + + it('should correctly update the checked property', () => { + expect(slideToggle.checked).toBeFalsy(); + expect(inputElement.getAttribute('aria-checked')).toBe('false'); + + testComponent.slideChecked = true; + fixture.detectChanges(); + + expect(inputElement.checked).toBeTruthy(); + expect(inputElement.getAttribute('aria-checked')).toBe('true'); + }); + + it('should set the toggle to checked on click', () => { + expect(slideToggle.checked).toBe(false); + expect(inputElement.getAttribute('aria-checked')).toBe('false'); + expect(slideToggleElement.classList).not.toContain('mat-mdc-slide-toggle-checked'); + + labelElement.click(); + fixture.detectChanges(); + + expect(slideToggleElement.classList).toContain('mat-mdc-slide-toggle-checked'); + expect(slideToggle.checked).toBe(true); + expect(inputElement.getAttribute('aria-checked')).toBe('true'); + }); + + it('should trigger the change event properly', () => { + expect(inputElement.checked).toBe(false); + expect(slideToggleElement.classList).not.toContain('mat-mdc-slide-toggle-checked'); + + labelElement.click(); + fixture.detectChanges(); + + expect(inputElement.checked).toBe(true); + expect(slideToggleElement.classList).toContain('mat-mdc-slide-toggle-checked'); + expect(testComponent.onSlideChange).toHaveBeenCalledTimes(1); + }); + + it('should not trigger the change event by changing the native value', fakeAsync(() => { + expect(inputElement.checked).toBe(false); + expect(slideToggleElement.classList).not.toContain('mat-mdc-slide-toggle-checked'); + + testComponent.slideChecked = true; + fixture.detectChanges(); + + expect(inputElement.checked).toBe(true); + expect(slideToggleElement.classList).toContain('mat-mdc-slide-toggle-checked'); + tick(); + + expect(testComponent.onSlideChange).not.toHaveBeenCalled(); + })); + + it('should not trigger the change event on initialization', fakeAsync(() => { + expect(inputElement.checked).toBe(false); + expect(slideToggleElement.classList).not.toContain('mat-mdc-slide-toggle-checked'); + + testComponent.slideChecked = true; + fixture.detectChanges(); + + expect(inputElement.checked).toBe(true); + expect(slideToggleElement.classList).toContain('mat-mdc-slide-toggle-checked'); + tick(); + + expect(testComponent.onSlideChange).not.toHaveBeenCalled(); + })); + + it('should add a suffix to the inputs id', () => { + testComponent.slideId = 'myId'; + fixture.detectChanges(); + + expect(slideToggleElement.id).toBe('myId'); + expect(inputElement.id).toBe(`${slideToggleElement.id}-input`); + + testComponent.slideId = 'nextId'; + fixture.detectChanges(); + + expect(slideToggleElement.id).toBe('nextId'); + expect(inputElement.id).toBe(`${slideToggleElement.id}-input`); + + testComponent.slideId = null; + fixture.detectChanges(); + + // Once the id binding is set to null, the id property should auto-generate a unique id. + expect(inputElement.id).toMatch(/mat-slide-toggle-\d+-input/); + }); + + it('should forward the tabIndex to the underlying input', () => { + fixture.detectChanges(); + + expect(inputElement.tabIndex).toBe(0); + + testComponent.slideTabindex = 4; + fixture.detectChanges(); + + expect(inputElement.tabIndex).toBe(4); + }); + + it('should forward the specified name to the input', () => { + testComponent.slideName = 'myName'; + fixture.detectChanges(); + + expect(inputElement.name).toBe('myName'); + + testComponent.slideName = 'nextName'; + fixture.detectChanges(); + + expect(inputElement.name).toBe('nextName'); + + testComponent.slideName = null; + fixture.detectChanges(); + + expect(inputElement.name).toBe(''); + }); + + it('should forward the aria-label attribute to the input', () => { + testComponent.slideLabel = 'ariaLabel'; + fixture.detectChanges(); + + expect(inputElement.getAttribute('aria-label')).toBe('ariaLabel'); + + testComponent.slideLabel = null; + fixture.detectChanges(); + + expect(inputElement.hasAttribute('aria-label')).toBeFalsy(); + }); + + it('should forward the aria-labelledby attribute to the input', () => { + testComponent.slideLabelledBy = 'ariaLabelledBy'; + fixture.detectChanges(); + + expect(inputElement.getAttribute('aria-labelledby')).toBe('ariaLabelledBy'); + + testComponent.slideLabelledBy = null; + fixture.detectChanges(); + + expect(inputElement.hasAttribute('aria-labelledby')).toBeFalsy(); + }); + + it('should set the `for` attribute to the id of the input element', () => { + expect(labelElement.getAttribute('for')).toBeTruthy(); + expect(inputElement.getAttribute('id')).toBeTruthy(); + expect(labelElement.getAttribute('for')).toBe(inputElement.getAttribute('id')); + }); + + it('should emit the new values properly', fakeAsync(() => { + labelElement.click(); + fixture.detectChanges(); + tick(); + + // We're checking the arguments type / emitted value to be a boolean, because sometimes the + // emitted value can be a DOM Event, which is not valid. + // See angular/angular#4059 + expect(testComponent.lastEvent.checked).toBe(true); + })); + + it('should support subscription on the change observable', fakeAsync(() => { + const spy = jasmine.createSpy('change spy'); + const subscription = slideToggle.change.subscribe(spy); + + labelElement.click(); + fixture.detectChanges(); + tick(); + + expect(spy).toHaveBeenCalledWith(jasmine.objectContaining({checked: true})); + subscription.unsubscribe(); + })); + + it('should forward the required attribute', () => { + testComponent.isRequired = true; + fixture.detectChanges(); + + expect(inputElement.required).toBe(true); + + testComponent.isRequired = false; + fixture.detectChanges(); + + expect(inputElement.required).toBe(false); + }); + + it('should focus on underlying input element when focus() is called', () => { + expect(document.activeElement).not.toBe(inputElement); + + slideToggle.focus(); + fixture.detectChanges(); + + expect(document.activeElement).toBe(inputElement); + }); + + it('should set a element class if labelPosition is set to before', () => { + const formField = slideToggleElement.querySelector('.mdc-form-field')!; + + expect(formField.classList).not.toContain('mdc-form-field--align-end'); + + testComponent.labelPosition = 'before'; + fixture.detectChanges(); + + expect(formField.classList).toContain('mdc-form-field--align-end'); + }); + + it('should show ripples on switch element', () => { + const rippleSelector = '.mat-ripple-element'; + const switchElement = slideToggleElement.querySelector('.mdc-switch')!; + + expect(slideToggleElement.querySelectorAll(rippleSelector).length).toBe(0); + + dispatchFakeEvent(switchElement, 'mousedown'); + dispatchFakeEvent(switchElement, 'mouseup'); + + expect(slideToggleElement.querySelectorAll(rippleSelector).length).toBe(1); + }); + + it('should not show ripples when disableRipple is set', () => { + const switchElement = slideToggleElement.querySelector('.mdc-switch')!; + const rippleSelector = '.mat-ripple-element'; + testComponent.disableRipple = true; + fixture.detectChanges(); + + expect(slideToggleElement.querySelectorAll(rippleSelector).length).toBe(0); + + dispatchFakeEvent(switchElement, 'mousedown'); + dispatchFakeEvent(switchElement, 'mouseup'); + + expect(slideToggleElement.querySelectorAll(rippleSelector).length).toBe(0); + }); + }); + + describe('custom template', () => { + it('should not trigger the change event on initialization', fakeAsync(() => { + const fixture = TestBed.createComponent(SlideToggleBasic); + + fixture.componentInstance.slideChecked = true; + fixture.detectChanges(); + + expect(fixture.componentInstance.lastEvent).toBeFalsy(); + })); + + it('should be able to set the tabindex via the native attribute', fakeAsync(() => { + const fixture = TestBed.createComponent(SlideToggleWithTabindexAttr); + + fixture.detectChanges(); + + const slideToggle = fixture.debugElement + .query(By.directive(MatSlideToggle)).componentInstance as MatSlideToggle; + + expect(slideToggle.tabIndex) + .toBe(5, 'Expected tabIndex property to have been set based on the native attribute'); + })); + + it('should remove the tabindex from the host element', fakeAsync(() => { + const fixture = TestBed.createComponent(SlideToggleWithTabindexAttr); + + fixture.detectChanges(); + + const slideToggle = fixture.debugElement.query(By.directive(MatSlideToggle)).nativeElement; + expect(slideToggle.hasAttribute('tabindex')).toBe(false); + })); + + it('should remove the tabindex from the host element when disabled', fakeAsync(() => { + const fixture = TestBed.createComponent(SlideToggleWithTabindexAttr); + + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + const slideToggle = fixture.debugElement.query(By.directive(MatSlideToggle)).nativeElement; + expect(slideToggle.hasAttribute('tabindex')).toBe(false); + })); + }); + + it('should not change value on click when click action is noop when using custom a ' + + 'action configuration', fakeAsync(() => { + TestBed + .resetTestingModule() + .configureTestingModule({ + imports: [MatSlideToggleModule], + declarations: [SlideToggleBasic], + providers: [{ + provide: MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS, useValue: {disableToggleValue: true} + }] + }); + const fixture = TestBed.createComponent(SlideToggleBasic); + fixture.detectChanges(); + + const testComponent = fixture.debugElement.componentInstance; + const slideToggleDebug = fixture.debugElement.query(By.css('mat-slide-toggle')); + + const slideToggle = slideToggleDebug.componentInstance; + const inputElement = fixture.debugElement.query(By.css('input')).nativeElement; + const labelElement = fixture.debugElement.query(By.css('label')).nativeElement; + + expect(testComponent.toggleTriggered).toBe(0); + expect(testComponent.dragTriggered).toBe(0); + expect(slideToggle.checked).toBe(false, 'Expect slide toggle value not changed'); + + labelElement.click(); + fixture.detectChanges(); + + expect(slideToggle.checked).toBe(false, 'Expect slide toggle value not changed'); + expect(testComponent.toggleTriggered).toBe(1, 'Expect toggle once'); + expect(testComponent.dragTriggered).toBe(0); + + inputElement.click(); + fixture.detectChanges(); + + expect(slideToggle.checked).toBe(false, 'Expect slide toggle value not changed'); + expect(testComponent.toggleTriggered).toBe(2, 'Expect toggle twice'); + expect(testComponent.dragTriggered).toBe(0); + })); +}); + +describe('MatSlideToggle with forms', () => { + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [MatSlideToggleModule, FormsModule, ReactiveFormsModule], + declarations: [ + SlideToggleWithForm, + SlideToggleWithModel, + SlideToggleWithFormControl, + SlideToggleWithModelAndChangeEvent, + ] + }); + + TestBed.compileComponents(); + })); + + describe('using ngModel', () => { + let fixture: ComponentFixture; + + let testComponent: SlideToggleWithModel; + let slideToggle: MatSlideToggle; + let slideToggleElement: HTMLElement; + let slideToggleModel: NgModel; + let inputElement: HTMLInputElement; + let labelElement: HTMLLabelElement; + + // This initialization is async() because it needs to wait for ngModel to set the initial value. + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(SlideToggleWithModel); + fixture.detectChanges(); + + const slideToggleDebug = fixture.debugElement.query(By.directive(MatSlideToggle)); + + testComponent = fixture.debugElement.componentInstance; + slideToggle = slideToggleDebug.componentInstance; + slideToggleElement = slideToggleDebug.nativeElement; + slideToggleModel = slideToggleDebug.injector.get(NgModel); + inputElement = fixture.debugElement.query(By.css('input')).nativeElement; + labelElement = fixture.debugElement.query(By.css('label')).nativeElement; + })); + + it('should be initially set to ng-pristine', () => { + expect(slideToggleElement.classList).toContain('ng-pristine'); + expect(slideToggleElement.classList).not.toContain('ng-dirty'); + }); + + it('should update the model programmatically', fakeAsync(() => { + expect(slideToggleElement.classList).not.toContain('mat-mdc-slide-toggle-checked'); + + testComponent.modelValue = true; + fixture.detectChanges(); + + // Flush the microtasks because the forms module updates the model state asynchronously. + flushMicrotasks(); + + fixture.detectChanges(); + expect(slideToggleElement.classList).toContain('mat-mdc-slide-toggle-checked'); + })); + + it('should have the correct control state initially and after interaction', fakeAsync(() => { + // The control should start off valid, pristine, and untouched. + expect(slideToggleModel.valid).toBe(true); + expect(slideToggleModel.pristine).toBe(true); + expect(slideToggleModel.touched).toBe(false); + + // After changing the value from the view, the control should + // become dirty (not pristine), but remain untouched if focus is still there. + slideToggle.checked = true; + + // Dispatch a change event on the input element to fake a user interaction that triggered + // the state change. + dispatchFakeEvent(inputElement, 'change'); + + expect(slideToggleModel.valid).toBe(true); + expect(slideToggleModel.pristine).toBe(false); + expect(slideToggleModel.touched).toBe(false); + + // Once the input element loses focus, the control should remain dirty but should + // also turn touched. + dispatchFakeEvent(inputElement, 'blur'); + fixture.detectChanges(); + flushMicrotasks(); + + expect(slideToggleModel.valid).toBe(true); + expect(slideToggleModel.pristine).toBe(false); + expect(slideToggleModel.touched).toBe(true); + })); + + it('should not throw an error when disabling while focused', fakeAsync(() => { + expect(() => { + // Focus the input element because after disabling, the `blur` event should automatically + // fire and not result in a changed after checked exception. Related: #12323 + inputElement.focus(); + + // Flush the two nested timeouts from the FocusMonitor that are being created on `focus`. + flush(); + + slideToggle.disabled = true; + fixture.detectChanges(); + flushMicrotasks(); + }).not.toThrow(); + })); + + it('should not set the control to touched when changing the state programmatically', + fakeAsync(() => { + + // The control should start off with being untouched. + expect(slideToggleModel.touched).toBe(false); + + slideToggle.checked = true; + fixture.detectChanges(); + + expect(slideToggleModel.touched).toBe(false); + expect(slideToggleElement.classList).toContain('mat-mdc-slide-toggle-checked'); + + // Once the input element loses focus, the control should remain dirty but should + // also turn touched. + dispatchFakeEvent(inputElement, 'blur'); + fixture.detectChanges(); + flushMicrotasks(); + + expect(slideToggleModel.touched).toBe(true); + expect(slideToggleElement.classList).toContain('mat-mdc-slide-toggle-checked'); + })); + + it('should not set the control to touched when changing the model', fakeAsync(() => { + // The control should start off with being untouched. + expect(slideToggleModel.touched).toBe(false); + + testComponent.modelValue = true; + fixture.detectChanges(); + + // Flush the microtasks because the forms module updates the model state asynchronously. + flushMicrotasks(); + + // The checked property has been updated from the model and now the view needs + // to reflect the state change. + fixture.detectChanges(); + + expect(slideToggleModel.touched).toBe(false); + expect(slideToggle.checked).toBe(true); + expect(slideToggleElement.classList).toContain('mat-mdc-slide-toggle-checked'); + })); + + it('should update checked state on click if control is checked initially', fakeAsync(() => { + fixture = TestBed.createComponent(SlideToggleWithModel); + slideToggle = fixture.debugElement.query(By.directive(MatSlideToggle)).componentInstance; + labelElement = fixture.debugElement.query(By.css('label')).nativeElement; + + fixture.componentInstance.modelValue = true; + fixture.detectChanges(); + + // Flush the microtasks because the forms module updates the model state asynchronously. + flushMicrotasks(); + + // Now the new checked variable has been updated in the slide-toggle and the slide-toggle + // is marked for check because it still needs to update the underlying input. + fixture.detectChanges(); + + expect(slideToggle.checked) + .toBe(true, 'Expected slide-toggle to be checked initially'); + + labelElement.click(); + fixture.detectChanges(); + tick(); + + expect(slideToggle.checked) + .toBe(false, 'Expected slide-toggle to be no longer checked after label click.'); + })); + + it('should be pristine if initial value is set from NgModel', fakeAsync(() => { + fixture = TestBed.createComponent(SlideToggleWithModel); + + fixture.componentInstance.modelValue = true; + fixture.detectChanges(); + + const debugElement = fixture.debugElement.query(By.directive(MatSlideToggle)); + const modelInstance = debugElement.injector.get(NgModel); + + // Flush the microtasks because the forms module updates the model state asynchronously. + flushMicrotasks(); + + expect(modelInstance.pristine).toBe(true); + })); + + it('should set the model value when toggling via the `toggle` method', fakeAsync(() => { + expect(testComponent.modelValue).toBe(false); + + fixture.debugElement.query(By.directive(MatSlideToggle)).componentInstance.toggle(); + fixture.detectChanges(); + flushMicrotasks(); + + fixture.detectChanges(); + expect(testComponent.modelValue).toBe(true); + })); + + }); + + describe('with a FormControl', () => { + let fixture: ComponentFixture; + + let testComponent: SlideToggleWithFormControl; + let slideToggle: MatSlideToggle; + let inputElement: HTMLInputElement; + + beforeEach(() => { + fixture = TestBed.createComponent(SlideToggleWithFormControl); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + slideToggle = fixture.debugElement.query(By.directive(MatSlideToggle)).componentInstance; + inputElement = fixture.debugElement.query(By.css('input')).nativeElement; + }); + + it('should toggle the disabled state', () => { + expect(slideToggle.disabled).toBe(false); + expect(inputElement.disabled).toBe(false); + + testComponent.formControl.disable(); + fixture.detectChanges(); + + expect(slideToggle.disabled).toBe(true); + expect(inputElement.disabled).toBe(true); + + testComponent.formControl.enable(); + fixture.detectChanges(); + + expect(slideToggle.disabled).toBe(false); + expect(inputElement.disabled).toBe(false); + }); + }); + + describe('with form element', () => { + let fixture: ComponentFixture; + let testComponent: SlideToggleWithForm; + let buttonElement: HTMLButtonElement; + let inputElement: HTMLInputElement; + + // This initialization is async() because it needs to wait for ngModel to set the initial value. + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(SlideToggleWithForm); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + buttonElement = fixture.debugElement.query(By.css('button')).nativeElement; + inputElement = fixture.debugElement.query(By.css('input')).nativeElement; + })); + + it('should prevent the form from submit when being required', () => { + if (typeof (inputElement as any).reportValidity === 'undefined') { + // If the browser does not report the validity then the tests will break. + // e.g Safari 8 on Mobile. + return; + } + + testComponent.isRequired = true; + + fixture.detectChanges(); + + buttonElement.click(); + fixture.detectChanges(); + + expect(testComponent.isSubmitted).toBe(false); + + testComponent.isRequired = false; + fixture.detectChanges(); + + buttonElement.click(); + fixture.detectChanges(); + + expect(testComponent.isSubmitted).toBe(true); + }); + }); + + describe('with model and change event', () => { + it('should report changes to NgModel before emitting change event', () => { + const fixture = TestBed.createComponent(SlideToggleWithModelAndChangeEvent); + fixture.detectChanges(); + + const labelEl = fixture.debugElement.query(By.css('label')).nativeElement; + + spyOn(fixture.componentInstance, 'onChange').and.callFake(() => { + expect(fixture.componentInstance.checked) + .toBe(true, 'Expected the model value to have changed before the change event fired.'); + }); + + labelEl.click(); + + expect(fixture.componentInstance.onChange).toHaveBeenCalledTimes(1); + }); + }); +}); + +@Component({ + template: ` + + Test Slide Toggle + `, +}) +class SlideToggleBasic { + isDisabled: boolean = false; + isRequired: boolean = false; + disableRipple: boolean = false; + slideChecked: boolean = false; + slideColor: string; + slideId: string | null; + slideName: string | null; + slideLabel: string | null; + slideLabelledBy: string | null; + slideTabindex: number; + lastEvent: MatSlideToggleChange; + labelPosition: string; + toggleTriggered: number = 0; + dragTriggered: number = 0; + direction: Direction = 'ltr'; + + onSlideClick: (event?: Event) => void = () => {}; + onSlideChange = (event: MatSlideToggleChange) => this.lastEvent = event; + onSlideToggleChange = () => this.toggleTriggered++; + onSlideDragChange = () => this.dragTriggered++; +} + +@Component({ + template: ` +
+ Required + +
` +}) +class SlideToggleWithForm { + isSubmitted: boolean = false; + isRequired: boolean = false; +} + +@Component({ + template: `` +}) +class SlideToggleWithModel { + modelValue = false; +} + +@Component({ + template: ` + + Test Slide Toggle + `, +}) +class SlideToggleWithFormControl { + formControl = new FormControl(); +} + +@Component({template: ``}) +class SlideToggleWithTabindexAttr { + disabled = false; +} + +@Component({ + template: `{{label}}` +}) +class SlideToggleWithoutLabel { + label: string; +} + +@Component({ + template: `` +}) +class SlideToggleWithModelAndChangeEvent { + checked: boolean; + onChange: () => void = () => {}; +} + +@Component({ + template: `` +}) +class SlideToggleProjectedLabel {} + +@Component({ + selector: 'some-text', + template: `{{text}}` +}) +class TextBindingComponent { + text: string = 'Some text'; +} diff --git a/src/material-experimental/mdc-slide-toggle/slide-toggle.ts b/src/material-experimental/mdc-slide-toggle/slide-toggle.ts index 9a078493a80..6947473eb7d 100644 --- a/src/material-experimental/mdc-slide-toggle/slide-toggle.ts +++ b/src/material-experimental/mdc-slide-toggle/slide-toggle.ts @@ -6,7 +6,51 @@ * found in the LICENSE file at https://angular.io/license */ -import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, + AfterViewInit, + OnDestroy, + forwardRef, + ViewChild, + ElementRef, + Input, + Output, + EventEmitter, + ChangeDetectorRef, + Attribute, + Inject, + Optional, +} from '@angular/core'; +import {MDCSwitchAdapter, MDCSwitchFoundation} from '@material/switch'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; +import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion'; +import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; +import {ThemePalette, RippleAnimationConfig} from '@angular/material/core'; +import { + MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS, + MatSlideToggleDefaultOptions, +} from './slide-toggle-config'; + +// Increasing integer for generating unique ids for slide-toggle components. +let nextUniqueId = 0; + +/** @docs-private */ +export const MAT_SLIDE_TOGGLE_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MatSlideToggle), + multi: true +}; + +/** Change event object emitted by a MatSlideToggle. */ +export class MatSlideToggleChange { + constructor( + /** The source MatSlideToggle of the event. */ + public source: MatSlideToggle, + /** The new `checked` value of the MatSlideToggle. */ + public checked: boolean) { } +} @Component({ moduleId: module.id, @@ -15,11 +59,261 @@ import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/co styleUrls: ['slide-toggle.css'], host: { 'class': 'mat-mdc-slide-toggle', + '[id]': 'id', + '[attr.tabindex]': 'null', + '[class.mat-primary]': 'color == "primary"', + '[class.mat-accent]': 'color == "accent"', + '[class.mat-warn]': 'color == "warn"', + '[class.mat-mdc-slide-toggle-focused]': '_focused', + '[class.mat-mdc-slide-toggle-checked]': 'checked', + '[class._mat-animation-noopable]': '_animationMode === "NoopAnimations"', + '(focus)': '_inputElement.nativeElement.focus()', }, exportAs: 'matSlideToggle', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, + providers: [MAT_SLIDE_TOGGLE_VALUE_ACCESSOR], + }) -export class MatSlideToggle { - // TODO: set up MDC foundation class. +export class MatSlideToggle implements ControlValueAccessor, AfterViewInit, OnDestroy { + private onChange = (_: any) => {}; + private onTouched = () => {}; + + private _uniqueId: string = `mat-slide-toggle-${++nextUniqueId}`; + private _required: boolean = false; + private _checked: boolean = false; + private _foundation: MDCSwitchFoundation; + private _adapter: MDCSwitchAdapter = { + addClass: (className) => { + this._toggleClass(className, true); + }, + removeClass: (className) => { + this._toggleClass(className, false); + }, + setNativeControlChecked: (checked) => { + this._checked = checked; + }, + setNativeControlDisabled: (disabled) => { + this._disabled = disabled; + }, + }; + + /** Whether the slide toggle is currently focused. */ + _focused: boolean; + + /** The set of classes that should be applied to the native input. */ + _classes: {[key: string]: boolean} = {'mdc-switch': true}; + + /** Configuration for the underlying ripple. */ + _rippleAnimation: RippleAnimationConfig = { + // TODO(crisbeto): Use the MDC constants once they are exported separately from the + // foundation. Grabbing them off the foundation prevents the foundation class from being + // tree-shaken. There is an open PR for this: + // https://github.com/material-components/material-components-web/pull/4593 + enterDuration: 225 /* MDCRippleFoundation.numbers.DEACTIVATION_TIMEOUT_MS */, + exitDuration: 150 /* MDCRippleFoundation.numbers.FG_DEACTIVATION_MS */, + }; + + /** The color palette for this slide toggle. */ + @Input() color: ThemePalette = 'accent'; + + /** Name value will be applied to the input element if present. */ + @Input() name: string | null = null; + + /** A unique id for the slide-toggle input. If none is supplied, it will be auto-generated. */ + @Input() id: string = this._uniqueId; + + /** Tabindex for the input element. */ + @Input() + get tabIndex(): number { return this._tabIndex; } + set tabIndex(value: number) { + this._tabIndex = coerceNumberProperty(value); + } + private _tabIndex: number; + + /** Whether the label should appear after or before the slide-toggle. Defaults to 'after'. */ + @Input() labelPosition: 'before' | 'after' = 'after'; + + /** Used to set the aria-label attribute on the underlying input element. */ + @Input('aria-label') ariaLabel: string | null = null; + + /** Used to set the aria-labelledby attribute on the underlying input element. */ + @Input('aria-labelledby') ariaLabelledby: string | null = null; + + /** Whether the slide-toggle is required. */ + @Input() + get required(): boolean { return this._required; } + set required(value) { this._required = coerceBooleanProperty(value); } + + /** Whether the slide-toggle element is checked or not. */ + @Input() + get checked(): boolean { return this._checked; } + set checked(value) { + this._checked = coerceBooleanProperty(value); + + if (this._foundation) { + this._foundation.setChecked(this._checked); + } + + this._changeDetectorRef.markForCheck(); + } + + /** Whether to disable the ripple on this checkbox. */ + @Input() + get disableRipple(): boolean { + return this._disableRipple; + } + set disableRipple(disableRipple: boolean) { + this._disableRipple = coerceBooleanProperty(disableRipple); + } + private _disableRipple = false; + + /** Whether the slide toggle is disabled. */ + @Input() + get disabled(): boolean { + return this._disabled; + } + set disabled(disabled) { + this._disabled = coerceBooleanProperty(disabled); + + if (this._foundation) { + this._foundation.setDisabled(this._disabled); + } + + this._changeDetectorRef.markForCheck(); + } + private _disabled = false; + + /** An event will be dispatched each time the slide-toggle changes its value. */ + @Output() readonly change: EventEmitter = + new EventEmitter(); + + /** Event will be dispatched each time the slide-toggle input is toggled. */ + @Output() readonly toggleChange: EventEmitter = new EventEmitter(); + + /** + * An event will be dispatched each time the slide-toggle is dragged. + * This event is always emitted when the user drags the slide toggle to make a change greater + * than 50%. It does not mean the slide toggle's value is changed. The event is not emitted when + * the user toggles the slide toggle to change its value. + * @deprecated No longer being used. + * @breaking-change 9.0.0 + */ + @Output() readonly dragChange: EventEmitter = new EventEmitter(); + + /** Returns the unique id for the visual hidden input. */ + get inputId(): string { return `${this.id || this._uniqueId}-input`; } + + /** Reference to the underlying input element. */ + @ViewChild('input', {static: false}) _inputElement: ElementRef; + + constructor(private _changeDetectorRef: ChangeDetectorRef, + @Attribute('tabindex') tabIndex: string, + @Inject(MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS) + public defaults: MatSlideToggleDefaultOptions, + @Optional() @Inject(ANIMATION_MODULE_TYPE) public _animationMode?: string) { + this.tabIndex = parseInt(tabIndex) || 0; + } + + ngAfterViewInit() { + const foundation = this._foundation = new MDCSwitchFoundation(this._adapter); + foundation.setDisabled(this.disabled); + foundation.setChecked(this.checked); + } + + ngOnDestroy() { + if (this._foundation) { + this._foundation.destroy(); + } + } + + /** Method being called whenever the underlying input emits a change event. */ + _onChangeEvent(event: Event) { + // We always have to stop propagation on the change event. + // Otherwise the change event, from the input element, will bubble up and + // emit its event object to the component's `change` output. + event.stopPropagation(); + this.toggleChange.emit(); + this._foundation.handleChange(event); + + // When the slide toggle's config disabled toggle change event by setting + // `disableToggleValue: true`, the slide toggle's value does not change, + // and the checked state of the underlying input needs to be changed back. + if (this.defaults.disableToggleValue) { + this._inputElement.nativeElement.checked = this.checked; + return; + } + + // Sync the value from the underlying input element with the component instance. + this.checked = this._inputElement.nativeElement.checked; + + // Emit our custom change event only if the underlying input emitted one. This ensures that + // there is no change event, when the checked state changes programmatically. + this.onChange(this.checked); + this.change.emit(new MatSlideToggleChange(this, this.checked)); + } + + /** Method being called whenever the slide-toggle has been clicked. */ + _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 `slide-toggle` 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(); + } + + /** Implemented as part of ControlValueAccessor. */ + writeValue(value: any): void { + this.checked = !!value; + } + + /** Implemented as part of ControlValueAccessor. */ + registerOnChange(fn: any): void { + this.onChange = fn; + } + + /** Implemented as part of ControlValueAccessor. */ + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + /** Implemented as a part of ControlValueAccessor. */ + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + this._changeDetectorRef.markForCheck(); + } + + /** Focuses the slide-toggle. */ + focus(): void { + this._inputElement.nativeElement.focus(); + } + + /** Toggles the checked state of the slide-toggle. */ + toggle(): void { + this.checked = !this.checked; + this.onChange(this.checked); + } + + /** Handles blur events on the native input. */ + _onBlur() { + // When a focused element becomes disabled, the browser *immediately* fires a blur event. + // Angular does not expect events to be raised during change detection, so any state change + // (such as a form control's 'ng-touched') will cause a changed-after-checked error. + // See https://github.com/angular/angular/issues/17793. To work around this, we defer + // telling the form control it has been touched until the next tick. + Promise.resolve().then(() => { + this._focused = false; + this.onTouched(); + this._changeDetectorRef.markForCheck(); + }); + } + + /** Toggles a class on the switch element. */ + private _toggleClass(cssClass: string, active: boolean) { + this._classes[cssClass] = active; + this._changeDetectorRef.markForCheck(); + } } diff --git a/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.html b/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.html index 42ccf0b3fb8..738d63d81ca 100644 --- a/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.html +++ b/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.html @@ -31,5 +31,7 @@ Not yet implemented.

MDC slide-toggle

- -Not yet implemented. + + + +with a label