Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Alec Irwin
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Alec Irwin

Creating An Incrementing Input Directive Inspired By Chrome Dev Tools In Angular 9.0.0-next.5

By Ben Nadel on

The Chrome Dev Tools are amazing. Fact! One of the features that makes the Chrome Dev Tools so enjoyable is that you can increment and decrement embedded values - like 7px - using the Up/Down Arrows on your keyboard. This keyboard-based incrementation is also a common feature of design tools like InVision Studio. I can't come up with a great use-case for this in a "normal" web application; but, I thought it would be fun to try and implement this type of incrementable input control using a Directive in Angular 9.0.0-next.5.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

There are two different and interesting aspects to this one feature. Primarily, it's the ability to increment and decrement a substring of an Input using the Up/Down arrow keys and the location of the user's cursor within the form control. But, secondarily, it's the applied Selection that highlights the altered substring after the new input value has been rendered.

The first part of this is algorithm is funky and uses some pattern-based string-parsing. But, the second part - the applied Selection - is where I really stumbled. When changing an input programmatically in Angular, a common approach is to leave the actual value as-is and simply emit an event - with the "proposed change" - that the calling context can either apply or ignore. The complexity here is, if we don't change the input value directly from within our Directive, how do we know if and when to update the Selection?

In my approach, I store the emitted value inside the directive. Then, I watch for changes in the Input control using the ngAfterContentChecked() life-cycle method; and, if the next change matches the pending change, I assume that this is the emitted change being piped back into the view-model. And, as such, I apply the Selection to the rendered form control.

With that said, let's take a look at how this Directive can be used. To demonstrate, I've applied the incrementingInput directive to an Input that uses [(value)]; and, to another input that uses [(ngModel)]. The Directive works by listening for the keyboard events and emitting a (valueChange) event:

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

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

@Component({
	selector: "app-root",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<h3>
			Using <code>[(value)]</code>
		</h3>

		<p>
			<!--
				When using [value], we can just use the "box of bananas" syntax to
				implicitly catch the (valueChange) event and pipe it back into the value
				property binding.
			-->
			<input
				type="text"
				incrementingInput
				[(value)]="value"
			/>
		</p>

		<h3>
			Using <code>[(ngModel)]</code> And <code>(valueChange)</code>
		</h3>

		<p>
			<!--
				When using ngModel to control the input, we have to explicitly catch the
				(valueChange) event for the increment and then pipe it back into the
				view-model where ngModel will be able to apply it the input control.
			-->
			<input
				type="text"
				[(ngModel)]="value"
				incrementingInput
				(valueChange)="( value = $event )"
			/>
		</p>
	`
})
export class AppComponent {

	public value: string = "box-shadow: 3px 2px 2px rgba( 0, 0, 0, 0.2 )";

}

As you can see, with the input that uses [(value)], the emitted (valueChange) event is implicitly piped back into the [value] property binding. With the [(ngModel)] version, however, nothing consumes the (valueChange) event explicitly. As such, I have to add a (valueChange) event binding that explicitly pipes the emitted value back into the ngModel view-model.

Now, if we run this Angular code in the browser, we get the following output:

Incrementing input directive used to increment embedded pixel values in Angular 9.0.0-next.5.

As you can see, if I hit the ArrowUp or ArrowDown keys on the keyboard, while my cursor is over an embedded numeric substring, the Input control will update to include the incremented value. And, if I hold down the Shift key while hitting the Arrow key, the value will increment (or decrement) by a value of 10.

Now that we see what this Angular Directive is going, let's take a look at how it works. As I stated before, it's an attribute directive that triggers on the selector, [incrementingInput]. This directive has two parts, the handleKeydown() method that calculates and emits the change; and, the ngAfterContentChecked() life-cycle method that checks to see if an updated Selection should be applied to the rendered form control:

// Import the core angular services.
import { Directive } from "@angular/core";
import { ElementRef } from "@angular/core";
import { EventEmitter } from "@angular/core";
import { SimpleChanges } from "@angular/core";

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

@Directive({
	selector: "input[incrementingInput]",
	outputs: [ "valueChange" ],
	host: {
		"(keydown.ArrowUp)": "handleKeydown( $event )",
		"(keydown.Shift.ArrowUp)": "handleKeydown( $event )",
		"(keydown.ArrowDown)": "handleKeydown( $event )",
		"(keydown.Shift.ArrowDown)": "handleKeydown( $event )"
	}
})
export class IncrementingInputDirective {

	public valueChange: EventEmitter<string>;

	private elementRef: ElementRef;
	private pendingSelectionEnd: number;
	private pendingSelectionStart: number;
	private pendingValue: string;
	private valueSnapshot: string;

	// I initialize the directive.
	constructor( elementRef: ElementRef ) {

		this.elementRef = elementRef;

		this.valueChange = new EventEmitter();
		// As the user increments a substring of the value, we want to be able to expand
		// the input Selection to contain the affected characters. In order to do this,
		// without mutating the Input directly, we have to keep track of the emitted
		// value so that we can test it against the rendered value of the input after
		// change-detection as occurred.
		this.pendingSelectionEnd = -1;
		this.pendingSelectionStart = -1;
		this.pendingValue = "";
		this.valueSnapshot = "";

	}

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

	// I handle the ArrowUp and ArrowDown keydown events on the input. 
	public handleKeydown( event: KeyboardEvent ) : void {

		// Get the current state of the input control.
		var value = this.elementRef.nativeElement.value;
		var start = this.elementRef.nativeElement.selectionStart;
		var end = start;

		// Based on the current selectionStart, we're going to spread out in both
		// directions, consuming characters that meet the following RegExp pattern. As we
		// do this, we have to use a pattern that is lenient enough to get partial
		// matches that wouldn't be valid on their own; but, that will become valid as we
		// gather more characters (ex, "-" that precedes "-4").
		var pattern = /^(-|[0-9])[0-9]*$/i;

		// Gather characters to the RIGHT of the selection start.
		while (
			( end < value.length ) &&
			pattern.test( value.slice( start, ( end + 1 ) ) )
			) {

			end++;

		}

		// Gather characters to the LEFT of the selection start.
		while (
			( start > 0 ) &&
			pattern.test( value.slice( ( start - 1 ), end ) )
			) {

			start--;

		}

		// If we couldn't gather any characters that matched the pattern, then the cursor
		// isn't near any incrementable value.
		if ( start === end ) {

			return;

		}

		// At this point, we should have located a substring that contains a numeric
		// value. Let's parse it as a number so we can start to manipulate it.
		var selectionValue = ( value.slice( start, end ) * 1 );

		// Our RegExp pattern should have constrained our search to numeric characters;
		// but, as a safe-guard, let's just confirm that the parsed value is actually
		// numeric before we start to use it a Number.
		if ( isNaN( selectionValue ) ) {

			return;

		}

		// If we've made it this far, we know that we have a selection and that the
		// characters within that selection have been parsed into a numeric value. This
		// means that we're going to apply custom behavior in response to this keyboard
		// event, which means we now need to cancel the default behavior of the event.
		event.preventDefault();

		var increment = this.getIncrementFromEvent( event );
		var prefix = value.slice( 0, start );
		var suffix = value.slice( end );
		var incrementedSelectionValue = ( selectionValue + increment ).toString();

		// Before we emit the (valueChange) event, we need to keep track of the proposed
		// value and its selection boundaries so that we can figure out (if at all) to
		// affect the selection state after the Directive content has been checked.
		this.pendingSelectionStart = start;
		this.pendingSelectionEnd = ( start + incrementedSelectionValue.length );
		this.pendingValue = ( prefix + incrementedSelectionValue + suffix );
		this.valueSnapshot = value;

		// Emit proposed value alteration.
		this.valueChange.emit( this.pendingValue );

	}


	// I get called after the projected content has been checked for changes.
	public ngAfterContentChecked() : void {

		var element = this.elementRef.nativeElement;

		// If we have a pending value based on a proposed increment, let's check to see
		// if the view has been updated to match the proposal. If so, we can reinstate
		// the selection of the incremented substring.
		if ( this.pendingValue && ( this.valueSnapshot !== element.value ) ) {

			// Only update the selection if the rendered value matches the proposed
			// value. If it does not, then the calling context applied an unrelated
			// change to the view-model.
			if ( element.value === this.pendingValue ) {

				element.selectionStart = this.pendingSelectionStart;
				element.selectionEnd = this.pendingSelectionEnd;

			}

			// Clear out the pending value - this will only give the view one chance to
			// update the view-model in accordance with our emit.
			this.pendingValue = "";
			this.pendingSelectionStart = -1;
			this.pendingSelectionEnd = -1;
			this.valueSnapshot = "";

		}

	}

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

	// I determine which increment to use based on the given keyboard event.
	private getIncrementFromEvent( event: KeyboardEvent ) : number {

		if ( event.key === "ArrowUp" ) {

			return( event.shiftKey ? 10 : 1 );

		} else {

			return( event.shiftKey ? -10 : -1 );

		}

	}

}

The algorithm is far from perfect. Particularly because it's unclear in my head what the rules aught to be, especially around things like fractional numbers. So, for this version, I'm just looking for integers that may or may not be negative. And, anything that looks like a decimal value is treated like an integer that happens to come after a period.

This Angular Directive was a lot of fun to write; especially because it forced me to think about how I can emit values and react to changes without manipulating the Document Object Model (DOM) directly. This was also the first time - I think - that I ever augmented an input's behavior when that input was already being augmented by the ngModel directive. I think my approach was clean-enough. I don't love the way that I'm applying the Selection; but, I can't think a better approach at the moment (with my skill-set).


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.