Skip to content

Commit c934753

Browse files
crisbetokara
authored andcommitted
fix(radio): add focus indication (#3402)
Fixes #3102
1 parent fb565c0 commit c934753

File tree

3 files changed

+73
-53
lines changed

3 files changed

+73
-53
lines changed

src/lib/radio/radio.html

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
[attr.aria-label]="ariaLabel"
1919
[attr.aria-labelledby]="ariaLabelledby"
2020
(change)="_onInputChange($event)"
21-
(focus)="_onInputFocus()"
2221
(blur)="_onInputBlur()"
2322
(click)="_onInputClick($event)">
2423

src/lib/radio/radio.spec.ts

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,21 @@ import {MdRadioGroup, MdRadioButton, MdRadioChange, MdRadioModule} from './radio
66
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
77
import {FakeViewportRuler} from '../core/overlay/position/fake-viewport-ruler';
88
import {dispatchFakeEvent} from '../core/testing/dispatch-events';
9+
import {FocusOriginMonitor, FocusOrigin} from '../core';
10+
import {RIPPLE_FADE_IN_DURATION, RIPPLE_FADE_OUT_DURATION} from '../core/ripple/ripple-renderer';
11+
import {Subject} from 'rxjs/Subject';
912

1013

1114
describe('MdRadio', () => {
15+
let fakeFocusOriginMonitorStream = new Subject<FocusOrigin>();
16+
let fakeFocusOriginMonitor = {
17+
monitor: () => fakeFocusOriginMonitorStream.asObservable(),
18+
unmonitor: () => {},
19+
focusVia: (element: HTMLElement, renderer: any, origin: FocusOrigin) => {
20+
element.focus();
21+
fakeFocusOriginMonitorStream.next(origin);
22+
}
23+
};
1224

1325
beforeEach(async(() => {
1426
TestBed.configureTestingModule({
@@ -21,6 +33,7 @@ describe('MdRadio', () => {
2133
],
2234
providers: [
2335
{provide: ViewportRuler, useClass: FakeViewportRuler},
36+
{provide: FocusOriginMonitor, useValue: fakeFocusOriginMonitor}
2437
]
2538
});
2639

@@ -177,37 +190,22 @@ describe('MdRadio', () => {
177190
expect(changeSpy).toHaveBeenCalledTimes(1);
178191
});
179192

180-
// TODO(jelbourn): test this in an e2e test with *real* focus, rather than faking
181-
// a focus / blur event.
182-
it('should focus individual radio buttons', () => {
183-
let nativeRadioInput = <HTMLElement> radioNativeElements[0].querySelector('input');
184-
185-
expect(nativeRadioInput.classList).not.toContain('mat-radio-focused');
186-
187-
dispatchFakeEvent(nativeRadioInput, 'focus');
188-
fixture.detectChanges();
193+
it('should show a ripple when focusing via the keyboard', fakeAsync(() => {
194+
expect(radioNativeElements[0].querySelectorAll('.mat-ripple-element').length)
195+
.toBe(0, 'Expected no ripples on init.');
189196

190-
expect(radioNativeElements[0].classList).toContain('mat-radio-focused');
197+
fakeFocusOriginMonitorStream.next('keyboard');
198+
tick(RIPPLE_FADE_IN_DURATION);
191199

192-
dispatchFakeEvent(nativeRadioInput, 'blur');
193-
fixture.detectChanges();
194-
195-
expect(radioNativeElements[0].classList).not.toContain('mat-radio-focused');
196-
});
200+
expect(radioNativeElements[0].querySelectorAll('.mat-ripple-element').length)
201+
.toBe(1, 'Expected one ripple after keyboard focus.');
197202

198-
it('should focus individual radio buttons', () => {
199-
let nativeRadioInput = <HTMLElement> radioNativeElements[0].querySelector('input');
203+
dispatchFakeEvent(radioNativeElements[0].querySelector('input'), 'blur');
204+
tick(RIPPLE_FADE_OUT_DURATION);
200205

201-
radioInstances[0].focus();
202-
fixture.detectChanges();
203-
204-
expect(radioNativeElements[0].classList).toContain('mat-radio-focused');
205-
206-
dispatchFakeEvent(nativeRadioInput, 'blur');
207-
fixture.detectChanges();
208-
209-
expect(radioNativeElements[0].classList).not.toContain('mat-radio-focused');
210-
});
206+
expect(radioNativeElements[0].querySelectorAll('.mat-ripple-element').length)
207+
.toBe(0, 'Expected no ripples on blur.');
208+
}));
211209

212210
it('should update the group and radios when updating the group value', () => {
213211
expect(groupInstance.value).toBeFalsy();

src/lib/radio/radio.ts

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
ElementRef,
77
Renderer,
88
EventEmitter,
9-
HostBinding,
109
Input,
1110
OnInit,
1211
Optional,
@@ -17,17 +16,23 @@ import {
1716
NgModule,
1817
ModuleWithProviders,
1918
ViewChild,
19+
OnDestroy,
20+
AfterViewInit,
2021
} from '@angular/core';
2122
import {CommonModule} from '@angular/common';
2223
import {NG_VALUE_ACCESSOR, ControlValueAccessor} from '@angular/forms';
2324
import {
2425
MdRippleModule,
26+
RippleRef,
2527
UniqueSelectionDispatcher,
2628
CompatibilityModule,
2729
UNIQUE_SELECTION_DISPATCHER_PROVIDER,
30+
MdRipple,
31+
FocusOriginMonitor,
2832
} from '../core';
2933
import {coerceBooleanProperty} from '../core/coercion/boolean-property';
3034
import {VIEWPORT_RULER_PROVIDER} from '../core/overlay/position/viewport-ruler';
35+
import {Subscription} from 'rxjs/Subscription';
3136

3237

3338
/**
@@ -265,24 +270,21 @@ export class MdRadioGroup implements AfterContentInit, ControlValueAccessor {
265270
encapsulation: ViewEncapsulation.None,
266271
host: {
267272
'[class.mat-radio-button]': 'true',
273+
'[class.mat-radio-checked]': 'checked',
274+
'[class.mat-radio-disabled]': 'disabled',
275+
'[attr.id]': 'id',
268276
}
269277
})
270-
export class MdRadioButton implements OnInit {
271-
272-
@HostBinding('class.mat-radio-focused')
273-
_isFocused: boolean;
278+
export class MdRadioButton implements OnInit, AfterViewInit, OnDestroy {
274279

275280
/** Whether this radio is checked. */
276281
private _checked: boolean = false;
277282

278283
/** The unique ID for the radio button. */
279-
@HostBinding('id')
280-
@Input()
281-
id: string = `md-radio-${_uniqueIdCounter++}`;
284+
@Input() id: string = `md-radio-${_uniqueIdCounter++}`;
282285

283286
/** Analog to HTML 'name' attribute used to group radios for unique selection. */
284-
@Input()
285-
name: string;
287+
@Input() name: string;
286288

287289
/** Used to set the 'aria-label' attribute on the underlying input element. */
288290
@Input('aria-label') ariaLabel: string;
@@ -299,6 +301,15 @@ export class MdRadioButton implements OnInit {
299301
/** Whether the ripple effect on click should be disabled. */
300302
private _disableRipple: boolean;
301303

304+
/** The child ripple instance. */
305+
@ViewChild(MdRipple) _ripple: MdRipple;
306+
307+
/** Stream of focus event from the focus origin monitor. */
308+
private _focusOriginMonitorSubscription: Subscription;
309+
310+
/** Reference to the current focus ripple. */
311+
private _focusedRippleRef: RippleRef;
312+
302313
/** The parent radio group. May or may not be present. */
303314
radioGroup: MdRadioGroup;
304315

@@ -321,6 +332,7 @@ export class MdRadioButton implements OnInit {
321332
constructor(@Optional() radioGroup: MdRadioGroup,
322333
private _elementRef: ElementRef,
323334
private _renderer: Renderer,
335+
private _focusOriginMonitor: FocusOriginMonitor,
324336
public radioDispatcher: UniqueSelectionDispatcher) {
325337
// Assertions. Ideally these should be stripped out by the compiler.
326338
// TODO(jelbourn): Assert that there's no name binding AND a parent radio group.
@@ -340,7 +352,6 @@ export class MdRadioButton implements OnInit {
340352
}
341353

342354
/** Whether this radio button is checked. */
343-
@HostBinding('class.mat-radio-checked')
344355
@Input()
345356
get checked(): boolean {
346357
return this._checked;
@@ -415,7 +426,6 @@ export class MdRadioButton implements OnInit {
415426
}
416427

417428
/** Whether the radio button is disabled. */
418-
@HostBinding('class.mat-radio-disabled')
419429
@Input()
420430
get disabled(): boolean {
421431
return this._disabled || (this.radioGroup != null && this.radioGroup.disabled);
@@ -435,6 +445,25 @@ export class MdRadioButton implements OnInit {
435445
}
436446
}
437447

448+
ngAfterViewInit() {
449+
this._focusOriginMonitorSubscription = this._focusOriginMonitor
450+
.monitor(this._inputElement.nativeElement, this._renderer, false)
451+
.subscribe(focusOrigin => {
452+
if (focusOrigin === 'keyboard' && !this._focusedRippleRef) {
453+
this._focusedRippleRef = this._ripple.launch(0, 0, { persistent: true, centered: true });
454+
}
455+
});
456+
}
457+
458+
ngOnDestroy() {
459+
this._focusOriginMonitor.unmonitor(this._inputElement.nativeElement);
460+
461+
if (this._focusOriginMonitorSubscription) {
462+
this._focusOriginMonitorSubscription.unsubscribe();
463+
this._focusOriginMonitorSubscription = null;
464+
}
465+
}
466+
438467
/** Dispatch change event with current value. */
439468
private _emitChangeEvent(): void {
440469
let event = new MdRadioChange();
@@ -447,23 +476,16 @@ export class MdRadioButton implements OnInit {
447476
return this.disableRipple || this.disabled;
448477
}
449478

450-
/**
451-
* We use a hidden native input field to handle changes to focus state via keyboard navigation,
452-
* with visual rendering done separately. The native element is kept in sync with the overall
453-
* state of the component.
454-
*/
455-
_onInputFocus() {
456-
this._isFocused = true;
457-
}
458-
459479
/** Focuses the radio button. */
460480
focus(): void {
461-
this._renderer.invokeElementMethod(this._inputElement.nativeElement, 'focus');
462-
this._onInputFocus();
481+
this._focusOriginMonitor.focusVia(this._inputElement.nativeElement, this._renderer, 'keyboard');
463482
}
464483

465484
_onInputBlur() {
466-
this._isFocused = false;
485+
if (this._focusedRippleRef) {
486+
this._focusedRippleRef.fadeOut();
487+
this._focusedRippleRef = null;
488+
}
467489

468490
if (this.radioGroup) {
469491
this.radioGroup._touch();
@@ -503,13 +525,14 @@ export class MdRadioButton implements OnInit {
503525
}
504526
}
505527
}
528+
506529
}
507530

508531

509532
@NgModule({
510533
imports: [CommonModule, MdRippleModule, CompatibilityModule],
511534
exports: [MdRadioGroup, MdRadioButton, CompatibilityModule],
512-
providers: [UNIQUE_SELECTION_DISPATCHER_PROVIDER, VIEWPORT_RULER_PROVIDER],
535+
providers: [UNIQUE_SELECTION_DISPATCHER_PROVIDER, VIEWPORT_RULER_PROVIDER, FocusOriginMonitor],
513536
declarations: [MdRadioGroup, MdRadioButton],
514537
})
515538
export class MdRadioModule {

0 commit comments

Comments
 (0)