DEV Community

Rails Designer
Rails Designer

Posted on Originally published at railsdesigner.com on

Custom Actions in Attractive.js: one interface, from small to big

In last week’s announcement, I promised that when the built-in actions aren’t enough, you can write your own. Let me show you how, because this is what was bugging me most: why pull in another library when Attractive.js can do it as well?

Next to that I will also highlight some extra goodies and the bundled extensions that can now be (optionally) added.

Start with a one-liner

The quickest custom action is the js: prefix. It evaluates a JavaScript expression on the element, so you can do things too small for a full action:

<button @click="js:this.textContent=1">0 (I will become 1)</button>

Enter fullscreen mode Exit fullscreen mode

Use it for one-liners only. It goes through the full action pipeline (so all events works as well as data-debounce and data-delay). It runs new Function() under the hood (which needs unsafe-eval in your CSP).

A custom action as a function

The step up from js: is a plain function. Register it with addActions on activate():

import Attractive from "attractivejs";

const sharePage = (element, { dataset }) => {
  const title = dataset.shareTitle || document.title;
  const url = dataset.shareUrl || window.location.href;

  if (navigator.share) {
    navigator.share({ title, url });
  } else {
    // write to clipboard or however you want to handle this
  }
};

Attractive.activate({
  addActions: { sharePage }
});


<button @action="sharePage" data-share-title="Check this out">Share</button>

Enter fullscreen mode Exit fullscreen mode

The function receives the element and a context object with value, target, targets, event and dataset. In a Rails app you’d put this in app/javascript/application.js and you’re done. The markup stays identical to a built-in action.

A custom action as a class

When an action grows beyond a reasonable size, it becomes a class. Any class with a run() method works:

class Reorder {
  run() {
    this.element; // the element with @action
    this.options; // { value, target, targets, event, dataset, … }
  }
}

Enter fullscreen mode Exit fullscreen mode

Register it the same way as the function:

Attractive.activate({
  addActions: { reorder: Reorder }
});

Enter fullscreen mode Exit fullscreen mode

There’s an optional Action base class that gives you value, dataset and a dispatchEvent helper:

import { Action } from "attractivejs";

export default class Reorder extends Action {
  run() {
    this.value; // the string after # in @action="reorder#…"
    this.dataset;

    this.dispatchEvent("reorder:done", { ids: [1, 2, 3] });
  }
}

Enter fullscreen mode Exit fullscreen mode

Notice what didn’t change: registration is the same as the function version. You started with a one-liner, then a function, then a class and the interface stayed the same the whole way. Pretty cool, right?

Organizing actions

When you have a handful of actions, keep each in its own file and collect them in a barrel (same as how Stimulus is organized):

app/javascript/
  actions/
    reorder.js
    syntax_highlighting.js
    index.js

Enter fullscreen mode Exit fullscreen mode

Each file exports a single function or class as default. The barrel collects them:

// app/javascript/actions/index.js
import syntax_highlighting from "./syntax_highlighting.js";
import reorder from "./reorder.js";

export default { syntax_highlighting, reorder };

Enter fullscreen mode Exit fullscreen mode

Then your application file imports the barrel and activates:

import Attractive from "attractivejs";
import actions from "./actions/index.js";

Attractive.activate({ addActions: actions });

Enter fullscreen mode Exit fullscreen mode

Hooks and error handling

Hooks let you run code around every action and handle errors in one place.

beforeAction runs before each action, return false to cancel it. afterAction runs after a success. onError handles a thrown action:

attractive.onError(({ name, element, options, event, error }) => {
  console.warn(`Action ${name} failed:`, error.message);
});

Enter fullscreen mode Exit fullscreen mode

There are no unhandled rejections, the rest of a chained action still runs. There’s a global Attractive.onError fallback too, handy for wiring up a monitoring service.

The extensions

Attractive ships a few optional extensions you opt into with extendWith.

Keyboard

The keyboard extension gives you @keydown.enter, combos like @keydown.ctrl+k and global @hotkey shortcuts:

import { keyboard } from "attractivejs/keyboard";

Attractive.activate({ extendWith: [keyboard] });


<input @keydown.mod+enter="submit" @target="form">

<a href="/" aria-hidden="true" @hotkey.g.h="/">Homepage</a>

Enter fullscreen mode Exit fullscreen mode

Validate

The validate extension brings client-side form validation using the browser’s native Constraint Validation API. Just add @validate to a form and style the invalid state:

import { validate } from "attractivejs/validate";

Attractive.activate({ extendWith: [validate] });


input:user-invalid {
  border-color: red;
}

Enter fullscreen mode Exit fullscreen mode

Reactive

The reactive extension adds a shared JSON store with @text bindings and a setStore:

<input @input="setStore#name" />

<p>Hello, <span @text="name"></span></p>

Enter fullscreen mode Exit fullscreen mode

Attractive Element

Attractive Element is the last piece I want to highlight today: a custom element base class. It scopes Attractive to a component, so actions and targets work inside it without a connectedCallback, a querySelector or manual teardown.

import { AttractiveElement } from "attractivejs/element";

class Counter extends AttractiveElement {
  connect() {
    this.count = 0;
  }

  increment() {
    this.target("count").textContent = ++this.count;
  }
}

customElements.define("ui-counter", Counter);


<ui-counter>
  <button @click="increment">+</button>

  <output id="count">0</output>
</ui-counter>

Enter fullscreen mode Exit fullscreen mode

connect() and disconnect() replace the lifecycle callbacks. Methods in the HTML become actions. this.target("id") and this.targets(".selector") query within the component, so each component resolves its own targets even when the same id appears twice. It works alongside a document-wide activation without actions firing twice.


One interface for everything, from a one-liner to a function to a class to a custom element. That’s why I am so excited about Attractive: it works neatly alongside your bigger frameworks but can also stand on its own feet.

Everything you need is in the docs and the repo is always open for a star and improvement and bugfixes. ☺️

Next week, it comes all together: all of this inside a real Rails app (optimistic UI included 🤫).

Top comments (0)