From 672e205b6a72477b44b3f3bc0671394b5870ff30 Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Mon, 2 May 2016 14:36:51 -0700 Subject: [PATCH] fix(radio): refactor tests and fix ngModel --- src/components/checkbox/checkbox.spec.ts | 9 +- src/components/radio/radio.spec.ts | 680 +++++++++++------------ src/components/radio/radio.ts | 251 +++++---- src/components/radio/radio_dispatcher.ts | 20 +- 4 files changed, 502 insertions(+), 458 deletions(-) diff --git a/src/components/checkbox/checkbox.spec.ts b/src/components/checkbox/checkbox.spec.ts index e95e977d6e48..96d4bc4145c5 100644 --- a/src/components/checkbox/checkbox.spec.ts +++ b/src/components/checkbox/checkbox.spec.ts @@ -1,11 +1,4 @@ -import { - it, - beforeEach, - inject, - async, - fakeAsync, - flushMicrotasks -} from '@angular/core/testing'; +import {it, beforeEach, inject, async, fakeAsync, flushMicrotasks} from '@angular/core/testing'; import {FORM_DIRECTIVES, NgModel, NgControl} from '@angular/common'; import {TestComponentBuilder, ComponentFixture} from '@angular/compiler/testing'; import {Component, DebugElement} from '@angular/core'; diff --git a/src/components/radio/radio.spec.ts b/src/components/radio/radio.spec.ts index 19bdb2af78b5..3d4333ef6256 100644 --- a/src/components/radio/radio.spec.ts +++ b/src/components/radio/radio.spec.ts @@ -1,396 +1,390 @@ import { - it, - describe, - expect, - beforeEach, - fakeAsync, - inject, - tick, + it, + describe, + beforeEach, + beforeEachProviders, + inject, + async, + fakeAsync, + tick } from '@angular/core/testing'; -import {TestComponentBuilder} from '@angular/compiler/testing'; -import {Component, DebugElement} from '@angular/core'; +import {FORM_DIRECTIVES, NgControl} from '@angular/common'; +import {TestComponentBuilder, ComponentFixture} from '@angular/compiler/testing'; +import {Component, DebugElement, provide} from '@angular/core'; import {By} from '@angular/platform-browser'; - -import {MdRadioButton, MdRadioGroup, MdRadioChange} from './radio'; +import {MD_RADIO_DIRECTIVES, MdRadioGroup, MdRadioButton} from './radio'; import {MdRadioDispatcher} from './radio_dispatcher'; -export function main() { - describe('MdRadioButton', () => { - let builder: TestComponentBuilder; - beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => { - builder = tcb; +describe('MdRadio', () => { + let builder: TestComponentBuilder; + let dispatcher: MdRadioDispatcher; + + beforeEachProviders(() => [ + provide(MdRadioDispatcher, {useFactory: () => { + dispatcher = new MdRadioDispatcher(); + return dispatcher; + }}) + ]); + + beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => { + builder = tcb; + })); + + describe('inside of a group', () => { + let fixture: ComponentFixture; + let groupDebugElement: DebugElement; + let groupNativeElement: HTMLElement; + let radioDebugElements: DebugElement[]; + let radioNativeElements: HTMLElement[]; + let groupInstance: MdRadioGroup; + let radioInstances: MdRadioButton[]; + let testComponent: RadiosInsideRadioGroup; + + beforeEach(async(() => { + builder.createAsync(RadiosInsideRadioGroup).then(f => { + fixture = f; + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + groupDebugElement = fixture.debugElement.query(By.directive(MdRadioGroup)); + groupNativeElement = groupDebugElement.nativeElement; + groupInstance = groupDebugElement.injector.get(MdRadioGroup); + + radioDebugElements = fixture.debugElement.queryAll(By.directive(MdRadioButton)); + radioNativeElements = radioDebugElements.map(debugEl => debugEl.nativeElement); + radioInstances = radioDebugElements.map(debugEl => debugEl.componentInstance); + }); })); - it('should have same name as radio group', () => { - return builder - .overrideTemplate(TestApp, ` - - - `) - .createAsync(TestApp) - .then(fixture => { - let button = fixture.debugElement.query(By.css('md-radio-button')); - - fixture.detectChanges(); - expect(button.componentInstance.name).toBe('my_group'); - }); + it('should set individual radio names based on the group name', () => { + expect(groupInstance.name).toBeTruthy(); + for (let radio of radioInstances) { + expect(radio.name).toBe(groupInstance.name); + } }); - it('should not allow click selection if disabled', () => { - return builder - .overrideTemplate(TestApp, '') - .createAsync(TestApp) - .then(fixture => { - let button = fixture.debugElement.query(By.css('md-radio-button')); + it('should disable click interaction when the group is disabled', () => { + testComponent.isGroupDisabled = true; + fixture.detectChanges(); - fixture.detectChanges(); - expect(button.componentInstance.checked).toBe(false); - - button.nativeElement.click(); - expect(button.componentInstance.checked).toBe(false); - }); + radioNativeElements[0].click(); + expect(radioInstances[0].checked).toBe(false); }); - it('should be disabled if radio group disabled', () => { - return builder - .overrideTemplate(TestApp, ` - - - `) - .createAsync(TestApp) - .then(fixture => { - let button = fixture.debugElement.query(By.css('md-radio-button')); - - fixture.detectChanges(); - expect(button.componentInstance.disabled).toBe(true); - }); - }); + it('should disable each individual radio when the group is disabled', () => { + testComponent.isGroupDisabled = true; + fixture.detectChanges(); - it('updates parent group value when selected and value changed', () => { - return builder - .overrideTemplate(TestApp, ` - - - `) - .createAsync(TestApp) - .then(fixture => { - let button = fixture.debugElement.query(By.css('md-radio-button')); - let group = fixture.debugElement.query(By.css('md-radio-group')); - let radioGroupInstance = group.injector.get(MdRadioGroup); - - radioGroupInstance.selected = button.componentInstance; - fixture.detectChanges(); - expect(radioGroupInstance.value).toBe('1'); - - button.componentInstance.value = '2'; - fixture.detectChanges(); - expect(radioGroupInstance.value).toBe('2'); - }); + for (let radio of radioInstances) { + expect(radio.disabled).toBe(true); + } }); - it('should be checked after input change event', () => { - return builder - .overrideTemplate(TestApp, '') - .createAsync(TestApp) - .then(fixture => { - let button = fixture.debugElement.query(By.css('md-radio-button')); - let input = button.query(By.css('input')); - - fixture.detectChanges(); - expect(button.componentInstance.checked).toBe(false); - - let event = createEvent('change'); - input.nativeElement.dispatchEvent(event); - expect(button.componentInstance.checked).toBe(true); - }); + it('should update the group value when one of the radios changes', () => { + expect(groupInstance.value).toBeFalsy(); + + radioInstances[0].checked = true; + fixture.detectChanges(); + + expect(groupInstance.value).toBe('fire'); + expect(groupInstance.selected).toBe(radioInstances[0]); }); - it('should emit event when checked', () => { - return builder - .overrideTemplate(TestApp, '') - .createAsync(TestApp) - .then(fixture => { - fakeAsync(function() { - let button = fixture.debugElement.query(By.css('md-radio-button')); - let changeEvent: MdRadioChange = null; - button.componentInstance.change.subscribe((evt: MdRadioChange) => { - changeEvent = evt; - }); - button.componentInstance.checked = true; - fixture.detectChanges(); - tick(); - - expect(changeEvent).not.toBe(null); - expect(changeEvent.source).toBe(button.componentInstance); - }); - }); + it('should update the group and radios when one of the radios is clicked', () => { + expect(groupInstance.value).toBeFalsy(); + + radioNativeElements[0].click(); + fixture.detectChanges(); + + expect(groupInstance.value).toBe('fire'); + expect(groupInstance.selected).toBe(radioInstances[0]); + expect(radioInstances[0].checked).toBe(true); + expect(radioInstances[1].checked).toBe(false); + + radioNativeElements[1].click(); + fixture.detectChanges(); + + expect(groupInstance.value).toBe('water'); + expect(groupInstance.selected).toBe(radioInstances[1]); + expect(radioInstances[0].checked).toBe(false); + expect(radioInstances[1].checked).toBe(true); }); - it('should be focusable', () => { - return builder - .overrideTemplate(TestApp, '') - .createAsync(TestApp) - .then(fixture => { - let button = fixture.debugElement.query(By.css('md-radio-button')); - let input = button.query(By.css('input')); - - fixture.detectChanges(); - expect(button.nativeElement.classList.contains('md-radio-focused')).toBe(false); - - let event = createEvent('focus'); - input.nativeElement.dispatchEvent(event); - fixture.detectChanges(); - expect(button.nativeElement.classList.contains('md-radio-focused')).toBe(true); - - event = createEvent('blur'); - input.nativeElement.dispatchEvent(event); - fixture.detectChanges(); - expect(button.nativeElement.classList.contains('md-radio-focused')).toBe(false); - }); + it('should check a radio upon interaction with the underlying native radio button', () => { + let nativeRadioInput = radioNativeElements[0].querySelector('input'); + + nativeRadioInput.click(); + fixture.detectChanges(); + + expect(radioInstances[0].checked).toBe(true); + expect(groupInstance.value).toBe('fire'); + expect(groupInstance.selected).toBe(radioInstances[0]); }); - }); - describe('MdRadioDispatcher', () => { - let builder: TestComponentBuilder; - let dispatcher: MdRadioDispatcher; + it('should emit a change event from radio buttons', fakeAsync(() => { + expect(radioInstances[0].checked).toBe(false); - beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => { - builder = tcb; - dispatcher = new MdRadioDispatcher(); - })); + let changeSpy = jasmine.createSpy('radio change listener'); + radioInstances[0].change.subscribe(changeSpy); - it('notifies listeners', () => { - let notificationCount = 0; - const numListeners = 2; + radioInstances[0].checked = true; + fixture.detectChanges(); + tick(); + expect(changeSpy).toHaveBeenCalled(); - for (let i = 0; i < numListeners; i++) { - dispatcher.listen(() => { - notificationCount++; - }); - } + radioInstances[0].checked = false; + fixture.detectChanges(); + tick(); + expect(changeSpy).toHaveBeenCalledTimes(2); + })); - dispatcher.notify('hello'); + it('should emit a change event from the radio group', fakeAsync(() => { + expect(groupInstance.value).toBeFalsy(); - expect(notificationCount).toBe(numListeners); - }); - }); + let changeSpy = jasmine.createSpy('radio-group change listener'); + groupInstance.change.subscribe(changeSpy); - describe('MdRadioGroup', () => { - let builder: TestComponentBuilder; + groupInstance.value = 'fire'; + fixture.detectChanges(); + tick(); + expect(changeSpy).toHaveBeenCalled(); - beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => { - builder = tcb; + groupInstance.value = 'water'; + fixture.detectChanges(); + tick(); + expect(changeSpy).toHaveBeenCalledTimes(2); })); - it('can select by value', () => { - return builder - .overrideTemplate(TestApp, ` - - - - `) - .createAsync(TestApp) - .then(fixture => { - let buttons = fixture.debugElement.queryAll(By.css('md-radio-button')); - let group = fixture.debugElement.query(By.css('md-radio-group')); - let radioGroupInstance = group.injector.get(MdRadioGroup); - - fixture.detectChanges(); - expect(radioGroupInstance.selected).toBe(null); - - radioGroupInstance.value = '2'; - - fixture.detectChanges(); - expect(radioGroupInstance.selected).toBe(buttons[1].componentInstance); - }); - }); + // 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'); - it('should select uniquely', () => { - return builder - .overrideTemplate(TestApp, ` - - - - `) - .createAsync(TestApp) - .then(fixture => { - let buttons = fixture.debugElement.queryAll(By.css('md-radio-button')); - let group = fixture.debugElement.query(By.css('md-radio-group')); - let radioGroupInstance = group.injector.get(MdRadioGroup); - - fixture.detectChanges(); - expect(radioGroupInstance.selected).toBe(null); - - radioGroupInstance.selected = buttons[0].componentInstance; - fixture.detectChanges(); - expect(isSinglySelected(buttons[0], buttons)).toBe(true); - - radioGroupInstance.selected = buttons[1].componentInstance; - fixture.detectChanges(); - expect(isSinglySelected(buttons[1], buttons)).toBe(true); - }); - }); + expect(nativeRadioInput.classList).not.toContain('md-radio-focused'); - it('should emit event when value changes', () => { - return builder - .overrideTemplate(TestApp, ` - - - - `) - .createAsync(TestApp) - .then(fixture => { - fakeAsync(function() { - let buttons = fixture.debugElement.queryAll(By.css('md-radio-button')); - let group = fixture.debugElement.query(By.css('md-radio-group')); - let radioGroupInstance = group.injector.get(MdRadioGroup); - - let changeEvent: MdRadioChange = null; - radioGroupInstance.change.subscribe((evt: MdRadioChange) => { - changeEvent = evt; - }); - - radioGroupInstance.selected = buttons[1].componentInstance; - fixture.detectChanges(); - tick(); - - expect(changeEvent).not.toBe(null); - expect(changeEvent.source).toBe(buttons[1].componentInstance); - }); - }); - }); + dispatchFocusChangeEvent('focus', nativeRadioInput); + fixture.detectChanges(); - it('should bind value to model without initial value', () => { - return builder - .overrideTemplate(TestApp, ` - - - - `) - .createAsync(TestApp) - .then(fixture => { - fakeAsync(function() { - let buttons = fixture.debugElement.queryAll(By.css('md-radio-button')); - let group = fixture.debugElement.query(By.css('md-radio-group')); - let radioGroupInstance = group.injector.get(MdRadioGroup); - - fixture.detectChanges(); - expect(buttons[0].componentInstance.checked).toBe(false); - expect(buttons[1].componentInstance.checked).toBe(false); - expect(fixture.componentInstance.choice).toBe(undefined); - - radioGroupInstance.selected = buttons[0].componentInstance; - fixture.detectChanges(); - expect(isSinglySelected(buttons[0], buttons)).toBe(true); - expect(fixture.componentInstance.choice).toBe(0); - - radioGroupInstance.selected = buttons[1].componentInstance; - fixture.detectChanges(); - expect(isSinglySelected(buttons[1], buttons)).toBe(true); - expect(fixture.componentInstance.choice).toBe(1); - }); - }); + expect(radioNativeElements[0].classList).toContain('md-radio-focused'); + + dispatchFocusChangeEvent('blur', nativeRadioInput); + fixture.detectChanges(); + + expect(radioNativeElements[0].classList).not.toContain('md-radio-focused'); }); - it('should bind value to model with initial value', () => { - return builder - .overrideTemplate(TestAppWithInitialValue, ` - - - - `) - .createAsync(TestAppWithInitialValue) - .then(fixture => { - fakeAsync(function() { - let buttons = fixture.debugElement.queryAll(By.css('md-radio-button')); - let group = fixture.debugElement.query(By.css('md-radio-group')); - let radioGroupInstance = group.injector.get(MdRadioGroup); - - fixture.detectChanges(); - expect(isSinglySelected(buttons[1], buttons)).toBe(true); - expect(fixture.componentInstance.choice).toBe(1); - - radioGroupInstance.selected = buttons[0].componentInstance; - fixture.detectChanges(); - expect(isSinglySelected(buttons[0], buttons)).toBe(true); - expect(fixture.componentInstance.choice).toBe(0); - - radioGroupInstance.selected = buttons[1].componentInstance; - fixture.detectChanges(); - expect(isSinglySelected(buttons[1], buttons)).toBe(true); - expect(fixture.componentInstance.choice).toBe(1); - }); - }); + it('should update the group and radios when updating the group value', () => { + expect(groupInstance.value).toBeFalsy(); + + testComponent.groupValue = 'fire'; + fixture.detectChanges(); + + expect(groupInstance.value).toBe('fire'); + expect(groupInstance.selected).toBe(radioInstances[0]); + expect(radioInstances[0].checked).toBe(true); + expect(radioInstances[1].checked).toBe(false); + + testComponent.groupValue = 'water'; + fixture.detectChanges(); + + expect(groupInstance.value).toBe('water'); + expect(groupInstance.selected).toBe(radioInstances[1]); + expect(radioInstances[0].checked).toBe(false); + expect(radioInstances[1].checked).toBe(true); }); - it('should deselect all buttons when model is null or undefined', () => { - return builder - .overrideTemplate(TestAppWithInitialValue, ` - - - - - `) - .createAsync(TestAppWithInitialValue) - .then(fixture => { - let buttons = fixture.debugElement.queryAll(By.css('md-radio-button')); - - fixture.componentInstance.choice = 0; - fixture.detectChanges(); - expect(isSinglySelected(buttons[0], buttons)).toBe(true); - - fixture.componentInstance.choice = null; - fixture.detectChanges(); - expect(allDeselected(buttons)).toBe(true); - }); + it('should deselect all of the checkboxes when the group value is cleared', () => { + radioInstances[0].checked = true; + fixture.detectChanges(); + + expect(groupInstance.value).toBeTruthy(); + + groupInstance.value = null; + fixture.detectChanges(); + expect(radioInstances.every(radio => !radio.checked)).toBe(true); }); + }); + + describe('group with ngModel', () => { + let fixture: ComponentFixture; + let groupDebugElement: DebugElement; + let groupNativeElement: HTMLElement; + let radioDebugElements: DebugElement[]; + let radioNativeElements: HTMLElement[]; + let groupInstance: MdRadioGroup; + let radioInstances: MdRadioButton[]; + let testComponent: RadioGroupWithNgModel; + let groupNgControl: NgControl; + + beforeEach(async(() => { + builder.createAsync(RadioGroupWithNgModel).then(f => { + fixture = f; + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + groupDebugElement = fixture.debugElement.query(By.directive(MdRadioGroup)); + groupNativeElement = groupDebugElement.nativeElement; + groupInstance = groupDebugElement.injector.get(MdRadioGroup); + groupNgControl = groupDebugElement.injector.get(NgControl); + + radioDebugElements = fixture.debugElement.queryAll(By.directive(MdRadioButton)); + radioNativeElements = radioDebugElements.map(debugEl => debugEl.nativeElement); + radioInstances = radioDebugElements.map(debugEl => debugEl.componentInstance); + }); + })); + + it('should have the correct ngControl state initially and after interaction', fakeAsync(() => { + // The control should start off valid, pristine, and untouched. + expect(groupNgControl.valid).toBe(true); + expect(groupNgControl.pristine).toBe(true); + expect(groupNgControl.touched).toBe(false); + + // After changing the value programmatically, the control should become dirty (not pristine), + // but remain untouched. + radioInstances[1].checked = true; + fixture.detectChanges(); + tick(); + + expect(groupNgControl.valid).toBe(true); + expect(groupNgControl.pristine).toBe(false); + expect(groupNgControl.touched).toBe(false); + + // After a user interaction occurs (such as a click), the control should remain dirty and + // now also be touched. + radioNativeElements[2].click(); + fixture.detectChanges(); + tick(); + + expect(groupNgControl.valid).toBe(true); + expect(groupNgControl.pristine).toBe(false); + expect(groupNgControl.touched).toBe(true); + })); + + it('should update the ngModel value when selecting a radio button', fakeAsync(() => { + radioInstances[1].checked = true; + fixture.detectChanges(); + tick(); + + expect(testComponent.modelValue).toBe('chocolate'); + })); }); -} -/** Checks whether a given button is uniquely selected from a group of buttons. */ -function isSinglySelected(button: DebugElement, buttons: DebugElement[]): boolean { - let component = button.componentInstance; - let otherSelectedButtons = - buttons.filter((e: DebugElement) => - e.componentInstance != component && e.componentInstance.checked); - return component.checked && otherSelectedButtons.length == 0; -} + describe('as standalone', () => { + let fixture: ComponentFixture; + let radioDebugElements: DebugElement[]; + let seasonRadioInstances: MdRadioButton[]; + let weatherRadioInstances: MdRadioButton[]; + let testComponent: StandaloneRadioButtons; + + beforeEach(async(() => { + builder.createAsync(StandaloneRadioButtons).then(f => { + fixture = f; + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + radioDebugElements = fixture.debugElement.queryAll(By.directive(MdRadioButton)); + seasonRadioInstances = radioDebugElements + .filter(debugEl => debugEl.componentInstance.name == 'season') + .map(debugEl => debugEl.componentInstance); + weatherRadioInstances = radioDebugElements + .filter(debugEl => debugEl.componentInstance.name == 'weather') + .map(debugEl => debugEl.componentInstance); + }); + })); + + it('should uniquely select radios by a name', () => { + seasonRadioInstances[0].checked = true; + weatherRadioInstances[1].checked = true; + + fixture.detectChanges(); + expect(seasonRadioInstances[0].checked).toBe(true); + expect(seasonRadioInstances[1].checked).toBe(false); + expect(seasonRadioInstances[2].checked).toBe(false); + expect(weatherRadioInstances[0].checked).toBe(false); + expect(weatherRadioInstances[1].checked).toBe(true); + expect(weatherRadioInstances[2].checked).toBe(false); + + seasonRadioInstances[1].checked = true; + fixture.detectChanges(); + expect(seasonRadioInstances[0].checked).toBe(false); + expect(seasonRadioInstances[1].checked).toBe(true); + expect(seasonRadioInstances[2].checked).toBe(false); + expect(weatherRadioInstances[0].checked).toBe(false); + expect(weatherRadioInstances[1].checked).toBe(true); + expect(weatherRadioInstances[2].checked).toBe(false); + + weatherRadioInstances[2].checked = true; + expect(seasonRadioInstances[0].checked).toBe(false); + expect(seasonRadioInstances[1].checked).toBe(true); + expect(seasonRadioInstances[2].checked).toBe(false); + expect(weatherRadioInstances[0].checked).toBe(false); + expect(weatherRadioInstances[1].checked).toBe(false); + expect(weatherRadioInstances[2].checked).toBe(true); + }); + }); +}); -function allDeselected(buttons: DebugElement[]): boolean { - return buttons.every(debugEl => !debugEl.componentInstance.checked); -} -/** Browser-agnostic function for creating an event. */ -function createEvent(name: string): Event { - let ev: Event; - try { - ev = createEvent(name); - } catch (e) { - ev = document.createEvent('Event'); - ev.initEvent(name, true, true); - } - return ev; +@Component({ + directives: [MD_RADIO_DIRECTIVES], + template: ` + + Charmander + Squirtle + Bulbasaur + + ` +}) +class RadiosInsideRadioGroup { + isGroupDisabled: boolean = false; + groupValue: string = null; } -/** Test component. */ @Component({ - directives: [MdRadioButton, MdRadioGroup], - providers: [MdRadioDispatcher], - template: '' + directives: [MD_RADIO_DIRECTIVES], + template: ` + Spring + Summer + Autumn + + Spring + Summer + Autumn + ` }) -class TestApp { - choice: number; -} +class StandaloneRadioButtons { } + -/** Test component. */ @Component({ - directives: [MdRadioButton, MdRadioGroup], - providers: [MdRadioDispatcher], - template: '' + directives: [MD_RADIO_DIRECTIVES, FORM_DIRECTIVES], + template: ` + + Vanilla + Chocolate + Strawberry + + ` }) -class TestAppWithInitialValue { - choice: number = 1; +class RadioGroupWithNgModel { + modelValue: string; +} + +// TODO(jelbourn): remove eveything below when Angular supports faking events. + + +/** + * Dispatches a focus change event from an element. + * @param eventName Name of the event, either 'focus' or 'blur'. + * @param element The element from which the event will be dispatched. + */ +function dispatchFocusChangeEvent(eventName: string, element: HTMLElement): void { + let event = document.createEvent('Event'); + event.initEvent(eventName, true, true); + element.dispatchEvent(event); } diff --git a/src/components/radio/radio.ts b/src/components/radio/radio.ts index 72247ed96b72..2ea34b5e82a9 100644 --- a/src/components/radio/radio.ts +++ b/src/components/radio/radio.ts @@ -21,6 +21,7 @@ import { import {MdRadioDispatcher} from './radio_dispatcher'; +// Re-exports. export {MdRadioDispatcher} from './radio_dispatcher'; @@ -59,7 +60,12 @@ export class MdRadioChange { }, }) export class MdRadioGroup implements AfterContentInit, ControlValueAccessor { - /** The value for the radio group. Should match currently selected button. */ + /** + * Selected value for group. Should equal the value of the selected radio button if there *is* + * a corresponding radio button with a matching value. If there is *not* such a corresponding + * radio button, this value persists to be applied in case a new radio button is added with a + * matching value. + */ private _value: any = null; /** The HTML name attribute applied to radio buttons in this group. */ @@ -71,9 +77,13 @@ export class MdRadioGroup implements AfterContentInit, ControlValueAccessor { /** The currently selected radio button. Should match value. */ private _selected: MdRadioButton = null; + /** Whether the `value` has been set to its initial value. */ + private _isInitialized: boolean = false; + /** Change event subscription set up by registerOnChange (ControlValueAccessor). */ private _changeSubscription: {unsubscribe: () => any} = null; + /** onTouch function registered via registerOnTouch (ControlValueAccessor). */ onTouched: () => any = () => {}; /** Event emitted when the group value changes. */ @@ -84,18 +94,6 @@ export class MdRadioGroup implements AfterContentInit, ControlValueAccessor { @ContentChildren(forwardRef(() => MdRadioButton)) private _radios: QueryList = null; - /** - * Initialize properties once content children are available. - * This allows us to propagate relevant attributes to associated buttons. - */ - ngAfterContentInit() { - if (this._name == null) { - this.name = `md-radio-group-${_uniqueIdCounter++}`; - } else { - this._updateChildRadioNames(); - } - } - @Input() get name(): string { return this._name; @@ -103,17 +101,14 @@ export class MdRadioGroup implements AfterContentInit, ControlValueAccessor { set name(value: string) { this._name = value; - this._updateChildRadioNames(); } /** Propagate name attribute to radio buttons. */ private _updateChildRadioNames(): void { - if (this._radios != null) { - this._radios.forEach((radio) => { - radio.name = this._name; - }); - } + (this._radios || []).forEach(radio => { + radio.name = this._name; + }); } @Input() @@ -137,26 +132,68 @@ export class MdRadioGroup implements AfterContentInit, ControlValueAccessor { this._value = newValue; this._updateSelectedRadioFromValue(); - this._emitChangeEvent(); + + // Only fire a change event if this isn't the first time the value is ever set. + if (this._isInitialized) { + this._emitChangeEvent(); + } + } + } + + @Input() + get selected() { + return this._selected; + } + + set selected(selected: MdRadioButton) { + this._selected = selected; + this.value = selected ? selected.value : null; + + if (selected && !selected.checked) { + selected.checked = true; } } + /** + * Initialize properties once content children are available. + * This allows us to propagate relevant attributes to associated buttons. + * @internal + */ + ngAfterContentInit() { + if (!this._name) { + this.name = `md-radio-group-${_uniqueIdCounter++}`; + } + + // Mark this component as initialized in AfterContentInit because the initial value can + // possibly be set by NgModel on MdRadioGroup, and it is possible that the OnInit of the + // NgModel occurs *after* the OnInit of the MdRadioGroup. + this._isInitialized = true; + } + + /** + * Mark this group as being "touched" (for ngModel). Meant to be called by the contained + * radio buttons upon their blur. + * @internal + */ + touch() { + if (this.onTouched) { + this.onTouched(); + } + } + + /** Updates the `selected` radio button from the internal _value state. */ private _updateSelectedRadioFromValue(): void { - // Update selected if different from current value. + // If the value already matches the selected radio, no dothing. let isAlreadySelected = this._selected != null && this._selected.value == this._value; + if (this._radios != null && !isAlreadySelected) { - let matched = this._radios.filter((radio) => { - return radio.value == this._value; - }); + let matchingRadio = this._radios.filter(radio => radio.value == this._value)[0]; - if (matched.length == 0) { - // When the value of the group is cleared to null, deselect all radio button in the group. - if (this.value == null) { + if (matchingRadio) { + this.selected = matchingRadio; + } else if (this.value == null) { this.selected = null; - this._radios.forEach(radio => radio.checked = false); - } - } else { - this.selected = matched[0]; + this._radios.forEach(radio => { radio.checked = false; }); } } } @@ -169,38 +206,32 @@ export class MdRadioGroup implements AfterContentInit, ControlValueAccessor { this.change.emit(event); } - @Input() - get selected() { - return this._selected; - } - - set selected(selected: MdRadioButton) { - if (selected) { - this._selected = selected; - this.value = selected.value; - - selected.checked = true; - } else { - this._selected = null; - this._value = null; - } - } - - /** Implemented as part of ControlValueAccessor. */ + /** + * Implemented as part of ControlValueAccessor. + * @internal + */ writeValue(value: any) { this.value = value; } - /** Implemented as part of ControlValueAccessor. */ + /** + * Implemented as part of ControlValueAccessor. + * @internal + */ registerOnChange(fn: any) { if (this._changeSubscription) { this._changeSubscription.unsubscribe(); } - this._changeSubscription = <{unsubscribe: () => any}>this.change.subscribe( - (changeEvent: MdRadioChange) => { fn(changeEvent.value); }); + + this._changeSubscription = this.change.subscribe((changeEvent: MdRadioChange) => { + fn(changeEvent.value); + }); } - /** Implemented as part of ControlValueAccessor. */ + /** + * Implemented as part of ControlValueAccessor. + * @internal + */ registerOnTouched(fn: any) { this.onTouched = fn; } @@ -251,41 +282,13 @@ export class MdRadioButton implements OnInit { this.radioGroup = radioGroup; - radioDispatcher.listen((name: string) => { - if (name == this.name) { + radioDispatcher.listen((id: string, name: string) => { + if (id != this.id && name == this.name) { this.checked = false; } }); } - ngOnInit() { - if (this.id == null) { - this.id = `md-radio-${_uniqueIdCounter++}`; - } - - if (this.radioGroup && this._value == this.radioGroup.value) { - this._checked = true; - } - } - - /* - * 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; - } - - onInputBlur() { - this._isFocused = false; - } - - /** Input change handler, called only on keyboard selection. */ - onInputChange() { - this.checked = true; - } - get inputId(): string { return `${this.id}-input`; } @@ -296,16 +299,21 @@ export class MdRadioButton implements OnInit { return this._checked; } - set checked(value: boolean) { - if (value) { + set checked(newCheckedState: boolean) { + if (newCheckedState) { // Notify all radio buttons with the same name to un-check. - this.radioDispatcher.notify(this.name); + this.radioDispatcher.notify(this.id, this.name); + } - if (!this._checked) { - this._emitChangeEvent(); - } + if (newCheckedState != this._checked) { + this._emitChangeEvent(); + } + + this._checked = newCheckedState; + + if (newCheckedState && this.radioGroup && this.radioGroup.value != this.value) { + this.radioGroup.selected = this; } - this._checked = value; } /** MdRadioGroup reads this to assign its own value. */ @@ -323,14 +331,6 @@ export class MdRadioButton implements OnInit { } } - /** Dispatch change event with current value. */ - private _emitChangeEvent(): void { - let event = new MdRadioChange(); - event.source = this; - event.value = this._value; - this.change.emit(event); - } - @HostBinding('class.md-radio-disabled') @Input() get disabled(): boolean { @@ -342,6 +342,29 @@ export class MdRadioButton implements OnInit { this._disabled = (value != null && value !== false) ? true : null; } + /** @internal */ + ngOnInit() { + // All radio buttons must have a unique id. + if (!this.id) { + this.id = `md-radio-${_uniqueIdCounter++}`; + } + + // If the radio is inside of a radio group and it matches that group's value upon + // initialization, start off as checked. + if (this.radioGroup && this.radioGroup.value === this._value) { + this.checked = true; + } + } + + /** Dispatch change event with current value. */ + private _emitChangeEvent(): void { + let event = new MdRadioChange(); + event.source = this; + event.value = this._value; + this.change.emit(event); + } + + /** @internal */ onClick(event: Event) { if (this.disabled) { event.preventDefault(); @@ -353,8 +376,40 @@ export class MdRadioButton implements OnInit { // Propagate the change one-way via the group, which will in turn mark this // button as checked. this.radioGroup.selected = this; + this.radioGroup.touch(); } else { this.checked = true; } } + + /** + * 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. + * @internal + */ + onInputFocus() { + this._isFocused = true; + } + + /** @internal */ + onInputBlur() { + this._isFocused = false; + if (this.radioGroup) { + this.radioGroup.touch(); + } + } + + /** + * Checks the radio due to an interaction with the underlying native + * @internal + */ + onInputChange() { + this.checked = true; + if (this.radioGroup) { + this.radioGroup.touch(); + } + } } + +export const MD_RADIO_DIRECTIVES = [MdRadioGroup, MdRadioButton]; diff --git a/src/components/radio/radio_dispatcher.ts b/src/components/radio/radio_dispatcher.ts index cfd6d9eee1f9..cfe40eadfbee 100644 --- a/src/components/radio/radio_dispatcher.ts +++ b/src/components/radio/radio_dispatcher.ts @@ -1,29 +1,31 @@ import {Injectable} from '@angular/core'; +// Users of the Dispatcher never need to see this type, but TypeScript requires it to be exported. +export type MdRadioDispatcherListener = (id: string, name: string) => void; /** * Class for radio buttons to coordinate unique selection based on name. * Intended to be consumed as an Angular service. * This service is needed because native radio change events are only fired on the item currently * being selected, and we still need to uncheck the previous selection. + * + * This service does not *store* any IDs and names because they may change at any time, so it is + * less error-prone if they are simply passed through when the events occur. */ @Injectable() export class MdRadioDispatcher { - // TODO(jelbourn): Change this to TypeScript syntax when supported. - private _listeners: Function[]; - - constructor() { - this._listeners = []; - } + private _listeners: MdRadioDispatcherListener[] = []; /** Notify other radio buttons that selection for the given name has been set. */ - notify(name: string) { - this._listeners.forEach(listener => listener(name)); + notify(id: string, name: string) { + for (let listener of this._listeners) { + listener(id, name); + } } /** Listen for future changes to radio button selection. */ - listen(listener: (name: string) => void) { + listen(listener: MdRadioDispatcherListener) { this._listeners.push(listener); } }