prototype(slide-toggle): create prototype menu based on MDC Web (#15893)

Creates a prototype of `mat-slide-toggle` that uses MDC web.
This commit is contained in:
Kristiyan Kostadinov
2019-04-25 23:11:59 +02:00
committed by mmalerba
parent fde980c8ad
commit b730ba856c
18 changed files with 1485 additions and 21 deletions
+75 -1
View File
@@ -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');
});
});
@@ -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],
})
@@ -1,2 +1,29 @@
<!-- TODO: copy in demo template from existing mat-slide-toggle demo. -->
Not yet implemented.
<div class="demo-slide-toggle">
<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>
<p>Example where the slide toggle is required inside of a form.</p>
<form #form="ngForm" (ngSubmit)="onFormSubmit()" ngNativeValidate>
<mat-slide-toggle name="slideToggle" required ngModel>
Slide Toggle
</mat-slide-toggle>
<p>
<button mat-raised-button type="submit">Submit Form</button>
</p>
</form>
</div>
@@ -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;
}
}
@@ -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.`);
}
}
@@ -1 +1,2 @@
<!-- TODO: copy implementation from existing mat-slide-toggle e2e page. -->
<mat-slide-toggle id="normal-slide-toggle">Slide Toggle</mat-slide-toggle>
<mat-slide-toggle id="disabled-slide-toggle" disabled>Disabled Slide Toggle</mat-slide-toggle>
@@ -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 {}
@@ -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",
]
)
@@ -1 +1,93 @@
This is a placeholder for the MDC-based implementation of slide toggle.
This is prototype of an alternate version of `<mat-slide-toggle>` 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 `<mat-slide-toggle>`. 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 `<mat-slide-toggle>` in your component's template, just like you would the normal
`<mat-slide-toggle>`:
```html
<mat-slide-toggle [checked]="isChecked">Toggle me</mat-slide-toggle>
```
5. Add the theme and typography mixins to your Sass. (There is currently no pre-built CSS option for
the experimental `<mat-slide-toggle>`):
```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.
@@ -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);
}
}
@@ -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],
})
@@ -7,4 +7,5 @@
*/
export * from './slide-toggle';
export * from './slide-toggle-config';
export * from './module';
@@ -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<MatSlideToggleDefaultOptions>('mat-slide-toggle-default-options', {
providedIn: 'root',
factory: () => ({disableToggleValue: false, disableDragValue: false})
});
@@ -1 +1,37 @@
<!-- TODO: MDC template here. -->
<div class="mdc-form-field"
[class.mdc-form-field--align-end]="labelPosition == 'before'">
<div [ngClass]="_classes" #switch>
<div class="mdc-switch__track"></div>
<div class="mdc-switch__thumb-underlay">
<div class="mat-mdc-slide-toggle-ripple" mat-ripple
[matRippleTrigger]="switch"
[matRippleDisabled]="disableRipple || disabled"
[matRippleCentered]="true"
[matRippleRadius]="24"
[matRippleAnimation]="_rippleAnimation"></div>
<div class="mdc-switch__thumb">
<input #input class="mdc-switch__native-control" type="checkbox"
role="switch"
[id]="inputId"
[required]="required"
[tabIndex]="tabIndex"
[checked]="checked"
[disabled]="disabled"
[attr.name]="name"
[attr.aria-checked]="checked.toString()"
[attr.aria-label]="ariaLabel"
[attr.aria-labelledby]="ariaLabelledby"
(change)="_onChangeEvent($event)"
(click)="_onInputClick($event)"
(blur)="_onBlur()"
(focus)="_focused = true">
</div>
</div>
</div>
<label #label
[for]="inputId"
(click)="$event.stopPropagation()">
<ng-content></ng-content>
</label>
</div>
@@ -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);
}
}
@@ -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<any>;
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<SlideToggleWithModel>;
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>(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>(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<SlideToggleWithFormControl>;
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<any>;
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: `
<mat-slide-toggle [dir]="direction" [required]="isRequired"
[disabled]="isDisabled"
[color]="slideColor"
[id]="slideId"
[checked]="slideChecked"
[name]="slideName"
[aria-label]="slideLabel"
[aria-labelledby]="slideLabelledBy"
[tabIndex]="slideTabindex"
[labelPosition]="labelPosition"
[disableRipple]="disableRipple"
(toggleChange)="onSlideToggleChange()"
(dragChange)="onSlideDragChange()"
(change)="onSlideChange($event)"
(click)="onSlideClick($event)">
<span>Test Slide Toggle</span>
</mat-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: `
<form ngNativeValidate (ngSubmit)="isSubmitted = true">
<mat-slide-toggle name="slide" ngModel [required]="isRequired">Required</mat-slide-toggle>
<button type="submit"></button>
</form>`
})
class SlideToggleWithForm {
isSubmitted: boolean = false;
isRequired: boolean = false;
}
@Component({
template: `<mat-slide-toggle [(ngModel)]="modelValue"></mat-slide-toggle>`
})
class SlideToggleWithModel {
modelValue = false;
}
@Component({
template: `
<mat-slide-toggle [formControl]="formControl">
<span>Test Slide Toggle</span>
</mat-slide-toggle>`,
})
class SlideToggleWithFormControl {
formControl = new FormControl();
}
@Component({template: `<mat-slide-toggle tabindex="5" [disabled]="disabled"></mat-slide-toggle>`})
class SlideToggleWithTabindexAttr {
disabled = false;
}
@Component({
template: `<mat-slide-toggle>{{label}}</mat-slide-toggle>`
})
class SlideToggleWithoutLabel {
label: string;
}
@Component({
template: `<mat-slide-toggle [(ngModel)]="checked" (change)="onChange()"></mat-slide-toggle>`
})
class SlideToggleWithModelAndChangeEvent {
checked: boolean;
onChange: () => void = () => {};
}
@Component({
template: `<mat-slide-toggle><some-text></some-text></mat-slide-toggle>`
})
class SlideToggleProjectedLabel {}
@Component({
selector: 'some-text',
template: `<span>{{text}}</span>`
})
class TextBindingComponent {
text: string = 'Some text';
}
@@ -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<MatSlideToggleChange> =
new EventEmitter<MatSlideToggleChange>();
/** Event will be dispatched each time the slide-toggle input is toggled. */
@Output() readonly toggleChange: EventEmitter<void> = new EventEmitter<void>();
/**
* 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<void> = new EventEmitter<void>();
/** 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<HTMLInputElement>;
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();
}
}
@@ -31,5 +31,7 @@ Not yet implemented.
<h2>MDC slide-toggle</h2>
<!-- TODO: Copy kitchen sink examples for mat-slide-toggle here -->
Not yet implemented.
<mat-slide-toggle></mat-slide-toggle>
<mat-slide-toggle checked></mat-slide-toggle>
<mat-slide-toggle checked disabled></mat-slide-toggle>
<mat-slide-toggle>with a label</mat-slide-toggle>