Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Manil Chowdhury
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Manil Chowdhury

Trying To Enable Tabbing In Textareas In Angular 2 Beta 17

By
Published in

Last week, I learned two really important things by digging into the Angular 2 source code. First, I realized that Angular 2 has native support for key combinations. Second, I discovered that the JavaScript DOM API supports a synchronous "input" event on input elements (and that this event is what powers some of the underlying ngModel mechanics). While both of these features are powerful, they don't necessarily play well together. Meaning, if you start implementing custom key combinations, you might inadvertently break the "input" event which, in turn, might break your ngModel bindings. To explore this kind of event interaction, I wanted to see if I could enable tabbing within a textarea element while still maintaining the expected ngModel behavior in Angular 2 Beta 17.

Run this demo in my JavaScript Demos project on GitHub.

On their own, HTML textarea elements don't support tabbing. The default behavior is such that when you hit the tab key, while focused on a textarea, the browser will push focus to the next focus-enabled element (such as the next form field). To override this, we have to listen for the tab-key event and prevent the default event behavior:

event.preventDefault()

This gives us an opportunity to programmatically update the textarea in response to the key combination. However, by preventing the default behavior of the tab-key event, we also prevent the native "input" event from being triggered. And, since programmatically changing an input or textarea value won't inherently trigger any subsequent events, no "input" event will be emitted in response to our value override.

As such, if we want to ensure that features like ngModel continue to work in conjunction with our custom key-combination handling, we have to programmatically trigger an "input" event after we alter the textarea's value. Doing so requires a little cross-browser finagling:

// I dispatch a custom (input) event.
function dispatchInputEvent() {

	var bubbles = true;
	var cancelable = false;

	// IE (shakes fist) uses some other kind of event initialization.
	// As such, we'll default to trying the "normal" event generation
	// and then fallback to using the IE version.
	try {

		var inputEvent = new CustomEvent(
			"input",
			{
				bubbles: bubbles,
				cancelable: cancelable
			}
		);

	} catch ( error ) {

		var inputEvent = document.createEvent( "CustomEvent" );

		inputEvent.initCustomEvent( "input", bubbles, cancelable );

	}

	elementRef.nativeElement.dispatchEvent( inputEvent );

}

Honestly, I almost never dispatch a "native event" in my JavaScript, so I am not entirely sure that the above code is accurate. Or, how compatible it is with older browser versions. For the purposes of this demo, it seems to work in Chrome, Firefox, Safari, and Internet Explorer (IE) 11. Though, of course, the "input" event (that we're trying to facilitate) only dates back to IE9; so, we're already limited in how far back we have to support.

At this point, the rest of the code - between the call to .preventDefault() and the call to .dispatchEvent() - is just an implementation detail regarding our programmatic mutration of the textarea value. But, it's quite a non-trivial detail at that. Basically, it's a bunch of string manipulation and coordination between changing cursor offsets. In fact, getting this "implementation detail" right is why it took me 3 days to get this blog post done.

That said, let's take a look at the code. In the following demo, I have a single textarea element that is using the tabEnabled attribute directive in order to support tabbing. I'm then binging to the (ngModelChange) event in order to demonstrate that the ngModel functionality has been kept in tact.

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

	<title>
		Trying To Enable Tabbing In Textareas In Angular 2 Beta 17
	</title>

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

	<h1>
		Trying To Enable Tabbing In Textareas In Angular 2 Beta 17
	</h1>

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

	<!-- Load demo scripts. -->
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/17/es6-shim.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/17/Rx.umd.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/17/angular2-polyfills.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/17/angular2-all.umd.js"></script>
	<!-- AlmondJS - minimal implementation of RequireJS. -->
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/17/almond.js"></script>
	<script type="text/javascript">

		// Defer bootstrapping until all of the components have been declared.
		requirejs(
			[ /* Using require() for better readability. */ ],
			function run() {

				ng.platform.browser.bootstrap( require( "App" ) );

			}
		);


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


		// I provide the root application component.
		define(
			"App",
			function registerApp() {

				// Configure the App component definition.
				ng.core
					.Component({
						selector: "my-app",
						directives: [ require( "TabEnabled" ) ],

						// In our textarea, we are using the tabEnabled directive to add
						// adding custom key-press behavior; and, we're doing so in a way
						// that needs to play nicely with the events that drive ngModel.
						// That's one of the hardest parts of feature augmentation - not
						// just the base implementation; but, implementing it in a way
						// that doesn't break other "expected" behavior.
						template:
						`
							<textarea
								[(ngModel)]="content"
								(ngModelChange)="logCurrentValue()"
								tabEnabled
								autofocus>
							</textarea>
						`
					})
					.Class({
						constructor: AppController
					})
				;

				return( AppController );


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

					var vm = this;

					// I hold the content of the textarea. We're using the ngModel
					// directive to facilitate two-way data binding. As such, the user's
					// edits will automatically be pushed back into this property.
					vm.content = "Hello world.";

					// Expose the public methods.
					vm.logCurrentValue = logCurrentValue;


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


					// I log the current value (that has been synchronized by ngModel).
					function logCurrentValue() {

						console.log( "(ngModelChange):", vm.content );

					}

				}

			}
		);


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


		// I provide a directive that enables tabbing and shift-tabbing in a textarea.
		define(
			"TabEnabled",
			function registerTabEnabled() {

				// Configure the TabEnabled directive definition.
				ng.core
					.Directive({
						selector: "textarea[tabEnabled]",
						host: {
							"(keydown.tab)": "handleTab( $event )",
							"(keydown.shift.tab)": "handleShiftTab( $event )",
							"(keydown.enter)": "handleEnter( $event )"
						}
					})
					.Class({
						constructor: TabEnabledController
					})
				;

				TabEnabledController.parameters = [
					new ng.core.Inject( ng.core.ElementRef )
				];

				return( TabEnabledController );


				// I control the TabEnabled directive.
				function TabEnabledController( elementRef ) {

					var vm = this;

					// I hold the tab and newline implementations.
					// --
					// TODO: Wire "tab" into input binding so it can be dynamically
					// defined (such as if someone went CRAZY and wanted spaces).
					var newline = getNewlineImplementation();
					var tab = "\t";

					// Expose the public methods.
					vm.handleEnter = handleEnter;
					vm.handleShiftTab = handleShiftTab;
					vm.handleTab = handleTab;


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


					// I handle the Enter key combination.
					function handleEnter( event ) {

						event.preventDefault();

						// If we end up changing the textarea value, we need to dispatch
						// a custom (input) event so that we play nicely with other
						// directives (like ngModel) and event handlers.
						if ( setConfig( insertEnterAtSelection( getConfig() ) ) ) {

							dispatchInputEvent();

						}

					}


					// I handle the Shift+Tab key combination.
					function handleShiftTab( event ) {

						event.preventDefault();

						// If we end up changing the textarea value, we need to dispatch
						// a custom (input) event so that we play nicely with other
						// directives (like ngModel) and event handlers.
						if ( setConfig( removeTabAtSelection( getConfig() ) ) ) {

							dispatchInputEvent();

						}

					}


					// I handle the Tab key combination.
					function handleTab( event ) {

						event.preventDefault();

						// If we end up changing the textarea value, we need to dispatch
						// a custom (input) event so that we play nicely with other
						// directives (like ngModel) and event handlers.
						if ( setConfig( insertTabAtSelection( getConfig() ) ) ) {

							dispatchInputEvent();

						}

					}


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


					// I dispatch a custom (input) event.
					function dispatchInputEvent() {

						var bubbles = true;
						var cancelable = false;

						// IE (shakes fist) uses some other kind of event initialization.
						// As such, we'll default to trying the "normal" event generation
						// and then fallback to using the IE version.
						try {

							var inputEvent = new CustomEvent(
								"input",
								{
									bubbles: bubbles,
									cancelable: cancelable
								}
							);

						} catch ( error ) {

							var inputEvent = document.createEvent( "CustomEvent" );

							inputEvent.initCustomEvent( "input", bubbles, cancelable );

						}

						elementRef.nativeElement.dispatchEvent( inputEvent );

					}


					// I find the index of the line-start that contains the given offset.
					function findStartOfLine( value, offset ) {

						var delimiter = /[\r\n]/i;

						for ( var i = ( offset - 1 ) ; i >= 0 ; i-- ) {

							if ( delimiter.test( value.charAt( i ) ) ) {

								return( i + 1 );

							}

						}

						return( 0 );

					}


					// I get the current selection and value configuration for the
					// textarea element.
					function getConfig() {

						var element = elementRef.nativeElement;

						return({
							value: element.value,
							start: element.selectionStart,
							end: element.selectionEnd
						});

					}


					// I calculate and return the newline implementation. Different
					// operating systems and browsers implement a "newline" with different
					// character combinations.
					function getNewlineImplementation() {

						var fragment = document.createElement( "textarea" );
						fragment.value = "\r\n";

						return( fragment.value );

					}


					// I apply the Enter key combination to the given configuration.
					function insertEnterAtSelection( config ) {

						var value = config.value;
						var start = config.start;
						var end = config.end;

						var leadingTabs = value
							.slice( findStartOfLine( value, start ), start )
							.match( new RegExp( ( "^(?:" + tab + ")+" ), "i" ) )
						;

						var tabCount = leadingTabs
							? leadingTabs[ 0 ].length
							: 0
						;

						var preDelta = value.slice( 0, start );
						var postDelta = value.slice( start );
						var delta = ( newline + repeat( tab, tabCount ) );

						return({
							value: ( preDelta + delta + postDelta ),
							start: ( start + delta.length ),
							end: ( end + delta.length )
						});

					}


					// I apply the Tab key combination to the given configuration.
					function insertTabAtSelection( config ) {

						var value = config.value;
						var start = config.start;
						var end = config.end;

						var deltaStart = ( start === end )
							? start
							: findStartOfLine( value, start )
						;
						var deltaEnd = end;
						var deltaValue = value.slice( deltaStart, deltaEnd );

						var preDelta = value.slice( 0, deltaStart );
						var postDelta = value.slice( deltaEnd );

						var replacement = deltaValue.replace( new RegExp( ( "(^|" + newline + ")" ), "g" ), ( "$1" + tab ) );

						var newValue = ( preDelta + replacement + postDelta );
						var newStart = ( start + tab.length );
						var newEnd = ( end + ( replacement.length - deltaValue.length ) );

						return({
							value: newValue,
							start: newStart,
							end: newEnd
						});

					}


					// I apply the Shift+Tab key combination to the given configuration.
					function removeTabAtSelection( config ) {

						var value = config.value;
						var start = config.start;
						var end = config.end;

						var deltaStart = findStartOfLine( value, start )
						var deltaEnd = end;
						var deltaValue = value.slice( deltaStart, deltaEnd );
						var deltaHasLeadingTab = ( deltaValue.indexOf( tab ) === 0 );

						var preDelta = value.slice( 0, deltaStart );
						var postDelta = value.slice( deltaEnd );

						var replacement = deltaValue.replace( new RegExp( ( "^" + tab ), "gm" ), "" );

						var newValue = ( preDelta + replacement + postDelta );
						var newStart = deltaHasLeadingTab
							? ( start - tab.length )
							: start
						;
						var newEnd = ( end - ( deltaValue.length - replacement.length ) );

						return({
							value: newValue,
							start: newStart,
							end: newEnd
						});

					}


					// I repeat the given string the given number of times.
					function repeat( value, count ) {

						return( new Array( count + 1 ).join( value ) );

					}


					// I apply the given config to the textarea and return a flag
					// indicating as to whether or not any changes were precipitated.
					function setConfig( config ) {

						var element = elementRef.nativeElement;

						// If the value hasn't actually changed, just return out. There's
						// no need to set the selection if nothing changed.
						if ( config.value === element.value ) {

							return( false );

						}

						element.value = config.value;
						element.selectionStart = config.start;
						element.selectionEnd = config.end;

						return( true );

					}

				}

			}
		);

	</script>

</body>
</html>

I tried to keep the complexity of the directive isolated within a few methods so that the flow of control was easier to see. And, if you look at each of the key-event handlers, you can clearly see that the order of operations is:

  • Prevent default behavior for key stroke.
  • Mutate the textarea value as needed.
  • Emit "input" event.

The emitted "input" event is then observed by the [(ngModel)] directive, which pushes our textarea changes back into our view model. And, when we log out our view model, we can see that everything is copacetic:

Creating a tab-enabled textarea directive in Angular 2 Beta 17.

As you can see, our tab-enabled textarea value is immediately consumed by the ngModel directive which makes it available in our view model. Even though we overrode the native behavior of the tab key, we upheld the expectation of the "input" event and kept everything running smoothly.

When you start messing with the DOM (Document Object Model), you start running into a number of cross-browser compatibility issues. I have no doubt that parts of this code probably don't work in all versions of IE, for example. But, the point of this post was really to explore the interplay between key-combination overrides and input events. And, doing so in a way that doesn't break the expected behavior in Angular 2 Beta 17.

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