Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at CFUNITED 2008 (Washington, D.C.) with: Hal Helms
Ben Nadel at CFUNITED 2008 (Washington, D.C.) with: Hal Helms@halhelms )

Creating An Inline Auto-Complete Directive Using NgModel And A Control Value Accessor In Angular 7.2.15

By Ben Nadel on

A few weeks ago, I demonstrated that you could create a custom ControlValueAccessor in Angular in order to change the behavior of File Inputs such that NgModel would emit the selected Files objects rather than just the file paths. More than anything, this was a testament to how powerful and flexible the NgModel concept is in Angular. As another fun experiment in modifying the default NgModel behavior in Angular 7.2.15, I wanted to see if I could create a custom ControlValueAccessor for Text inputs that would allow me to provide an inline auto-complete suggestion as the user entered their text.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

For this proof-of-concept, I wanted to be able to take a normal NgModel instance and provide it with an Array of string values. These string values would then be used to apply an "auto-complete" suggestion to the Input element as the user was typing. So, for example, in this App component, I am taking an array of predefined strings and providing them to the Input using [ngModelSuggestions]:

// Import the core angular services.
import { Component } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Component({
	selector: "my-app",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<input
			name="value"
			type="text"
			[(ngModel)]="form.value"
			
			[ngModelSuggestions]="suggestions"
			
			autocomplete="off"
			autofocus
			class="input"
		/>

		<p>
			<strong>NgModel Value:</strong> {{ form.value }}
		</p>

		<h2>
			Suggestions:
		</h2>

		<ul>
			<li *ngFor="let suggestion of suggestions">
				{{ suggestion }}
			</li>
		</ul>
	`
})
export class AppComponent {

	public form: {
		value: string;
	};
	public suggestions: string[];

	// I initialize the app component.
	constructor() {

		this.form = {
			value: ""
		};
		this.suggestions = [
			"I like to move it move it",
			"I like big butts and I cannot lie",
			"I like it like that",
			"I like turtles",
			"I like the way you move",
			"I love lamp",
			"I love the way you make me feel",
			"I love that thing you do"
		];

	}

}

As you can see, this use of ngModel looks completely normal; except for the additional binding of the [ngModelSuggestions] input to my array of strings. The existence of this [ngModelSuggestions] attribute is, behind the scenes, causing a different ControlValueAccessor to be applied to the Input. And, it's this custom ControlValueAccessor that implements our auto-complete functionality. So, when the user goes to type into the Input element, we get the following output:

Demonstration of user typing into auto-complete input and seeing suggestions in Angular 7.2.15.

As you can see, as the user enters text into the Input element, we are suggesting possible auto-complete opportunities. Notice, however, that the ngModel value, echoed below the Input element, doesn't include these auto-complete portions - they are known only to the ControlValueAccessor and do not corrupt the view-model values.

This whole thing works by overriding the ControlValueAccessor being provided to NgModel for this particular Input. It does this with the following Directive selector:

"input[type=text][ngModel][ngModelSuggestions]"

As you can see, it's the existence of the [ngModelSuggestions] attribute that allows us to target this type of Input element, and this type of Input element only. In this way, we don't change the default behavior of any other NgModel instance - only the ones specifically targeted by this attribute.

Normally, the ControlValueAccessor for an Input acts as a mere conduit between NgModel and the underlying Element. However, in this case, the underlying Element can't represent the source-of-truth for the NgModel value. After all, it contains auto-complete text that isn't part of the view-model. As such, the ControlValueAccessor must store the source-of-truth as an internal property; and then, render the underlying Input element with the augmented text values.

Here's my implementation of the custom ControlValueAccessor. I tried to add plenty of comments that detail my approach. The main point-of-focus is the handleInput() method - the rest of the methods just support the general Input interaction workflows:

// 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=text][ngModel][ngModelSuggestions]",
	inputs: [
		"suggestions: ngModelSuggestions"
	],
	host: {
		"(blur)": "handleBlur( $event )",
		"(input)": "handleInput( $event )",
		"(keydown)": "handleKeydown( $event )",
		"(mousedown)": "handleMousedown( $event )"
	},
	// 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.
	providers: [
		{
			provide: NG_VALUE_ACCESSOR,
			useExisting: NgModelSuggestionsDirective,
			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 NgModelSuggestionsDirective implements ControlValueAccessor {

	public suggestions: string[];

	private activeSuggestion: string | null;
	private elementRef: ElementRef;
	private onChangeCallback: Function;
	private onTouchedCallback: Function;
	private value: string;

	// I initialize the ng-model-suggestions value accessor and directive.
	constructor( elementRef: ElementRef ) {

		this.elementRef = elementRef;

		// CAUTION: These will be called by Angular when rendering the View.
		this.onChangeCallback = noop;
		this.onTouchedCallback = noop;

		this.activeSuggestion = null;
		this.suggestions = [];

		// Normally, the Control Value Accessor just acts as a conduit for the underlying
		// Input element. However, in this case, since we are going to be adding extra
		// text-data to the Input, we need to store an internal "value" here as the
		// source of truth for what the NgModel value contains.
		this.value = "";

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I handle the blur event on the Input element.
	public handleBlur( event: Event ) : void {

		this.clearActiveSuggestion();
		this.onTouchedCallback();

	}


	// I handle the input event on the Input element.
	public handleInput( event: KeyboardEvent ) : void {

		var previousValue = this.value;
		var newValue = this.elementRef.nativeElement.value;
		var selectionStart = this.elementRef.nativeElement.selectionStart;

		// In order to create a more intuitive user experience, we're only going to
		// suggest text if the user appears to be "continuing" the previous value.
		// Meaning, they are actively typing a single cohesive value. This will prevent
		// us from trying to suggest something while the user is hitting BACKSAPCE, which
		// creates a confusing experience.
		if ( newValue.startsWith( previousValue ) ) {

			// Similar to the constraint above, we only want to suggest text if the
			// user's cursor is at the end of the text value. Again, we're trying to
			// cater to a "continuation" of the previous value.
			if ( selectionStart === newValue.length ) {

				if ( this.activeSuggestion = this.getFirstMatchingSuggestion( newValue ) ) {

					// NOTE: We are using only the ending portion of the suggestion,
					// rather than applying the suggestion in its entirety, so that we
					// don't override the key-casing of the existing user-provided text.
					var suggestionSuffix = this.activeSuggestion.slice( selectionStart );

					// NOTE: We are changing the value of the INPUT ELEMENT; however, we
					// are NOT CHANGING the "source of truth" value that we have stored
					// in the class.
					this.elementRef.nativeElement.value = ( newValue + suggestionSuffix );

					// After we update the Input element, we want to select the portion
					// of the text that makes up the suggestion. This way, as the user
					// continues to type, the selected text will naturally be removed.
					this.elementRef.nativeElement.selectionStart = selectionStart;
					this.elementRef.nativeElement.selectionEnd = this.activeSuggestion.length;

				}

			}

		}

		this.onChangeCallback( this.value = newValue );

	}


	// I handle the keydown event on the Input element.
	public handleKeydown( event: KeyboardEvent ) : void {

		// If there's no active suggestion being applied to the Input element, then we
		// don't care about any key-events. We can handle any subsequent (input) events
		// that are triggered by text-changes.
		if ( ! this.activeSuggestion ) {

			return;

		}

		// If the key event represents an acceptance of the active suggestion, commit the
		// suggestion to the current value and emit the change.
		if ( this.isAcceptSuggestionEvent( event ) ) {

			event.preventDefault();

			// Save the Input value back into our internal "source of truth" value.
			this.value = this.elementRef.nativeElement.value;
			this.elementRef.nativeElement.selectionStart = this.value.length;
			this.elementRef.nativeElement.selectionEnd = this.value.length;
			this.activeSuggestion = null;

			this.onChangeCallback( this.value );

		// Any other key should remove the active suggestion entirely.
		} else {

			this.clearActiveSuggestion();

		}

	}


	// I handle the mousedown event on the Input element.
	public handleMousedown( event: Event ) : void {

		// A mouse-action may alter the "selection" within the current Input element. As
		// such, let's remove any active suggestion so that we don't accidentally commit
		// it to the Input value.
		this.clearActiveSuggestion();

	}


	// I register the callback to be invoked when the value of the text 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 text Input element has been
	// "touched" by the user.
	public registerOnTouched( callback: Function ) : void {

		this.onTouchedCallback = callback;

	}


	// I set the disabled property of the text Input element.
	public setDisabledState( isDisabled: boolean ) : void {

		this.elementRef.nativeElement.disabled = isDisabled;

	}


	// I set the value property of the text Input element.
	public writeValue( value: string ) : void {

		// NOTE: This normalization step is copied from the default Accessory, which
		// seems to be protecting against null values.
		var normalizedValue = ( value || "" );

		if ( this.value !== normalizedValue ) {

			this.value = this.elementRef.nativeElement.value = normalizedValue;
			this.activeSuggestion = null;

		}

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I remove any active suggestion from the Input element.
	private clearActiveSuggestion() : void {

		if ( this.activeSuggestion ) {

			this.activeSuggestion = null;
			this.elementRef.nativeElement.value = this.value;

		}

	}


	// I get the first matching suggestion for the given prefix.
	private getFirstMatchingSuggestion( prefix: string ) : string | null {

		var normalizedPrefix = prefix.toLowerCase();

		for ( var suggestion of this.suggestions ) {

			// Skip over any suggestions that don't have enough content to matter.
			if ( suggestion.length <= normalizedPrefix.length ) {

				continue;

			}

			if ( suggestion.toLowerCase().startsWith( normalizedPrefix ) ) {

				return( suggestion );

			}

		}

		// If we made it this far, no suggestions matched the given prefix.
		return( null );

	}


	// I determine if the given keyboard event represents a desire by the user to
	// accept the currently active suggestion.
	private isAcceptSuggestionEvent( event: KeyboardEvent ) : boolean {

		return(
			( event.key === "Tab" ) ||
			( event.key === "ArrowRight" ) || 
			( event.key === "ArrowDown" )
		);

	}

}

As you can see, when the user changes the value of the Input element, we examine the text and see if it corresponds to any of the known suggestions. If it does, we then add the rest of the suggestion text to the Input element and highlight / select the additional text such that any subsequent text entry, by the user, will automatically remove the suggested portion:

if ( this.activeSuggestion = this.getFirstMatchingSuggestion( newValue ) ) {

	// NOTE: We are using only the ending portion of the suggestion,
	// rather than applying the suggestion in its entirety, so that we
	// don't override the key-casing of the existing user-provided text.
	var suggestionSuffix = this.activeSuggestion.slice( selectionStart );

	// NOTE: We are changing the value of the INPUT ELEMENT; however, we
	// are NOT CHANGING the "source of truth" value that we have stored
	// in the class.
	this.elementRef.nativeElement.value = ( newValue + suggestionSuffix );

	// After we update the Input element, we want to select the portion
	// of the text that makes up the suggestion. This way, as the user
	// continues to type, the selected text will naturally be removed.
	this.elementRef.nativeElement.selectionStart = selectionStart;
	this.elementRef.nativeElement.selectionEnd = this.activeSuggestion.length;

}

I feel like this is some exciting stuff right here! Hopefully, it brings to light how well thought-out the NgModel and two-way data-binding functionality in Angular is. I mean, really?! In other frameworks, you know you'd have to create some entirely new custom component just to implement this type of behavior; but, in Angular, it's literally as simple as changing the ControlValueAccessor that's being provided for the given Input element.

Hats off to the Angular team!



Reader Comments

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.