From c63b9f4c5f105e93aa0200885ee4a77f5ae7a7be Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Wed, 26 Oct 2016 11:40:43 -0700 Subject: [PATCH] chore: remove BooleanFieldValue (#1290) --- .gitignore | 1 + src/lib/button-toggle/button-toggle.ts | 11 +- src/lib/button/button.ts | 8 +- src/lib/checkbox/checkbox.ts | 9 +- src/lib/core/annotations/field-value.spec.ts | 34 - src/lib/core/annotations/field-value.ts | 31 - .../core/coersion/boolean-property.spec.ts | 48 ++ src/lib/core/coersion/boolean-property.ts | 4 + src/lib/core/core.ts | 6 +- src/lib/input/input.ts | 65 +- src/lib/sidenav/sidenav.ts | 7 +- src/lib/slide-toggle/slide-toggle.ts | 655 +++++++++--------- src/lib/slider/slider.ts | 20 +- src/lib/tabs/tabs.ts | 7 +- 14 files changed, 467 insertions(+), 439 deletions(-) delete mode 100644 src/lib/core/annotations/field-value.spec.ts delete mode 100644 src/lib/core/annotations/field-value.ts create mode 100644 src/lib/core/coersion/boolean-property.spec.ts create mode 100644 src/lib/core/coersion/boolean-property.ts diff --git a/.gitignore b/.gitignore index 37b6881acdb2..855c73dc0ecf 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ /libpeerconnection.log npm-debug.log testem.log +/.chrome diff --git a/src/lib/button-toggle/button-toggle.ts b/src/lib/button-toggle/button-toggle.ts index 1f4d3785ed0e..c1b922055da6 100644 --- a/src/lib/button-toggle/button-toggle.ts +++ b/src/lib/button-toggle/button-toggle.ts @@ -15,13 +15,9 @@ import { forwardRef, AfterViewInit } from '@angular/core'; -import { - NG_VALUE_ACCESSOR, - ControlValueAccessor, - FormsModule, -} from '@angular/forms'; +import {NG_VALUE_ACCESSOR, ControlValueAccessor, FormsModule} from '@angular/forms'; import {Observable} from 'rxjs/Observable'; -import {BooleanFieldValue, MdUniqueSelectionDispatcher} from '../core'; +import {MdUniqueSelectionDispatcher, coerceBooleanProperty} from '../core'; export type ToggleType = 'checkbox' | 'radio'; @@ -102,13 +98,12 @@ export class MdButtonToggleGroup implements AfterViewInit, ControlValueAccessor } @Input() - @BooleanFieldValue() get disabled(): boolean { return this._disabled; } set disabled(value) { - this._disabled = (value != null && value !== false) ? true : null; + this._disabled = coerceBooleanProperty(value); } @Input() diff --git a/src/lib/button/button.ts b/src/lib/button/button.ts index 03ac5438f1a3..9763d851a706 100644 --- a/src/lib/button/button.ts +++ b/src/lib/button/button.ts @@ -10,7 +10,7 @@ import { ModuleWithProviders, } from '@angular/core'; import {CommonModule} from '@angular/common'; -import {BooleanFieldValue, MdRippleModule} from '../core'; +import {MdRippleModule, coerceBooleanProperty} from '../core'; // TODO(jelbourn): Make the `isMouseDown` stuff done with one global listener. // TODO(kara): Convert attribute selectors to classes when attr maps become available @@ -41,7 +41,11 @@ export class MdButton { _isMouseDown: boolean = false; /** Whether the ripple effect on click should be disabled. */ - @Input() @BooleanFieldValue() disableRipple: boolean = false; + private _disableRipple: boolean = false; + + @Input() + get disableRipple() { return this._disableRipple; } + set disableRipple(v) { this._disableRipple = coerceBooleanProperty(v); } constructor(private _elementRef: ElementRef, private _renderer: Renderer) { } diff --git a/src/lib/checkbox/checkbox.ts b/src/lib/checkbox/checkbox.ts index fd8dd3a427b0..8d595def9bd4 100644 --- a/src/lib/checkbox/checkbox.ts +++ b/src/lib/checkbox/checkbox.ts @@ -12,7 +12,8 @@ import { ModuleWithProviders, } from '@angular/core'; import {NG_VALUE_ACCESSOR, ControlValueAccessor} from '@angular/forms'; -import {BooleanFieldValue} from '../core'; +import {coerceBooleanProperty} from '../core/coersion/boolean-property'; + /** * Monotonically increasing integer used to auto-generate unique ids for checkbox components. @@ -93,8 +94,12 @@ export class MdCheckbox implements ControlValueAccessor { return `input-${this.id}`; } + private _required: boolean; + /** Whether the checkbox is required or not. */ - @Input() @BooleanFieldValue() required: boolean = false; + @Input() + get required(): boolean { return this._required; } + set required(value) { this._required = coerceBooleanProperty(value); } /** Whether or not the checkbox should come before or after the label. */ @Input() align: 'start' | 'end' = 'start'; diff --git a/src/lib/core/annotations/field-value.spec.ts b/src/lib/core/annotations/field-value.spec.ts deleted file mode 100644 index 55bcc5d25c0a..000000000000 --- a/src/lib/core/annotations/field-value.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {BooleanFieldValue} from './field-value'; - -describe('BooleanFieldValue', () => { - it('should work for null values', () => { - let x = new BooleanFieldValueTest(); - - x.field = null; - expect(x.field).toBe(false); - - x.field = undefined; - expect(x.field).toBe(false); - }); - - it('should work for string values', () => { - let x = new BooleanFieldValueTest(); - - (x).field = 'hello'; - expect(x.field).toBe(true); - - (x).field = 'true'; - expect(x.field).toBe(true); - - (x).field = ''; - expect(x.field).toBe(true); - - (x).field = 'false'; - expect(x.field).toBe(false); - }); -}); - - -class BooleanFieldValueTest { - @BooleanFieldValue() field: boolean; -} diff --git a/src/lib/core/annotations/field-value.ts b/src/lib/core/annotations/field-value.ts deleted file mode 100644 index 46001c9812f6..000000000000 --- a/src/lib/core/annotations/field-value.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Annotation Factory that allows HTML style boolean attributes. For example, - * a field declared like this: - - * @Directive({ selector: 'component' }) class MyComponent { - * @Input() @BooleanFieldValueFactory() myField: boolean; - * } - * - * You could set it up this way: - * - * or: - * - * @deprecated - */ -function booleanFieldValueFactory() { - return function booleanFieldValueMetadata(target: any, key: string): void { - const defaultValue = target[key]; - const localKey = `__md_private_symbol_${key}`; - target[localKey] = defaultValue; - - Object.defineProperty(target, key, { - get() { return (this)[localKey]; }, - set(value: boolean) { - (this)[localKey] = value != null && `${value}` !== 'false'; - } - }); - }; -} - - -export { booleanFieldValueFactory as BooleanFieldValue }; diff --git a/src/lib/core/coersion/boolean-property.spec.ts b/src/lib/core/coersion/boolean-property.spec.ts new file mode 100644 index 000000000000..b16c4ea75714 --- /dev/null +++ b/src/lib/core/coersion/boolean-property.spec.ts @@ -0,0 +1,48 @@ +import {coerceBooleanProperty} from './boolean-property'; + + +describe('coerceBooleanProperty', () => { + it('should coerce undefined to false', () => { + expect(coerceBooleanProperty(undefined)).toBe(false); + }); + + it('should coerce null to false', () => { + expect(coerceBooleanProperty(null)).toBe(false); + }); + + it('should coerce the empty string to true', () => { + expect(coerceBooleanProperty('')).toBe(true); + }); + + it('should coerce zero to true', () => { + expect(coerceBooleanProperty(0)).toBe(true); + }); + + it('should coerce the string "false" to false', () => { + expect(coerceBooleanProperty('false')).toBe(false); + }); + + it('should coerce the boolean false to false', () => { + expect(coerceBooleanProperty(false)).toBe(false); + }); + + it('should coerce the boolean true to true', () => { + expect(coerceBooleanProperty(true)).toBe(true); + }); + + it('should coerce the string "true" to true', () => { + expect(coerceBooleanProperty('true')).toBe(true); + }); + + it('should coerce an arbitrary string to true', () => { + expect(coerceBooleanProperty('pink')).toBe(true); + }); + + it('should coerce an object to true', () => { + expect(coerceBooleanProperty({})).toBe(true); + }); + + it('should coerce an array to true', () => { + expect(coerceBooleanProperty([])).toBe(true); + }); +}); diff --git a/src/lib/core/coersion/boolean-property.ts b/src/lib/core/coersion/boolean-property.ts new file mode 100644 index 000000000000..eebe9f2f2d66 --- /dev/null +++ b/src/lib/core/coersion/boolean-property.ts @@ -0,0 +1,4 @@ +/** Coerces a data-bound value (typically a string) to a boolean. */ +export function coerceBooleanProperty(value: any): boolean { + return value != null && `${value}` !== 'false'; +} diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index 21bdb492d739..5802e3b40284 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -70,9 +70,6 @@ export {applyCssTransform} from './style/apply-transform'; // Error export {MdError} from './errors/error'; -// Annotations. -export {BooleanFieldValue} from './annotations/field-value'; - // Misc export {ComponentType} from './overlay/generic-component-type'; @@ -84,6 +81,9 @@ export * from './compatibility/style-compatibility'; // Animation export * from './animation/animation'; +// Coersion +export {coerceBooleanProperty} from './coersion/boolean-property'; + @NgModule({ imports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule, A11yModule], diff --git a/src/lib/input/input.ts b/src/lib/input/input.ts index 66c4fbe4bec4..4abbf4148b05 100644 --- a/src/lib/input/input.ts +++ b/src/lib/input/input.ts @@ -18,13 +18,9 @@ import { ModuleWithProviders, ViewEncapsulation, } from '@angular/core'; -import { - NG_VALUE_ACCESSOR, - ControlValueAccessor, - FormsModule, -} from '@angular/forms'; +import {NG_VALUE_ACCESSOR, ControlValueAccessor, FormsModule} from '@angular/forms'; import {CommonModule} from '@angular/common'; -import {BooleanFieldValue, MdError} from '../core'; +import {MdError, coerceBooleanProperty} from '../core'; import {Observable} from 'rxjs/Observable'; @@ -118,9 +114,22 @@ export class MdInput implements ControlValueAccessor, AfterContentInit, OnChange */ @Input('aria-label') ariaLabel: string; @Input('aria-labelledby') ariaLabelledBy: string; - @Input('aria-disabled') @BooleanFieldValue() ariaDisabled: boolean; - @Input('aria-required') @BooleanFieldValue() ariaRequired: boolean; - @Input('aria-invalid') @BooleanFieldValue() ariaInvalid: boolean; + + private _ariaDisabled: boolean; + private _ariaRequired: boolean; + private _ariaInvalid: boolean; + + @Input('aria-disabled') + get ariaDisabled(): boolean { return this._ariaDisabled; } + set ariaDisabled(value) { this._ariaDisabled = coerceBooleanProperty(value); } + + @Input('aria-required') + get ariaRequired(): boolean { return this._ariaRequired; } + set ariaRequired(value) { this._ariaRequired = coerceBooleanProperty(value); } + + @Input('aria-invalid') + get ariaInvalid(): boolean { return this._ariaInvalid; } + set ariaInvalid(value) { this._ariaInvalid = coerceBooleanProperty(value); } /** * Content directives. @@ -141,14 +150,11 @@ export class MdInput implements ControlValueAccessor, AfterContentInit, OnChange */ @Input() align: 'start' | 'end' = 'start'; @Input() dividerColor: 'primary' | 'accent' | 'warn' = 'primary'; - @Input() @BooleanFieldValue() floatingPlaceholder: boolean = true; @Input() hintLabel: string = ''; @Input() autocomplete: string; @Input() autocorrect: string; @Input() autocapitalize: string; - @Input() @BooleanFieldValue() autofocus: boolean = false; - @Input() @BooleanFieldValue() disabled: boolean = false; @Input() id: string = `md-input-${nextUniqueId++}`; @Input() list: string = null; @Input() max: string | number = null; @@ -156,14 +162,43 @@ export class MdInput implements ControlValueAccessor, AfterContentInit, OnChange @Input() min: string | number = null; @Input() minlength: number = null; @Input() placeholder: string = null; - @Input() @BooleanFieldValue() readonly: boolean = false; - @Input() @BooleanFieldValue() required: boolean = false; - @Input() @BooleanFieldValue() spellcheck: boolean = false; @Input() step: number = null; @Input() tabindex: number = null; @Input() type: string = 'text'; @Input() name: string = null; + private _floatingPlaceholder: boolean = false; + private _autofocus: boolean = false; + private _disabled: boolean = false; + private _readonly: boolean = false; + private _required: boolean = false; + private _spellcheck: boolean = false; + + @Input() + get floatingPlaceholder(): boolean { return this._floatingPlaceholder; } + set floatingPlaceholder(value) { this._floatingPlaceholder = coerceBooleanProperty(value); } + + @Input() + get autofocus(): boolean { return this._autofocus; } + set autofocus(value) { this._autofocus = coerceBooleanProperty(value); } + + @Input() + get disabled(): boolean { return this._disabled; } + set disabled(value) { this._disabled = coerceBooleanProperty(value); } + + @Input() + get readonly(): boolean { return this._readonly; } + set readonly(value) { this._readonly = coerceBooleanProperty(value); } + + @Input() + get required(): boolean { return this._required; } + set required(value) { this._required = coerceBooleanProperty(value); } + + @Input() + get spellcheck(): boolean { return this._spellcheck; } + set spellcheck(value) { this._spellcheck = coerceBooleanProperty(value); } + + private _blurEmitter: EventEmitter = new EventEmitter(); private _focusEmitter: EventEmitter = new EventEmitter(); diff --git a/src/lib/sidenav/sidenav.ts b/src/lib/sidenav/sidenav.ts index 567c687d7f6e..ebba37cd54d7 100644 --- a/src/lib/sidenav/sidenav.ts +++ b/src/lib/sidenav/sidenav.ts @@ -16,7 +16,8 @@ import { ViewEncapsulation, } from '@angular/core'; import {CommonModule} from '@angular/common'; -import {Dir, MdError} from '../core'; +import {Dir, MdError, coerceBooleanProperty} from '../core'; + /** Exception thrown when two MdSidenav are matching the same side. */ export class MdDuplicatedSidenavError extends MdError { @@ -79,9 +80,7 @@ export class MdSidenav { @Input() get opened(): boolean { return this._opened; } set opened(v: boolean) { - // TODO(jelbourn): this coercion goes away when BooleanFieldValue is removed. - let booleanValue = v != null && `${v}` !== 'false'; - this.toggle(booleanValue); + this.toggle(coerceBooleanProperty(v)); } diff --git a/src/lib/slide-toggle/slide-toggle.ts b/src/lib/slide-toggle/slide-toggle.ts index a8aaa1d07223..66632b784156 100644 --- a/src/lib/slide-toggle/slide-toggle.ts +++ b/src/lib/slide-toggle/slide-toggle.ts @@ -1,326 +1,329 @@ -import { - Component, - ElementRef, - Renderer, - forwardRef, - ChangeDetectionStrategy, - Input, - Output, - EventEmitter, - AfterContentInit, - NgModule, - ModuleWithProviders, - ViewEncapsulation, -} from '@angular/core'; -import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; -import { - FormsModule, - ControlValueAccessor, - NG_VALUE_ACCESSOR -} from '@angular/forms'; -import {BooleanFieldValue, applyCssTransform} from '../core'; -import {Observable} from 'rxjs/Observable'; -import {MdGestureConfig} from '../core'; - - -export const MD_SLIDE_TOGGLE_VALUE_ACCESSOR: any = { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MdSlideToggle), - multi: true -}; - -// A simple change event emitted by the MdSlideToggle component. -export class MdSlideToggleChange { - source: MdSlideToggle; - checked: boolean; -} - -// Increasing integer for generating unique ids for slide-toggle components. -let nextId = 0; - -@Component({ - moduleId: module.id, - selector: 'md-slide-toggle', - host: { - '[class.md-checked]': 'checked', - '[class.md-disabled]': 'disabled', - // This md-slide-toggle prefix will change, once the temporary ripple is removed. - '[class.md-slide-toggle-focused]': '_hasFocus', - '(mousedown)': '_setMousedown()' - }, - templateUrl: 'slide-toggle.html', - styleUrls: ['slide-toggle.css'], - providers: [MD_SLIDE_TOGGLE_VALUE_ACCESSOR], - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class MdSlideToggle implements AfterContentInit, 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; - private _isMousedown: boolean = false; - private _slideRenderer: SlideToggleRenderer = null; - - // Needs to be public to support AOT compilation (as host binding). - _hasFocus: boolean = false; - - @Input() @BooleanFieldValue() disabled: boolean = false; - @Input() @BooleanFieldValue() required: boolean = false; - @Input() name: string = null; - @Input() id: string = this._uniqueId; - @Input() tabIndex: number = 0; - @Input() ariaLabel: string = null; - @Input() ariaLabelledby: string = null; - - private _change: EventEmitter = new EventEmitter(); - @Output() 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) {} - - /** TODO: internal */ - ngAfterContentInit() { - this._slideRenderer = new SlideToggleRenderer(this._elementRef); - } - - /** - * 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. - */ - _onChangeEvent(event: Event) { - // We always have to stop propagation on the change event. - // Otherwise the change event, from the input element, will bubble up and - // emit its event object to the component's `change` output. - event.stopPropagation(); - - // Once a drag is currently in progress, we do not want to toggle the slide-toggle on a click. - if (!this.disabled && !this._slideRenderer.isDragging()) { - this.toggle(); - - // Emit our custom change event if the native input emitted one. - // It is important to only emit it, if the native input triggered one, because - // we don't want to trigger a change event, when the `checked` variable changes for example. - this._emitChangeEvent(); - } - } - - _onInputClick(event: Event) { - this.onTouched(); - - // We have to stop propagation for click events on the visual hidden input element. - // By default, when a user clicks on a label element, a generated click event will be - // dispatched on the associated input element. Since we are using a label element as our - // root container, the click event on the `slide-toggle` will be executed twice. - // The real click event will bubble up, and the generated click event also tries to bubble up. - // This will lead to multiple click events. - // Preventing bubbling for the second event will solve that issue. - event.stopPropagation(); - } - - _setMousedown() { - // We only *show* the focus style when focus has come to the button via the keyboard. - // The Material Design spec is silent on this topic, and without doing this, the - // button continues to look :active after clicking. - // @see http://marcysutton.com/button-focus-hell/ - this._isMousedown = true; - setTimeout(() => this._isMousedown = false, 100); - } - - _onInputFocus() { - // Only show the focus / ripple indicator when the focus was not triggered by a mouse - // interaction on the component. - if (!this._isMousedown) { - this._hasFocus = true; - } - } - - _onInputBlur() { - this._hasFocus = false; - this.onTouched(); - } - - /** - * Implemented as part of ControlValueAccessor. - * TODO: internal - */ - writeValue(value: any): void { - this.checked = value; - } - - /** - * Implemented as part of ControlValueAccessor. - * TODO: internal - */ - registerOnChange(fn: any): void { - this.onChange = fn; - } - - /** - * Implemented as part of ControlValueAccessor. - * TODO: 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); - } - } - - @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); - } - } - - /** Emits the change event to the `change` output EventEmitter */ - private _emitChangeEvent() { - let event = new MdSlideToggleChange(); - event.source = this; - event.checked = this.checked; - this._change.emit(event); - } - - - /** TODO: internal */ - _onDragStart() { - if (!this.disabled) { - this._slideRenderer.startThumbDrag(this.checked); - } - } - - /** TODO: internal */ - _onDrag(event: HammerInput) { - if (this._slideRenderer.isDragging()) { - this._slideRenderer.updateThumbPosition(event.deltaX); - } - } - - /** TODO: internal */ - _onDragEnd() { - if (!this._slideRenderer.isDragging()) { - return; - } - - // Notice that we have to stop outside of the current event handler, - // because otherwise the click event will be fired and will reset the new checked variable. - setTimeout(() => { - this.checked = this._slideRenderer.stopThumbDrag(); - this._emitChangeEvent(); - }, 0); - } - -} - -/** - * Renderer for the Slide Toggle component, which separates DOM modification in its own class - */ -class SlideToggleRenderer { - - private _thumbEl: HTMLElement; - private _thumbBarEl: HTMLElement; - private _thumbBarWidth: number; - private _checked: boolean; - private _percentage: number; - - constructor(private _elementRef: ElementRef) { - this._thumbEl = _elementRef.nativeElement.querySelector('.md-slide-toggle-thumb-container'); - this._thumbBarEl = _elementRef.nativeElement.querySelector('.md-slide-toggle-bar'); - } - - /** Whether the slide-toggle is currently dragging. */ - isDragging(): boolean { - return !!this._thumbBarWidth; - } - - - /** Initializes the drag of the slide-toggle. */ - startThumbDrag(checked: boolean) { - if (!this.isDragging()) { - this._thumbBarWidth = this._thumbBarEl.clientWidth - this._thumbEl.clientWidth; - this._checked = checked; - this._thumbEl.classList.add('md-dragging'); - } - } - - /** Stops the current drag and returns the new checked value. */ - stopThumbDrag(): boolean { - if (this.isDragging()) { - this._thumbBarWidth = null; - this._thumbEl.classList.remove('md-dragging'); - - applyCssTransform(this._thumbEl, ''); - - return this._percentage > 50; - } - } - - /** Updates the thumb containers position from the specified distance. */ - updateThumbPosition(distance: number) { - this._percentage = this._getThumbPercentage(distance); - applyCssTransform(this._thumbEl, `translate3d(${this._percentage}%, 0, 0)`); - } - - /** Retrieves the percentage of thumb from the moved distance. */ - private _getThumbPercentage(distance: number) { - let percentage = (distance / this._thumbBarWidth) * 100; - - // When the toggle was initially checked, then we have to start the drag at the end. - if (this._checked) { - percentage += 100; - } - - return Math.max(0, Math.min(percentage, 100)); - } - -} - - -@NgModule({ - imports: [FormsModule], - exports: [MdSlideToggle], - declarations: [MdSlideToggle], -}) -export class MdSlideToggleModule { - static forRoot(): ModuleWithProviders { - return { - ngModule: MdSlideToggleModule, - providers: [{provide: HAMMER_GESTURE_CONFIG, useClass: MdGestureConfig}] - }; - } -} +import { + Component, + ElementRef, + Renderer, + forwardRef, + ChangeDetectionStrategy, + Input, + Output, + EventEmitter, + AfterContentInit, + NgModule, + ModuleWithProviders, + ViewEncapsulation, +} from '@angular/core'; +import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; +import {FormsModule, ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; +import {applyCssTransform, coerceBooleanProperty, MdGestureConfig} from '../core'; +import {Observable} from 'rxjs/Observable'; + + +export const MD_SLIDE_TOGGLE_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MdSlideToggle), + multi: true +}; + +// A simple change event emitted by the MdSlideToggle component. +export class MdSlideToggleChange { + source: MdSlideToggle; + checked: boolean; +} + +// Increasing integer for generating unique ids for slide-toggle components. +let nextId = 0; + +@Component({ + moduleId: module.id, + selector: 'md-slide-toggle', + host: { + '[class.md-checked]': 'checked', + '[class.md-disabled]': 'disabled', + // This md-slide-toggle prefix will change, once the temporary ripple is removed. + '[class.md-slide-toggle-focused]': '_hasFocus', + '(mousedown)': '_setMousedown()' + }, + templateUrl: 'slide-toggle.html', + styleUrls: ['slide-toggle.css'], + providers: [MD_SLIDE_TOGGLE_VALUE_ACCESSOR], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class MdSlideToggle implements AfterContentInit, 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; + private _isMousedown: boolean = false; + private _slideRenderer: SlideToggleRenderer = null; + private _disabled: boolean = false; + private _required: boolean = false; + + // Needs to be public to support AOT compilation (as host binding). + _hasFocus: boolean = false; + + @Input() name: string = null; + @Input() id: string = this._uniqueId; + @Input() tabIndex: number = 0; + @Input() ariaLabel: string = null; + @Input() ariaLabelledby: string = null; + + @Input() + get disabled(): boolean { return this._disabled; } + set disabled(value) { this._disabled = coerceBooleanProperty(value); } + + @Input() + get required(): boolean { return this._required; } + set required(value) { this._required = coerceBooleanProperty(value); } + + private _change: EventEmitter = new EventEmitter(); + @Output() 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) {} + + /** TODO: internal */ + ngAfterContentInit() { + this._slideRenderer = new SlideToggleRenderer(this._elementRef); + } + + /** + * 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. + */ + _onChangeEvent(event: Event) { + // We always have to stop propagation on the change event. + // Otherwise the change event, from the input element, will bubble up and + // emit its event object to the component's `change` output. + event.stopPropagation(); + + // Once a drag is currently in progress, we do not want to toggle the slide-toggle on a click. + if (!this.disabled && !this._slideRenderer.isDragging()) { + this.toggle(); + + // Emit our custom change event if the native input emitted one. + // It is important to only emit it, if the native input triggered one, because + // we don't want to trigger a change event, when the `checked` variable changes for example. + this._emitChangeEvent(); + } + } + + _onInputClick(event: Event) { + this.onTouched(); + + // We have to stop propagation for click events on the visual hidden input element. + // By default, when a user clicks on a label element, a generated click event will be + // dispatched on the associated input element. Since we are using a label element as our + // root container, the click event on the `slide-toggle` will be executed twice. + // The real click event will bubble up, and the generated click event also tries to bubble up. + // This will lead to multiple click events. + // Preventing bubbling for the second event will solve that issue. + event.stopPropagation(); + } + + _setMousedown() { + // We only *show* the focus style when focus has come to the button via the keyboard. + // The Material Design spec is silent on this topic, and without doing this, the + // button continues to look :active after clicking. + // @see http://marcysutton.com/button-focus-hell/ + this._isMousedown = true; + setTimeout(() => this._isMousedown = false, 100); + } + + _onInputFocus() { + // Only show the focus / ripple indicator when the focus was not triggered by a mouse + // interaction on the component. + if (!this._isMousedown) { + this._hasFocus = true; + } + } + + _onInputBlur() { + this._hasFocus = false; + this.onTouched(); + } + + /** + * Implemented as part of ControlValueAccessor. + * TODO: internal + */ + writeValue(value: any): void { + this.checked = value; + } + + /** + * Implemented as part of ControlValueAccessor. + * TODO: internal + */ + registerOnChange(fn: any): void { + this.onChange = fn; + } + + /** + * Implemented as part of ControlValueAccessor. + * TODO: 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); + } + } + + @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); + } + } + + /** Emits the change event to the `change` output EventEmitter */ + private _emitChangeEvent() { + let event = new MdSlideToggleChange(); + event.source = this; + event.checked = this.checked; + this._change.emit(event); + } + + + /** TODO: internal */ + _onDragStart() { + if (!this.disabled) { + this._slideRenderer.startThumbDrag(this.checked); + } + } + + /** TODO: internal */ + _onDrag(event: HammerInput) { + if (this._slideRenderer.isDragging()) { + this._slideRenderer.updateThumbPosition(event.deltaX); + } + } + + /** TODO: internal */ + _onDragEnd() { + if (!this._slideRenderer.isDragging()) { + return; + } + + // Notice that we have to stop outside of the current event handler, + // because otherwise the click event will be fired and will reset the new checked variable. + setTimeout(() => { + this.checked = this._slideRenderer.stopThumbDrag(); + this._emitChangeEvent(); + }, 0); + } + +} + +/** + * Renderer for the Slide Toggle component, which separates DOM modification in its own class + */ +class SlideToggleRenderer { + + private _thumbEl: HTMLElement; + private _thumbBarEl: HTMLElement; + private _thumbBarWidth: number; + private _checked: boolean; + private _percentage: number; + + constructor(private _elementRef: ElementRef) { + this._thumbEl = _elementRef.nativeElement.querySelector('.md-slide-toggle-thumb-container'); + this._thumbBarEl = _elementRef.nativeElement.querySelector('.md-slide-toggle-bar'); + } + + /** Whether the slide-toggle is currently dragging. */ + isDragging(): boolean { + return !!this._thumbBarWidth; + } + + + /** Initializes the drag of the slide-toggle. */ + startThumbDrag(checked: boolean) { + if (!this.isDragging()) { + this._thumbBarWidth = this._thumbBarEl.clientWidth - this._thumbEl.clientWidth; + this._checked = checked; + this._thumbEl.classList.add('md-dragging'); + } + } + + /** Stops the current drag and returns the new checked value. */ + stopThumbDrag(): boolean { + if (this.isDragging()) { + this._thumbBarWidth = null; + this._thumbEl.classList.remove('md-dragging'); + + applyCssTransform(this._thumbEl, ''); + + return this._percentage > 50; + } + } + + /** Updates the thumb containers position from the specified distance. */ + updateThumbPosition(distance: number) { + this._percentage = this._getThumbPercentage(distance); + applyCssTransform(this._thumbEl, `translate3d(${this._percentage}%, 0, 0)`); + } + + /** Retrieves the percentage of thumb from the moved distance. */ + private _getThumbPercentage(distance: number) { + let percentage = (distance / this._thumbBarWidth) * 100; + + // When the toggle was initially checked, then we have to start the drag at the end. + if (this._checked) { + percentage += 100; + } + + return Math.max(0, Math.min(percentage, 100)); + } + +} + + +@NgModule({ + imports: [FormsModule], + exports: [MdSlideToggle], + declarations: [MdSlideToggle], +}) +export class MdSlideToggleModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: MdSlideToggleModule, + providers: [{provide: HAMMER_GESTURE_CONFIG, useClass: MdGestureConfig}] + }; + } +} diff --git a/src/lib/slider/slider.ts b/src/lib/slider/slider.ts index 8c80ee4ad7ee..36ddd8f04431 100644 --- a/src/lib/slider/slider.ts +++ b/src/lib/slider/slider.ts @@ -9,13 +9,9 @@ import { AfterContentInit, forwardRef, } from '@angular/core'; -import { - NG_VALUE_ACCESSOR, - ControlValueAccessor, - FormsModule, -} from '@angular/forms'; +import {NG_VALUE_ACCESSOR, ControlValueAccessor, FormsModule} from '@angular/forms'; import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; -import {BooleanFieldValue, MdGestureConfig, applyCssTransform} from '../core'; +import {MdGestureConfig, applyCssTransform, coerceBooleanProperty} from '../core'; import {Input as HammerInput} from 'hammerjs'; /** @@ -58,16 +54,20 @@ export class MdSlider implements AfterContentInit, ControlValueAccessor { /** The dimensions of the slider. */ private _sliderDimensions: ClientRect = null; + private _disabled: boolean = false; + @Input() - @BooleanFieldValue() @HostBinding('class.md-slider-disabled') @HostBinding('attr.aria-disabled') - disabled: boolean = false; + get disabled(): boolean { return this._disabled; } + set disabled(value) { this._disabled = coerceBooleanProperty(value); } /** Whether or not to show the thumb label. */ + private _thumbLabel: boolean = false; + @Input('thumb-label') - @BooleanFieldValue() - thumbLabel: boolean = false; + get thumbLabel(): boolean { return this._thumbLabel; } + set thumbLabel(value) { this._thumbLabel = coerceBooleanProperty(value); } /** The miniumum value that the slider can have. */ private _min: number = 0; diff --git a/src/lib/tabs/tabs.ts b/src/lib/tabs/tabs.ts index ee77040a63d1..9ba8ef91019e 100644 --- a/src/lib/tabs/tabs.ts +++ b/src/lib/tabs/tabs.ts @@ -13,14 +13,14 @@ import { ContentChildren } from '@angular/core'; import {CommonModule} from '@angular/common'; -import {PortalModule} from '../core'; +import {PortalModule, RIGHT_ARROW, LEFT_ARROW, ENTER, coerceBooleanProperty} from '../core'; import {MdTabLabel} from './tab-label'; import {MdTabContent} from './tab-content'; import {MdTabLabelWrapper} from './tab-label-wrapper'; import {MdInkBar} from './ink-bar'; import {Observable} from 'rxjs/Observable'; import 'rxjs/add/operator/map'; -import {RIGHT_ARROW, LEFT_ARROW, ENTER} from '../core'; + /** Used to generate unique ID's for each tab component */ let nextId = 0; @@ -38,11 +38,10 @@ export class MdTab { @ContentChild(MdTabLabel) label: MdTabLabel; @ContentChild(MdTabContent) content: MdTabContent; - // TODO: Replace this when BooleanFieldValue is removed. private _disabled = false; @Input('disabled') set disabled(value: boolean) { - this._disabled = (value != null && `${value}` !== 'false'); + this._disabled = coerceBooleanProperty(value); } get disabled(): boolean { return this._disabled;