Skip to content

allmarkedup/stimulus-x

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

77 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Reactivity engine for Stimulus controllers

NPM Version CI


StimulusX brings modern reactive programming paradigms to Stimulus controllers.

Features include:

❎  Automatic UI updates with reactive DOM bindings
❎  Declarative binding syntax based on Stimulus' action descriptors
❎  Chainable value modifiers
❎  Property watcher callback
❎  Extension API

Who is StimulusX for?

If you are a Stimulus user and are tired of writing repetitive DOM manipulation code then StimulusX's declarative, live-updating controller→HTML bindings might be just what you need to brighten up your day. StimulusX will make your controllers cleaner & leaner whilst ensuring they are less tightly coupled to a specific markup structure.

However if you are not currently a Stimulus user then I'd definitely recommend looking at something like Alpine, VueJS or Svelte first before considering a Stimulus + StimulusX combo, as they will likely provide a more elegant fit for your needs.

↓ Skip examples and jump to the docs ↓

Example: A simple counter

Below is an example of a simple counter controller implemented using StimulusX's reactive DOM bindings.

<div data-controller="counter">
  <span data-bind-attr="class~counter#displayClasses">
    <span data-bind-text="counter#count"></span> of
    <span data-bind-text="counter#max"></span>
  </span>

  <button data-action="counter#increment">⬆️</button>
  <button
    data-bind-attr="disabled~counter#count:lte(0)"
    data-action="counter#decrement"
  >⬇️</button>
</div>
// controllers/counter_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  initialize(){
    this.count = 0;
    this.max = 5;
  }

  increment(){
    this.count++;
  }

  decrement(){
    this.count--;
  }

  get displayClasses(){
    return {
      "text-green": this.count <= this.max,
      "text-red font-bold": this.count > this.max,
    }
  }  
}

Installation

Add the stimulus-x package to your package.json:

Using NPM:

npm i stimulus-x

Using Yarn:

yarn add stimulus-x

Without a bundler

You can use StimulusX with native browser module imports by loading from it from Skypack:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <script type="module">
    import { Application } from "https://cdn.skypack.dev/@hotwired/stimulus"
    import StimulusX from "https://cdn.skypack.dev/stimulus-x"
    // ...see docs below for usage info
  </script>
</head>
<body>
</body>
</html>

Usage

StimulusX hooks into your Stimulus application instance via the StimulusX.init method.

import { Application, Controller } from "@hotwired/stimulus";
import StimulusX from "stimulus-x";

window.Stimulus = Application.start();

// You must call the `StimulusX.init` method _before_ registering any controllers.
StimulusX.init(Stimulus); 

// Register controllers as usual...
Stimulus.register("example", ExampleController);

By default, all registered controllers will automatically have access to StimulusX's reactive features - including attribute bindings (e.g. class names, data- and aria- attributes, hidden etc), text content bindings, HTML bindings and more.

Explicit controller opt-in

If you don't want to automatically enable reactivity for all of your controllers you can instead choose to opt-in to StimulusX features on a controller-by-controller basis.

To enable individual controller opt-in set the optIn option to true when initializing StimulusX:

StimulusX.init(Stimulus, { optIn: true }); 

To then enable reactive features on a per-controller basis, set the static reactive variable to true in the controller class:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static reactive = true; // enable StimulusX reactive features for this controller
  // ...
}

Reactive DOM bindings - overview

HTML attributes, text and HTML content can be tied to the value of controller properties using data-bind-* attributes in your HTML.

These bindings are reactive which means the DOM is automatically updated when the value of the controller properties change.

Binding descriptors

Bindings are specified declaratively in your HTML using data-bind-(attr|text|html) attributes where the value of the attribute is a binding descriptor.

Attribute binding descriptors take the form attribute~identifier#property where attribute is the name of the HTML attribute to set, identifier is the controller identifier and property is the name of the property to bind to.

<!-- keep the `src` attribute value in sync with the value of the lightbox controller `.imageUrl` property -->
<img data-bind-attr="src~lightbox#imageUrl">

πŸ“š Read more: Attribute bindings β†’

Text and HTML binding descriptors take the form identifier#property where identifier is the controller identifier and property is the name of the property to bind to.

<!-- keep `element.textContent` in sync with the value of the article controller `.title` property -->
<h1 data-bind-text="article#title"></h1>

<!-- keep `element.innerHTML` in sync with the value of the article controller `.proseContent` property -->
<div data-bind-html="article#proseContent"></div>

πŸ“š Read more: text bindings and HTML bindings β†’

Note

If you are familiar with Stimulus action descriptors then binding descriptors should feel familiar as they have a similar role and syntax.

Value modifiers

Binding value modifiers are a convenient way to transform or test property values in-situ before updating the DOM.

<h1 data-bind-text="article#title:upcase"></h1>
<input data-bind-attr="disabled~workflow#status:is('complete')">

πŸ“š Read more: Binding value modifiers β†’

Negating property values

Boolean property values can be negated (inverted) by prefixing the identifier#property part of the binding descriptor with an exclaimation mark:.

<details data-bind-attr="open~!panel#closed"></details>

Note

The ! prefix is really just an more concise alternative syntax for applying the :not modifier.

Shallow vs deep reactivity

By default StimulusX only tracks changes to top level controller properties to figure out when to update the DOM. This is shallow reactivity.

To enable deep reactivity for a controller (i.e. the ability to track changes to properties in nested objects) you can can set the static reactive property to "deep" within your controller:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static reactive = "deep"; // enable deep reactivity mode
  // ...
}

Alternatively you can enable deep reactivity for all controllers using the trackDeep option when initializing StimulusX:

StimulusX.init(Stimulus, { trackDeep: true }); 

Attribute bindings

Attribute bindings connect HTML attribute values to controller properties, and ensure that the attribute value is automatically updated so as to stay in sync with the value of the controller property at all times.

They are specified using data-bind-attr attributes with value descriptors that take the general form {attribute}~{identifier}#{property}.

<div data-controller="lightbox">
  <img data-bind-attr="src~lightbox#imageUrl">
</div>
export default class extends Controller {
  initialize(){
    this.imageUrl = "https://placeholder.com/kittens.jpg";
  }
}

In the attribute binding descriptor src~lightbox#imageUrl above:

  • src is the HTML attribute to be added/updated/remove
  • lightbox is the controller identifier
  • imageUrl is the name of the property that the attribute value should be bound to

So the image src attribute will initially be set to the default value of the imageUrl property (i.e. https://placeholder.com/kittens.jpg). And whenever the imageUrl property is changed, the image src attribute value in the DOM will be automatically updated to reflect the new value.

this.imageUrl = "https://kittens.com/daily-kitten.jpg"
// <img src="https://kittens.com/daily-kitten.jpg">

Boolean attributes

Boolean attributes such as checked, disabled, open etc will be added if the value of the property they are bound to is true, and removed completely when it is false.

<div data-controller="example">
  <button data-bind-attr="disabled~example#incomplete">submit</button>
</div>
export default class extends Controller {
  initialize(){
    this.incomplete = true;
  }
}

Boolean attribute bindings often pair nicely with comparison modifiers such as :is:

<div data-controller="form">
  <input type="text" data-action="form#checkCompleted">
  <button data-bind-attr="disabled~form#status:is('incomplete')">submit</button>
</div>
export default class extends Controller {
  initialize(){
    this.status = "incomplete";
  }

  // called when the text input value is changed
  checkCompleted({ currentTarget }){
    if (currentTarget.value?.length > 0) {
      this.status === "complete"; // button will be enabled
    }
  }
}

Binding classes

class attribute bindings let you set specific classes on an element based on controller state.

<div data-controller="counter">
  <div data-bind-attr="class~counter#validityClasses">
    ...
  </div>
</div>
// controllers/counter_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  initialize(){
    this.count = 0;
  }

  get validityClasses(){
    if (this.count > 10) {
      return "text-red font-bold";
    } else {
      return "text-green";
    }
  }
}

In the example above, the value of the validityClasses property is a string of classes that depends on whether or not the value of the count property is greater than 10:

  • If this.count > 10 then the element class attribute will be set to "text-red font-bold".
  • If this.count < 10 then the element class attribute will be set to "text-green".

The list of classes can be returned as a string or as an array - or as a special class object.

Class objects

If you prefer, you can use a class object syntax to specify the class names. These are objects where the classes are the keys and booleans are the values.

The example above could be rewritten to use a class object as follows:

export default class extends Controller {
  // ...
  get validityClasses(){
    return {
      "text-red font-bold": this.count > 10,
      "text-green": this.count <= 10,
    }
  }
}

The list of class names will be resolved by merging all the class names from keys with a value of true and ignoring all the rest.

Text content bindings

Text content bindings connect the textContent of an element to a controller property. They are useful when you want to dynamically update text on the page based on controller state.

Text content bindings are specified using data-bind-text attributes where the value is a binding descriptor in the form {identifier}#{property}.

<div data-controller="workflow">
  Status: <span data-bind-text="workflow#status"></span>
</div>
export default class extends Controller {
  static values = {
    status: {
      type: String,
      default: "in progress"
    }
  }
}

HTML bindings

HTML bindings are very similar to text content bindings except they update the element's innerHTML instead of textContent.

HTML bindings are specified using data-bind-html attributes where the value is a binding descriptor in the form {identifier}#{property}.

<div data-controller="workflow">
  <div class="status-icon" data-bind-html="workflow#statusIcon"></div>
</div>
export default class extends Controller {
  initialize(){
    this.status = "in progress";
  }

  get statusIcon(){
    if (this.status === "complete"){
      return `<i data-icon="in-complete"></i>`;
    } else {
      return `<i data-icon="in-progress"></i>`;
    }
  }
}

Binding value modifiers

Inline value modifiers are a convenient way to transform or test property values before updating the DOM.

Modifiers are appended to the end of binding descriptors and are separated from the descriptor (or from each other) by a : colon.

The example below uses the upcase modifier to transform the title to upper case before displaying it on the page:

<h1 data-bind-text="article#title:upcase"></h1>

Tip

Multiple modifiers can be piped together one after each other, separated by colons, e.g. article#title:upcase:trim

String transform modifiers

String transform modifiers provide stackable output transformations for string values.

Available string modifiers:

  • :upcase - transform text to uppercase
  • :downcase - transform text to lowercase
  • :strip - strip leading and trailing whitespace
:upcase

Converts the string to uppercase.

<h1 data-bind-text="article#title:upcase"></h1>
:downcase

Converts the string to lowercase.

<h1 data-bind-text="article#title:downcase"></h1>
:strip

Strips leading and trailing whitespace from the string value.

<h1 data-bind-text="article#title:downcase"></h1>

Comparison modifiers

Comparison modifiers compare the resolved controller property value against a provided test value.

<input data-bind-attr="disabled~workflow#status:is('complete')">

They are primarily intended for use with boolean attribute bindings to conditionally add or remove attributes based on the result of value comparisons.

Tip

Comparison modifiers play nicely with other chained modifiers - the comparison will be done against the property value after it has been transformed by any other preceeding modifiers:

<input data-bind-attr="disabled~workflow#status:upcase:is('COMPLETE')">`

Available comparison modifiers:

  • :is(<value>) - equality test (read more)
  • :isNot(<value>) - negated equality test (read more)
  • :gt(<value>) - 'greater than' test (read more)
  • :gte(<value>) - 'greater than or equal to' test (read more)
  • :lt(<value>) - 'less than' test (read more)
  • :lte(<value>) - 'less than or equal to' test (read more)
:is(<value>)

The :is modifier compares the resolved property value with the <value> provided as an argument, returning true if they match and false if not.

<!-- input is disabled if `workflow#status` === "complete" -->
<input data-bind-attr="disabled~workflow#status:is('complete')">
  • String comparison: :is('single quoted string'), :is("double quoted string")
  • Integer comparison: :is(123)
  • Float comparison: :is(1.23)
  • Boolean comparison: :is(true), :is(false)
:isNot(<value>)

The :isNot modifier works exactly the same as the :is modifier, but returns true if the value comparison fails and false if the values match.

Important

The :is and :isNot modifiers only accept simple String, Number or Boolean values. Object and Array values are not supported.

:gt(<value>)

The :gt modifier returns true if the resolved property value is greater than the numeric <value> provided as an argument.

<!-- button is disabled if `counter#count` is > 9 -->
<button data-bind-attr="disabled~counter#count:gt(9)">+</button>
:gte(<value>)

The :gte modifier returns true if the resolved property value is greater than or equal to the numeric <value> provided as an argument.

<!-- button is disabled if `counter#count` is >= 10 -->
<button data-bind-attr="disabled~counter#count:gte(10)">+</button>
:lt(<value>)

The :lt modifier returns true if the resolved property value is less than the numeric <value> provided as an argument.

<!-- button is disabled if `counter#count` is < 1 -->
<button data-bind-attr="disabled~counter#count:lt(1)">-</button>
:lte(<value>)

The :lte modifier returns true if the resolved property value is less than or equal to the numeric <value> provided as an argument.

<!-- button is disabled if `counter#count` is <= 0 -->
<button data-bind-attr="disabled~counter#count:lte(0)">-</button>

Other modifiers

  • :not - negate (invert) a boolean value

Tip

You can add your own custom modifiers if required. See Extending StimulusX for more info.

Watching properties for changes

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static watch = ["enabled", "userInput"];

  connect(){
    this.enabled = false;
    this.userInput = "";
  }

  enabledPropertyChanged(currentValue, previousValue){
    if (currentValue) {
      console.log("Controller is enabled");
    } else {
      console.log("Controller has been disabled");
    }
  }

  userInputPropertyChanged(currentValue, previousValue){
    console.log(`User input has changed from "${previousValue}" to "${currentValue}"`);
  }

  // ...
}

🚧 More docs coming soon...

Extending StimulusX

Custom modifiers

You can add your own modifiers using the StimulusX.modifier method:

StimulusX.modifier("modifierName", (value) => {
  // Do something to `value` and return the result of the transformation.
  const transformedValue = doSomethingTo(value);
  return transformedValue;
}); 

Custom directives

🚧 Documentation coming soon...

Known issues, caveats and workarounds

❌ Private properties and methods

Unfortunately it is not possible to use StimulusX with controllers that define private methods or properties (i.e. with names using the # prefix). See Lea Verou's excellent blog post on the topic for more details.

If you have existing controllers with private methods and want to add new StimulusX-based controllers alongside them then you should enable explicit controller opt-in to prevent errors being thrown at initialization time.

Credits and inspiration

StimulusX uses VueJS's reactivity engine under the hood and was inspired by (and borrows much of its code from) the excellent Alpine.JS library.

License

StimulusX is available as open source under the terms of the MIT License.

About

StimulusX brings the power of reactive programming to Stimulus JS.

Topics

Resources

License

Stars

Watchers

Forks