I'm solving this in a similar way like web-master-now. But instead of writing a complete own ControlValueAccessor
I'm delegating everything to an inner <input>
ControlValueAccessor
. The result is a much shorter code and I don't have to handle the interaction with the <input>
element on my own.
Here is my code
@Component({
selector: 'form-field',
template: `
<label>
{{label}}
<input ngDefaultControl type="text" >
</label>
`,
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => FormFieldComponent),
multi: true
}]
})
export class FormFieldComponent implements ControlValueAccessor, AfterViewInit {
@Input() label: String;
@Input() formControlName: String;
@ViewChild(DefaultValueAccessor) valueAccessor: DefaultValueAccessor;
delegatedMethodCalls = new ReplaySubject<(_: ControlValueAccessor) => void>();
ngAfterViewInit(): void {
this.delegatedMethodCalls.subscribe(fn => fn(this.valueAccessor));
}
registerOnChange(fn: (_: any) => void): void {
this.delegatedMethodCalls.next(valueAccessor => valueAccessor.registerOnChange(fn));
}
registerOnTouched(fn: () => void): void {
this.delegatedMethodCalls.next(valueAccessor => valueAccessor.registerOnTouched(fn));
}
setDisabledState(isDisabled: boolean): void {
this.delegatedMethodCalls.next(valueAccessor => valueAccessor.setDisabledState(isDisabled));
}
writeValue(obj: any): void {
this.delegatedMethodCalls.next(valueAccessor => valueAccessor.writeValue(obj));
}
}
How does it work?
Generally this won't work, as a simpel <input>
won't be a ControlValueAccessor
without formControlName
-directive, which is not allowed in the component due to missing [formGroup]
, as others already pointed out. However if we look at Angular's code for the DefaultValueAccessor
implementation
@Directive({
selector:
'input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]',
//...
})
export class DefaultValueAccessor implements ControlValueAccessor {
... we can see that there is another attribute selector ngDefaultControl
. It's available for a different purpose, but it seems to be supported officially.
A little disadvantage is that the @ViewChild
query result with the value accessor will be available not before the ngAfterViewInit
handler is called. (It will be available earlier depending on your template, but that's not supported officially .)
That's why I'm buffering all calls we want to delegate to our inner DefaultValueAccessor
using a ReplaySubject
. A ReplaySubject
is an Observable
, which buffers all events and emits them on subscription. A normal Subject
would throw them away until subscription.
We emit lambda expressions representing the actual call that can be executed later. On ngAfterViewInit
we subscribe to our ReplaySubject
and simply call the received lambda functions.
I'm sharing two other ideas here, as they are very important for my own projects and it took me a while to work everything out. I see a lot of people having similar problems and use cases, so I hope this is useful for you:
Idea for improvement 1: Provide the FormControl
for the view
I replaced the ngDefaultControl
by formControl
in my project, so we can pass the FormControl
instance to the inner <input>
. This is not useful by itself, however it is if you are using other directives which interact with FormControl
s, such as Angular Material's MatInput
. E.g. if we replace our form-field
template by...
<mat-form-field>
<input [placeholder]="label" [formControl]="formControl>
<mat-error>Error!</mat-error>
</mat-form-field>
...Angular Material is able to automatically show errors set in the form control.
I have to adjust the component in order to pass the form control. I retrieve the form control from our FormControlName
directive:
export class FormFieldComponent implements ControlValueAccessor, AfterContentInit {
// ... see above
@ContentChild(FormControlName) private formControlNameRef: FormControlName;
formControl: FormControl;
ngAfterContentInit(): void {
this.formControl = <FormControl>this.formControlNameRef.control;
}
// ... see above
}
You should also adjust your selector to require the formControlName
attribute: selector: 'form-field[formControlName]'
.
Idea for improvement 2: Delegate to a more generic value accessor
I replaced the DefaultValueAccessor
@ViewChild
query by a query for all ControlValueAccessor
implementations. This allows other HTML form controls than <input>
like <select>
and is useful if you want to make your form control type configurable.
@Component({
selector: 'form-field',
template: `
<label [ngSwitch]="controlType">
{{label}}
<input *ngSwitchCase="'text'" ngDefaultControl type="text" #valueAccessor>
<select *ngSwitchCase="'dropdown'" ngModel #valueAccessor>
<ng-content></ng-content>
</select>
</label>
`,
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => FormFieldComponent),
multi: true
}]
})
export class FormFieldComponent implements ControlValueAccessor {
// ... see above
@Input() controlType: String = 'text';
@ViewChild('valueAccessor', {read: NG_VALUE_ACCESSOR}) valueAccessor: ControlValueAccessor;
// ... see above
}
Usage example:
<form [formGroup]="form">
<form-field formControlName="firstName" label="First Name"></form-field>
<form-field formControlName="lastName" label="Last Name" controlType="dropdown">
<option>foo</option>
<option>bar</option>
</form-field>
<p>Hello "{{form.get('firstName').value}} {{form.get('lastName').value}}"</p>
</form>
A problem with the select
above is that ngModel
is already deprecated together with reactive forms. Unfortunately there is nothing like ngDefaultControl
for Angular's <select>
control value accessor. Therefore I suggest to combine this with my first improvement idea.
formControlName
, I passed directly theFormControl
Object withformControl="formControlObject"
to the custom-input. (with an@Input formControlObject : FormControl
). – Félix Brunet