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: ` +
+ Be good +
+ ` +}) +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 + } +}