diff --git a/src/lib/radio/radio.html b/src/lib/radio/radio.html index d4293cbee92d..02423b631eec 100644 --- a/src/lib/radio/radio.html +++ b/src/lib/radio/radio.html @@ -18,7 +18,6 @@ [attr.aria-label]="ariaLabel" [attr.aria-labelledby]="ariaLabelledby" (change)="_onInputChange($event)" - (focus)="_onInputFocus()" (blur)="_onInputBlur()" (click)="_onInputClick($event)"> diff --git a/src/lib/radio/radio.spec.ts b/src/lib/radio/radio.spec.ts index 92d0ee86d414..eaccd2ca4099 100644 --- a/src/lib/radio/radio.spec.ts +++ b/src/lib/radio/radio.spec.ts @@ -6,9 +6,21 @@ import {MdRadioGroup, MdRadioButton, MdRadioChange, MdRadioModule} from './radio 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('MdRadio', () => { + let fakeFocusOriginMonitorStream = new Subject(); + let fakeFocusOriginMonitor = { + monitor: () => fakeFocusOriginMonitorStream.asObservable(), + unmonitor: () => {}, + focusVia: (element: HTMLElement, renderer: any, origin: FocusOrigin) => { + element.focus(); + fakeFocusOriginMonitorStream.next(origin); + } + }; beforeEach(async(() => { TestBed.configureTestingModule({ @@ -21,6 +33,7 @@ describe('MdRadio', () => { ], providers: [ {provide: ViewportRuler, useClass: FakeViewportRuler}, + {provide: FocusOriginMonitor, useValue: fakeFocusOriginMonitor} ] }); @@ -177,37 +190,22 @@ describe('MdRadio', () => { expect(changeSpy).toHaveBeenCalledTimes(1); }); - // TODO(jelbourn): test this in an e2e test with *real* focus, rather than faking - // a focus / blur event. - it('should focus individual radio buttons', () => { - let nativeRadioInput = radioNativeElements[0].querySelector('input'); - - expect(nativeRadioInput.classList).not.toContain('mat-radio-focused'); - - dispatchFakeEvent(nativeRadioInput, 'focus'); - fixture.detectChanges(); + it('should show a ripple when focusing via the keyboard', fakeAsync(() => { + expect(radioNativeElements[0].querySelectorAll('.mat-ripple-element').length) + .toBe(0, 'Expected no ripples on init.'); - expect(radioNativeElements[0].classList).toContain('mat-radio-focused'); + fakeFocusOriginMonitorStream.next('keyboard'); + tick(RIPPLE_FADE_IN_DURATION); - dispatchFakeEvent(nativeRadioInput, 'blur'); - fixture.detectChanges(); - - expect(radioNativeElements[0].classList).not.toContain('mat-radio-focused'); - }); + expect(radioNativeElements[0].querySelectorAll('.mat-ripple-element').length) + .toBe(1, 'Expected one ripple after keyboard focus.'); - it('should focus individual radio buttons', () => { - let nativeRadioInput = radioNativeElements[0].querySelector('input'); + dispatchFakeEvent(radioNativeElements[0].querySelector('input'), 'blur'); + tick(RIPPLE_FADE_OUT_DURATION); - radioInstances[0].focus(); - fixture.detectChanges(); - - expect(radioNativeElements[0].classList).toContain('mat-radio-focused'); - - dispatchFakeEvent(nativeRadioInput, 'blur'); - fixture.detectChanges(); - - expect(radioNativeElements[0].classList).not.toContain('mat-radio-focused'); - }); + expect(radioNativeElements[0].querySelectorAll('.mat-ripple-element').length) + .toBe(0, 'Expected no ripples on blur.'); + })); it('should update the group and radios when updating the group value', () => { expect(groupInstance.value).toBeFalsy(); diff --git a/src/lib/radio/radio.ts b/src/lib/radio/radio.ts index 4e0809eb0bf3..e02802de9a54 100644 --- a/src/lib/radio/radio.ts +++ b/src/lib/radio/radio.ts @@ -6,7 +6,6 @@ import { ElementRef, Renderer, EventEmitter, - HostBinding, Input, OnInit, Optional, @@ -17,17 +16,23 @@ import { NgModule, ModuleWithProviders, ViewChild, + OnDestroy, + AfterViewInit, } from '@angular/core'; import {CommonModule} from '@angular/common'; import {NG_VALUE_ACCESSOR, ControlValueAccessor} from '@angular/forms'; import { MdRippleModule, + RippleRef, UniqueSelectionDispatcher, CompatibilityModule, UNIQUE_SELECTION_DISPATCHER_PROVIDER, + MdRipple, + FocusOriginMonitor, } from '../core'; import {coerceBooleanProperty} from '../core/coercion/boolean-property'; import {VIEWPORT_RULER_PROVIDER} from '../core/overlay/position/viewport-ruler'; +import {Subscription} from 'rxjs/Subscription'; /** @@ -265,24 +270,21 @@ export class MdRadioGroup implements AfterContentInit, ControlValueAccessor { encapsulation: ViewEncapsulation.None, host: { '[class.mat-radio-button]': 'true', + '[class.mat-radio-checked]': 'checked', + '[class.mat-radio-disabled]': 'disabled', + '[attr.id]': 'id', } }) -export class MdRadioButton implements OnInit { - - @HostBinding('class.mat-radio-focused') - _isFocused: boolean; +export class MdRadioButton implements OnInit, AfterViewInit, OnDestroy { /** Whether this radio is checked. */ private _checked: boolean = false; /** The unique ID for the radio button. */ - @HostBinding('id') - @Input() - id: string = `md-radio-${_uniqueIdCounter++}`; + @Input() id: string = `md-radio-${_uniqueIdCounter++}`; /** Analog to HTML 'name' attribute used to group radios for unique selection. */ - @Input() - name: string; + @Input() name: string; /** Used to set the 'aria-label' attribute on the underlying input element. */ @Input('aria-label') ariaLabel: string; @@ -299,6 +301,15 @@ export class MdRadioButton implements OnInit { /** Whether the ripple effect on click should be disabled. */ private _disableRipple: boolean; + /** The child ripple instance. */ + @ViewChild(MdRipple) private _ripple: MdRipple; + + /** Stream of focus event from the focus origin monitor. */ + private _focusOriginMonitorSubscription: Subscription; + + /** Reference to the current focus ripple. */ + private _focusedRippleRef: RippleRef; + /** The parent radio group. May or may not be present. */ radioGroup: MdRadioGroup; @@ -321,6 +332,7 @@ export class MdRadioButton implements OnInit { constructor(@Optional() radioGroup: MdRadioGroup, private _elementRef: ElementRef, private _renderer: Renderer, + private _focusOriginMonitor: FocusOriginMonitor, public radioDispatcher: UniqueSelectionDispatcher) { // Assertions. Ideally these should be stripped out by the compiler. // TODO(jelbourn): Assert that there's no name binding AND a parent radio group. @@ -340,7 +352,6 @@ export class MdRadioButton implements OnInit { } /** Whether this radio button is checked. */ - @HostBinding('class.mat-radio-checked') @Input() get checked(): boolean { return this._checked; @@ -415,7 +426,6 @@ export class MdRadioButton implements OnInit { } /** Whether the radio button is disabled. */ - @HostBinding('class.mat-radio-disabled') @Input() get disabled(): boolean { return this._disabled || (this.radioGroup != null && this.radioGroup.disabled); @@ -435,6 +445,25 @@ export class MdRadioButton implements OnInit { } } + ngAfterViewInit() { + this._focusOriginMonitorSubscription = this._focusOriginMonitor + .monitor(this._inputElement.nativeElement, this._renderer, false) + .subscribe(focusOrigin => { + if (focusOrigin === 'keyboard' && !this._focusedRippleRef) { + this._focusedRippleRef = this._ripple.launch(0, 0, { persistent: true, centered: true }); + } + }); + } + + ngOnDestroy() { + this._focusOriginMonitor.unmonitor(this._inputElement.nativeElement); + + if (this._focusOriginMonitorSubscription) { + this._focusOriginMonitorSubscription.unsubscribe(); + this._focusOriginMonitorSubscription = null; + } + } + /** Dispatch change event with current value. */ private _emitChangeEvent(): void { let event = new MdRadioChange(); @@ -447,23 +476,16 @@ export class MdRadioButton implements OnInit { return this.disableRipple || this.disabled; } - /** - * We use a hidden native input field to handle changes to focus state via keyboard navigation, - * with visual rendering done separately. The native element is kept in sync with the overall - * state of the component. - */ - _onInputFocus() { - this._isFocused = true; - } - /** Focuses the radio button. */ focus(): void { - this._renderer.invokeElementMethod(this._inputElement.nativeElement, 'focus'); - this._onInputFocus(); + this._focusOriginMonitor.focusVia(this._inputElement.nativeElement, this._renderer, 'keyboard'); } _onInputBlur() { - this._isFocused = false; + if (this._focusedRippleRef) { + this._focusedRippleRef.fadeOut(); + this._focusedRippleRef = null; + } if (this.radioGroup) { this.radioGroup._touch(); @@ -503,13 +525,14 @@ export class MdRadioButton implements OnInit { } } } + } @NgModule({ imports: [CommonModule, MdRippleModule, CompatibilityModule], exports: [MdRadioGroup, MdRadioButton, CompatibilityModule], - providers: [UNIQUE_SELECTION_DISPATCHER_PROVIDER, VIEWPORT_RULER_PROVIDER], + providers: [UNIQUE_SELECTION_DISPATCHER_PROVIDER, VIEWPORT_RULER_PROVIDER, FocusOriginMonitor], declarations: [MdRadioGroup, MdRadioButton], }) export class MdRadioModule {