Skip to main content
Ben Nadel at CFCamp 2023 (Freising, Germany) with: Raphael Schürholz
Ben Nadel at CFCamp 2023 (Freising, Germany) with: Raphael Schürholz

Submitting Forms With CMD+Enter In Alpine.js

By
Published in

Now that I've taken the Alpine JS Masterclass by Sofiullah Chowdhury on Udemy, I need to start considering ways in which I can use Alpine.js to progressively enhance (PE) my web experience. That is, how can I take an already functioning site and provide a more luxurious user experience (UX)? One PE technique that has become somewhat of a standard is using the key-combo CMD+Enter to submit forms while in a <textarea> element. Let's consider ways in which this can be done in Alpine.js 3.13.5.

1 - Vanilla Alpine.js

The most vanilla way to do this is to put all of the keydown and form-submission logic directly in the HTML using Alpine.js semantics. In this approach, we're going to use the @keydown bindings to listen for the CMD+Enter (Mac OS) and CTRL+Enter (Windows OS) events on the <textarea>; and then, use the $el and $refs magics to request the form submissions via the submit button:

<!doctype html>
<html lang="en">
<body>

	<form x-data method="post">
		<p>
			<strong>Journal Entry</strong>:<br />
			<textarea
				name="description"
				autofocus
				cols="30"
				rows="5"
				@keydown.meta.enter.prevent="$el.form.requestSubmit( $refs.submitButton )"
				@keydown.ctrl.enter.prevent="$el.form.requestSubmit( $refs.submitButton )"
			></textarea>
		</p>
		<p>
			<button x-ref="submitButton" type="submit" name="submitter" value="default">
				Add Entry
			</button>
		</p>
	</form>

	<script type="text/javascript" src="../vendor/alpine.3.13.5.min.js" defer></script>

</body>
</html>

Within the context of the DOM (Document Object Model), the $el magic refers to the current element; and the $refs magic refers to the collection of elements that have been identified by an x-ref attribute. In this case, we have an x-ref attribute on the submit button; which allows us to pass the DOM element reference to the .requestSubmit() method in our @keydown event bindings.

This approach works well. When the textarea is focused, using either CMD+Enter (on Mac OS) or CTRL+Enter (on Windows OS) will successfully submit the parent form. But, the HTML is a bit verbose. Let's see if we can clean it up.

2 - Encapsulated x-bind Definition

One possible refactoring is to move the @keydown bindings out of the HTML and into an x-bind abstraction. For this, we'll introduce an Alpine.js component - metaEnterSubmit() - that defines a data structure that encapsulates the key binding definitions:

<!doctype html>
<html lang="en">
<body>

	<form x-data method="post">
		<p>
			<strong>Journal Entry</strong>:<br />
			<textarea
				name="description"
				autofocus
				cols="30"
				rows="5"
				x-data="metaEnterSubmit( $refs.submitButton )"
				x-bind="keydownBindings"
			></textarea>
		</p>
		<p>
			<button x-ref="submitButton" type="submit" name="submitter" value="default">
				Add Entry
			</button>
		</p>
	</form>

	<script type="text/javascript" src="../vendor/alpine.3.13.5.min.js" defer></script>
	<script type="text/javascript">

		document.addEventListener(
			"alpine:init",
			function setupAlpineBindings() {

				Alpine.data( "metaEnterSubmit", MetaEnterSubmitController );

			}
		);

		function MetaEnterSubmitController( submitter ) {

			return {
				keydownBindings: {
					"@keydown.meta.enter.prevent": handleKeydown,
					"@keydown.ctrl.enter.prevent": handleKeydown
				}
			};

			function handleKeydown( event ) {

				this.$el.form.requestSubmit( submitter );

			}

		}

	</script>

</body>
</html>

In this approach, we're using the x-data directive to instantiate an instance of the metaEnterSubmit() component and attach it to the <textarea> element. When we instantiate this component, we're passing in, as a constructor argument, the submit button reference. The metaEnterSubmit() constructor then returns a data structure that contains a sub-structure keydownBindings. This substructure defines our two @keydown bindings, which reference the submitter constructor argument.

This works. But, if we're already attaching an x-data directive to the <textarea>, do we really need the x-bind directive as well? Can't we just apply those keydown bindings directly from within the controller instance?

3 - Encapsulated Component Controller

Yes; and for this we'll use a few special callbacks. If an Alpine.js component exposes an init() method, Alpine.js will call this method during the initialization phase of the component. And, if a component exposes a destroy() method, Alpine.js will call this method during the teardown phase of the component. We can use these life-cycle hooks to add and remove the keydown bindings, respectively.

In the following code, notice that our <textarea> only has an x-data directive - no more x-bind:

<!doctype html>
<html lang="en">
<body>

	<form x-data method="post">
		<p>
			<strong>Journal Entry</strong>:<br />
			<textarea
				name="description"
				autofocus
				cols="30"
				rows="5"
				x-data="metaEnterSubmit( $refs.submitButton )"
			></textarea>
		</p>
		<p>
			<button x-ref="submitButton" type="submit" name="submitter" value="default">
				Add Entry
			</button>
		</p>
	</form>

	<script type="text/javascript" src="../vendor/alpine.3.13.5.min.js" defer></script>
	<script type="text/javascript">

		document.addEventListener(
			"alpine:init",
			function setupAlpineBindings() {

				Alpine.data( "metaEnterSubmit", MetaEnterSubmitController );

			}
		);

		function MetaEnterSubmitController( submitter ) {

			var vm = this;

			return {
				init: init,
				destroy: destroy
			};

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

			function init() {

				vm.$el.addEventListener( "keydown", handleKeydown );

			}

			function destroy() {

				vm.$el.removeEventListener( "keydown", handleKeydown );

			}

			function handleKeydown( event ) {

				if (
					( event.key === "Enter" ) &&
					( event.metaKey || event.ctrlKey )
					) {

					event.preventDefault();
					vm.$el.form.requestSubmit( submitter );

				}

			}

		}

	</script>

</body>
</html>

As you can see, the less we use the HTML-based Alpine.js directives, the more logic we have to implement in our JavaScript code. In this case, we're explicitly binding and unbind the keydown event handlers. This is more work for us in one sense; but, it cleans up the HTML code quite a bit.

You may have noticed that in the previous example, I use the this reference; and, in this example, I'm using the vm reference (as a function-local alias for this). With Alpine.js, the this bindings get a little confusing. When Alpine.js is invoking our controller methods, this references the component instance. But, in our case, we're the ones setting up the keydown event handler using native DOM methods. As such, when our event handler is invoked by the browser, the this binding doesn't point to the controller instance. Which is why it's up to us to maintain an appropriate this reference.

Aside: Managing the this scope can be done in a variety of ways. In this case, I'm choosing to pass a "naked" (ie, unbound) Function reference around as my event handler. Your preferred strategy may be different.

4 - Tightly Coupled Directive

In Alpine.js, there are two primary ways to extend the behavior of the DOM: by using components, which is what we did above; and, by using custom directives. In this next approach, we're going to replace the "component" approach with a "directive" approach.

Alpine.js directives represent a more low-level approach to interactions. Directives are the "glue" that bind the DOM to the higher-level constructs, such as the x-data controllers. In fact, x-data is a directive in an of itself.

In this refactoring, we're going to create a custom directive, meta-enter-submit, which accepts the desired submitter button as the directive expression. This directive then wires-up the keydown bindings much like our previous example did:

<!doctype html>
<html lang="en">
<body>

	<form x-data method="post">
		<p>
			<strong>Journal Entry</strong>:<br />
			<textarea
				name="description"
				autofocus
				cols="30"
				rows="5"
				x-meta-enter-submit="$refs.submitButton"
			></textarea>
		</p>
		<p>
			<button x-ref="submitButton" type="submit" name="submitter" value="default">
				Add Entry
			</button>
		</p>
	</form>

	<script type="text/javascript" src="../vendor/alpine.3.13.5.min.js" defer></script>
	<script type="text/javascript">

		document.addEventListener(
			"alpine:init",
			function setupAlpineBindings() {

				Alpine.directive( "meta-enter-submit", MetaEnterSubmitDirective );

			}
		);

		function MetaEnterSubmitDirective( element, metadata, framework ) {

			init();

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

			function init() {

				framework.cleanup( destroy );
				element.addEventListener( "keydown", handleKeydown );

			}

			function destroy() {

				element.removeEventListener( "keydown", handleKeydown );

			}

			function handleKeydown( event ) {

				if (
					( event.key === "Enter" ) &&
					( event.metaKey || event.ctrlKey )
					) {

					event.preventDefault();
					// Evaluate the expression "$refs.submitButton" in the context of the
					// DOM, which will evaluate it in the context of the current x-data
					// binding.
					element.form.requestSubmit( framework.evaluate( metadata.expression ) );

				}

			}

		}

	</script>

</body>
</html>

Compared to components, Alpine.js directives are farther removed from the "magic" of the reactive "data stack". Which means, in the context of a directive, the this binding does not refer to the current component (in the way that it did in our previous example). Instead, Alpine.js provides the directive with two objects that help the directive interact with the DOM.

For example, in the context of the directive, the $refs.submitButton value that we're passing into the x-meta-enter-submit attribute (in the DOM) has no inherent meaning. In order to use this attribute value to get a reference to the submit button, we need to ask Alpine.js to evaluate the expression in the context of the DOM:

framework.evaluate( metadata.expression )

Other than that, the component controllers and the directives both have the same life-cycle methods. Which allows our directive to setup and then teardown the keydown binding as needed.

Aside: When do you choose to build a component or a directive? Honestly, I'm not sure yet - I'm still learning this stuff. My sense is that directives are for more generic behaviors and components are for less generic behaviors. But, that's my best articulation at this time.

5 - Loosely Coupled Directive

In the previous example, our custom directive assumes that the goal of the CMD+Enter key combination is to submit the form. But, perhaps this is too great an assumption. Let's see if we can loosen-up that tight coupling.

In this next refactoring, we're going to attach an x-data component controller to the <form> itself. The component controller will expose a method, submitEntry(), which will submit the form. Then, our custom directive won't make any assumptions about how it's being used—it will merely evaluate the attribute expression and lean on the Alpine.js data stack to invoke the submitEntry() method:

<!doctype html>
<html lang="en">
<body>

	<form x-data="formController" method="post">
		<p>
			<strong>Journal Entry</strong>:<br />
			<textarea
				name="description"
				autofocus
				cols="30"
				rows="5"
				x-meta-enter="submitEntry( $refs.submitButton )"
			></textarea>
		</p>
		<p>
			<button x-ref="submitButton" type="submit" name="submitter" value="default">
				Add Entry
			</button>
		</p>
	</form>

	<script type="text/javascript" src="../vendor/alpine.3.13.5.min.js" defer></script>
	<script type="text/javascript">

		document.addEventListener(
			"alpine:init",
			function setupAlpineBindings() {

				Alpine.data( "formController", FormController );
				Alpine.directive( "meta-enter", MetaEnterDirective );

			}
		);

		function FormController() {

			return {
				submitEntry: submitEntry
			};

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

			function submitEntry( submitter ) {

				// NOTE: Even though this "controller" is defined on the form element, the
				// $el magic always references the "current element"; which, in this case,
				// is the textarea element. As such, we have to go up to the form in order
				// to request the submission.
				this.$el.form.requestSubmit( submitter );

			}

		}

		function MetaEnterDirective( element, metadata, framework ) {

			init();

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

			function init() {

				framework.cleanup( destroy );
				element.addEventListener( "keydown", handleKeydown );

			}

			function destroy() {

				element.removeEventListener( "keydown", handleKeydown );

			}

			function handleKeydown( event ) {

				if (
					( event.key === "Enter" ) &&
					( event.metaKey || event.ctrlKey )
					) {

					event.preventDefault();
					// Evaluate the expression "submitEntry( $refs.submitButton )" in the
					// context of the DOM, which will evaluate it in the context of the
					// current x-data binding (ie, the FormController).
					framework.evaluate( metadata.expression );

				}

			}

		}

	</script>

</body>
</html>

In this case, our meta-enter custom directive knows nothing about the form submission. It only knows that it's binding to the CMD+Enter (and CTRL+Enter) key combo and is evaluating the attribute expression in response:

framework.evaluate( metadata.expression );

This creates a cleaner separation of concerns. The custom directive only handles the actual keyboard bindings. And, the DOM handles the result of those keyboard bindings via the attribute expression:

submitEntry( $refs.submitButton )

6 - Custom Event Emitter

At this point, our directive is really nothing more than an event handler. Which got me thinking about dispatching custom events. As one final refactoring, I want to try wiring things together with a custom event instead of with an evaluate() call.

In this last version, the meta-enter directive doesn't process an attribute value. Instead, it emits a metaEnter event which the DOM can listen for via @meta-event bindings:

<!doctype html>
<html lang="en">
<body>

	<form x-data="formController" method="post">
		<p>
			<strong>Journal Entry</strong>:<br />
			<textarea
				name="description"
				autofocus
				cols="30"
				rows="5"
				x-meta-enter
				@meta-enter.camel="submitEntry( $refs.submitButton )"
			></textarea>
		</p>
		<p>
			<button x-ref="submitButton" type="submit" name="submitter" value="default">
				Add Entry
			</button>
		</p>
	</form>

	<script type="text/javascript" src="../vendor/alpine.3.13.5.min.js" defer></script>
	<script type="text/javascript">

		document.addEventListener(
			"alpine:init",
			function setupAlpineBindings() {

				Alpine.data( "formController", FormController );
				Alpine.directive( "meta-enter", MetaEnterDirective );

			}
		);

		function FormController() {

			return {
				submitEntry: submitEntry
			};

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

			function submitEntry( submitter ) {

				// NOTE: Even though this "controller" is defined on the form element, the
				// $el magic always references the "current element"; which, in this case,
				// is the textarea element. As such, we have to go up to the form in order
				// to request the submission.
				this.$el.form.requestSubmit( submitter );

			}

		}

		function MetaEnterDirective( element, metadata, framework ) {

			init();

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

			function init() {

				framework.cleanup( destroy );
				element.addEventListener( "keydown", handleKeydown );

			}

			function destroy() {

				element.removeEventListener( "keydown", handleKeydown );

			}

			function handleKeydown( event ) {

				if (
					( event.key === "Enter" ) &&
					( event.metaKey || event.ctrlKey )
					) {

					event.preventDefault();
					element.dispatchEvent( new CustomEvent( "metaEnter" ) );

				}

			}

		}

	</script>

</body>
</html>

In Alpine.js, all of the @ and x-on bindings are just thin wrappers around the native DOM event handling mechanics. Which means, our meta-enter directive can use the native Element.dispatchEvent() method internally.

All of these approaches do the same thing: they allow the form to be submitted when the user presses the CMD+Enter (or CTRL+Enter) key combination while focused within the <textarea>. But, each approach makes a different set of trade-offs. Personally, I prefer the second-to-last (5th) version in which the custom directive defers to the DOM-based expression that invokes the submitForm() controller method. This feels like the right separation of concerns; which leads to a great degree of reusability.

This is my first look at Alpine.js. It seems very cool. When I see it, I get some of the same butterflies that I got when I first saw Angular.js. In fact, there's quite a bit of cross-over in terms of functionality; only, where Angular.js used brute-force data-diffing, Alpine.js uses reactive Proxies.

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

Reader Comments

Post A Comment — I'd Love To Hear From You!

Post a Comment

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