Skip to content

Commit

Permalink
fix(checkbox): add focus indication (#3403)
Browse files Browse the repository at this point in the history
Fixes #3102
  • Loading branch information
crisbeto authored and kara committed Mar 4, 2017
1 parent 9d4d692 commit 01188d9
Show file tree
Hide file tree
Showing 3 changed files with 89 additions and 15 deletions.
1 change: 0 additions & 1 deletion src/lib/checkbox/checkbox.html
Expand Up @@ -12,7 +12,6 @@
[indeterminate]="indeterminate"
[attr.aria-label]="ariaLabel"
[attr.aria-labelledby]="ariaLabelledby"
(focus)="_onInputFocus()"
(blur)="_onInputBlur()"
(change)="_onInteractionEvent($event)"
(click)="_onInputClick($event)">
Expand Down
39 changes: 38 additions & 1 deletion src/lib/checkbox/checkbox.spec.ts
@@ -1,15 +1,34 @@
import {async, fakeAsync, flushMicrotasks, ComponentFixture, TestBed} from '@angular/core/testing';
import {
async,
fakeAsync,
flushMicrotasks,
ComponentFixture,
TestBed,
tick,
} from '@angular/core/testing';
import {NgControl, FormsModule, ReactiveFormsModule, FormControl} from '@angular/forms';
import {Component, DebugElement} from '@angular/core';
import {By} from '@angular/platform-browser';
import {MdCheckbox, MdCheckboxChange, MdCheckboxModule} from './checkbox';
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
import {FakeViewportRuler} from '../core/overlay/position/fake-viewport-ruler';
import {dispatchFakeEvent} from '../core/testing/dispatch-events';
import {FocusOriginMonitor, FocusOrigin} from '../core';
import {RIPPLE_FADE_IN_DURATION, RIPPLE_FADE_OUT_DURATION} from '../core/ripple/ripple-renderer';
import {Subject} from 'rxjs/Subject';


describe('MdCheckbox', () => {
let fixture: ComponentFixture<any>;
let fakeFocusOriginMonitorSubject: Subject<FocusOrigin> = new Subject();
let fakeFocusOriginMonitor = {
monitor: () => fakeFocusOriginMonitorSubject.asObservable(),
unmonitor: () => {},
focusVia: (element: HTMLElement, renderer: any, focusOrigin: FocusOrigin) => {
element.focus();
fakeFocusOriginMonitorSubject.next(focusOrigin);
}
};

beforeEach(async(() => {
TestBed.configureTestingModule({
Expand All @@ -27,6 +46,7 @@ describe('MdCheckbox', () => {
],
providers: [
{provide: ViewportRuler, useClass: FakeViewportRuler},
{provide: FocusOriginMonitor, useValue: fakeFocusOriginMonitor}
]
});

Expand Down Expand Up @@ -321,6 +341,23 @@ describe('MdCheckbox', () => {
expect(inputElement.value).toBe('basic_checkbox');
});

it('should show a ripple when focused by a keyboard action', fakeAsync(() => {
expect(fixture.nativeElement.querySelectorAll('.mat-ripple-element').length)
.toBe(0, 'Expected no ripples on load.');

fakeFocusOriginMonitorSubject.next('keyboard');
tick(RIPPLE_FADE_IN_DURATION);

expect(fixture.nativeElement.querySelectorAll('.mat-ripple-element').length)
.toBe(1, 'Expected ripple after element is focused.');

dispatchFakeEvent(checkboxInstance._inputElement.nativeElement, 'blur');
tick(RIPPLE_FADE_OUT_DURATION);

expect(fixture.nativeElement.querySelectorAll('.mat-ripple-element').length)
.toBe(0, 'Expected no ripple after element is blurred.');
}));

describe('ripple elements', () => {

it('should show ripples on label mousedown', () => {
Expand Down
64 changes: 51 additions & 13 deletions src/lib/checkbox/checkbox.ts
Expand Up @@ -12,11 +12,20 @@ import {
NgModule,
ModuleWithProviders,
ViewChild,
AfterViewInit,
OnDestroy,
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {NG_VALUE_ACCESSOR, ControlValueAccessor} from '@angular/forms';
import {coerceBooleanProperty} from '../core/coercion/boolean-property';
import {MdRippleModule, CompatibilityModule} from '../core';
import {Subscription} from 'rxjs/Subscription';
import {
CompatibilityModule,
MdRippleModule,
MdRipple,
RippleRef,
FocusOriginMonitor,
} from '../core';


/** Monotonically increasing integer used to auto-generate unique ids for checkbox components. */
Expand Down Expand Up @@ -73,13 +82,12 @@ export class MdCheckboxChange {
'[class.mat-checkbox-checked]': 'checked',
'[class.mat-checkbox-disabled]': 'disabled',
'[class.mat-checkbox-label-before]': 'labelPosition == "before"',
'[class.mat-checkbox-focused]': '_hasFocus',
},
providers: [MD_CHECKBOX_CONTROL_VALUE_ACCESSOR],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MdCheckbox implements ControlValueAccessor {
export class MdCheckbox implements ControlValueAccessor, AfterViewInit, OnDestroy {
/**
* Attached to the aria-label attribute of the host element. In most cases, arial-labelledby will
* take precedence so this may be omitted.
Expand Down Expand Up @@ -157,6 +165,8 @@ export class MdCheckbox implements ControlValueAccessor {
/** The native `<input type="checkbox"> element */
@ViewChild('input') _inputElement: ElementRef;

@ViewChild(MdRipple) _ripple: MdRipple;

/**
* Called when the checkbox is blurred. Needed to properly implement ControlValueAccessor.
* @docs-private
Expand All @@ -175,14 +185,38 @@ export class MdCheckbox implements ControlValueAccessor {

private _controlValueAccessorChangeFn: (value: any) => void = (value) => {};

_hasFocus: boolean = false;
/** Reference to the focused state ripple. */
private _focusedRipple: RippleRef;

/** Reference to the focus origin monitor subscription. */
private _focusedSubscription: Subscription;

constructor(private _renderer: Renderer,
private _elementRef: ElementRef,
private _changeDetectorRef: ChangeDetectorRef) {
private _changeDetectorRef: ChangeDetectorRef,
private _focusOriginMonitor: FocusOriginMonitor) {
this.color = 'accent';
}

ngAfterViewInit() {
this._focusedSubscription = this._focusOriginMonitor
.monitor(this._inputElement.nativeElement, this._renderer, false)
.subscribe(focusOrigin => {
if (!this._focusedRipple && focusOrigin === 'keyboard') {
this._focusedRipple = this._ripple.launch(0, 0, { persistent: true, centered: true });
}
});
}

ngOnDestroy() {
this._focusOriginMonitor.unmonitor(this._inputElement.nativeElement);

if (this._focusedSubscription) {
this._focusedSubscription.unsubscribe();
this._focusedSubscription = null;
}
}

/**
* Whether the checkbox is checked. Note that setting `checked` will immediately set
* `indeterminate` to false.
Expand Down Expand Up @@ -315,14 +349,9 @@ export class MdCheckbox implements ControlValueAccessor {
this.change.emit(event);
}

/** Informs the component when the input has focus so that we can style accordingly */
_onInputFocus() {
this._hasFocus = true;
}

/** Informs the component when we lose focus in order to style accordingly */
_onInputBlur() {
this._hasFocus = false;
this._removeFocusedRipple();
this.onTouched();
}

Expand All @@ -348,6 +377,8 @@ export class MdCheckbox implements ControlValueAccessor {
// Preventing bubbling for the second event will solve that issue.
event.stopPropagation();

this._removeFocusedRipple();

if (!this.disabled) {
this.toggle();
this._transitionCheckState(
Expand All @@ -362,8 +393,7 @@ export class MdCheckbox implements ControlValueAccessor {

/** Focuses the checkbox. */
focus(): void {
this._renderer.invokeElementMethod(this._inputElement.nativeElement, 'focus');
this._onInputFocus();
this._focusOriginMonitor.focusVia(this._inputElement.nativeElement, this._renderer, 'keyboard');
}

_onInteractionEvent(event: Event) {
Expand Down Expand Up @@ -405,13 +435,21 @@ export class MdCheckbox implements ControlValueAccessor {
return `mat-checkbox-anim-${animSuffix}`;
}

/** Fades out the focused state ripple. */
private _removeFocusedRipple(): void {
if (this._focusedRipple) {
this._focusedRipple.fadeOut();
this._focusedRipple = null;
}
}
}


@NgModule({
imports: [CommonModule, MdRippleModule, CompatibilityModule],
exports: [MdCheckbox, CompatibilityModule],
declarations: [MdCheckbox],
providers: [FocusOriginMonitor]
})
export class MdCheckboxModule {
/** @deprecated */
Expand Down

0 comments on commit 01188d9

Please sign in to comment.