diff --git a/package.json b/package.json
index d5e7fa812..27f239f87 100644
--- a/package.json
+++ b/package.json
@@ -159,6 +159,7 @@
"server-dev:select": "npm run server-dev -- --env.component select",
"server-dev:splitter": "npm run server-dev -- --env.component splitter",
"server-dev:tag": "npm run server-dev -- --env.component tag",
+ "server-dev:toggle": "npm run server-dev -- --env.component toggle",
"server-dev:theme-picker": "npm run server-dev -- --env.component theme-picker",
"server-dev:tree": "npm run server-dev -- --env.component tree",
"server-dev:typography": "npm run server-dev -- --env.component typography",
diff --git a/src/lib-dev/toggle/module.ts b/src/lib-dev/toggle/module.ts
new file mode 100644
index 000000000..0f431c6ae
--- /dev/null
+++ b/src/lib-dev/toggle/module.ts
@@ -0,0 +1,47 @@
+// tslint:disable:no-console
+import { Component, NgModule, ViewEncapsulation } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { BrowserModule } from '@angular/platform-browser';
+import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
+
+import { McButtonModule } from '../../lib/button/';
+import { McToggleModule } from '../../lib/toggle/';
+
+
+@Component({
+ selector: 'app',
+ template: require('./template.html'),
+ styleUrls: ['./styles.scss'],
+ encapsulation: ViewEncapsulation.None
+})
+export class DemoComponent {
+ valueSmallOff: boolean = false;
+ valueSmallOn: boolean = true;
+
+ valueBigOff: boolean = false;
+ valueBigOn: boolean = true;
+
+ disabled: boolean = false;
+}
+
+
+@NgModule({
+ declarations: [
+ DemoComponent
+ ],
+ imports: [
+ BrowserModule,
+ FormsModule,
+ McToggleModule,
+ McButtonModule
+ ],
+ bootstrap: [
+ DemoComponent
+ ]
+})
+export class DemoModule {}
+
+platformBrowserDynamic()
+ .bootstrapModule(DemoModule)
+ .catch((error) => console.error(error));
+
diff --git a/src/lib-dev/toggle/styles.scss b/src/lib-dev/toggle/styles.scss
new file mode 100644
index 000000000..48888d6e5
--- /dev/null
+++ b/src/lib-dev/toggle/styles.scss
@@ -0,0 +1,22 @@
+@import '../../lib/core/theming/prebuilt/default-theme';
+
+@import '../../lib/core/visual/prebuilt/default-visual';
+
+.container {
+ margin-top: 32px;
+ margin-left: 16px;
+}
+
+.toggle-demo-container {
+ margin-bottom: 8px;
+}
+
+.control-buttons-container {
+ display: inline-block;
+ margin-right: 16px;
+ margin-bottom: 16px;
+}
+
+.toggles-container {
+ max-width: 500px;
+}
diff --git a/src/lib-dev/toggle/template.html b/src/lib-dev/toggle/template.html
new file mode 100644
index 000000000..60e94edd2
--- /dev/null
+++ b/src/lib-dev/toggle/template.html
@@ -0,0 +1,67 @@
+
+
Toggles
+
+
+
+
+
+
+
+
+
+
+
Small Toggles
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Quack like a duck
+
+
+
+
+ Quack like a duck
+
+
+
+
+
Big Toggles
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Quack like a duck
+
+
+
+
+ Quack like a duck
+
+
+
+
+
diff --git a/src/lib/core/styles/typography/_all-typography.scss b/src/lib/core/styles/typography/_all-typography.scss
index b487bc11d..d37c4aa52 100644
--- a/src/lib/core/styles/typography/_all-typography.scss
+++ b/src/lib/core/styles/typography/_all-typography.scss
@@ -14,6 +14,7 @@
@import '../../../tag/tag-theme';
@import '../../option/option-theme';
@import '../../../tooltip/tooltip-theme';
+@import '../../../toggle/toggle-theme';
@mixin mosaic-typography($config: null) {
@@ -38,4 +39,5 @@
@include mc-modal-typography($config);
@include mc-option-typography($config);
@include mc-tag-typography($config);
+ @include mc-toggle-typography($config);
}
diff --git a/src/lib/core/theming/_all-theme.scss b/src/lib/core/theming/_all-theme.scss
index 86315ca5b..1b7cc9451 100644
--- a/src/lib/core/theming/_all-theme.scss
+++ b/src/lib/core/theming/_all-theme.scss
@@ -20,6 +20,7 @@
@import '../option/option-theme';
@import '../../tag/tag-theme';
@import '../../tooltip/tooltip-theme';
+@import '../../toggle/toggle-theme';
@import '../../splitter/splitter-theme';
@@ -47,5 +48,6 @@
@include mc-option-theme($theme);
@include mc-tag-theme($theme);
@include mc-tooltip-theme($theme);
+ @include mc-toggle-theme($theme);
@include mc-splitter-theme($theme);
}
diff --git a/src/lib/toggle/README.MD b/src/lib/toggle/README.MD
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/lib/toggle/_toggle-theme.scss b/src/lib/toggle/_toggle-theme.scss
new file mode 100644
index 000000000..43d65cd21
--- /dev/null
+++ b/src/lib/toggle/_toggle-theme.scss
@@ -0,0 +1,118 @@
+$hover-darken: 5%;
+
+@mixin _mc-toggle-color($palette) {
+ $color: mc-color($palette, 500);
+
+ .mc-toggle-bar {
+ background: $color;
+ }
+}
+
+@mixin _mc-toggle-border-color($palette) {
+ $color: mc-color($palette, 500);
+
+ .mc-toggle-bar {
+ border: 1px solid $color;
+ }
+}
+
+@mixin _mc-toggle-circle-color($darken) {
+ $toggle-circle-color-1: darken(white, $darken);
+ $toggle-circle-color-2: darken(mc-color($mc-grey, 100), $darken);
+
+ border: 1px solid mc-color($mc-grey, 300);
+ background:
+ $toggle-circle-color-1
+ linear-gradient(to bottom, $toggle-circle-color-1, $toggle-circle-color-2);
+}
+
+@mixin _mc-toggle-off-color($darken) {
+ $toggle-off-color-1: darken(mc-color($second, 100), $darken);
+ $toggle-off-color-2: darken(mc-color($second, 60), $darken);
+
+ background: $toggle-off-color-1 linear-gradient(to bottom, $toggle-off-color-1, $toggle-off-color-2);
+}
+
+@mixin mc-toggle-theme($theme) {
+ $primary: map-get($theme, primary);
+ $second: map-get($theme, second);
+ $error: map-get($theme, error);
+ $background: map-get($theme, background);
+
+ $toggle-off-color-1: mc-color($second, 100);
+ $toggle-off-color-2: mc-color($second, 60);
+
+ mc-toggle {
+ .mc-toggle__circle {
+ @include _mc-toggle-circle-color(0);
+ }
+
+ .mc-toggle-bar {
+ border: 1px solid mc-color($mc-grey, 300);
+ }
+
+ &.mc-toggle-off {
+ .mc-toggle-input {
+ ~ .mc-toggle-bar-container .mc-toggle-bar {
+ @include _mc-toggle-off-color(0)
+ }
+ }
+ }
+
+ &.mc-primary:not(.mc-toggle-off) {
+ @include _mc-toggle-color($primary);
+ @include _mc-toggle-border-color($primary);
+ }
+
+ &.mc-error:not(.mc-toggle-off) {
+ @include _mc-toggle-color($error);
+ @include _mc-toggle-border-color($error);
+ }
+ }
+
+ mc-toggle:not(.mc-disabled) {
+ .mc-toggle-input {
+ &:hover {
+ ~ .mc-toggle-bar-container .mc-toggle-bar {
+ .mc-toggle__circle {
+ @include _mc-toggle-circle-color($hover-darken);
+ }
+ }
+ }
+ }
+
+ &.cdk-keyboard-focused {
+ .mc-toggle-input {
+ + .mc-toggle-bar-container .mc-toggle__focus-frame {
+ border: 2px solid map-get($primary, 500);
+ box-shadow: inset 0 0 0 1px map-get($background, background);
+ }
+ }
+ }
+
+ &.mc-toggle-off {
+ .mc-toggle-input {
+ &:hover {
+ ~ .mc-toggle-bar-container .mc-toggle-bar {
+ @include _mc-toggle-off-color($hover-darken)
+ }
+ }
+ }
+ }
+ }
+
+ mc-toggle.mc-disabled {
+ opacity: 0.5;
+ }
+}
+
+
+@mixin mc-toggle-typography($config) {
+ mc-toggle:not(.mc-toggle_small) {
+ @include mc-typography-level-to-styles($config, body);
+ }
+
+ mc-toggle.mc-toggle_small {
+ @include mc-typography-level-to-styles($config, caption);
+ }
+}
diff --git a/src/lib/toggle/index.ts b/src/lib/toggle/index.ts
new file mode 100644
index 000000000..7e1a213e3
--- /dev/null
+++ b/src/lib/toggle/index.ts
@@ -0,0 +1 @@
+export * from './public-api';
diff --git a/src/lib/toggle/public-api.ts b/src/lib/toggle/public-api.ts
new file mode 100644
index 000000000..ead9cb1f0
--- /dev/null
+++ b/src/lib/toggle/public-api.ts
@@ -0,0 +1,2 @@
+export * from './toggle.module';
+export * from './toggle.component';
diff --git a/src/lib/toggle/toggle.component.html b/src/lib/toggle/toggle.component.html
new file mode 100644
index 000000000..7e235134b
--- /dev/null
+++ b/src/lib/toggle/toggle.component.html
@@ -0,0 +1,31 @@
+
diff --git a/src/lib/toggle/toggle.component.spec.ts b/src/lib/toggle/toggle.component.spec.ts
new file mode 100644
index 000000000..f73129a62
--- /dev/null
+++ b/src/lib/toggle/toggle.component.spec.ts
@@ -0,0 +1,618 @@
+// tslint:disable:no-magic-numbers
+// tslint:disable:no-empty
+import { Component, DebugElement, ViewChild } from '@angular/core';
+import { ComponentFixture, fakeAsync, TestBed, flush } from '@angular/core/testing';
+import { FormControl, FormsModule, NgModel, ReactiveFormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+
+import { McToggleComponent, McToggleModule } from './index';
+
+
+describe('McToggle', () => {
+ let fixture: ComponentFixture;
+
+ beforeEach(fakeAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [McToggleModule, FormsModule, ReactiveFormsModule],
+ declarations: [
+ SingleToggle,
+ ToggleWithFormDirectives,
+ MultipleToggles,
+ ToggleWithTabIndex,
+ ToggleWithAriaLabel,
+ ToggleWithAriaLabelledby,
+ ToggleWithNameAttribute,
+ ToggleWithFormControl,
+ ToggleWithoutLabel,
+ ToggleWithTabindexAttr,
+ ToggleUsingViewChild
+ ]
+ });
+
+ TestBed.compileComponents();
+ }));
+
+ describe('basic behaviors', () => {
+ let toggleDebugElement: DebugElement;
+ let toggleNativeElement: HTMLElement;
+ let toggleInstance: McToggleComponent;
+ let testComponent: SingleToggle;
+ let inputElement: HTMLInputElement;
+ let labelElement: HTMLLabelElement;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SingleToggle);
+ fixture.detectChanges();
+
+ toggleDebugElement = fixture.debugElement.query(By.directive(McToggleComponent));
+ toggleNativeElement = toggleDebugElement.nativeElement;
+ toggleInstance = toggleDebugElement.componentInstance;
+ testComponent = fixture.debugElement.componentInstance;
+ inputElement = toggleNativeElement.querySelector('input');
+ labelElement = toggleNativeElement.querySelector('label');
+ });
+
+ it('should add and remove the checked state', () => {
+ expect(toggleInstance.checked).toBe(false);
+ expect(inputElement.checked).toBe(false);
+
+ testComponent.value = true;
+
+ fixture.detectChanges();
+
+ expect(toggleInstance.checked).toBe(true);
+ expect(inputElement.checked).toBe(true);
+
+ testComponent.value = false;
+ fixture.detectChanges();
+
+ expect(toggleInstance.checked).toBe(false);
+ expect(inputElement.checked).toBe(false);
+ });
+
+ it('should change native element checked when check programmatically', () => {
+ expect(inputElement.checked).toBe(false);
+
+ toggleInstance.checked = true;
+ fixture.detectChanges();
+
+ expect(inputElement.checked).toBe(true);
+ });
+
+ it('should toggle checked state on click', () => {
+ expect(toggleInstance.checked).toBe(false);
+
+ labelElement.click();
+ fixture.detectChanges();
+
+ expect(toggleInstance.checked).toBe(true);
+
+ labelElement.click();
+ fixture.detectChanges();
+
+ expect(toggleInstance.checked).toBe(false);
+ });
+
+ it('should add and remove disabled state', () => {
+ expect(toggleInstance.disabled).toBe(false);
+ expect(toggleNativeElement.classList).not.toContain('mc-disabled');
+ expect(inputElement.tabIndex).toBe(0);
+ expect(inputElement.disabled).toBe(false);
+
+ testComponent.isDisabled = true;
+ fixture.detectChanges();
+
+ expect(toggleInstance.disabled).toBe(true);
+ expect(toggleNativeElement.classList).toContain('mc-disabled');
+ expect(inputElement.disabled).toBe(true);
+
+ testComponent.isDisabled = false;
+ fixture.detectChanges();
+
+ expect(toggleInstance.disabled).toBe(false);
+ expect(toggleNativeElement.classList).not.toContain('mc-disabled');
+ expect(inputElement.tabIndex).toBe(0);
+ expect(inputElement.disabled).toBe(false);
+ });
+
+ it('should not toggle `checked` state upon interation while disabled', () => {
+ testComponent.isDisabled = true;
+ fixture.detectChanges();
+
+ toggleNativeElement.click();
+ expect(toggleInstance.checked).toBe(false);
+ });
+
+ it('should preserve the user-provided id', () => {
+ expect(toggleNativeElement.id).toBe('simple-check');
+ expect(inputElement.id).toBe('simple-check-input');
+ });
+
+ it('should generate a unique id for the toggle input if no id is set', () => {
+ testComponent.toggleId = null;
+ fixture.detectChanges();
+
+ expect(toggleInstance.inputId).toMatch(/mc-toggle-\d+/);
+ expect(inputElement.id).toBe(toggleInstance.inputId);
+ });
+
+ it('should project the toggle content into the label element', () => {
+ const label = toggleNativeElement.querySelector('.mc-toggle-label');
+ expect(label.textContent!.trim()).toBe('Simple toggle');
+ });
+
+ it('should make the host element a tab stop', () => {
+ expect(inputElement.tabIndex).toBe(0);
+ });
+
+ it('should add a css class to position the label before the toggle', () => {
+ testComponent.labelPos = 'left';
+ fixture.detectChanges();
+
+ expect(toggleNativeElement.querySelector('.left')).not.toBeNull();
+ });
+
+ it('should not trigger the click event multiple times', () => {
+ spyOn(testComponent, 'onToggleClick');
+
+ expect(inputElement.checked).toBe(false);
+
+ labelElement.click();
+ fixture.detectChanges();
+
+ expect(inputElement.checked).toBe(true);
+
+ expect(testComponent.onToggleClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('should trigger a change event when the native input does', fakeAsync(() => {
+ spyOn(testComponent, 'onToggleChange');
+
+ expect(inputElement.checked).toBe(false);
+
+ labelElement.click();
+ fixture.detectChanges();
+
+ expect(inputElement.checked).toBe(true);
+
+ fixture.detectChanges();
+ flush();
+
+ expect(testComponent.onToggleChange).toHaveBeenCalledTimes(1);
+ }));
+
+ it('should not trigger the change event by changing the native value', fakeAsync(() => {
+ spyOn(testComponent, 'onToggleChange');
+
+ expect(inputElement.checked).toBe(false);
+
+ testComponent.value = true;
+ fixture.detectChanges();
+
+ expect(inputElement.checked).toBe(true);
+
+ fixture.detectChanges();
+ flush();
+
+ // The change event shouldn't fire, because the value change was not caused
+ // by any interaction.
+ expect(testComponent.onToggleChange).not.toHaveBeenCalled();
+ }));
+
+ it('should focus on underlying input element when focus() is called', () => {
+ expect(document.activeElement).not.toBe(inputElement);
+
+ toggleInstance.focus();
+ fixture.detectChanges();
+
+ expect(document.activeElement).toBe(inputElement);
+ });
+
+ describe('color behaviour', () => {
+ it('should apply class based on color attribute', () => {
+ testComponent.toggleColor = 'primary';
+ fixture.detectChanges();
+ expect(toggleNativeElement.classList.contains('mc-primary')).toBe(true);
+
+ testComponent.toggleColor = 'accent';
+ fixture.detectChanges();
+ expect(toggleNativeElement.classList.contains('mc-accent')).toBe(true);
+ });
+
+ it('should should not clear previous defined classes', () => {
+ toggleNativeElement.classList.add('custom-class');
+
+ testComponent.toggleColor = 'primary';
+ fixture.detectChanges();
+
+ expect(toggleNativeElement.classList.contains('mc-primary')).toBe(true);
+ expect(toggleNativeElement.classList.contains('custom-class')).toBe(true);
+
+ testComponent.toggleColor = 'accent';
+ fixture.detectChanges();
+
+ expect(toggleNativeElement.classList.contains('mc-primary')).toBe(false);
+ expect(toggleNativeElement.classList.contains('mc-accent')).toBe(true);
+ expect(toggleNativeElement.classList.contains('custom-class')).toBe(true);
+
+ });
+ });
+ });
+
+ describe('aria-label ', () => {
+ let toggleDebugElement: DebugElement;
+ let toggleNativeElement: HTMLElement;
+ let inputElement: HTMLInputElement;
+
+ it('should use the provided aria-label', () => {
+ fixture = TestBed.createComponent(ToggleWithAriaLabel);
+ toggleDebugElement = fixture.debugElement.query(By.directive(McToggleComponent));
+ toggleNativeElement = toggleDebugElement.nativeElement;
+ inputElement = toggleNativeElement.querySelector('input');
+
+ fixture.detectChanges();
+ expect(inputElement.getAttribute('aria-label')).toBe('Super effective');
+ });
+
+ it('should not set the aria-label attribute if no value is provided', () => {
+ fixture = TestBed.createComponent(SingleToggle);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('input').hasAttribute('aria-label')).toBe(false);
+ });
+ });
+
+ describe('with provided aria-labelledby ', () => {
+ let toggleDebugElement: DebugElement;
+ let toggleNativeElement: HTMLElement;
+ let inputElement: HTMLInputElement;
+
+ it('should use the provided aria-labelledby', () => {
+ fixture = TestBed.createComponent(ToggleWithAriaLabelledby);
+ toggleDebugElement = fixture.debugElement.query(By.directive(McToggleComponent));
+ toggleNativeElement = toggleDebugElement.nativeElement;
+ inputElement = toggleNativeElement.querySelector('input');
+
+ fixture.detectChanges();
+ expect(inputElement.getAttribute('aria-labelledby')).toBe('some-id');
+ });
+
+ it('should not assign aria-labelledby if none is provided', () => {
+ fixture = TestBed.createComponent(SingleToggle);
+ toggleDebugElement = fixture.debugElement.query(By.directive(McToggleComponent));
+ toggleNativeElement = toggleDebugElement.nativeElement;
+ inputElement = toggleNativeElement.querySelector('input');
+
+ fixture.detectChanges();
+ expect(inputElement.getAttribute('aria-labelledby')).toBe(null);
+ });
+ });
+
+ describe('with provided tabIndex', () => {
+ let toggleDebugElement: DebugElement;
+ let toggleNativeElement: HTMLElement;
+ let testComponent: ToggleWithTabIndex;
+ let inputElement: HTMLInputElement;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ToggleWithTabIndex);
+ fixture.detectChanges();
+
+ testComponent = fixture.debugElement.componentInstance;
+ toggleDebugElement = fixture.debugElement.query(By.directive(McToggleComponent));
+ toggleNativeElement = toggleDebugElement.nativeElement;
+ inputElement = toggleNativeElement.querySelector('input');
+ });
+
+ it('should preserve any given tabIndex', () => {
+ expect(inputElement.tabIndex).toBe(7);
+ });
+
+ it('should preserve given tabIndex when the toggle is disabled then enabled', () => {
+ testComponent.isDisabled = true;
+ fixture.detectChanges();
+
+ testComponent.customTabIndex = 13;
+ fixture.detectChanges();
+
+ testComponent.isDisabled = false;
+ fixture.detectChanges();
+
+ expect(inputElement.tabIndex).toBe(13);
+ });
+
+ });
+
+ describe('with native tabindex attribute', () => {
+ it('should properly detect native tabindex attribute', fakeAsync(() => {
+ fixture = TestBed.createComponent(ToggleWithTabindexAttr);
+ fixture.detectChanges();
+
+ const toggle = fixture.debugElement
+ .query(By.directive(McToggleComponent)).componentInstance as McToggleComponent;
+
+ expect(toggle.tabIndex)
+ .toBe(5, 'Expected tabIndex property to have been set based on the native attribute');
+ }));
+ });
+
+ describe('using ViewChild', () => {
+ let toggleDebugElement: DebugElement;
+ let toggleNativeElement: HTMLElement;
+ let testComponent: ToggleUsingViewChild;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ToggleUsingViewChild);
+ fixture.detectChanges();
+
+ toggleDebugElement = fixture.debugElement.query(By.directive(McToggleComponent));
+ toggleNativeElement = toggleDebugElement.nativeElement;
+ testComponent = fixture.debugElement.componentInstance;
+ });
+
+ it('should toggle disabledness correctly', () => {
+ const toggleInstance = toggleDebugElement.componentInstance;
+ const inputElement = toggleNativeElement.querySelector('input');
+ expect(toggleInstance.disabled).toBe(false);
+ expect(toggleNativeElement.classList).not.toContain('mc-disabled');
+ expect(inputElement.tabIndex).toBe(0);
+ expect(inputElement.disabled).toBe(false);
+
+ testComponent.isDisabled = true;
+ fixture.detectChanges();
+
+ expect(toggleInstance.disabled).toBe(true);
+ expect(toggleNativeElement.classList).toContain('mc-disabled');
+ expect(inputElement.disabled).toBe(true);
+
+ testComponent.isDisabled = false;
+ fixture.detectChanges();
+
+ expect(toggleInstance.disabled).toBe(false);
+ expect(toggleNativeElement.classList).not.toContain('mc-disabled');
+ expect(inputElement.tabIndex).toBe(0);
+ expect(inputElement.disabled).toBe(false);
+ });
+ });
+
+ describe('with multiple toggles', () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MultipleToggles);
+ fixture.detectChanges();
+ });
+
+ it('should assign a unique id to each toggle', () => {
+ const [firstId, secondId] =
+ fixture.debugElement.queryAll(By.directive(McToggleComponent))
+ .map((debugElement) => debugElement.nativeElement.querySelector('input').id);
+
+ expect(firstId).toMatch(/mc-toggle-\d+-input/);
+ expect(secondId).toMatch(/mc-toggle-\d+-input/);
+ expect(firstId).not.toEqual(secondId);
+ });
+ });
+
+ describe('with ngModel', () => {
+ let toggleDebugElement: DebugElement;
+ let toggleNativeElement: HTMLElement;
+ let toggleInstance: McToggleComponent;
+ let inputElement: HTMLInputElement;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ToggleWithFormDirectives);
+ fixture.detectChanges();
+
+ toggleDebugElement = fixture.debugElement.query(By.directive(McToggleComponent));
+ toggleNativeElement = toggleDebugElement.nativeElement;
+ toggleInstance = toggleDebugElement.componentInstance;
+ inputElement = toggleNativeElement.querySelector('input');
+ });
+
+ it('should be in pristine, untouched, and valid states initially', fakeAsync(() => {
+ flush();
+
+ const toggleElement = fixture.debugElement.query(By.directive(McToggleComponent));
+ const ngModel = toggleElement.injector.get(NgModel);
+
+ expect(ngModel.valid).toBe(true);
+ expect(ngModel.pristine).toBe(true);
+ expect(ngModel.touched).toBe(false);
+
+ // TODO(jelbourn): test that `touched` and `pristine` state are modified appropriately.
+ // This is currently blocked on issues with async() and fakeAsync().
+ }));
+
+ it('should toggle checked state on click', () => {
+ expect(toggleInstance.checked).toBe(false);
+
+ inputElement.click();
+ fixture.detectChanges();
+
+ expect(toggleInstance.checked).toBe(true);
+
+ inputElement.click();
+ fixture.detectChanges();
+
+ expect(toggleInstance.checked).toBe(false);
+ });
+ });
+
+ describe('with name attribute', () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ToggleWithNameAttribute);
+ fixture.detectChanges();
+ });
+
+ it('should forward name value to input element', () => {
+ const toggleElement = fixture.debugElement.query(By.directive(McToggleComponent));
+ const inputElement = toggleElement.nativeElement.querySelector('input');
+
+ expect(inputElement.getAttribute('name')).toBe('test-name');
+ });
+ });
+
+ describe('with form control', () => {
+ let toggleDebugElement: DebugElement;
+ let toggleInstance: McToggleComponent;
+ let testComponent: ToggleWithFormControl;
+ let inputElement: HTMLInputElement;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ToggleWithFormControl);
+ fixture.detectChanges();
+
+ toggleDebugElement = fixture.debugElement.query(By.directive(McToggleComponent));
+ toggleInstance = toggleDebugElement.componentInstance;
+ testComponent = fixture.debugElement.componentInstance;
+ inputElement = toggleDebugElement.nativeElement.querySelector('input');
+ });
+
+ it('should toggle the disabled state', () => {
+ expect(toggleInstance.disabled).toBe(false);
+
+ testComponent.formControl.disable();
+ fixture.detectChanges();
+
+ expect(toggleInstance.disabled).toBe(true);
+ expect(inputElement.disabled).toBe(true);
+
+ testComponent.formControl.enable();
+ fixture.detectChanges();
+
+ expect(toggleInstance.disabled).toBe(false);
+ expect(inputElement.disabled).toBe(false);
+ });
+ });
+
+ describe('without label', () => {
+ let toggleInnerContainer: HTMLElement;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ToggleWithoutLabel);
+
+ const toggleDebugEl = fixture.debugElement.query(By.directive(McToggleComponent));
+
+ toggleInnerContainer = toggleDebugEl
+ .query(By.css('.mc-toggle__container')).nativeElement;
+ });
+
+ it('should not add the "name" attribute if it is not passed in', () => {
+ fixture.detectChanges();
+ expect(toggleInnerContainer.querySelector('input')!.hasAttribute('name')).toBe(false);
+ });
+
+ it('should not add the "value" attribute if it is not passed in', () => {
+ fixture.detectChanges();
+ expect(toggleInnerContainer.querySelector('input')!.hasAttribute('value')).toBe(false);
+ });
+ });
+});
+
+@Component({
+ template: `
+
+
+ Simple toggle
+
+
`
+})
+class SingleToggle {
+ labelPos: 'left' | 'right' = 'left';
+ value: boolean = false;
+ isDisabled: boolean = false;
+ parentElementClicked: boolean = false;
+ parentElementKeyedUp: boolean = false;
+ toggleId: string | null = 'simple-check';
+ toggleColor: string = 'primary';
+
+ onToggleClick: (event?: Event) => void = () => {};
+ onToggleChange: (event?: any) => void = () => {};
+}
+
+
+@Component({
+ template: `
+
+ `
+})
+class ToggleWithFormDirectives {
+ isGood: boolean = false;
+}
+
+@Component(({
+ template: `
+ Option 1
+ Option 2
+ `
+}))
+class MultipleToggles {}
+
+@Component({
+ template: `
+
+ `
+})
+class ToggleWithTabIndex {
+ customTabIndex: number = 7;
+ isDisabled: boolean = false;
+}
+
+@Component({
+ template: `
+ `
+})
+class ToggleUsingViewChild {
+ @ViewChild(McToggleComponent) toggle;
+
+ set isDisabled(value: boolean) {
+ this.toggle.disabled = value;
+ }
+}
+
+@Component({
+ template: ``
+})
+class ToggleWithAriaLabel {
+}
+
+@Component({
+ template: ``
+})
+class ToggleWithAriaLabelledby {
+}
+
+@Component({
+ template: ``
+})
+class ToggleWithNameAttribute {
+}
+
+@Component({
+ template: ``
+})
+class ToggleWithFormControl {
+ formControl = new FormControl();
+}
+
+@Component({
+ template: `{{ label }}`
+})
+class ToggleWithoutLabel {
+ label: string;
+}
+
+@Component({
+ template: ``
+})
+class ToggleWithTabindexAttr {}
diff --git a/src/lib/toggle/toggle.component.ts b/src/lib/toggle/toggle.component.ts
new file mode 100644
index 000000000..6087810c4
--- /dev/null
+++ b/src/lib/toggle/toggle.component.ts
@@ -0,0 +1,196 @@
+// tslint:disable:no-empty
+import { animate, state, style, transition, trigger } from '@angular/animations';
+import {
+ Attribute,
+ ChangeDetectionStrategy, ChangeDetectorRef,
+ Component,
+ ElementRef, EventEmitter, forwardRef,
+ Input, Output, ViewChild,
+ ViewEncapsulation
+} from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { FocusMonitor } from '@ptsecurity/cdk/a11y';
+import {
+ ThemePalette,
+ CanColor, CanColorCtor,
+ CanDisable, CanDisableCtor,
+ HasTabIndex, HasTabIndexCtor,
+ mixinColor,
+ mixinDisabled,
+ mixinTabIndex
+} from '@ptsecurity/mosaic/core';
+
+
+let nextUniqueId = 0;
+
+type ToggleLabelPositionType = 'left' | 'right';
+
+export class McToggleBase {
+ constructor(public _elementRef: ElementRef) {}
+}
+
+export const _McToggleMixinBase:
+ HasTabIndexCtor &
+ CanDisableCtor &
+ CanColorCtor &
+ typeof McToggleBase = mixinTabIndex(mixinColor(mixinDisabled(McToggleBase)));
+
+export class McToggleChange {
+ source: McToggleComponent;
+ checked: boolean;
+}
+
+@Component({
+ selector: 'mc-toggle',
+ exportAs: 'mcToggle',
+ templateUrl: './toggle.component.html',
+ styleUrls: ['./toggle.css'],
+ providers: [
+ {provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => McToggleComponent), multi: true}
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+ inputs: ['disabled', 'color', 'tabIndex'],
+ host: {
+ '[id]': 'id',
+ '[attr.id]': 'id',
+ '[class.mc-disabled]': 'disabled',
+ '[class.mc-toggle-off]': '!checked'
+ },
+ animations: [
+ trigger('switch', [
+ state('true' , style({ right: '-1px' })),
+ state('false', style({ right: '*' })),
+ transition('* => *', animate('150ms'))
+ ])
+ ]
+})
+export class McToggleComponent extends _McToggleMixinBase
+ implements ControlValueAccessor, CanColor, CanDisable, HasTabIndex {
+
+ color: ThemePalette = ThemePalette.Primary;
+
+ @ViewChild('input') _inputElement: ElementRef;
+
+ @Input() labelPosition: ToggleLabelPositionType = 'right';
+
+ @Input('aria-label') ariaLabel: string = '';
+ @Input('aria-labelledby') ariaLabelledby: string | null = null;
+
+ private _uniqueId: string = `mc-toggle-${++nextUniqueId}`;
+
+ // tslint:disable:member-ordering
+ @Input() id: string = this._uniqueId;
+
+ get inputId(): string {
+ return `${this.id || this._uniqueId}-input`;
+ }
+
+ @Input() name: string | null = null;
+
+ @Input() value: string;
+
+ private _disabled: boolean = false;
+
+ @Input()
+ get disabled() {
+ return this._disabled;
+ }
+
+ set disabled(value: any) {
+ if (value !== this._disabled) {
+ this._disabled = value;
+ this._changeDetectorRef.markForCheck();
+ }
+ }
+
+ private _checked: boolean = false;
+
+ get checked() {
+ return this._checked;
+ }
+
+ @Input()
+ set checked(value: boolean) {
+ if (value !== this._checked) {
+ this._checked = value;
+ this._changeDetectorRef.markForCheck();
+ }
+ }
+
+ @Output() readonly change: EventEmitter =
+ new EventEmitter();
+
+ constructor(public _elementRef: ElementRef,
+ private _focusMonitor: FocusMonitor,
+ private _changeDetectorRef: ChangeDetectorRef,
+ @Attribute('tabindex') tabIndex: string
+ ) {
+ super(_elementRef);
+
+ this.tabIndex = parseInt(tabIndex) || 0;
+
+ this._focusMonitor.monitor(this._elementRef.nativeElement, true);
+ }
+
+ ngOnDestroy() {
+ this._focusMonitor.stopMonitoring(this._elementRef.nativeElement);
+ }
+
+ focus(): void {
+ this._focusMonitor.focusVia(this._inputElement.nativeElement, 'keyboard');
+ }
+
+ _getAriaChecked(): boolean {
+ return this.checked;
+ }
+
+ _onInteractionEvent(event: Event) {
+ event.stopPropagation();
+ }
+
+ _onLabelTextChange() {
+ this._changeDetectorRef.markForCheck();
+ }
+
+ _onInputClick(event: MouseEvent) {
+ event.stopPropagation();
+ this._updateModelValue();
+ this._emitChangeEvent();
+ }
+
+ writeValue(value: any) {
+ this.checked = !!value;
+ }
+
+ registerOnChange(fn: any) {
+ this._onChangeCallback = fn;
+ }
+
+ registerOnTouched(fn: any) {
+ this._onTouchedCallback = fn;
+ }
+
+ setDisabledState(isDisabled: boolean) {
+ this.disabled = isDisabled;
+ }
+
+ private _onTouchedCallback = () => {};
+
+ private _onChangeCallback = (_: any) => {};
+
+ private _updateModelValue() {
+ this._checked = !this.checked;
+ this._onChangeCallback(this.checked);
+ this._onTouchedCallback();
+ }
+
+ private _emitChangeEvent() {
+ const event = new McToggleChange();
+ event.source = this;
+ event.checked = this.checked;
+
+ this._onChangeCallback(this.checked);
+ this.change.emit(event);
+ }
+}
diff --git a/src/lib/toggle/toggle.module.ts b/src/lib/toggle/toggle.module.ts
new file mode 100644
index 000000000..ea4cb55e8
--- /dev/null
+++ b/src/lib/toggle/toggle.module.ts
@@ -0,0 +1,16 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { A11yModule } from '@ptsecurity/cdk/a11y';
+import { McCommonModule } from '@ptsecurity/mosaic/core';
+
+import { McToggleComponent } from './toggle.component';
+
+
+@NgModule({
+ imports: [CommonModule, BrowserAnimationsModule, A11yModule, McCommonModule],
+ exports: [McToggleComponent],
+ declarations: [McToggleComponent]
+})
+export class McToggleModule {
+}
diff --git a/src/lib/toggle/toggle.scss b/src/lib/toggle/toggle.scss
new file mode 100644
index 000000000..fa9774bbb
--- /dev/null
+++ b/src/lib/toggle/toggle.scss
@@ -0,0 +1,111 @@
+$toggle-height: 16px;
+$toggle-width: 28px;
+$toggle-border-radius: 9px;
+
+$small-toggle-height: 14px;
+$small-toggle-width: 24px;
+$small-toggle-border-radius: 8px;
+
+$label-margin: 8px;
+
+mc-toggle {
+ display: inline-block;
+
+ .mc-toggle-layout {
+ cursor: inherit;
+ align-items: baseline;
+ vertical-align: middle;
+ display: inline-flex;
+ white-space: nowrap;
+ }
+
+ .mc-toggle__container {
+ display: flex;
+ align-items: center;
+ position: relative;
+
+ &.left {
+ flex-direction: row-reverse;
+ }
+ }
+
+ .mc-toggle__content {
+ &.left {
+ margin-right: $label-margin;
+ }
+
+ &.right {
+ margin-left: $label-margin;
+ }
+ }
+
+ .mc-toggle-bar {
+ position: relative;
+
+ &.mc-toggle-label-position-left {
+ order: 1;
+ }
+ }
+
+ .mc-toggle-bar-container {
+ position: relative;
+ }
+
+ .mc-toggle__focus-frame {
+ position: absolute;
+ z-index: 1;
+ top: -1px; // due to border
+ left: -1px; // due to border
+ }
+
+ .mc-toggle__circle {
+ position: absolute;
+ border-radius: 100%;
+ margin-top: -1px; // due to border
+ margin-left: -1px; // due to border
+ }
+}
+
+mc-toggle:not(.mc-toggle_small) {
+
+ .mc-toggle-bar {
+ height: $toggle-height;
+ width: $toggle-width;
+ border-radius: $toggle-border-radius;
+ }
+
+ .mc-toggle__focus-frame {
+ border-radius: $toggle-border-radius + 1;
+ height: $toggle-height + 2;
+ width: $toggle-width + 2;
+ }
+
+ .mc-toggle__circle {
+ height: $toggle-height;
+ width: $toggle-height;
+ }
+}
+
+mc-toggle.mc-toggle_small {
+
+ .mc-toggle-bar {
+ height: $small-toggle-height;
+ width: $small-toggle-width;
+ border-radius: $small-toggle-border-radius;
+ }
+
+ .mc-toggle__focus-frame {
+ border-radius: $small-toggle-border-radius + 1;
+ height: $small-toggle-height + 2;
+ width: $small-toggle-width + 2;
+ }
+
+ .mc-toggle__circle {
+ height: $small-toggle-height;
+ width: $small-toggle-height;
+ }
+}
+
+mc-toggle:not(.mc-disabled) {
+ cursor: pointer;
+}
diff --git a/src/lib/toggle/tsconfig.build.json b/src/lib/toggle/tsconfig.build.json
new file mode 100644
index 000000000..421ae16d4
--- /dev/null
+++ b/src/lib/toggle/tsconfig.build.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../tsconfig.build",
+ "files": ["public-api.ts"],
+ "angularCompilerOptions": {
+ "strictMetadataEmit": true,
+ "flatModuleOutFile": "index.js",
+ "flatModuleId": "@ptsecurity/mosaic/toggle",
+ "skipTemplateCodegen": true,
+ "fullTemplateTypeCheck": true
+ }
+}