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): radio-button should only emit change event if native input does. #911

Merged
merged 4 commits into from
Aug 8, 2016
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
3 changes: 2 additions & 1 deletion src/components/button-toggle/button-toggle.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
[checked]="checked"
[disabled]="disabled"
[name]="name"
(change)="_onInputChange($event)">
(change)="_onInputChange($event)"
(click)="_onInputClick($event)">

<div class="md-button-toggle-label-content">
<ng-content></ng-content>
Expand Down
81 changes: 70 additions & 11 deletions src/components/button-toggle/button-toggle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ describe('MdButtonToggle', () => {
}));

describe('inside of an exclusive selection group', () => {

let fixture: ComponentFixture<ButtonTogglesInsideButtonToggleGroup>;
let groupDebugElement: DebugElement;
let groupNativeElement: HTMLElement;
let buttonToggleDebugElements: DebugElement[];
let buttonToggleNativeElements: HTMLElement[];
let buttonToggleLabelElements: HTMLLabelElement[];
let groupInstance: MdButtonToggleGroup;
let buttonToggleInstances: MdButtonToggle[];
let testComponent: ButtonTogglesInsideButtonToggleGroup;
Expand All @@ -62,8 +64,13 @@ describe('MdButtonToggle', () => {
groupInstance = groupDebugElement.injector.get(MdButtonToggleGroup);

buttonToggleDebugElements = fixture.debugElement.queryAll(By.directive(MdButtonToggle));
buttonToggleNativeElements =
buttonToggleDebugElements.map(debugEl => debugEl.nativeElement);

buttonToggleNativeElements = buttonToggleDebugElements
.map(debugEl => debugEl.nativeElement);

buttonToggleLabelElements = fixture.debugElement.queryAll(By.css('label'))
.map(debugEl => debugEl.nativeElement);

buttonToggleInstances = buttonToggleDebugElements.map(debugEl => debugEl.componentInstance);
});
}));
Expand Down Expand Up @@ -133,15 +140,19 @@ describe('MdButtonToggle', () => {
let changeSpy = jasmine.createSpy('button-toggle change listener');
buttonToggleInstances[0].change.subscribe(changeSpy);

buttonToggleInstances[0].checked = true;

buttonToggleLabelElements[0].click();
fixture.detectChanges();
tick();
expect(changeSpy).toHaveBeenCalled();

buttonToggleInstances[0].checked = false;
buttonToggleLabelElements[0].click();
fixture.detectChanges();
tick();
expect(changeSpy).toHaveBeenCalledTimes(2);

// The default browser behavior is to not emit a change event, when the value was set
// to false. That's because the current input type is set to `radio`
expect(changeSpy).toHaveBeenCalledTimes(1);
}));

it('should emit a change event from the button toggle group', fakeAsync(() => {
Expand Down Expand Up @@ -330,6 +341,7 @@ describe('MdButtonToggle', () => {
let groupNativeElement: HTMLElement;
let buttonToggleDebugElements: DebugElement[];
let buttonToggleNativeElements: HTMLElement[];
let buttonToggleLabelElements: HTMLLabelElement[];
let groupInstance: MdButtonToggleGroupMultiple;
let buttonToggleInstances: MdButtonToggle[];
let testComponent: ButtonTogglesInsideButtonToggleGroupMultiple;
Expand All @@ -346,8 +358,10 @@ describe('MdButtonToggle', () => {
groupInstance = groupDebugElement.injector.get(MdButtonToggleGroupMultiple);

buttonToggleDebugElements = fixture.debugElement.queryAll(By.directive(MdButtonToggle));
buttonToggleNativeElements =
buttonToggleDebugElements.map(debugEl => debugEl.nativeElement);
buttonToggleNativeElements = buttonToggleDebugElements
.map(debugEl => debugEl.nativeElement);
buttonToggleLabelElements = fixture.debugElement.queryAll(By.css('label'))
.map(debugEl => debugEl.nativeElement);
buttonToggleInstances = buttonToggleDebugElements.map(debugEl => debugEl.componentInstance);
});
}));
Expand Down Expand Up @@ -398,12 +412,36 @@ describe('MdButtonToggle', () => {

expect(buttonToggleInstances[0].checked).toBe(false);
});

it('should emit a change event for state changes', fakeAsync(() => {

expect(buttonToggleInstances[0].checked).toBe(false);

let changeSpy = jasmine.createSpy('button-toggle change listener');
buttonToggleInstances[0].change.subscribe(changeSpy);

buttonToggleLabelElements[0].click();
fixture.detectChanges();
tick();
expect(changeSpy).toHaveBeenCalled();

buttonToggleLabelElements[0].click();
fixture.detectChanges();
tick();

// The default browser behavior is to emit an event, when the value was set
// to false. That's because the current input type is set to `checkbox` when
// using the multiple mode.
expect(changeSpy).toHaveBeenCalledTimes(2);
}));

});

describe('as standalone', () => {
let fixture: ComponentFixture<StandaloneButtonToggle>;
let buttonToggleDebugElement: DebugElement;
let buttonToggleNativeElement: HTMLElement;
let buttonToggleLabelElement: HTMLLabelElement;
let buttonToggleInstance: MdButtonToggle;
let testComponent: StandaloneButtonToggle;

Expand All @@ -416,24 +454,45 @@ describe('MdButtonToggle', () => {

buttonToggleDebugElement = fixture.debugElement.query(By.directive(MdButtonToggle));
buttonToggleNativeElement = buttonToggleDebugElement.nativeElement;
buttonToggleLabelElement = fixture.debugElement.query(By.css('label')).nativeElement;
buttonToggleInstance = buttonToggleDebugElement.componentInstance;
});
}));

it('should toggle when clicked', () => {
let nativeCheckboxLabel = buttonToggleDebugElement.query(By.css('label')).nativeElement;

nativeCheckboxLabel.click();
buttonToggleLabelElement.click();

fixture.detectChanges();

expect(buttonToggleInstance.checked).toBe(true);

nativeCheckboxLabel.click();
buttonToggleLabelElement.click();
fixture.detectChanges();

expect(buttonToggleInstance.checked).toBe(false);
});

it('should emit a change event for state changes', fakeAsync(() => {

expect(buttonToggleInstance.checked).toBe(false);

let changeSpy = jasmine.createSpy('button-toggle change listener');
buttonToggleInstance.change.subscribe(changeSpy);

buttonToggleLabelElement.click();
fixture.detectChanges();
tick();
expect(changeSpy).toHaveBeenCalled();

buttonToggleLabelElement.click();
fixture.detectChanges();
tick();

// The default browser behavior is to emit an event, when the value was set
// to false. That's because the current input type is set to `checkbox`.
expect(changeSpy).toHaveBeenCalledTimes(2);
}));

});
});

Expand Down
20 changes: 16 additions & 4 deletions src/components/button-toggle/button-toggle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,10 +303,6 @@ export class MdButtonToggle implements OnInit {
// Notify all button toggles with the same name (in the same group) to un-check.
this.buttonToggleDispatcher.notify(this.id, this.name);
}

if (newCheckedState != this._checked) {
this._emitChangeEvent();
}
}

this._checked = newCheckedState;
Expand Down Expand Up @@ -368,6 +364,22 @@ export class MdButtonToggle implements OnInit {
} else {
this._toggle();
}

// Emit a change event when the native input does.
this._emitChangeEvent();
}

/** TODO: internal */
_onInputClick(event: Event) {

// We have to stop propagation for click events on the visual hidden input element.
// By default, when a user clicks on a label element, a generated click event will be
// dispatched on the associated input element. Since we are using a label element as our
// root container, the click event on the `slide-toggle` will be executed twice.
// The real click event will bubble up, and the generated click event also tries to bubble up.
// This will lead to multiple click events.
// Preventing bubbling for the second event will solve that issue.
event.stopPropagation();
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/components/radio/radio.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
[attr.aria-labelledby]="ariaLabelledby"
(change)="_onInputChange($event)"
(focus)="_onInputFocus()"
(blur)="_onInputBlur()">
(blur)="_onInputBlur()"
(click)="_onInputClick($event)">

<!-- The label content for radio control. -->
<div class="md-radio-label-content" [class.md-radio-align-end]="align == 'end'">
Expand Down
55 changes: 34 additions & 21 deletions src/components/radio/radio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
inject,
async,
fakeAsync,
tick,
TestComponentBuilder,
ComponentFixture,
TestBed,
Expand Down Expand Up @@ -45,6 +44,7 @@ describe('MdRadio', () => {
let groupNativeElement: HTMLElement;
let radioDebugElements: DebugElement[];
let radioNativeElements: HTMLElement[];
let radioLabelElements: HTMLLabelElement[];
let groupInstance: MdRadioGroup;
let radioInstances: MdRadioButton[];
let testComponent: RadiosInsideRadioGroup;
Expand All @@ -63,6 +63,9 @@ describe('MdRadio', () => {
radioDebugElements = fixture.debugElement.queryAll(By.directive(MdRadioButton));
radioNativeElements = radioDebugElements.map(debugEl => debugEl.nativeElement);
radioInstances = radioDebugElements.map(debugEl => debugEl.componentInstance);

radioLabelElements = radioDebugElements
.map(debugEl => debugEl.query(By.css('label')).nativeElement);
});
}));

Expand All @@ -77,7 +80,7 @@ describe('MdRadio', () => {
testComponent.isGroupDisabled = true;
fixture.detectChanges();

radioNativeElements[0].click();
radioLabelElements[0].click();
expect(radioInstances[0].checked).toBe(false);
});

Expand Down Expand Up @@ -119,15 +122,15 @@ describe('MdRadio', () => {
it('should update the group and radios when one of the radios is clicked', () => {
expect(groupInstance.value).toBeFalsy();

radioNativeElements[0].click();
radioLabelElements[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();
radioLabelElements[1].click();
fixture.detectChanges();

expect(groupInstance.value).toBe('water');
Expand All @@ -150,18 +153,23 @@ describe('MdRadio', () => {
it('should emit a change event from radio buttons', fakeAsync(() => {
expect(radioInstances[0].checked).toBe(false);

let changeSpy = jasmine.createSpy('radio change listener');
radioInstances[0].change.subscribe(changeSpy);
let spies = radioInstances
.map((value, index) => jasmine.createSpy(`onChangeSpy ${index}`));

radioInstances[0].checked = true;
spies.forEach((spy, index) => radioInstances[index].change.subscribe(spy));

radioLabelElements[0].click();
fixture.detectChanges();
tick();
expect(changeSpy).toHaveBeenCalled();

radioInstances[0].checked = false;
expect(spies[0]).toHaveBeenCalled();

radioLabelElements[1].click();
fixture.detectChanges();
tick();
expect(changeSpy).toHaveBeenCalledTimes(2);

// To match the native radio button behavior, the change event shouldn't
// be triggered when the radio got unselected.
expect(spies[0]).toHaveBeenCalledTimes(1);
expect(spies[1]).toHaveBeenCalledTimes(1);
}));

it('should emit a change event from the radio group', fakeAsync(() => {
Expand All @@ -172,12 +180,12 @@ describe('MdRadio', () => {

groupInstance.value = 'fire';
fixture.detectChanges();
tick();

expect(changeSpy).toHaveBeenCalled();

groupInstance.value = 'water';
fixture.detectChanges();
tick();

expect(changeSpy).toHaveBeenCalledTimes(2);
}));

Expand Down Expand Up @@ -236,6 +244,7 @@ describe('MdRadio', () => {
let groupNativeElement: HTMLElement;
let radioDebugElements: DebugElement[];
let radioNativeElements: HTMLElement[];
let radioLabelElements: HTMLLabelElement[];
let groupInstance: MdRadioGroup;
let radioInstances: MdRadioButton[];
let testComponent: RadioGroupWithNgModel;
Expand All @@ -256,6 +265,9 @@ describe('MdRadio', () => {
radioDebugElements = fixture.debugElement.queryAll(By.directive(MdRadioButton));
radioNativeElements = radioDebugElements.map(debugEl => debugEl.nativeElement);
radioInstances = radioDebugElements.map(debugEl => debugEl.componentInstance);

radioLabelElements = radioDebugElements
.map(debugEl => debugEl.query(By.css('label')).nativeElement);
});
}));

Expand Down Expand Up @@ -294,17 +306,15 @@ describe('MdRadio', () => {
// 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();
radioLabelElements[2].click();
fixture.detectChanges();
tick();

expect(groupNgControl.valid).toBe(true);
expect(groupNgControl.pristine).toBe(false);
Expand All @@ -315,8 +325,6 @@ describe('MdRadio', () => {
radioInstances[1].checked = true;
fixture.detectChanges();

tick();

expect(testComponent.modelValue).toBe('chocolate');
}));
});
Expand Down Expand Up @@ -358,9 +366,14 @@ describe('MdRadio', () => {
groupInstance.value = 'chocolate';
fixture.detectChanges();

tick();
expect(testComponent.modelValue).toBe('chocolate');
expect(testComponent.lastEvent.value).toBe('chocolate');

groupInstance.value = 'vanilla';
fixture.detectChanges();

expect(testComponent.modelValue).toBe('vanilla');
expect(testComponent.lastEvent.value).toBe('vanilla');
}));
});

Expand Down Expand Up @@ -500,7 +513,7 @@ class RadiosInsideRadioGroup {
<md-radio-button name="weather" value="hot">Summer</md-radio-button>
<md-radio-button name="weather" value="cool">Autumn</md-radio-button>

<span id="xyz">Baby Banana<span>
<span id="xyz">Baby Banana</span>
<md-radio-button name="fruit" value="banana" aria-label="Banana" aria-labelledby="xyz">
</md-radio-button>
<md-radio-button name="fruit" value="raspberry">Raspberry</md-radio-button>
Expand Down
Loading