Using NgModel With Input Type="File" And A Custom ControlValueAccessor In Angular 7.2.12
As I've been learning about how to upload files using the HttpClient service in Angular 7, one of the framework features that I've stumbled-over is the fact that, by default, the NgModel directive doesn't play very nicely with the File-Input form control. When you attach the [ngModel] directive to an input of type="file", Angular uses the DefaultValueAccessor. But, the DefaultValueAccessor only looks at the "value" property of the Input, not the "files" property. And, since I've been using the "files" property in my recent upload experiments, I wanted to see if I could create a custom ControlValueAccessor implementation for File-Inputs that synchronizes the "files" property with the view-model in Angular 7.2.12.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
It's been a couple of years since I last looked at creating custom ControlValueAccessors in Angular (not since the Angular 2.0.0 days). But, in essence, a ControlValueAccessor is just a class that acts an adapter for the NgModel Form Control directive - an adapter that understands how to push and pull values to and from a native HTML element reference.
So, in order to get the NgModel directive to look at a File Input's "files" property instead of its "value" property, we're need to create a ControlValueAccessor that translates the "(changes)" event into a File operation. But, we only want to apply this behavior-override to explicitly targeted inputs. As such, our ControlValueAccessor will need to be attached to a Directive that uses an attribute differentiator. And, for this exploration, I'm using the HTML attribute, "observeFiles":
<input type="file" [(ngModel)]="form.file" observeFiles />
Notice that this Input element has a binary attribute, "observeFiles". We can then use this attribute in our Directive Selector such that we only apply the override to a subset of File Inputs:
selector: "input[type=file][ngModel][observeFiles]"
To see this in action, let's work from the top-down. Meaning, let's look at our App component to see how we can use the NgModel directive to synchronize an input's "files" property with our component's view-model:
// Import the core angular services.
import { Component } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
template:
`
<form (submit)="processForm()">
<div class="field">
<label>Name:</label>
<input type="text" name="name" [(ngModel)]="form.name" />
</div>
<div class="field">
<label>Email:</label>
<input type="text" name="email" [(ngModel)]="form.email" />
</div>
<div class="field">
<label>Resume:</label>
<input type="file" name="resume" [(ngModel)]="form.resume" observeFiles />
</div>
<div class="actions">
<button type="submit">
Process Form
</button>
</div>
</form>
`
})
export class AppComponent {
public form: {
name: string;
email: string;
resume: File | null;
};
// I initialize the app component.
constructor() {
this.form = {
name: "",
email: "",
resume: null
};
}
// ---
// PUBLIC METHODS.
// ---
// I handle the form processing.
public processForm() : void {
console.group( "Form View-Model" );
console.log( "Name:", this.form.name );
console.log( "Email:", this.form.email );
console.log( "Resume:", this.form.resume );
console.groupEnd();
}
}
As you can see, we have almost nothing going on in this component. When the user submits the form, all we do is dump the Form's view-model out to the console. The only thing of note is that the "form.resume" value is of type File (or null). And, that we're using the NgModel directive with the "observeFiles" differentiator in order to access the File selected by the user.
Now, if we run this application, fill out the form, and submit it, we get the following console output:
As you can see, the "form.resume" value in our view-model contains the File selected by the user. This is because we have a custom ControlValueAccessor that is looking at the underlying "files" property, not the "value" property. Let's look at the ControlValueAccessor to see how this works.
A ControlValueAccessor is a little strange because it typically plays two unrelated roles at the same time:
- It is a Directive that targets an HTML element and defines a Providers collection.
- Is is an implementation of the ControlValueAccessor interface.
Essentially, it's a Directive that binds to an Element and then provides "itself" as the local implementation of the ControlValueAccessor interface. It does this by defining a "providers" collection in its own @Directive() decorator. This providers collection then overrides the NG_VALUE_ACCESSOR dependency-injection token, allowing our custom accessor to take precedence over the DefaultValueAccessor that is defined higher up in the dependency-injection hierarchy.
The mechanics of it are a bit weird. But, if you just "go with it", you will see that implementation is actually quite straightforward:
// Import the core angular services.
import { ControlValueAccessor } from "@angular/forms";
import { Directive } from "@angular/core";
import { ElementRef } from "@angular/core";
import { NG_VALUE_ACCESSOR } from "@angular/forms";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
var noop = () => {
// ...
};
@Directive({
selector: "input[type=file][ngModel][observeFiles]",
host: {
"(blur)": "onTouchedCallback()",
"(change)": "handleChange( $event.target.files )"
},
// By overriding the NG_VALUE_ACCESSOR dependency-injection token at this level of
// the component tree / hierarchical injectors, we are effectively replacing the
// DefaultValueAccessor for this Input Element context. As such, when Angular looks
// for a ControlValueAccessor implementation in the local dependency-injection
// container, it will only find this one - the one that looks at the "FileList"
// property, not the "value" property.
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: FileInputValueAccessor,
multi: true
// NOTE: I _believe_ that because I am using Ahead-of-Time (AoT) compiling in
// this demo, I don't need to use the forwardRef() wrapper to reference the
// Class that hasn't been defined yet.
}
]
})
export class FileInputValueAccessor implements ControlValueAccessor {
private elementRef: ElementRef;
private onChangeCallback: Function;
private onTouchedCallback: Function;
// I initialize the file-input value accessor service.
constructor( elementRef: ElementRef ) {
this.elementRef = elementRef;
// CAUTION: These will be called by Angular when rendering the View.
this.onChangeCallback = noop;
this.onTouchedCallback = noop;
}
// ---
// PUBLIC METHODS.
// ---
// I handle changes to the file input element value made by the user. Instead of
// pushing the "value," I push the underlying File or File[] references to the
// calling context.
public handleChange( files: FileList ) : void {
// If the input is set to allow MULTIPLE files, then always push an ARRAY of
// files through to the calling context (even if it is empty).
// --
// NOTE: We are using Array.from() in order to create a proper Array from the
// Array-like FileList collection.
if ( this.elementRef.nativeElement.multiple ) {
this.onChangeCallback( Array.from( files ) );
// If the input is set to allow only a SINGLE file, then let's only push the
// first file in the collection (or NULL if no file is available).
} else {
this.onChangeCallback( files.length ? files[ 0 ] : null );
}
}
// I register the callback to be invoked when the value of the file input element
// has been changed by the user.
public registerOnChange( callback: Function ) : void {
this.onChangeCallback = callback;
}
// I register the callback to be invoked when the file input element has been
// "touched" by the user.
public registerOnTouched( callback: Function ) : void {
this.onTouchedCallback = callback;
}
// I set the disabled property of the file input element.
public setDisabledState( isDisabled: boolean ) : void {
this.elementRef.nativeElement.disabled = isDisabled;
}
// I set the value property of the file input element.
// --
// CAUTION: Only a limited set of values can be used on file inputs.
public writeValue( value: any ) : void {
if ( value instanceof FileList ) {
this.elementRef.nativeElement.files = value;
} else if ( Array.isArray( value ) && ! value.length ) {
this.elementRef.nativeElement.files = null;
} else if ( value === null ) {
this.elementRef.nativeElement.files = null;
} else {
// Since we cannot manually construct a FileList instance, we have to ignore
// any attempt to push a non-FileList instance into the input.
if ( console && console.warn && console.log ) {
console.warn( "Ignoring attempt to assign non-FileList to input[type=file]." );
console.log( "Value:", value );
}
}
}
}
As you can see, the FileInputValueAccessor "directive" is also a service that implements the ControlValueAccessor interface. And, this implementation works by translating the "(changes)" event into an operation that exposes the underlying File references - instead of the "value" - to the NgModel directive. This is how our File instance ends up in the "form.resume" view-model.
When I see this in action, I am reminded of just how flexible and elegant Angular is. No messing around with unnecessary "higher order components" that hide the Input element from the developer. No having to bind to the "change" event. Just seamless and consistent two-way data-binding with the NgModel directive. All we have to do is create a reusable ControlValueAccessor and suddenly our File Input is synchronizing File references with our view-model in Angular 7.2.12.
Want to use code from this post? Check out the license.
Reader Comments
Wow. I was doing OK, until I got to the last 4 methods in your directive:
I don't see any references to these methods.
Maybe, I have missed something?
Also, looking at your selector, I presume that this directive will only take effect, if the following prerequisites are met:
So, I assume this directive wouldn't work on a reactive form?
@Charles,
Right, so this is the weird part about the way Value Accessors are defined. This doesn't have to be organized this way; but, this seems to be the way it is done in almost all cases that I've seen. The Class here is serving two roles:
selector
.ControlValueAccessor
interface.It's this second part that requires the four methods you mentioned. They are part of the
ControlValueAccessor
interface. And, what makes this even more confusing is that you can't see where these methods are being called from because they are being called from somewhere else inside of the FormsModule.To be honest, I can't even find the invocation point in the Angular source-code - ha ha. But, I think it would be OK to assume that the built-in
NgModel
directive injecting our Custom Value Accessor.Ok, so try to think about it like this, we have our Input tag:
... right now, there are two directives that are targeting this same element. The first is
[ngModel]
and the second is my custom one,[ngModel][type=file][observeFiles]
. My Class is also defining aproviders
collection. And, in that collection, it's providing its own instance as the token forNG_VALUE_ACCESSOR
.So, when the
[ngModel]
directive asks the Dependency-Injector for an instance ofNG_VALUE_ACCESSOR
, it is receiving the one that is being provided on the same element by my custom class.This is a really weird thing to try and hold in your held :D Hopefully I didn't make it more confusing!
@Charles,
As far as Reactive Forms, I don't know -- I still haven't played around with them yet. Still on my list.
Actually. This has explained it perfectly. Thanks...
I rarely leave comments but you just saved my day. So, a big thank you to you!!
@Gautier,
Woot woot, that's an awesome way to start my Friday :D Glad you found this helpful.
@All,
On a related note, I just posted a custom
ControlValueAccessor
for text-inputs that allows you to provide[ngModelSuggestions]
for an inline auto-complete behavior:www.bennadel.com/blog/3628-creating-an-inline-auto-complete-directive-using-ngmodel-and-a-control-value-accessor-in-angular-7-2-15.htm
Angular is so freaking flexible and powerful!
https://bennadel.github.io/JavaScript-Demos/demos/ng-model-accessor-file-input-angular7/
The above link doesn't work on MS edge, further i get file as Null and in Chrome I get file path as C:\Fakepath<file name>. However you get file as a reference in chrome, I tried making the project exactly as yours. Please let me know what am I doing incorrect.
@Mohammed,
I can confirm that Edge is not working for me either. I am getting a "readonly" assignment error on page load. And, I am getting the
null
reference you are referring to. I will see if I can figure out why this isn't working.@Ben,
We implemented your solution in production but unfortunately this does not work in IE11. Kindly let me know you are able fix it. Thanks in advance
@Privacy,
changed line #121 and #125 as below to fix the TypeError: Assignment to read-only properties is not allowed in strict mode at e.prototype.writeValue in IE and Edge browser
this.elementRef.nativeElement.setAttribute('files', null);
hope this will be useful to someone
If i reset the value to null and add the same file again it does not listen to onchange event can you help ?
Hi Ben, I really want to thank you from the core of my heart. You don't know how much relieved I'm feeling after reading this post, implementing it, and then seeing it work. Thank you a ton.
"YOU ARE MY HERO". A lot of good wishes to you from India.