Skip to content

Commit

Permalink
feat(radio): add radio buttons
Browse files Browse the repository at this point in the history
Closes #216
  • Loading branch information
maxokorokov authored and pkozlowski-opensource committed Jun 15, 2016
1 parent ab30a4f commit 44adfd7
Show file tree
Hide file tree
Showing 3 changed files with 332 additions and 1 deletion.
4 changes: 3 additions & 1 deletion src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {NgbPagination} from './pagination/pagination';
import {NgbProgressbar} from './progressbar/progressbar';
import {NgbRating} from './rating/rating';
import {NgbTabset, NgbTab, NgbTabContent, NgbTabTitle} from './tabset/tabset';
import {NgbRadioGroup, NgbRadioLabel, NgbRadio, NGB_RADIO_DIRECTIVES} from './radio/radio';

export {NgbAccordion, NgbPanel} from './accordion/accordion';
export {NgbAlert} from './alert/alert';
Expand All @@ -15,8 +16,9 @@ export {NgbPagination} from './pagination/pagination';
export {NgbProgressbar} from './progressbar/progressbar';
export {NgbRating} from './rating/rating';
export {NgbTabset, NgbTab, NgbTabContent, NgbTabTitle} from './tabset/tabset';
export {NgbRadioGroup, NgbRadioLabel, NgbRadio, NGB_RADIO_DIRECTIVES} from './radio/radio';

export const NGB_DIRECTIVES = [
NgbAccordion, NgbAlert, NgbCollapse, NgbPanel, NgbDropdown, NgbDropdownToggle, NgbPagination, NgbProgressbar,
NgbRating, NgbTabset, NgbTab, NgbTabContent, NgbTabTitle
NgbRating, NgbTabset, NgbTab, NgbTabContent, NgbTabTitle, NgbRadioGroup, NgbRadio, NgbRadioLabel, NGB_RADIO_DIRECTIVES
];
211 changes: 211 additions & 0 deletions src/radio/radio.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import {it, iit, expect, inject, async, tick, fakeAsync, describe, ddescribe} from '@angular/core/testing';
import {TestComponentBuilder} from '@angular/compiler/testing';
import {Component} from '@angular/core';
import {NGB_RADIO_DIRECTIVES} from './radio';


function expectRadios(element: HTMLElement, states: number[]) {
var labels = element.querySelectorAll('label');
expect(labels.length).toEqual(states.length);

for (let i = 0; i < states.length; i++) {
let state = states[i];

if (state === 1) {
expect(labels[i]).toHaveCssClass('active');
} else if (state === 0) {
expect(labels[i]).not.toHaveCssClass('active');
}
}
}

function getInput(nativeEl: HTMLElement, idx: number): HTMLInputElement {
return <HTMLInputElement>nativeEl.querySelectorAll('input')[idx];
}

describe('ng-radio-group', () => {

const defaultHtml = `<div [(ngModel)]="model" ngb-radio-group>
<label class="btn">
<input type="radio" name="radio" [value]="values[0]" ngb-radio/> {{ values[0] }}
</label>
<label class="btn">
<input type="radio" name="radio" [value]="values[1]" ngb-radio/> {{ values[1] }}
</label>
</div>`;

it('toggles radio inputs based on model changes', async(inject([TestComponentBuilder], (tcb) => {
tcb.overrideTemplate(TestComponent, defaultHtml).createAsync(TestComponent).then((fixture) => {

let values = fixture.componentInstance.values;

fixture.detectChanges();
expectRadios(fixture.nativeElement, [0, 0]);

// checking null
fixture.componentInstance.model = null;
fixture.detectChanges();
expectRadios(fixture.nativeElement, [0, 0]);

// checking first radio
fixture.componentInstance.model = values[0];
fixture.detectChanges();
expectRadios(fixture.nativeElement, [1, 0]);

// checking second radio
fixture.componentInstance.model = values[1];
fixture.detectChanges();
expectRadios(fixture.nativeElement, [0, 1]);

// checking non-matching value
fixture.componentInstance.model = values[3];
fixture.detectChanges();
expectRadios(fixture.nativeElement, [0, 0]);
});
})));

it('updates model based on radio input clicks', fakeAsync(inject([TestComponentBuilder], (tcb) => {
tcb.overrideTemplate(TestComponent, defaultHtml).createAsync(TestComponent).then((fixture) => {

fixture.detectChanges();
expectRadios(fixture.nativeElement, [0, 0]);

// clicking first radio
getInput(fixture.nativeElement, 0).click();
tick();
fixture.detectChanges();
expectRadios(fixture.nativeElement, [1, 0]);
expect(fixture.componentInstance.model).toBe('one');

// clicking second radio
getInput(fixture.nativeElement, 1).click();
tick();
fixture.detectChanges();
expectRadios(fixture.nativeElement, [0, 1]);
expect(fixture.componentInstance.model).toBe('two');
});
})));

it('can be used with objects as values', fakeAsync(inject([TestComponentBuilder], (tcb) => {
tcb.overrideTemplate(TestComponent, defaultHtml).createAsync(TestComponent).then((fixture) => {

let [one, two] = [{one: 'one'}, {two: 'two'}];

fixture.componentInstance.values[0] = one;
fixture.componentInstance.values[1] = two;

fixture.detectChanges();
expectRadios(fixture.nativeElement, [0, 0]);

// checking model -> radio input
fixture.componentInstance.model = one;
fixture.detectChanges();
expectRadios(fixture.nativeElement, [1, 0]);

// checking radio click -> model
getInput(fixture.nativeElement, 1).click();
tick();
fixture.detectChanges();
expectRadios(fixture.nativeElement, [0, 1]);
expect(fixture.componentInstance.model).toBe(two);
});
})));

it('updates radio input values dynamically', fakeAsync(inject([TestComponentBuilder], (tcb) => {
tcb.overrideTemplate(TestComponent, defaultHtml).createAsync(TestComponent).then((fixture) => {

let values = fixture.componentInstance.values;

// checking first radio
fixture.componentInstance.model = values[0];
fixture.detectChanges();
expectRadios(fixture.nativeElement, [1, 0]);

// updating radio value
fixture.componentInstance.values[0] = 'ten';
fixture.detectChanges();
expectRadios(fixture.nativeElement, [1, 0]);
tick();
expect(fixture.componentInstance.model).toEqual('ten');
});
})));

it('can be used with ngFor', async(inject([TestComponentBuilder], (tcb) => {

const forHtml = `<div [(ngModel)]="model" ngb-radio-group>
<label *ngFor="let v of values" class="btn">
<input type="radio" name="radio" [value]="v" ngb-radio/> {{ v }}
</label>
</div>`;

tcb.overrideTemplate(TestComponent, forHtml).createAsync(TestComponent).then((fixture) => {

let values = fixture.componentInstance.values;

fixture.detectChanges();
expectRadios(fixture.nativeElement, [0, 0, 0]);

fixture.componentInstance.model = values[1];
fixture.detectChanges();
expectRadios(fixture.nativeElement, [0, 1, 0]);
});
})));

it('cleans up the model when radio inputs are hidden', fakeAsync(inject([TestComponentBuilder], (tcb) => {

const ifHtml = `<div [(ngModel)]="model" ngb-radio-group>
<label class="btn">
<input type="radio" name="radio" [value]="values[0]" ngb-radio/> {{ values[0] }}
</label>
<label *ngIf="shown" class="btn">
<input type="radio" name="radio" [value]="values[1]" ngb-radio/> {{ values[1] }}
</label>
</div>`;

tcb.overrideTemplate(TestComponent, ifHtml).createAsync(TestComponent).then((fixture) => {

let values = fixture.componentInstance.values;

// hiding/showing non-selected radio -> expecting initial model value
fixture.detectChanges();
expectRadios(fixture.nativeElement, [0, 0]);

fixture.componentInstance.shown = false;
fixture.detectChanges();
expectRadios(fixture.nativeElement, [0]);
tick();
expect(fixture.componentInstance.model).toBeUndefined();

fixture.componentInstance.shown = true;
fixture.detectChanges();
expectRadios(fixture.nativeElement, [0, 0]);
tick();
expect(fixture.componentInstance.model).toBeUndefined();

// hiding/showing selected radio -> expecting model to become null
fixture.componentInstance.model = values[1];
fixture.detectChanges();
expectRadios(fixture.nativeElement, [0, 1]);

fixture.componentInstance.shown = false;
fixture.detectChanges();
expectRadios(fixture.nativeElement, [0]);
tick();
expect(fixture.componentInstance.model).toBeNull();

fixture.componentInstance.shown = true;
fixture.detectChanges();
expectRadios(fixture.nativeElement, [0, 0]);
tick();
expect(fixture.componentInstance.model).toBeNull();
});
})));

});

@Component({selector: 'test-cmp', directives: [NGB_RADIO_DIRECTIVES], template: ''})
class TestComponent {
model;
values = ['one', 'two', 'three'];
shown = true;
}
118 changes: 118 additions & 0 deletions src/radio/radio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {Directive, forwardRef, Provider, Host, Optional, Input, Renderer, ElementRef, OnDestroy} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/common';

const NGB_RADIO_VALUE_ACCESSOR =
new Provider(NG_VALUE_ACCESSOR, {useExisting: forwardRef(() => NgbRadioGroup), multi: true});

@Directive({selector: '[ngb-radio-group][ngModel]', bindings: [NGB_RADIO_VALUE_ACCESSOR]})
export class NgbRadioGroup implements ControlValueAccessor {
private _radios: Set<NgbRadio> = new Set<NgbRadio>();
private _selectedRadio: NgbRadio = null;
private _value = null;

onChange = (_: any) => {};
onTouched = () => {};

onRadioChange(radio: NgbRadio) { this._setGroupValue(radio); }

onRadioValueUpdate(radio: NgbRadio) {
if (this._selectedRadio === radio) {
this.onRadioChange(radio);
} else {
this._updateRadios();
}
}

register(radio: NgbRadio) {
this._radios.add(radio);
this._updateRadios();
}

registerOnChange(fn: (value: any) => any): void { this.onChange = fn; }

registerOnTouched(fn: () => any): void { this.onTouched = fn; }

unregister(radio: NgbRadio) {
this._radios.delete(radio);
if (this._selectedRadio === radio) {
this._setGroupValue(null);
}
}

writeValue(value) {
this._value = value;
this._updateRadios();
}

private _setGroupValue(radio: NgbRadio) {
this._selectedRadio = radio;
var value = radio ? radio.value : null;
this.writeValue(value);
this.onChange(value);
}

private _updateRadios() {
this._selectedRadio = null;
this._radios.forEach((radio) => {
if (radio.markChecked(this._value)) {
this._selectedRadio = radio;
}
});
}
}


@Directive({selector: 'label.btn', host: {'[class.active]': 'checked'}})
export class NgbRadioLabel {
@Input() checked: boolean;
}


@Directive({selector: 'input[type=radio]', host: {'(change)': 'onChange($event.target.value)', '[checked]': 'checked'}})
export class NgbRadio implements OnDestroy {
private _value = null;

@Input() checked: boolean;

@Input('value')
set value(value) {
this._value = value;
var stringValue = value ? value.toString() : '';
this.renderer.setElementProperty(this.element.nativeElement, 'value', stringValue);

if (this.group) {
this.group.onRadioValueUpdate(this);
}
}

get value() { return this._value; }

constructor(
@Optional() @Host() private group: NgbRadioGroup, @Optional() @Host() private label: NgbRadioLabel,
private renderer: Renderer, private element: ElementRef) {
if (this.group) {
this.group.register(this);
}
}

markChecked(value): boolean {
this.checked = (this.value === value && value !== null);
this.label.checked = this.checked;

return this.checked;
}

ngOnDestroy() {
if (this.group) {
this.group.unregister(this);
}
}

onChange() {
if (this.group) {
this.group.onRadioChange(this);
}
}
}

export const NGB_RADIO_DIRECTIVES = [NgbRadio, NgbRadioLabel, NgbRadioGroup];

0 comments on commit 44adfd7

Please sign in to comment.