Accessing Stimulus Controllers From A Given DOM Element In Hotwire
As I'm continuing to migrate my blog over to using Hotwire, I'm finding myself needing to perform some cross-controller communication. Stimulus has the concept of Outlets, which wire one controller into another. But, outlets seem to have significant developer experience (DX) issues at this time. Thankfully, Stimulus provides an older means of accessing controllers on a given DOM (Document Object Model) element. I wanted to briefly explore these mechanics in Hotwire.
View this code in my ColdFusion + Hotwire Demos project on GitHub.
In Hotwire, a given DOM element can have any number of controllers applied to it. As such, having a DOM element reference isn't sufficient - you need to have both the DOM element and the identifier of the controller that you want to access. And, for this, the Stimulus application
exposes a method:
getControllerForElementAndIdentifier()
Just as the method names implies, this takes an element reference and an identifier and returns the Controller instance.
To explore this method, I'm going to create a Form element that contains a Textarea element and some Buttons. The Textarea will have its own controller that knows how to apply some markdown formatting to its own text-content. Each of the buttons represents a formatting style (ex, Bold, Italic). Since the buttons aren't children of the Textarea, they can't have data-action
attributes that invoke Textarea methods. However, since they are children of the Form, they can invoke Form methods. These form methods will then turn around and relay the formatting request to the Textarea controller.
The ColdFusion markup looks like this:
<cfmodule template="./tags/page.cfm">
<cfoutput>
<!--- Parent controller. --->
<form data-controller="form">
<!---
The textarea controller has methods for manipulating the text value in
low-level ways so that the complexity of text manipulation doesn't have to
leak up and out into the form itself. This also increases the ability to
reuse the text formatting practices in different places.
--->
<textarea
data-controller="textarea"
data-form-target="textarea"
></textarea>
<!---
These buttons ask the FORM to apply different formatting styles (which
will turn around and ask the textarea controller to apply the formatting).
--->
<button
type="button"
data-action="form##applyFormatting"
data-form-command-param="bold">
Bold
</button>
<button
type="button"
data-action="form##applyFormatting"
data-form-command-param="italic">
Italic
</button>
<button
type="button"
data-action="form##applyFormatting"
data-form-command-param="strikethrough">
Strike-Through
</button>
</form>
</cfoutput>
</cfmodule>
Notice that the Textarea has two Hotwire attributes:
data-controller
- this binds the controller to the host element.data-form-target
- this makes the textarea element available inside the form controller as thetextareaTarget
property. We'll need this in order for the Form controller to be able to access the Textarea controller.
Each button will invoke the applyFormatting()
method on the Form controller, passing through a parameter that dictates the desired text style. Let's see how our Stimulus controllers route this event:
// Import core modules.
import { Application } from "@hotwired/stimulus";
import { Controller } from "@hotwired/stimulus";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export class FormController extends Controller {
static targets = [ "textarea" ];
// ---
// PUBLIC METHODS.
// ---
/**
* I apply the given formatting command event to the nested textarea.
*/
applyFormatting( event ) {
this.application
// Stimulus gives us the ability to extract a Controller instance from a given
// element. However, since a given element can have any number of controllers
// attached to it, we have to provide an IDENTIFIER that tells Stimulus which
// controller to extract. In this case, we're looking for the one with the
// "textarea" identifier. I'm OK with the tight-coupling here because we are
// explicitly deferring work to another controller.
.getControllerForElementAndIdentifier( this.textareaTarget, "textarea" )
// Then, once we have the controller instance, we can invoke methods on it
// like we would with any other JavaScript object.
.formatSelection( event.params.command )
;
}
}
export class TextareaController extends Controller {
/**
* I apply the given style command to the currently-selected text.
*/
formatSelection( style ) {
switch ( style ) {
case "bold":
this.wrapSelection( "**" );
break;
case "italic":
this.wrapSelection( "_" );
break;
case "strikethrough":
this.wrapSelection( "~~" );
break;
default:
throw( new Error( `Unsupported text format: ${ style }` ) );
break;
}
}
// ---
// PRIVATE METHODS.
// ---
/**
* I wrap the current selection in the given prefix/suffix markers.
*/
wrapSelection( prefix, suffix = prefix ) {
var value = this.element.value;
var start = this.element.selectionStart;
var end = this.element.selectionEnd;
var wrappedValue = (
value.slice( 0, start ) +
prefix +
value.slice( start, end ) +
suffix +
value.slice( end )
);
this.element.value = wrappedValue;
this.element.selectionStart = ( start + prefix.length );
this.element.selectionEnd = ( end + prefix.length );
this.element.focus();
}
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
window.Stimulus = Application.start();
// When not using the Ruby On Rails asset pipeline / build system, Stimulus doesn't know
// how to map controller classes to data-controller attributes. As such, we have to
// explicitly register the Controllers on Stimulus startup.
Stimulus.register( "form", FormController );
Stimulus.register( "textarea", TextareaController );
As you can see in the applyFormatting()
method, our FormController
receives the formatting event and then turns around and calls:
.getControllerForElementAndIdentifier( this.textareaTarget, "textarea" )
The first argument is the <textarea>
element reference provided by the "target" mechanics. The second argument - textarea
- is the identifier that our ColdFusion DOM used in the textarea's data-controller
attribute. This method gives us our TextareaController
instance which is how we can then invoke the .formatSelection()
method.
Now, if we run this ColdFusion application and click on the formatting buttons, we get the following output:
As you can see, when we click the buttons, our FormController
turns around and invokes the TextareaController
, which encapsulates all of the low-level logic for applying markdown formatting to the text.
Cross-controller communication obviously makes any application more complicated because it tightly-couples multiple controllers. That said, allowing for cross-controller communication means that certain responsibilities can be better encapsulated which, in turn, can reduce overall complexity. It's nice that Stimulus provides a relatively simple means for accessing one controller from another.
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 →