Skip to content

Instantly share code, notes, and snippets.

@bennadel
Created September 9, 2019 12:24
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bennadel/26f6baa7bed214d2074156e247967cfb to your computer and use it in GitHub Desktop.
Save bennadel/26f6baa7bed214d2074156e247967cfb to your computer and use it in GitHub Desktop.
Creating An Incrementing Input Directive Inspired By Chrome Dev Tools In Angular 9.0.0-next.5
// 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 )";
}
// 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 );
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment