From eee076690874ae3777450ec9976c42eef1837aec Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 18 May 2016 17:40:54 +0200 Subject: [PATCH] feat(): add slide-toggle component. --- src/components/slide-toggle/README.md | 66 +++++ src/components/slide-toggle/slide-toggle.html | 22 ++ src/components/slide-toggle/slide-toggle.scss | 153 ++++++++++ .../slide-toggle/slide-toggle.spec.ts | 266 ++++++++++++++++++ src/components/slide-toggle/slide-toggle.ts | 140 +++++++++ src/core/style/_variables.scss | 4 + src/demo-app/demo-app.html | 1 + src/demo-app/demo-app.ts | 2 + .../slide-toggle/slide-toggle-demo.html | 14 + .../slide-toggle/slide-toggle-demo.scss | 0 .../slide-toggle/slide-toggle-demo.ts | 10 + 11 files changed, 678 insertions(+) create mode 100644 src/components/slide-toggle/README.md create mode 100644 src/components/slide-toggle/slide-toggle.html create mode 100644 src/components/slide-toggle/slide-toggle.scss create mode 100644 src/components/slide-toggle/slide-toggle.spec.ts create mode 100644 src/components/slide-toggle/slide-toggle.ts create mode 100644 src/demo-app/slide-toggle/slide-toggle-demo.html create mode 100644 src/demo-app/slide-toggle/slide-toggle-demo.scss create mode 100644 src/demo-app/slide-toggle/slide-toggle-demo.ts diff --git a/src/components/slide-toggle/README.md b/src/components/slide-toggle/README.md new file mode 100644 index 000000000000..0095e3c0be8a --- /dev/null +++ b/src/components/slide-toggle/README.md @@ -0,0 +1,66 @@ +# MdSlideToggle +`MdSlideToggle` is a two-state control, which can be also called `switch` + +### Screenshots +![image](https://material.angularjs.org/material2_assets/slide-toggle/toggles.png) + +## `` +### Bound Properties + +| Name | Type | Description | +| --- | --- | --- | +| `disabled` | boolean | Disables the `slide-toggle` | +| `color` | `"primary" | "accent" | "warn"` | The color palette of the `slide-toggle` | +| `checked` | boolean | Sets the value of the `slide-toggle` | + +### Events +| Name | Type | Description | +| --- | --- | --- | +| `change` | boolean | Event will be emitted on every value change.
It emits the new `checked` value. | + +### Examples +A basic slide-toggle would have the following markup. +```html + + Default Slide Toggle + +``` + +Slide toggle can be also disabled. +```html + + Disabled Slide Toggle + +``` + +The `slide-toggle` can be also set to checked without a `ngModel` +```html + + Input Binding + +``` + +You may also want to listen on changes of the `slide-toggle`
+The `slide-toggle` always emits the new value to the event binding `(change)` +```html + + Prints Value on Change + +``` + +## Theming +A slide-toggle is default using the `accent` palette for its styling. + +Modifying the color on a `slide-toggle` can be easily done, by using the following markup. +```html + + Primary Slide Toggle + +``` + +The color can be also set dynamically by using a property binding. +```html + + Dynamic Color + +``` \ No newline at end of file diff --git a/src/components/slide-toggle/slide-toggle.html b/src/components/slide-toggle/slide-toggle.html new file mode 100644 index 000000000000..5e22fef49ac9 --- /dev/null +++ b/src/components/slide-toggle/slide-toggle.html @@ -0,0 +1,22 @@ + \ No newline at end of file diff --git a/src/components/slide-toggle/slide-toggle.scss b/src/components/slide-toggle/slide-toggle.scss new file mode 100644 index 000000000000..d0d9bcecfa4a --- /dev/null +++ b/src/components/slide-toggle/slide-toggle.scss @@ -0,0 +1,153 @@ +@import "../../core/style/variables"; +@import "../../core/style/mixins"; +@import "../../core/style/elevation"; + +//TODO(): remove the default theme. +@import "../../core/style/default-theme"; + +$md-slide-toggle-width: 36px !default; +$md-slide-toggle-height: 24px !default; +$md-slide-toggle-bar-height: 14px !default; +$md-slide-toggle-thumb-size: 20px !default; +$md-slide-toggle-margin: 16px !default; + +@mixin md-switch-checked($palette) { + .md-slide-toggle-thumb { + background-color: md-color($palette); + } + + .md-slide-toggle-bar { + background-color: md-color($palette, 0.5); + } +} + +:host { + display: flex; + height: $md-slide-toggle-height; + + margin: $md-slide-toggle-margin 0; + line-height: $md-slide-toggle-height; + + white-space: nowrap; + user-select: none; + + outline: none; + + &.md-checked { + @include md-switch-checked($md-accent); + + &.md-primary { + @include md-switch-checked($md-primary); + } + + &.md-warn { + @include md-switch-checked($md-warn); + } + + .md-slide-toggle-thumb-container { + transform: translate3d(100%, 0, 0); + } + } + + &.md-disabled { + + .md-slide-toggle-label, .md-slide-toggle-container { + cursor: default; + } + + .md-slide-toggle-thumb { + // The thumb of the slide-toggle always uses the hue 400 of the grey palette in dark or light themes. + // Since this is very specific to the slide-toggle component, we're not providing + // it in the background palette. + background-color: md-color($md-grey, 400); + } + .md-slide-toggle-bar { + background-color: md-color($md-foreground, divider); + } + } +} + +// The label is our root container for the slide-toggle / switch indicator and label text. +// It has to be a label, to support accessibility for the visual hidden input. +.md-slide-toggle-label { + display: flex; + flex: 1; + + cursor: pointer; +} + +// Container for the composition of the slide-toggle / switch indicator. +.md-slide-toggle-container { + cursor: grab; + width: $md-slide-toggle-width; + height: $md-slide-toggle-height; + + position: relative; + user-select: none; + + margin-right: 8px; +} + +// The thumb container is responsible for the dragging functionality. +// It moves around and holds the actual circle as a thumb. +.md-slide-toggle-thumb-container { + position: absolute; + top: $md-slide-toggle-height / 2 - $md-slide-toggle-thumb-size / 2; + left: 0; + z-index: 1; + + width: $md-slide-toggle-width - $md-slide-toggle-thumb-size; + + transform: translate3d(0, 0, 0); + + transition: $swift-linear; + transition-property: transform; +} + +// The thumb will be elevated from the slide-toggle bar. +// Also the thumb is bound to its parent thumb-container, which manages the movement of the thumb. +.md-slide-toggle-thumb { + position: absolute; + margin: 0; + left: 0; + top: 0; + + height: $md-slide-toggle-thumb-size; + width: $md-slide-toggle-thumb-size; + border-radius: 50%; + + background-color: md-color($md-background, background); + @include md-elevation(1); +} + +// Horizontal bar for the slide-toggle. +// The slide-toggle bar is shown behind the thumb container. +.md-slide-toggle-bar { + position: absolute; + left: 1px; + top: $md-slide-toggle-height / 2 - $md-slide-toggle-bar-height / 2; + + width: $md-slide-toggle-width - 2px; + height: $md-slide-toggle-bar-height; + + // The bar of the slide-toggle always uses the hue 500 of the grey palette in dark or light themes. + // Since this is very specific to the slide-toggle component, we're not providing + // it in the background palette. + background-color: md-color($md-grey, 500); + + border-radius: 8px; +} + +// The slide toggle shows a visually hidden checkbox inside of the component. +// This checkbox allows us to take advantage of the browsers support. +// Like accessibility and keyboard interaction. +.md-slide-toggle-checkbox { + @include md-visually-hidden(); +} + +.md-slide-toggle-bar, +.md-slide-toggle-thumb { + transition: $swift-linear; + transition-property: background-color; + transition-delay: 0.05s; +} \ No newline at end of file diff --git a/src/components/slide-toggle/slide-toggle.spec.ts b/src/components/slide-toggle/slide-toggle.spec.ts new file mode 100644 index 000000000000..9b03a95e852a --- /dev/null +++ b/src/components/slide-toggle/slide-toggle.spec.ts @@ -0,0 +1,266 @@ +import { + it, + describe, + expect, + beforeEach, + inject, + async, +} from '@angular/core/testing'; +import {TestComponentBuilder, ComponentFixture} from '@angular/compiler/testing'; +import {By} from '@angular/platform-browser'; +import {Component} from '@angular/core'; +import {MdSlideToggle} from './slide-toggle'; +import {NgControl} from '@angular/common'; + +export function main() { + describe('MdSlideToggle', () => { + let builder: TestComponentBuilder; + + beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => { + builder = tcb; + })); + + describe('basic behavior', () => { + + let fixture: ComponentFixture; + + let testComponent: SlideToggleTestApp; + let slideToggle: MdSlideToggle; + let slideToggleElement: HTMLElement; + let slideToggleControl: NgControl; + let labelElement: HTMLLabelElement; + let inputElement: HTMLInputElement; + + beforeEach(async(() => { + builder.createAsync(SlideToggleTestApp).then(f => { + fixture = f; + fixture.detectChanges(); + + let slideToggleDebug = fixture.debugElement.query(By.css('md-slide-toggle')); + + testComponent = fixture.debugElement.componentInstance; + slideToggle = slideToggleDebug.componentInstance; + slideToggleElement = slideToggleDebug.nativeElement; + slideToggleControl = slideToggleDebug.injector.get(NgControl); + inputElement = fixture.debugElement.query(By.css('input')).nativeElement; + labelElement = fixture.debugElement.query(By.css('label')).nativeElement; + }); + })); + + + it('should update the model correctly', () => { + expect(slideToggleElement.classList).not.toContain('md-checked'); + + testComponent.slideModel = true; + fixture.detectChanges(); + + expect(slideToggleElement.classList).toContain('md-checked'); + }); + + it('should apply class based on color attribute', () => { + testComponent.slideColor = 'primary'; + fixture.detectChanges(); + + expect(slideToggleElement.classList).toContain('md-primary'); + + testComponent.slideColor = 'accent'; + fixture.detectChanges(); + + expect(slideToggleElement.classList).toContain('md-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(); + + testComponent.slideChecked = true; + fixture.detectChanges(); + + expect(inputElement.checked).toBeTruthy(); + }); + + it('should set the toggle to checked on click', () => { + expect(slideToggle.checked).toBe(false); + expect(slideToggleElement.classList).not.toContain('md-checked'); + + labelElement.click(); + fixture.detectChanges(); + + expect(slideToggleElement.classList).toContain('md-checked'); + expect(slideToggle.checked).toBe(true); + }); + + it('should add a suffix to the inputs id', () => { + testComponent.slideId = 'myId'; + fixture.detectChanges(); + + expect(inputElement.id).toBe('myId-input'); + + testComponent.slideId = 'nextId'; + fixture.detectChanges(); + + expect(inputElement.id).toBe('nextId-input'); + + testComponent.slideId = null; + fixture.detectChanges(); + + // Once the id input is falsy, we use a default prefix with a incrementing unique number. + expect(inputElement.id).toMatch(/md-slide-toggle-[0-9]+-input/g); + }); + + 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 be initially set to ng-pristine', () => { + expect(slideToggleElement.classList).toContain('ng-pristine'); + expect(slideToggleElement.classList).not.toContain('ng-dirty'); + }); + + it('should emit the new values', () => { + expect(testComponent.changeCount).toBe(0); + + labelElement.click(); + fixture.detectChanges(); + + expect(testComponent.changeCount).toBe(1); + }); + + it('should support subscription on the change observable', () => { + slideToggle.change.subscribe(value => { + expect(value).toBe(true); + }); + + slideToggle.toggle(); + fixture.detectChanges(); + }); + + it('should have the correct ngControl state initially and after interaction', () => { + // The control should start off valid, pristine, and untouched. + expect(slideToggleControl.valid).toBe(true); + expect(slideToggleControl.pristine).toBe(true); + expect(slideToggleControl.touched).toBe(false); + + // After changing the value programmatically, the control should + // become dirty (not pristine), but remain untouched. + slideToggle.checked = true; + fixture.detectChanges(); + + expect(slideToggleControl.valid).toBe(true); + expect(slideToggleControl.pristine).toBe(false); + expect(slideToggleControl.touched).toBe(false); + + // After a user interaction occurs (such as a click), the control should remain dirty and + // now also be touched. + labelElement.click(); + fixture.detectChanges(); + + expect(slideToggleControl.valid).toBe(true); + expect(slideToggleControl.pristine).toBe(false); + expect(slideToggleControl.touched).toBe(true); + }); + + it('should not set the ngControl to touched when changing the state programmatically', () => { + // The control should start off with being untouched. + expect(slideToggleControl.touched).toBe(false); + + testComponent.slideChecked = true; + fixture.detectChanges(); + + expect(slideToggleControl.touched).toBe(false); + expect(slideToggleElement.classList).toContain('md-checked'); + + // After a user interaction occurs (such as a click), the control should remain dirty and + // now also be touched. + inputElement.click(); + fixture.detectChanges(); + + expect(slideToggleControl.touched).toBe(true); + expect(slideToggleElement.classList).not.toContain('md-checked'); + }); + + it('should not set the ngControl to touched when changing the model', () => { + // The control should start off with being untouched. + expect(slideToggleControl.touched).toBe(false); + + testComponent.slideModel = true; + fixture.detectChanges(); + + expect(slideToggleControl.touched).toBe(false); + expect(slideToggle.checked).toBe(true); + expect(slideToggleElement.classList).toContain('md-checked'); + }); + + }); + + }); +} + +@Component({ + selector: 'slide-toggle-test-app', + template: ` + + Test Slide Toggle + + `, + directives: [MdSlideToggle] +}) +class SlideToggleTestApp { + isDisabled: boolean = false; + slideModel: boolean = false; + slideChecked: boolean = false; + slideColor: string; + slideId: string; + slideName: string; + slideLabel: string; + slideLabelledBy: string; + changeCount: number = 0; +} diff --git a/src/components/slide-toggle/slide-toggle.ts b/src/components/slide-toggle/slide-toggle.ts new file mode 100644 index 000000000000..d2b5cabdf560 --- /dev/null +++ b/src/components/slide-toggle/slide-toggle.ts @@ -0,0 +1,140 @@ +import { + Component, + ElementRef, + Renderer, + forwardRef, + ChangeDetectionStrategy, + Input, + Output, + EventEmitter +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR +} from '@angular/common'; +import { BooleanFieldValue } from '../../core/annotations/field-value'; +import { Observable } from 'rxjs/Observable'; + +export const MD_SLIDE_TOGGLE_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MdSlideToggle), + multi: true +}; + +// Increasing integer for generating unique ids for slide-toggle components. +let nextId = 0; + +@Component({ + selector: 'md-slide-toggle', + host: { + '[class.md-checked]': 'checked', + '[class.md-disabled]': 'disabled', + '(click)': 'onTouched()' + }, + templateUrl: './components/slide-toggle/slide-toggle.html', + styleUrls: ['./components/slide-toggle/slide-toggle.css'], + providers: [MD_SLIDE_TOGGLE_VALUE_ACCESSOR], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class MdSlideToggle implements ControlValueAccessor { + + private onChange = (_: any) => {}; + private onTouched = () => {}; + + // A unique id for the slide-toggle. By default the id is auto-generated. + private _uniqueId = `md-slide-toggle-${++nextId}`; + private _checked: boolean = false; + private _color: string; + + @Input() @BooleanFieldValue() disabled: boolean = false; + @Input() name: string = null; + @Input() id: string = this._uniqueId; + @Input() tabIndex: number = 0; + @Input() ariaLabel: string = null; + @Input() ariaLabelledby: string = null; + + @Output('change') private _change: EventEmitter = new EventEmitter(); + change: Observable = this._change.asObservable(); + + // Returns the unique id for the visual hidden input. + getInputId = () => `${this.id || this._uniqueId}-input`; + + constructor(private _elementRef: ElementRef, + private _renderer: Renderer) { + } + + /** + * The onChangeEvent method will be also called on click. + * This is because everything for the slide-toggle is wrapped inside of a label, + * which triggers a onChange event on click. + * @internal + */ + onChangeEvent() { + if (!this.disabled) { + this.toggle(); + } + } + + /** + * Implemented as part of ControlValueAccessor. + * @internal + */ + writeValue(value: any): void { + this.checked = value; + } + + /** + * Implemented as part of ControlValueAccessor. + * @internal + */ + registerOnChange(fn: any): void { + this.onChange = fn; + } + + /** + * Implemented as part of ControlValueAccessor. + * @internal + */ + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + @Input() + get checked() { + return !!this._checked; + } + + set checked(value) { + if (this.checked !== !!value) { + this._checked = value; + this.onChange(this._checked); + this._change.emit(this._checked); + } + } + + @Input() + get color(): string { + return this._color; + } + + set color(value: string) { + this._updateColor(value); + } + + toggle() { + this.checked = !this.checked; + } + + private _updateColor(newColor: string) { + this._setElementColor(this._color, false); + this._setElementColor(newColor, true); + this._color = newColor; + } + + private _setElementColor(color: string, isAdd: boolean) { + if (color != null && color != '') { + this._renderer.setElementClass(this._elementRef.nativeElement, `md-${color}`, isAdd); + } + } + +} diff --git a/src/core/style/_variables.scss b/src/core/style/_variables.scss index 00d65b5fc226..417a6cd0a0cf 100644 --- a/src/core/style/_variables.scss +++ b/src/core/style/_variables.scss @@ -41,3 +41,7 @@ $swift-ease-in: all $swift-ease-in-duration $swift-ease-in-timing-function !defa $swift-ease-in-out-duration: 0.5s !default; $swift-ease-in-out-timing-function: $ease-in-out-curve-function !default; $swift-ease-in-out: all $swift-ease-in-out-duration $swift-ease-in-out-timing-function !default; + +$swift-linear-duration: 0.08s !default; +$swift-linear-timing-function: linear !default; +$swift-linear: all $swift-linear-duration $swift-linear-timing-function !default; \ No newline at end of file diff --git a/src/demo-app/demo-app.html b/src/demo-app/demo-app.html index 5e42b23dab4c..5e79da12437e 100644 --- a/src/demo-app/demo-app.html +++ b/src/demo-app/demo-app.html @@ -16,6 +16,7 @@ Progress Bar Radio Sidenav + Slide Toggle Toolbar Tab Group diff --git a/src/demo-app/demo-app.ts b/src/demo-app/demo-app.ts index 375ca30ffd25..e2c484bf5bd8 100644 --- a/src/demo-app/demo-app.ts +++ b/src/demo-app/demo-app.ts @@ -23,6 +23,7 @@ import {LiveAnnouncerDemo} from './live-announcer/live-announcer-demo'; import {GesturesDemo} from './gestures/gestures-demo'; import {GridListDemo} from './grid-list/grid-list-demo'; import {TabGroupDemo} from './tab-group/tab-group-demo'; +import {SlideToggleDemo} from './slide-toggle/slide-toggle-demo'; @Component({ selector: 'home', @@ -55,6 +56,7 @@ export class Home {} new Route({path: '/card', component: CardDemo}), new Route({path: '/radio', component: RadioDemo}), new Route({path: '/sidenav', component: SidenavDemo}), + new Route({path: '/slide-toggle', component: SlideToggleDemo}), new Route({path: '/progress-circle', component: ProgressCircleDemo}), new Route({path: '/progress-bar', component: ProgressBarDemo}), new Route({path: '/portal', component: PortalDemo}), diff --git a/src/demo-app/slide-toggle/slide-toggle-demo.html b/src/demo-app/slide-toggle/slide-toggle-demo.html new file mode 100644 index 000000000000..cf78e71dea60 --- /dev/null +++ b/src/demo-app/slide-toggle/slide-toggle-demo.html @@ -0,0 +1,14 @@ +
+ + + Default Slide Toggle + + + + Disabled Slide Toggle + + + + Disable Bound + +
\ No newline at end of file diff --git a/src/demo-app/slide-toggle/slide-toggle-demo.scss b/src/demo-app/slide-toggle/slide-toggle-demo.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/demo-app/slide-toggle/slide-toggle-demo.ts b/src/demo-app/slide-toggle/slide-toggle-demo.ts new file mode 100644 index 000000000000..68bf0398c1d1 --- /dev/null +++ b/src/demo-app/slide-toggle/slide-toggle-demo.ts @@ -0,0 +1,10 @@ +import {Component} from '@angular/core'; +import {MdSlideToggle} from '../../components/slide-toggle/slide-toggle'; + +@Component({ + selector: 'switch-demo', + templateUrl: 'demo-app/slide-toggle/slide-toggle-demo.html', + styleUrls: ['demo-app/slide-toggle/slide-toggle-demo.css'], + directives: [MdSlideToggle] +}) +export class SlideToggleDemo {}