From c4ec66239077422ca529b8a1aae7d220f12f2850 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 29 Mar 2017 23:31:42 +0200 Subject: [PATCH] feat(slide-toggle): add ripple focus indicator (#3739) * feat(slide-toggle): add ripple focus indicator * Introduces a focus indiactor using the persistent ripples. * Address comments --- src/lib/slide-toggle/index.ts | 11 ++-- src/lib/slide-toggle/slide-toggle.html | 2 - src/lib/slide-toggle/slide-toggle.spec.ts | 32 ++++++---- src/lib/slide-toggle/slide-toggle.ts | 77 ++++++++++++++++------- 4 files changed, 82 insertions(+), 40 deletions(-) diff --git a/src/lib/slide-toggle/index.ts b/src/lib/slide-toggle/index.ts index 2c7409e1c60d..d4ce56ba801d 100644 --- a/src/lib/slide-toggle/index.ts +++ b/src/lib/slide-toggle/index.ts @@ -1,16 +1,19 @@ import {NgModule, ModuleWithProviders} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; -import {GestureConfig, CompatibilityModule} from '../core'; import {MdSlideToggle} from './slide-toggle'; -import {MdRippleModule} from '../core/ripple/index'; - +import { + GestureConfig, CompatibilityModule, MdRippleModule, FOCUS_ORIGIN_MONITOR_PROVIDER +} from '../core'; @NgModule({ imports: [FormsModule, MdRippleModule, CompatibilityModule], exports: [MdSlideToggle, CompatibilityModule], declarations: [MdSlideToggle], - providers: [{provide: HAMMER_GESTURE_CONFIG, useClass: GestureConfig}], + providers: [ + FOCUS_ORIGIN_MONITOR_PROVIDER, + { provide: HAMMER_GESTURE_CONFIG, useClass: GestureConfig } + ], }) export class MdSlideToggleModule { /** @deprecated */ diff --git a/src/lib/slide-toggle/slide-toggle.html b/src/lib/slide-toggle/slide-toggle.html index aa05e74f1357..e1001c880499 100644 --- a/src/lib/slide-toggle/slide-toggle.html +++ b/src/lib/slide-toggle/slide-toggle.html @@ -11,8 +11,6 @@ [attr.name]="name" [attr.aria-label]="ariaLabel" [attr.aria-labelledby]="ariaLabelledby" - (blur)="_onInputBlur()" - (focus)="_onInputFocus()" (change)="_onChangeEvent($event)" (click)="_onInputClick($event)"> diff --git a/src/lib/slide-toggle/slide-toggle.spec.ts b/src/lib/slide-toggle/slide-toggle.spec.ts index ddce38d7225b..7f623a11a12a 100644 --- a/src/lib/slide-toggle/slide-toggle.spec.ts +++ b/src/lib/slide-toggle/slide-toggle.spec.ts @@ -5,6 +5,7 @@ import {FormsModule, NgControl, ReactiveFormsModule, FormControl} from '@angular import {MdSlideToggle, MdSlideToggleChange, MdSlideToggleModule} from './index'; import {TestGestureConfig} from '../slider/test-gesture-config'; import {dispatchFakeEvent} from '../core/testing/dispatch-events'; +import {RIPPLE_FADE_IN_DURATION, RIPPLE_FADE_OUT_DURATION} from '../core/ripple/ripple-renderer'; describe('MdSlideToggle', () => { @@ -268,6 +269,26 @@ describe('MdSlideToggle', () => { fixture.detectChanges(); }); + it('should show a ripple when focused by a keyboard action', fakeAsync(() => { + expect(slideToggleElement.querySelectorAll('.mat-ripple-element').length) + .toBe(0, 'Expected no ripples to be present.'); + + dispatchFakeEvent(inputElement, 'keydown'); + dispatchFakeEvent(inputElement, 'focus'); + + tick(RIPPLE_FADE_IN_DURATION); + + expect(slideToggleElement.querySelectorAll('.mat-ripple-element').length) + .toBe(1, 'Expected the focus ripple to be showing up.'); + + dispatchFakeEvent(inputElement, 'blur'); + + tick(RIPPLE_FADE_OUT_DURATION); + + expect(slideToggleElement.querySelectorAll('.mat-ripple-element').length) + .toBe(0, 'Expected focus ripple to be removed.'); + })); + it('should have the correct control state initially and after interaction', () => { // The control should start off valid, pristine, and untouched. expect(slideToggleControl.valid).toBe(true); @@ -327,15 +348,6 @@ describe('MdSlideToggle', () => { }); })); - it('should correctly set the slide-toggle to checked on focus', () => { - expect(slideToggleElement.classList).not.toContain('mat-slide-toggle-focused'); - - dispatchFakeEvent(inputElement, 'focus'); - fixture.detectChanges(); - - expect(slideToggleElement.classList).toContain('mat-slide-toggle-focused'); - }); - it('should forward the required attribute', () => { testComponent.isRequired = true; fixture.detectChanges(); @@ -349,14 +361,12 @@ describe('MdSlideToggle', () => { }); it('should focus on underlying input element when focus() is called', () => { - expect(slideToggleElement.classList).not.toContain('mat-slide-toggle-focused'); expect(document.activeElement).not.toBe(inputElement); slideToggle.focus(); fixture.detectChanges(); expect(document.activeElement).toBe(inputElement); - expect(slideToggleElement.classList).toContain('mat-slide-toggle-focused'); }); it('should set a element class if labelPosition is set to before', () => { diff --git a/src/lib/slide-toggle/slide-toggle.ts b/src/lib/slide-toggle/slide-toggle.ts index bc96c9ca0f8f..68b98cece2f0 100644 --- a/src/lib/slide-toggle/slide-toggle.ts +++ b/src/lib/slide-toggle/slide-toggle.ts @@ -10,11 +10,20 @@ import { AfterContentInit, ViewChild, ViewEncapsulation, + OnDestroy, } from '@angular/core'; +import { + applyCssTransform, + coerceBooleanProperty, + HammerInput, + FocusOriginMonitor, + FocusOrigin, + MdRipple, + RippleRef +} from '../core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; -import {applyCssTransform, coerceBooleanProperty, HammerInput} from '../core'; import {Observable} from 'rxjs/Observable'; - +import {Subscription} from 'rxjs/Subscription'; export const MD_SLIDE_TOGGLE_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, @@ -41,8 +50,6 @@ let nextId = 0; '[class.mat-slide-toggle]': 'true', '[class.mat-checked]': 'checked', '[class.mat-disabled]': 'disabled', - // This mat-slide-toggle prefix will change, once the temporary ripple is removed. - '[class.mat-slide-toggle-focused]': '_hasFocus', '[class.mat-slide-toggle-label-before]': 'labelPosition == "before"', '(mousedown)': '_setMousedown()' }, @@ -52,7 +59,7 @@ let nextId = 0; encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush }) -export class MdSlideToggle implements AfterContentInit, ControlValueAccessor { +export class MdSlideToggle implements OnDestroy, AfterContentInit, ControlValueAccessor { private onChange = (_: any) => {}; private onTouched = () => {}; @@ -67,8 +74,11 @@ export class MdSlideToggle implements AfterContentInit, ControlValueAccessor { private _required: boolean = false; private _disableRipple: boolean = false; - // Needs to be public to support AOT compilation (as host binding). - _hasFocus: boolean = false; + /** Reference to the focus state ripple. */ + private _focusRipple: RippleRef; + + /** Subscription to focus-origin changes. */ + private _focusOriginSubscription: Subscription; /** Name value will be applied to the input element if present */ @Input() name: string = null; @@ -110,12 +120,31 @@ export class MdSlideToggle implements AfterContentInit, ControlValueAccessor { /** Returns the unique id for the visual hidden input. */ get inputId(): string { return `${this.id || this._uniqueId}-input`; } + /** Reference to the underlying input element. */ @ViewChild('input') _inputElement: ElementRef; - constructor(private _elementRef: ElementRef, private _renderer: Renderer) {} + /** Reference to the ripple directive on the thumb container. */ + @ViewChild(MdRipple) _ripple: MdRipple; + + constructor(private _elementRef: ElementRef, + private _renderer: Renderer, + private _focusOriginMonitor: FocusOriginMonitor) {} ngAfterContentInit() { this._slideRenderer = new SlideToggleRenderer(this._elementRef); + + this._focusOriginSubscription = this._focusOriginMonitor + .monitor(this._inputElement.nativeElement, this._renderer, false) + .subscribe(focusOrigin => this._onInputFocusChange(focusOrigin)); + } + + ngOnDestroy() { + this._focusOriginMonitor.unmonitor(this._inputElement.nativeElement); + + if (this._focusOriginSubscription) { + this._focusOriginSubscription.unsubscribe(); + this._focusOriginSubscription = null; + } } /** @@ -162,19 +191,6 @@ export class MdSlideToggle implements AfterContentInit, ControlValueAccessor { 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. */ writeValue(value: any): void { this.checked = value; @@ -197,8 +213,7 @@ export class MdSlideToggle implements AfterContentInit, ControlValueAccessor { /** Focuses the slide-toggle. */ focus() { - this._renderer.invokeElementMethod(this._inputElement.nativeElement, 'focus'); - this._onInputFocus(); + this._focusOriginMonitor.focusVia(this._inputElement.nativeElement, this._renderer, 'program'); } /** Whether the slide-toggle is checked. */ @@ -223,6 +238,22 @@ export class MdSlideToggle implements AfterContentInit, ControlValueAccessor { this.checked = !this.checked; } + /** Function is called whenever the focus changes for the input element. */ + private _onInputFocusChange(focusOrigin: FocusOrigin) { + if (!this._focusRipple && focusOrigin === 'keyboard') { + // For keyboard focus show a persistent ripple as focus indicator. + this._focusRipple = this._ripple.launch(0, 0, {persistent: true, centered: true}); + } else if (!focusOrigin) { + this.onTouched(); + + // Fade out and clear the focus ripple if one is currently present. + if (this._focusRipple) { + this._focusRipple.fadeOut(); + this._focusRipple = null; + } + } + } + private _updateColor(newColor: string) { this._setElementColor(this._color, false); this._setElementColor(newColor, true);