Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(radio): add focus indication #3402

Merged
merged 2 commits into from
Mar 4, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/lib/radio/radio.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
[attr.aria-label]="ariaLabel"
[attr.aria-labelledby]="ariaLabelledby"
(change)="_onInputChange($event)"
(focus)="_onInputFocus()"
(blur)="_onInputBlur()"
(click)="_onInputClick($event)">

Expand Down
52 changes: 25 additions & 27 deletions src/lib/radio/radio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FocusOrigin>();
let fakeFocusOriginMonitor = {
monitor: () => fakeFocusOriginMonitorStream.asObservable(),
unmonitor: () => {},
focusVia: (element: HTMLElement, renderer: any, origin: FocusOrigin) => {
element.focus();
fakeFocusOriginMonitorStream.next(origin);
}
};

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

Expand Down Expand Up @@ -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 = <HTMLElement> 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 = <HTMLElement> 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();
Expand Down
73 changes: 48 additions & 25 deletions src/lib/radio/radio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
ElementRef,
Renderer,
EventEmitter,
HostBinding,
Input,
OnInit,
Optional,
Expand All @@ -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';


/**
Expand Down Expand Up @@ -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;
Expand All @@ -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) _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;

Expand All @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down