fix(forms): add support for radio buttons

Closes #6877
This commit is contained in:
vsavkin 2016-02-05 16:08:53 -08:00 committed by Victor Savkin
parent 2337469753
commit e725542703
9 changed files with 252 additions and 25 deletions

View File

@ -31,7 +31,7 @@ export {
NgSelectOption,
SelectControlValueAccessor
} from './forms/directives/select_control_value_accessor';
export {FORM_DIRECTIVES} from './forms/directives';
export {FORM_DIRECTIVES, RadioButtonState} from './forms/directives';
export {NG_VALIDATORS, NG_ASYNC_VALIDATORS, Validators} from './forms/validators';
export {
RequiredValidator,
@ -39,4 +39,25 @@ export {
MaxLengthValidator,
Validator
} from './forms/directives/validators';
export {FormBuilder, FORM_PROVIDERS, FORM_BINDINGS} from './forms/form_builder';
export {FormBuilder} from './forms/form_builder';
import {FormBuilder} from './forms/form_builder';
import {RadioControlRegistry} from './forms/directives/radio_control_value_accessor';
import {Type, CONST_EXPR} from 'angular2/src/facade/lang';
/**
* Shorthand set of providers used for building Angular forms.
*
* ### Example
*
* ```typescript
* bootstrap(MyApp, [FORM_PROVIDERS]);
* ```
*/
export const FORM_PROVIDERS: Type[] = CONST_EXPR([FormBuilder, RadioControlRegistry]);
/**
* See {@link FORM_PROVIDERS} instead.
*
* @deprecated
*/
export const FORM_BINDINGS = FORM_PROVIDERS;

View File

@ -8,6 +8,7 @@ import {NgForm} from './directives/ng_form';
import {DefaultValueAccessor} from './directives/default_value_accessor';
import {CheckboxControlValueAccessor} from './directives/checkbox_value_accessor';
import {NumberValueAccessor} from './directives/number_value_accessor';
import {RadioControlValueAccessor} from './directives/radio_control_value_accessor';
import {NgControlStatus} from './directives/ng_control_status';
import {
SelectControlValueAccessor,
@ -23,6 +24,10 @@ export {NgFormModel} from './directives/ng_form_model';
export {NgForm} from './directives/ng_form';
export {DefaultValueAccessor} from './directives/default_value_accessor';
export {CheckboxControlValueAccessor} from './directives/checkbox_value_accessor';
export {
RadioControlValueAccessor,
RadioButtonState
} from './directives/radio_control_value_accessor';
export {NumberValueAccessor} from './directives/number_value_accessor';
export {NgControlStatus} from './directives/ng_control_status';
export {
@ -63,6 +68,7 @@ export const FORM_DIRECTIVES: Type[] = CONST_EXPR([
NumberValueAccessor,
CheckboxControlValueAccessor,
SelectControlValueAccessor,
RadioControlValueAccessor,
NgControlStatus,
RequiredValidator,

View File

@ -18,7 +18,7 @@ const CHECKBOX_VALUE_ACCESSOR = CONST_EXPR(new Provider(
selector:
'input[type=checkbox][ngControl],input[type=checkbox][ngFormControl],input[type=checkbox][ngModel]',
host: {'(change)': 'onChange($event.target.checked)', '(blur)': 'onTouched()'},
bindings: [CHECKBOX_VALUE_ACCESSOR]
providers: [CHECKBOX_VALUE_ACCESSOR]
})
export class CheckboxControlValueAccessor implements ControlValueAccessor {
onChange = (_) => {};

View File

@ -0,0 +1,126 @@
import {
Directive,
ElementRef,
Renderer,
Self,
forwardRef,
Provider,
Attribute,
Input,
OnInit,
OnDestroy,
Injector,
Injectable
} from 'angular2/core';
import {
NG_VALUE_ACCESSOR,
ControlValueAccessor
} from 'angular2/src/common/forms/directives/control_value_accessor';
import {NgControl} from 'angular2/src/common/forms/directives/ng_control';
import {CONST_EXPR, looseIdentical, isPresent} from 'angular2/src/facade/lang';
import {ListWrapper} from 'angular2/src/facade/collection';
const RADIO_VALUE_ACCESSOR = CONST_EXPR(new Provider(
NG_VALUE_ACCESSOR, {useExisting: forwardRef(() => RadioControlValueAccessor), multi: true}));
/**
* Internal class used by Angular to uncheck radio buttons with the matching name.
*/
@Injectable()
export class RadioControlRegistry {
private _accessors: any[] = [];
add(control: NgControl, accessor: RadioControlValueAccessor) {
this._accessors.push([control, accessor]);
}
remove(accessor: RadioControlValueAccessor) {
var indexToRemove = -1;
for (var i = 0; i < this._accessors.length; ++i) {
if (this._accessors[i][1] === accessor) {
indexToRemove = i;
}
}
ListWrapper.removeAt(this._accessors, indexToRemove);
}
select(accessor: RadioControlValueAccessor) {
this._accessors.forEach((c) => {
if (c[0].control.root === accessor._control.control.root && c[1] !== accessor) {
c[1].fireUncheck();
}
});
}
}
/**
* The value provided by the forms API for radio buttons.
*/
export class RadioButtonState {
constructor(public checked: boolean, public value: string) {}
}
/**
* The accessor for writing a radio control value and listening to changes that is used by the
* {@link NgModel}, {@link NgFormControl}, and {@link NgControlName} directives.
*
* ### Example
* ```
* @Component({
* template: `
* <input type="radio" name="food" [(ngModel)]="foodChicken">
* <input type="radio" name="food" [(ngModel)]="foodFish">
* `
* })
* class FoodCmp {
* foodChicken = new RadioButtonState(true, "chicken");
* foodFish = new RadioButtonState(false, "fish");
* }
* ```
*/
@Directive({
selector:
'input[type=radio][ngControl],input[type=radio][ngFormControl],input[type=radio][ngModel]',
host: {'(change)': 'onChange()', '(blur)': 'onTouched()'},
providers: [RADIO_VALUE_ACCESSOR]
})
export class RadioControlValueAccessor implements ControlValueAccessor,
OnDestroy, OnInit {
_state: RadioButtonState;
_control: NgControl;
@Input() name: string;
_fn: Function;
onChange = () => {};
onTouched = () => {};
constructor(private _renderer: Renderer, private _elementRef: ElementRef,
private _registry: RadioControlRegistry, private _injector: Injector) {}
ngOnInit(): void {
this._control = this._injector.get(NgControl);
this._registry.add(this._control, this);
}
ngOnDestroy(): void { this._registry.remove(this); }
writeValue(value: any): void {
this._state = value;
if (isPresent(value) && value.checked) {
this._renderer.setElementProperty(this._elementRef.nativeElement, 'checked', true);
}
}
registerOnChange(fn: (_: any) => {}): void {
this._fn = fn;
this.onChange = () => {
fn(new RadioButtonState(true, this._state.value));
this._registry.select(this);
};
}
fireUncheck(): void { this._fn(new RadioButtonState(false, this._state.value)); }
registerOnTouched(fn: () => {}): void { this.onTouched = fn; }
}

View File

@ -105,22 +105,4 @@ export class FormBuilder {
return this.control(controlConfig);
}
}
}
/**
* Shorthand set of providers used for building Angular forms.
*
* ### Example
*
* ```typescript
* bootstrap(MyApp, [FORM_PROVIDERS]);
* ```
*/
export const FORM_PROVIDERS: Type[] = CONST_EXPR([FormBuilder]);
/**
* See {@link FORM_PROVIDERS} instead.
*
* @deprecated
*/
export const FORM_BINDINGS = FORM_PROVIDERS;
}

View File

@ -208,6 +208,16 @@ export abstract class AbstractControl {
return isPresent(this.getError(errorCode, path));
}
get root(): AbstractControl {
let x: AbstractControl = this;
while (isPresent(x._parent)) {
x = x._parent;
}
return x;
}
/** @internal */
_updateControlsErrors(): void {
this._status = this._calculateStatus();

View File

@ -10,6 +10,7 @@ import {
dispatchEvent,
fakeAsync,
tick,
flushMicrotasks,
expect,
it,
inject,
@ -31,7 +32,8 @@ import {
NgFor,
NgForm,
Validators,
Validator
Validator,
RadioButtonState
} from 'angular2/common';
import {Provider, forwardRef, Input} from 'angular2/core';
import {By} from 'angular2/platform/browser';
@ -328,6 +330,33 @@ export function main() {
});
}));
it("should support <type=radio>",
inject([TestComponentBuilder, AsyncTestCompleter], (tcb: TestComponentBuilder, async) => {
var t = `<form [ngFormModel]="form">
<input type="radio" ngControl="foodChicken" name="food">
<input type="radio" ngControl="foodFish" name="food">
</form>`;
tcb.overrideTemplate(MyComp, t).createAsync(MyComp).then((fixture) => {
fixture.debugElement.componentInstance.form = new ControlGroup({
"foodChicken": new Control(new RadioButtonState(false, 'chicken')),
"foodFish": new Control(new RadioButtonState(true, 'fish'))
});
fixture.detectChanges();
var input = fixture.debugElement.query(By.css("input"));
expect(input.nativeElement.checked).toEqual(false);
dispatchEvent(input.nativeElement, "change");
fixture.detectChanges();
let value = fixture.debugElement.componentInstance.form.value;
expect(value['foodChicken'].checked).toEqual(true);
expect(value['foodFish'].checked).toEqual(false);
async.done();
});
}));
it("should support <select>",
inject([TestComponentBuilder, AsyncTestCompleter], (tcb: TestComponentBuilder, async) => {
var t = `<div [ngFormModel]="form">
@ -812,9 +841,50 @@ export function main() {
expect(fixture.debugElement.componentInstance.name).toEqual("updatedValue");
})));
});
it("should support <type=radio>",
inject([TestComponentBuilder], fakeAsync((tcb: TestComponentBuilder) => {
var t = `<form>
<input type="radio" name="food" ngControl="chicken" [(ngModel)]="data['chicken1']">
<input type="radio" name="food" ngControl="fish" [(ngModel)]="data['fish1']">
</form>
<form>
<input type="radio" name="food" ngControl="chicken" [(ngModel)]="data['chicken2']">
<input type="radio" name="food" ngControl="fish" [(ngModel)]="data['fish2']">
</form>`;
var fixture: ComponentFixture;
tcb.overrideTemplate(MyComp, t).createAsync(MyComp).then((f) => { fixture = f; });
tick();
fixture.debugElement.componentInstance.data = {
'chicken1': new RadioButtonState(false, 'chicken'),
'fish1': new RadioButtonState(true, 'fish'),
'chicken2': new RadioButtonState(false, 'chicken'),
'fish2': new RadioButtonState(true, 'fish')
};
fixture.detectChanges();
tick();
var input = fixture.debugElement.query(By.css("input"));
expect(input.nativeElement.checked).toEqual(false);
dispatchEvent(input.nativeElement, "change");
tick();
let data = fixture.debugElement.componentInstance.data;
expect(data['chicken1']).toEqual(new RadioButtonState(true, 'chicken'));
expect(data['fish1']).toEqual(new RadioButtonState(false, 'fish'));
expect(data['chicken2']).toEqual(new RadioButtonState(false, 'chicken'));
expect(data['fish2']).toEqual(new RadioButtonState(true, 'fish'));
})));
});
describe("setting status classes", () => {
it("should work with single fields",
inject([TestComponentBuilder, AsyncTestCompleter], (tcb: TestComponentBuilder, async) => {

View File

@ -51,6 +51,7 @@ var NG_COMMON = [
'AbstractControl.validator',
'AbstractControl.validator=',
'AbstractControl.value',
'AbstractControl.root',
'AbstractControl.valueChanges',
'AbstractControlDirective',
'AbstractControlDirective.control',
@ -102,6 +103,7 @@ var NG_COMMON = [
'Control.validator',
'Control.validator=',
'Control.value',
'Control.root',
'Control.valueChanges',
'ControlArray',
'ControlArray.asyncValidator',
@ -134,6 +136,7 @@ var NG_COMMON = [
'ControlArray.validator',
'ControlArray.validator=',
'ControlArray.value',
'ControlArray.root',
'ControlArray.valueChanges',
'ControlContainer',
'ControlContainer.control',
@ -179,6 +182,7 @@ var NG_COMMON = [
'ControlGroup.validator',
'ControlGroup.validator=',
'ControlGroup.value',
'ControlGroup.root',
'ControlGroup.valueChanges',
'ControlValueAccessor:dart',
'CurrencyPipe',
@ -447,7 +451,12 @@ var NG_COMMON = [
'Validators#maxLength()',
'Validators#minLength()',
'Validators#nullValidator()',
'Validators#required()'
'Validators#required()',
'RadioButtonState',
'RadioButtonState.checked',
'RadioButtonState.checked=',
'RadioButtonState.value',
'RadioButtonState.value='
];
var NG_COMPILER = [

View File

@ -566,6 +566,7 @@ const COMMON = [
'AbstractControl.updateValueAndValidity({onlySelf,emitEvent}:{onlySelf?:boolean, emitEvent?:boolean}):void',
'AbstractControl.valid:boolean',
'AbstractControl.value:any',
'AbstractControl.root:AbstractControl',
'AbstractControl.valueChanges:Observable<any>',
'AbstractControlDirective',
'AbstractControlDirective.control:AbstractControl',
@ -794,6 +795,8 @@ const COMMON = [
'Validators.minLength(minLength:number):Function',
'Validators.nullValidator(c:any):{[key:string]:boolean}',
'Validators.required(control:Control):{[key:string]:boolean}',
'RadioButtonState',
'RadioButtonState.constructor(checked:boolean, value:string)',
'const COMMON_DIRECTIVES:Type[][]',
'const COMMON_PIPES:any',
'const CORE_DIRECTIVES:Type[]',