Submitting Forms With CMD+Enter In Alpine.js
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 →