feat(): add slide-toggle component. (#362)

This commit is contained in:
Paul Gschwendtner
2016-05-18 18:06:38 +02:00
committed by Jeremy Elbourn
parent a25a8da9e7
commit e09a5bfed2
11 changed files with 678 additions and 0 deletions
+66
View File
@@ -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)
## `<md-slide-toggle>`
### 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.<br/>It emits the new `checked` value. |
### Examples
A basic slide-toggle would have the following markup.
```html
<md-slide-toggle [(ngModel)]="slideToggleModel">
Default Slide Toggle
</md-slide-toggle>
```
Slide toggle can be also disabled.
```html
<md-slide-toggle disabled>
Disabled Slide Toggle
</md-slide-toggle>
```
The `slide-toggle` can be also set to checked without a `ngModel`
```html
<md-slide-toggle [checked]="isChecked">
Input Binding
</md-slide-toggle>
```
You may also want to listen on changes of the `slide-toggle`<br/>
The `slide-toggle` always emits the new value to the event binding `(change)`
```html
<md-slide-toggle (change)="printValue($event)">
Prints Value on Change
</md-slide-toggle>
```
## 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
<md-slide-toggle color="primary">
Primary Slide Toggle
</md-slide-toggle>
```
The color can be also set dynamically by using a property binding.
```html
<md-slide-toggle [color]="myColor">
Dynamic Color
</md-slide-toggle>
```
@@ -0,0 +1,22 @@
<label class="md-slide-toggle-label">
<div class="md-slide-toggle-container">
<div class="md-slide-toggle-bar"></div>
<div class="md-slide-toggle-thumb-container">
<div class="md-slide-toggle-thumb"></div>
</div>
<input #input class="md-slide-toggle-checkbox" type="checkbox"
[id]="getInputId()"
[tabIndex]="tabIndex"
[checked]="checked"
[disabled]="disabled"
[attr.name]="name"
[attr.aria-label]="ariaLabel"
[attr.aria-labelledby]="ariaLabelledby"
(blur)="onTouched()"
(change)="onChangeEvent()">
</div>
<span class="md-slide-toggle-content">
<ng-content></ng-content>
</span>
</label>
@@ -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;
}
@@ -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<any>;
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: `
<md-slide-toggle [(ngModel)]="slideModel" [disabled]="isDisabled" [color]="slideColor"
[id]="slideId" [checked]="slideChecked" [name]="slideName"
[aria-label]="slideLabel" [ariaLabel]="slideLabel"
[ariaLabelledby]="slideLabelledBy" (change)="changeCount = changeCount + 1">
<span>Test Slide Toggle</span>
</md-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;
}
+140
View File
@@ -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<boolean> = new EventEmitter<boolean>();
change: Observable<boolean> = 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);
}
}
}
+4
View File
@@ -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;
+1
View File
@@ -16,6 +16,7 @@
<a md-list-item [routerLink]="['progress-bar']">Progress Bar</a>
<a md-list-item [routerLink]="['radio']">Radio</a>
<a md-list-item [routerLink]="['sidenav']">Sidenav</a>
<a md-list-item [routerLink]="['slide-toggle']">Slide Toggle</a>
<a md-list-item [routerLink]="['toolbar']">Toolbar</a>
<a md-list-item [routerLink]="['tab-group']">Tab Group</a>
</md-nav-list>
+2
View File
@@ -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}),
@@ -0,0 +1,14 @@
<div>
<md-slide-toggle color="primary" [(ngModel)]="firstToggle">
Default Slide Toggle
</md-slide-toggle>
<md-slide-toggle [(ngModel)]="firstToggle" disabled>
Disabled Slide Toggle
</md-slide-toggle>
<md-slide-toggle [disabled]="firstToggle">
Disable Bound
</md-slide-toggle>
</div>
@@ -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 {}