Skip to main content
Ben Nadel at CFUNITED 2008 (Washington, D.C.) with: Yaron Kohn
Ben Nadel at CFUNITED 2008 (Washington, D.C.) with: Yaron Kohn

Experimenting With Input Cursor Positions In Angular 2 Beta 3

By on

The other day, when I was experimenting with the uni-directional data flow implemented by Angular 2 Beta 3, I noticed something odd about input cursor positions. When using ngModel, the cursor position of an input was maintained as I typed. However, when I switched to using explicit data flow mechanics, the cursor position was no longer maintained. Then, by sheer chance, I stumbled upon a very interesting case in which applying a value-bound directive to an input seemed to demote the input to a sort of Frankensteinian "uncontrolled component" (to use ReactJS terminology) which allowed me to maintain the cursor position as I edited the input value.

Run this demo in my JavaScript Demos project on GitHub.

Because this post is about cursor positions, it is primarily concerned with user experience (UX) and less so with data flow. As such, it might be more worthwhile to watch the video than it is to read the actual post. But, I'll try to do my best to explain what I think is happening.

Out of the box, we can implement uni-direction data flow on an input in Angular 2 by binding to both the value property and the input event:

  • [value]="myModel"
  • (input)="( myModel = $event.target.value )"

This listens for changes on the input and then pipes those changes back into the value property of the input. This sort of works; but, it forces the cursor to always appear at the end of the input. You might not notice until you try to edit an existing input value, at which point, your cursor is promptly forced to the last character after every keystroke.

NOTE: You can see this more clearly in the video.

I think this happens because the value property is being set on the element no matter what, regardless of whether or not the rendered value and the incoming value are the same. With ngModel, this doesn't happen because (I believe) ngModel only pushes the model into the view if they are not "loosely identical."

Ok, so here's where it gets a little more interesting. By accident, I discovered that if I apply another directive to the input that exposes a "[value]" property, it fundamentally changes the default behavior of the property binding.

I believe what is happening is that, without my custom directive, the "[value]" property binding is applying to the native DOM (Document Object Model) node. However, when I do apply a custom directive that also exposes a "[value]" input, it breaks the connection between the property binding and the native DOM node. Now, when the "value" input is updated, those changes get piped into the directive binding and not into the DOM node binding.

In essence, this allows my custom directive to act as a sort of man-in-the-middle for the "[value]" property binding. Which, to some degree, allows me to re-implement some of the ngModel behavior by only pushing the model changes to the DOM when the model and the view represent different values.

By doing this, it means that I only set the underlying "value" property when the value-change originates from within the component. The byproduct of this is that I will never set the value mid-edit, which allows me to maintain the position of the cursor as I'm editing the input field.

<!doctype html>
<html>
<head>
	<meta charset="utf-8" />

	<title>
		Experimenting With Input Cursor Positions In Angular 2 Beta 3
	</title>

	<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body>

	<h1>
		Experimenting With Input Cursor Positions In Angular 2 Beta 3
	</h1>

	<my-app>
		Loading...
	</my-app>

	<!-- Load demo scripts. -->
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/3/es6-shim.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/3/Rx.umd.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/3/angular2-polyfills.min.js"></script>
	<!-- CAUTION: This demo does not work with the minified UMD code. -->
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/3/angular2-all.umd.js"></script>
	<!-- AlmondJS - minimal implementation of RequireJS. -->
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/3/almond.js"></script>
	<script type="text/javascript">

		// Defer bootstrapping until all of the components have been declared.
		// --
		// NOTE: Not all components have to be required here since they will be
		// implicitly required by other components.
		requirejs(
			[ "AppComponent" ],
			function run( AppComponent ) {

				ng.core.enableProdMode();

				ng.platform.browser.bootstrap( AppComponent );

			}
		);


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


		// I provide the root App component.
		define(
			"AppComponent",
			function registerAppComponent() {

				var MaintainInputCursor = require( "MaintainInputCursor" );

				// Configure the App component definition.
				ng.core
					.Component({
						selector: "my-app",

						// Comment this line out to see the default behavior of Inputs
						// that use uni-directional data flow.
						// --
						directives: [ MaintainInputCursor ],

						// Notice that we have two different inputs - one that uses
						// ngModel for the two-way data-binding sugar on top of the
						// one-way data flow; and, one that uses explicit bindings
						// to manage the flow of data into and out of the input.
						template:
						`
							<p>
								<input
									type="text"
									[(ngModel)]="form.name"
									placeholder="Name..."
								/>

								&mdash;&gt; {{ form.name }}
							</p>

							<p>
								<input
									#nickname
									type="text"
									[value]="form.nickname"
									(input)="applyNickname( nickname.value )"
									placeholder="Nickname..."
								/>

								&mdash;&gt; {{ form.nickname }}
							</p>

							<p>
								<a (click)="form.nickname = 'Bro-dog'">Set nickname</a>.
							</p>
						`
					})
					.Class({
						constructor: AppController
					})
				;

				return( AppController );


				// I control the App component.
				function AppController() {

					var vm = this;

					// I hold the form data for use with ngModel.
					vm.form = {
						name: "",
						nickname: ""
					};

					// Expose the public API.
					vm.applyNickname = applyNickname;


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


					// I receive the input-change events and apply them back to the view-
					// model, trying to maintain explicit uni-directional data flow.
					function applyNickname( newValue ) {

						vm.form.nickname = newValue;

						// If you discard the value emitted from the Input, you will
						// find out that the input acts somewhat like a "Controlled
						// Component" and somewhat like an "Uncontrolled Component"
						// (in ReactJS terminology).
						// --
						// vm.form.nickname = "Discarded...";

					}

				}

			}
		);


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


		// I get Angular to maintain the cursor position for inputs that are using
		// uni-directional data-flow.
		// --
		// MY THEORY: By applying an input directive that exposes a [value] binding,
		// we are breaking the connection between the view-model and the native DOM
		// element. Meaning, changes the [value] are now piped into the directive rather
		// than into the underlying Input DOM element. This allows the directive to act
		// as a proxy to the Input DOM element, applying value-changes only when the
		// value originates externally to the View.
		define(
			"MaintainInputCursor",
			function registerMaintainInputCursor() {

				// Configure the MaintainInputCursor directive.
				ng.core
					.Directive({
						selector: "input[value]:not([ngModel])",

						// CAUTION: Here, we are co-opting the use of the native "value"
						// property, which gives the directive control over how this
						// binding gets propagated down to the actual DOM rendering.
						inputs: [ "value" ],

						host: {
							"(input)": "trackValue( $event.target.value )"
						}
					})
					.Class({
						constructor: MaintainInputCursorController,

						// Define life-cycle events on the prototype so that they'll be
						// picked up at runtime.
						ngOnChanges: function noop() {}
					})
				;

				MaintainInputCursorController.parameters = [
					new ng.core.Inject( ng.core.Renderer ),
					new ng.core.Inject( ng.core.ElementRef )
				];

				return( MaintainInputCursorController );


				// I control the MaintainInputCursor directive.
				function MaintainInputCursorController( renderer, elementRef ) {

					var vm = this;

					// I hold the currently rendered value of the input.
					var renderedValue = null;

					// Expose the public methods.
					vm.ngOnChanges = ngOnChanges;
					vm.trackValue = trackValue;


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


					// I listen for changed on the input and update the input to reflect
					// the inputs as necessary (to maintain that one-way data flow).
					function ngOnChanges( event ) {

						var incomingValue = event.value.currentValue;

						// If the incoming value is not currently reflected in the state
						// of the input element, push the input value to the DOM.
						if ( incomingValue !== renderedValue ) {

							renderer.setElementProperty( elementRef.nativeElement, "value", incomingValue );

							renderedValue = incomingValue;

						}

					}


					// I track the rendered value of the input as the user interacts
					// with the DOM node.
					function trackValue( newValue ) {

						renderedValue = newValue;

					}

				}

			}
		);

	</script>

</body>
</html>

No matter what, when it comes to the one-way data flow, inputs in Angular 2 Beta 3 do not correspond to "Controlled Components" in ReactJS. Specifically, discarding an emitted change in Angular 2 does not create a "static" input value. Rather, it just means that the input value is never piped back into the model. But, I doubt this will ever cause a problem since the DOM is not the source of truth in an AngularJS application.

What started out as an exploration of cursor positions in Angular 2 actually ended up teaching me something fundamental about the interplay between native DOM properties and custom directive properties. Not knowing this is easily something that could have caused me a tremendous amount of stress while debugging. I am lucky that I just happened to happen across it.

Want to use code from this post? Check out the license.

Reader Comments

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel