DateValueAccessor: How to use date input controls with Angular Forms
12.10.2016
The problem
Working with forms is pretty easy in Angular.
You just need to decide between Template-Driven and Reactive Forms and you are ready to start with some bindings and validation. The following code shows a two-way data binding with ngModel
against a property of type string
:
<input type="text" name="name" [(ngModel)]="myName">
But there is one problem to tackle: models of type Date
!
You might wonder, because HTML5 date input controls are not working as expected:
<input type="date" name="releaseDate" [(ngModel)]="myBirtday">
Even if myBirtday
contains a valid date, the date input control is not rendering the value at all.
In fact, we are supposed to set a string that is representing a full-date as defined in RFC 3339. The same string is written back to the model, when changes have been made, e.g. "2016-10-13". This behavior is specified in the W3C HTML language reference for inputEl.value
. According to the specification, date input controls are based on strings. So what can we do to keep the Date type?
A solution
Let's review the possible solutions:
We could create a custom form control. This would be a clean and extendable solution, but it might lead to more code than required for the given use-case.
We could convert the strings directly in our
@Component
as described here. But do we really want to bloat our "business code layer" with boilerplate code?We could create a custom value accessor. The following article discusses this option.
It turns out, that date input control has another, not that well-known property: inputEl.valueAsDate
! The inputEl.valueAsDate
attribute represents the value (still a string) of the element, interpreted as a date. This is exactly what we need. Now we only need to convince Angular to use this property, instead of inputEl.value
.
Fortunately Angular is very expansible here. FormControls (both template-driven and reactive) subscribe for values and write values via Directives that implement ControlValueAccessor
. Take a look at the relevant method selectValueAccessor, which is used in all necessary directives. Normal input controls (e.g. <input type="text">
) or textareas are handled by the DefaultValueAccessor. Another example is the CheckboxValueAccessor which is applied to checkbox input controls.
The job isn't complicated at all. We just need to implement a new value accessor for date input controls.DateValueAccessor
is a nice name:
// date-value-accessor.ts
import { Directive, ElementRef, HostListener, Renderer, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
export const DATE_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DateValueAccessor),
multi: true
};
/**
* The accessor for writing a value and listening to changes on a date input element
*
* ### Example
* `<input type="date" name="myBirthday" ngModel useValueAsDate>`
*/
@Directive({
selector: '[useValueAsDate]',
providers: [DATE_VALUE_ACCESSOR]
})
export class DateValueAccessor implements ControlValueAccessor {
@HostListener('input', ['$event.target.valueAsDate']) onChange = (_: any) => { };
@HostListener('blur', []) onTouched = () => { };
constructor(private _renderer: Renderer, private _elementRef: ElementRef) { }
writeValue(value: Date): void {
this._renderer.setElementProperty(this._elementRef.nativeElement, 'valueAsDate', value);
}
registerOnChange(fn: (_: any) => void): void { this.onChange = fn; }
registerOnTouched(fn: () => void): void { this.onTouched = fn; }
setDisabledState(isDisabled: boolean): void {
this._renderer.setElementProperty(this._elementRef.nativeElement, 'disabled', isDisabled);
}
}
We attach the DateValueAccessor
to the multi-provider DATE_VALUE_ACCESSOR
, so that selectValueAccessor can find it.
The only question is, which selector should be used. I decided for an opt-in solution. Here the DateValueAccessor selects on the attribute "useValueAsDate".
<input type="date" name="myBirthday" ngModel useValueAsDate>
OR
<input type="date" name="myBirthday" [(ngModel)]="myBirthday" useValueAsDate>
OR
<input type="date" formControlName="myBirthday" useValueAsDate>
It is also possible to fix the default implementation. The following selector would activate the feature magically.
// this selector changes the previous behavior silently and might break existing code
selector: 'input[type=date][formControlName],input[type=date][formControl],input[type=date][ngModel]'
But please be aware, that this might break existing implementations that rely of the old behaviour. So I would go for the opt-in version!
It's all on NPM
For your convenience, I created the project angular-data-value-accessor
on Github.
There is also a NPM package available:
npm install --save angular-date-value-accessor
Then import the module via NgModule:
// app.module.ts
import { DateValueAccessorModule } from 'angular-date-value-accessor';
@NgModule({
imports: [
DateValueAccessorModule
]
})
export class AppModule { }
Demo
Of course, there is a demo at: http://johanneshoppe.github.io/angular-date-value-accessor/
Suggestions? Feedback? Bugs? Please