stimulus-use-actions 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,98 +1,110 @@
1
1
  # stimulus-use-actions
2
2
 
3
- Small helper for Stimulus controllers to declare `data-action` bindings in JavaScript, instead of markup.
3
+ Declare Stimulus `data-action` bindings in JavaScript instead of HTML markup.
4
4
 
5
5
  [![CI](https://github.com/botandrose/stimulus-use-actions/actions/workflows/ci.yml/badge.svg)](https://github.com/botandrose/stimulus-use-actions/actions/workflows/ci.yml)
6
6
 
7
7
  Requires Stimulus v3+ and ES modules.
8
8
 
9
- ## Quick Example
9
+ ## Quick Start
10
+
11
+ Import `Controller` from this package instead of `@hotwired/stimulus`. Declare
12
+ `static actions` alongside your `static targets` -- that's it.
13
+
10
14
  ```js
11
- // controllers/demo_controller.js
12
- import { Controller } from "@hotwired/stimulus";
13
- import useActions from "stimulus-use-actions";
15
+ import { Controller } from "stimulus-use-actions"
14
16
 
15
17
  export default class extends Controller {
16
- static targets = ["button"];
18
+ static targets = ["field", "checkbox"]
17
19
 
18
- connect() {
19
- useActions(this, {
20
- // plural targets: bind multiple events to each element
21
- buttonTargets: ["click->submit", "keyup->preview"],
22
- // window-scoped actions
23
- window: ["resize->reflow"],
24
- });
20
+ static actions = {
21
+ field: "input->update",
22
+ checkbox: "change->rerender",
23
+ window: "resize->layout",
25
24
  }
26
25
 
27
- submit() { /* ... */ }
28
- preview() { /* ... */ }
29
- reflow() { /* ... */ }
26
+ update(event) { /* ... */ }
27
+ rerender(event) { /* ... */ }
28
+ layout(event) { /* ... */ }
30
29
  }
31
30
  ```
32
31
 
33
- ### Using the built-in Controller
34
- Import the base `Controller` from this package to auto-bind `static actions` without calling anything in `connect()`.
32
+ Actions are bound via **event delegation** on the controller element. This
33
+ means:
35
34
 
36
- ```js
37
- import { Controller } from "stimulus-use-actions";
35
+ - Listeners are added on `connect()` and removed on `disconnect()`
36
+ - Target elements can appear and disappear in the DOM at any time -- events are
37
+ still captured
38
+ - No need to call anything in `connect()` yourself
38
39
 
39
- export default class extends Controller {
40
- static targets = ["button"];
40
+ ### Keys
41
41
 
42
- static actions = {
43
- buttonTarget: ["click->submit", "keyup->preview"],
44
- window: "resize->reflow",
45
- };
42
+ Each key in `static actions` is a **target name** matching an entry in
43
+ `static targets`, or the special key `window`.
46
44
 
47
- submit() {}
48
- preview() {}
49
- reflow() {}
50
- }
51
- ```
45
+ | Key | Listens on | Matches events from |
46
+ | --------- | ------------------- | -------------------------------------------- |
47
+ | `field` | controller element | descendants with `data-<id>-target="field"` |
48
+ | `window` | `window` | the window itself |
49
+
50
+ ### Values
51
+
52
+ Values use Stimulus action descriptor syntax with an **explicit event**:
52
53
 
53
- ### Using `static actions = {}`
54
- Define actions on the class and either:
55
- - Call `useActions(this)` without the second argument; or
56
- - Wrap your controller with `withActions()` to auto-bind in `connect()`.
54
+ | Value | Meaning |
55
+ | ------------------------------ | ------------------------------------------ |
56
+ | `"input->update"` | listen for `input`, call `this.update()` |
57
+ | `"click->save"` | listen for `click`, call `this.save()` |
58
+ | `["click->a", "keyup->b"]` | multiple actions on one target |
59
+
60
+ Stimulus modifiers work as usual, e.g. `"keyup.enter->submit"`.
61
+
62
+ ## Lower-Level API: `useActions`
63
+
64
+ For cases where you need manual control (conditional bindings, dynamic action
65
+ maps, etc.), import `useActions` directly:
57
66
 
58
67
  ```js
59
- import useActions, { withActions } from "stimulus-use-actions";
68
+ import { Controller } from "@hotwired/stimulus"
69
+ import useActions from "stimulus-use-actions"
60
70
 
61
- class Base extends Controller {
62
- static targets = ["button"];
63
- static actions = {
64
- buttonTarget: ["click->submit", "keyup->preview"],
65
- window: "resize->reflow",
66
- };
67
- submit() {}
68
- preview() {}
69
- reflow() {}
70
- connect() { useActions(this); }
71
- }
71
+ export default class extends Controller {
72
+ static targets = ["button"]
73
+
74
+ connect() {
75
+ useActions(this, {
76
+ buttonTargets: ["click->submit", "keyup->preview"],
77
+ window: "resize->reflow",
78
+ })
79
+ }
72
80
 
73
- // Or auto-bind without calling in connect:
74
- export default withActions(Base);
81
+ submit() { /* ... */ }
82
+ preview() { /* ... */ }
83
+ reflow() { /* ... */ }
84
+ }
75
85
  ```
76
86
 
77
- ## API
78
- `useActions(controller, actions)`
87
+ `useActions` binds directly to target elements (no delegation). It does **not**
88
+ clean up on disconnect -- Stimulus handles this through its own binding
89
+ observer.
79
90
 
80
- - `controller`: your Stimulus controller instance (`this`).
81
- - `actions`: map of target keys to action descriptors.
82
- - Target keys: `<name>Target`, `<name>Targets`, or the special key `window`.
83
- - Values: a string (e.g., `"click->save"`, `"save"`) or an array of such strings.
84
- - If omitted, actions are read from `controller.constructor.actions`.
91
+ ### `useActions(controller, actions?)`
85
92
 
86
- ## Action Descriptors
87
- - `"event->method"`: binds a specific DOM event to `controller.method`.
88
- - `"method"` (no event): Stimulus infers the element’s default event (e.g., `click` for buttons, `input` for text inputs).
89
- - Works with Stimulus modifiers, e.g. `"keyup.enter->submit"`.
93
+ - **controller** -- your Stimulus controller instance (`this`)
94
+ - **actions** -- map of target keys to action descriptors
95
+ - Keys: `<name>Target`, `<name>Targets`, or `window`
96
+ - Values: a string or array of `"event->method"` strings
97
+ - Event inference (e.g. `"submit"` without `click->`) is supported here --
98
+ Stimulus infers the default event for the element
99
+ - If omitted, reads from `controller.constructor.actions`
90
100
 
91
- ## Target Resolution
92
- - For `<name>Targets`, each element in that target list receives each action.
93
- - For `<name>Target`, the single element receives each action.
94
- - `window` binds to the `@window` target (e.g., `"resize->reflow"`).
101
+ A `withActions(BaseController)` wrapper is also available -- it returns a
102
+ subclass that auto-calls `useActions(this)` in `connect()`.
95
103
 
96
104
  ## Notes
97
- - Call once per controller instance (typically in `connect()`). Calling repeatedly will add duplicate bindings.
98
- - Keep action names in sync with your controller methods; mismatches throw at runtime via Stimulus.
105
+
106
+ - `useActions` binds once per call -- calling repeatedly adds duplicate
107
+ bindings.
108
+ - The `Controller` base class uses delegation -- no duplicates, automatic
109
+ cleanup.
110
+ - Keep method names in sync with your controller; mismatches throw at runtime.
package/index.js CHANGED
@@ -1,5 +1,8 @@
1
+ import { Controller as StimulusController } from "@hotwired/stimulus"
2
+
3
+ // --- Direct binding via Stimulus internals (useActions, withActions) ---
4
+
1
5
  function useActionsImpl(controller, actions) {
2
- // Allow calling without explicit map: read from static/class property
3
6
  if (!actions) actions = controller?.constructor?.actions || controller?.actions;
4
7
  if (!actions || typeof actions !== "object") return;
5
8
  const bindingObserver = controller.context.bindingObserver
@@ -33,23 +36,112 @@ function useActionsImpl(controller, actions) {
33
36
 
34
37
  export default useActionsImpl
35
38
 
36
- // Helper to wrap a Stimulus Controller class so actions bind automatically
37
39
  export function withActions(BaseController) {
38
40
  return class WithActions extends BaseController {
39
41
  connect() {
40
- if (super.connect) super.connect()
41
- // Bind actions declared on the class via `static actions = {}`
42
- // Call with only controller; it will resolve static actions
42
+ super.connect()
43
43
  useActionsImpl(this)
44
44
  }
45
45
  }
46
46
  }
47
- import { Controller as StimulusController } from "@hotwired/stimulus"
48
47
 
49
- // Base Controller that auto-binds actions declared via `static actions = {}`
48
+ // --- Delegated binding (Controller) ---
49
+
50
+ const KEY_MAP = {
51
+ enter: "Enter",
52
+ tab: "Tab",
53
+ esc: "Escape",
54
+ space: " ",
55
+ up: "ArrowUp",
56
+ down: "ArrowDown",
57
+ left: "ArrowLeft",
58
+ right: "ArrowRight",
59
+ }
60
+
61
+ function parseDescriptor(descriptor) {
62
+ const arrowIndex = descriptor.indexOf("->")
63
+ if (arrowIndex === -1) return null
64
+ const eventPart = descriptor.substring(0, arrowIndex)
65
+ const methodPart = descriptor.substring(arrowIndex + 2)
66
+ const dotIndex = eventPart.indexOf(".")
67
+ let eventName, filter
68
+ if (dotIndex === -1) {
69
+ eventName = eventPart
70
+ filter = null
71
+ } else {
72
+ eventName = eventPart.substring(0, dotIndex)
73
+ filter = eventPart.substring(dotIndex + 1)
74
+ }
75
+ const parts = methodPart.split(":")
76
+ const methodName = parts[0]
77
+ const options = parts.slice(1)
78
+ return { eventName, filter, methodName, options }
79
+ }
80
+
81
+ function bindDelegatedActions(controller) {
82
+ const actions = controller.constructor.actions
83
+ if (!actions || typeof actions !== "object") return []
84
+ const identifier = controller.identifier
85
+ const listeners = []
86
+
87
+ Object.entries(actions).forEach(([key, descriptors]) => {
88
+ const descriptorList = Array.isArray(descriptors) ? descriptors : [descriptors]
89
+ const isWindow = key === "window"
90
+ let targetName
91
+ if (!isWindow) {
92
+ if (key.endsWith("Targets")) targetName = key.slice(0, -7)
93
+ else if (key.endsWith("Target")) targetName = key.slice(0, -6)
94
+ else targetName = key
95
+ }
96
+
97
+ descriptorList.forEach(descriptor => {
98
+ const parsed = parseDescriptor(descriptor)
99
+ if (!parsed) return
100
+ const { eventName, filter, methodName, options } = parsed
101
+
102
+ if (isWindow) {
103
+ const handler = (event) => {
104
+ if (filter && event.key !== (KEY_MAP[filter] || filter)) return
105
+ if (options.includes("stop")) event.stopPropagation()
106
+ if (options.includes("prevent")) event.preventDefault()
107
+ controller[methodName](event)
108
+ }
109
+ window.addEventListener(eventName, handler)
110
+ listeners.push({ target: window, eventName, handler })
111
+ } else {
112
+ const selector = `[data-${identifier}-target~="${targetName}"]`
113
+ const handler = (event) => {
114
+ const matched = event.target.closest(selector)
115
+ if (!matched || !controller.element.contains(matched)) return
116
+ if (filter && event.key !== (KEY_MAP[filter] || filter)) return
117
+ if (options.includes("self") && event.target !== matched) return
118
+ if (options.includes("stop")) event.stopPropagation()
119
+ if (options.includes("prevent")) event.preventDefault()
120
+ controller[methodName](event)
121
+ }
122
+ controller.element.addEventListener(eventName, handler)
123
+ listeners.push({ target: controller.element, eventName, handler })
124
+ }
125
+ })
126
+ })
127
+
128
+ return listeners
129
+ }
130
+
131
+ function unbindDelegatedActions(listeners) {
132
+ listeners.forEach(({ target, eventName, handler }) => {
133
+ target.removeEventListener(eventName, handler)
134
+ })
135
+ }
136
+
50
137
  export class Controller extends StimulusController {
51
138
  connect() {
52
- if (super.connect) super.connect()
53
- useActionsImpl(this)
139
+ super.connect()
140
+ this._useActionListeners = bindDelegatedActions(this)
141
+ }
142
+ disconnect() {
143
+ super.disconnect()
144
+ unbindDelegatedActions(this._useActionListeners)
145
+ this._useActionListeners = []
54
146
  }
55
147
  }
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "stimulus-use-actions",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Stimulus mixin for declaring actions in the controller",
5
5
  "main": "index.js",
6
+ "files": ["index.js"],
6
7
  "scripts": {
7
8
  "test": "vitest run",
8
9
  "test:watch": "vitest",
@@ -1,17 +0,0 @@
1
- name: CI
2
- on: [ push, pull_request ]
3
- jobs:
4
- test:
5
- runs-on: ubuntu-latest
6
- steps:
7
- - name: Checkout
8
- uses: actions/checkout@v4
9
- - name: Setup Node
10
- uses: actions/setup-node@v4
11
- with:
12
- node-version: 20
13
- cache: npm
14
- - name: Install dependencies
15
- run: npm ci
16
- - name: Run tests with coverage
17
- run: npm run ci
package/AGENTS.md DELETED
@@ -1,39 +0,0 @@
1
- # Repository Guidelines
2
-
3
- ## Project Structure & Module Organization
4
- - Root files: `index.js` (library entry), `package.json` (metadata/scripts).
5
- - No build step; the module exports an ES module function for Stimulus controllers.
6
- - Suggested growth: place future sources in `src/` and re-export from `index.js`; put tests in `tests/`.
7
-
8
- ## Build, Test, and Development Commands
9
- - `npm install`: installs dev tools if added later. Currently no runtime deps.
10
- - `npm test`: placeholder that exits with error. If you add tests, replace with a real runner (e.g., `vitest` or `jest`).
11
- - Local check: import `index.js` in a small sandbox app to validate Stimulus behavior.
12
-
13
- ## Coding Style & Naming Conventions
14
- - Indentation: 2 spaces; avoid semicolons; prefer double quotes for strings; use template literals where helpful.
15
- - Exports: default export is the library entry. Keep API surface small and documented in JSDoc.
16
- - Naming: reflect Stimulus conventions. Keys in the `actions` map should match controller properties (e.g., `buttonTarget`, `buttonTargets`, or `window`). Action strings may be "click->method" or "method".
17
-
18
- ## Testing Guidelines
19
- - Framework: Vitest with `happy-dom`. Name files `*.test.js`.
20
- - Location: co-locate tests next to files or under `tests/`.
21
- - Coverage: enforced at 100% (lines, functions, branches, statements). Use `npm run coverage`.
22
- - Scope: cover multiple targets, `window` target, and event prefixes like `click->`.
23
- - Run: `npm test` for a single run; `npm run test:watch` in dev.
24
-
25
- ## Commit & Pull Request Guidelines
26
- - Commits: use Conventional Commits when possible (e.g., `feat: add window target support`, `fix: handle single target elements`). Keep commits focused.
27
- - PRs: include a concise description, rationale, example usage, and any behavioral changes. Link issues. Add before/after snippets when touching the API.
28
-
29
- ## Example Usage (for sanity checks)
30
- ```js
31
- import useActions from "./index.js";
32
- // Inside a Stimulus controller
33
- connect() {
34
- useActions(this, {
35
- buttonTargets: ["click->submit", "keyup->preview"],
36
- window: "resize->reflow",
37
- });
38
- }
39
- ```
@@ -1,37 +0,0 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
2
- import { Application } from "@hotwired/stimulus";
3
- import { Controller } from "../index.js";
4
-
5
- function nextTick() { return new Promise((r) => setTimeout(r, 0)); }
6
-
7
- describe("exported Controller base", () => {
8
- let app, root;
9
-
10
- beforeEach(async () => {
11
- document.body.innerHTML = "";
12
- root = document.createElement("div");
13
- document.body.appendChild(root);
14
- app = Application.start(root);
15
- await nextTick();
16
- });
17
-
18
- it("auto-binds static actions without calling useActions manually", async () => {
19
- root.innerHTML = `
20
- <div data-controller="demo">
21
- <button id="btn" data-demo-target="button"></button>
22
- </div>
23
- `;
24
-
25
- const calls = { submit: 0 };
26
- class DemoController extends Controller {
27
- static targets = ["button"];
28
- static actions = { buttonTarget: "click->submit" };
29
- submit() { calls.submit++; }
30
- }
31
- app.register("demo", DemoController);
32
- await nextTick();
33
- document.getElementById("btn").dispatchEvent(new Event("click", { bubbles: true }));
34
- expect(calls.submit).toBe(1);
35
- });
36
- });
37
-
@@ -1,102 +0,0 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
2
- import { Application, Controller } from "@hotwired/stimulus";
3
- import useActions from "../index.js";
4
-
5
- function nextTick() {
6
- return new Promise((r) => setTimeout(r, 0));
7
- }
8
-
9
- describe("Stimulus integration", () => {
10
- let app, root;
11
-
12
- beforeEach(async () => {
13
- document.body.innerHTML = "";
14
- root = document.createElement("div");
15
- document.body.appendChild(root);
16
- app = Application.start(root);
17
- await nextTick();
18
- });
19
-
20
- it("handles plural targets with prefixed events", async () => {
21
- // Build DOM with controller and two target elements
22
- root.innerHTML = `
23
- <div data-controller="demo">
24
- <button id="a" data-demo-target="button"></button>
25
- <button id="b" data-demo-target="button"></button>
26
- </div>
27
- `;
28
-
29
- const calls = { submit: 0, preview: 0, reflow: 0 };
30
-
31
- class DemoController extends Controller {
32
- static targets = ["button"];
33
- connect() {
34
- useActions(this, {
35
- buttonTargets: ["click->submit", "keyup->preview"],
36
- window: "resize->reflow",
37
- });
38
- }
39
- submit() { calls.submit++; }
40
- preview() { calls.preview++; }
41
- reflow() { calls.reflow++; }
42
- }
43
-
44
- app.register("demo", DemoController);
45
- await nextTick();
46
-
47
- const btnA = document.getElementById("a");
48
- const btnB = document.getElementById("b");
49
-
50
- // Fire events on buttons
51
- btnA.dispatchEvent(new Event("click", { bubbles: true }));
52
- btnB.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true }));
53
- // Fire resize on window
54
- window.dispatchEvent(new Event("resize"));
55
-
56
- expect(calls.submit).toBe(1);
57
- expect(calls.preview).toBe(1);
58
- expect(calls.reflow).toBe(1);
59
- });
60
-
61
- it("handles single target without event prefix (defaults to click)", async () => {
62
- root.innerHTML = `
63
- <div data-controller="demo">
64
- <button id="only" data-demo-target="button"></button>
65
- </div>
66
- `;
67
-
68
- let called = 0;
69
- class DemoController extends Controller {
70
- static targets = ["button"];
71
- connect() { useActions(this, { buttonTarget: "submit" }); }
72
- submit() { called++; }
73
- }
74
-
75
- app.register("demo", DemoController);
76
- await nextTick();
77
- const btn = document.getElementById("only");
78
- btn.dispatchEvent(new Event("click", { bubbles: true }));
79
- expect(called).toBe(1);
80
- });
81
-
82
- it("supports multiple actions array on a single target", async () => {
83
- root.innerHTML = `
84
- <div data-controller="demo">
85
- <button id="only" data-demo-target="button"></button>
86
- </div>
87
- `;
88
- const called = { a: 0, b: 0 };
89
- class DemoController extends Controller {
90
- static targets = ["button"];
91
- connect() { useActions(this, { buttonTarget: ["click->a", "click->b"] }); }
92
- a() { called.a++; }
93
- b() { called.b++; }
94
- }
95
- app.register("demo", DemoController);
96
- await nextTick();
97
- const btn = document.getElementById("only");
98
- btn.dispatchEvent(new Event("click", { bubbles: true }));
99
- expect(called.a).toBe(1);
100
- expect(called.b).toBe(1);
101
- });
102
- });
@@ -1,60 +0,0 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
2
- import { Application, Controller } from "@hotwired/stimulus";
3
- import useActions, { withActions } from "../index.js";
4
-
5
- function nextTick() { return new Promise((r) => setTimeout(r, 0)); }
6
-
7
- describe("static actions support", () => {
8
- let app, root;
9
-
10
- beforeEach(async () => {
11
- document.body.innerHTML = "";
12
- root = document.createElement("div");
13
- document.body.appendChild(root);
14
- app = Application.start(root);
15
- await nextTick();
16
- });
17
-
18
- it("binds from static actions when calling useActions(this)", async () => {
19
- root.innerHTML = `
20
- <div data-controller="demo">
21
- <button id="one" data-demo-target="button"></button>
22
- </div>
23
- `;
24
-
25
- const calls = { submit: 0 };
26
- class DemoController extends Controller {
27
- static targets = ["button"];
28
- static actions = { buttonTarget: "click->submit" };
29
- connect() { useActions(this); }
30
- submit() { calls.submit++; }
31
- }
32
-
33
- app.register("demo", DemoController);
34
- await nextTick();
35
- document.getElementById("one").dispatchEvent(new Event("click", { bubbles: true }));
36
- expect(calls.submit).toBe(1);
37
- });
38
-
39
- it("auto-binds via withActions() without calling connect helper", async () => {
40
- root.innerHTML = `
41
- <div data-controller="demo">
42
- <button id="two" data-demo-target="button"></button>
43
- </div>
44
- `;
45
-
46
- const calls = { submit: 0 };
47
- class Base extends Controller {
48
- static targets = ["button"];
49
- static actions = { buttonTarget: "click->submit" };
50
- submit() { calls.submit++; }
51
- }
52
- const DemoController = withActions(Base);
53
-
54
- app.register("demo", DemoController);
55
- await nextTick();
56
- document.getElementById("two").dispatchEvent(new Event("click", { bubbles: true }));
57
- expect(calls.submit).toBe(1);
58
- });
59
- });
60
-
@@ -1,40 +0,0 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
2
- import { Application, Controller } from "@hotwired/stimulus";
3
- import useActions from "../index.js";
4
-
5
- function nextTick() { return new Promise((r) => setTimeout(r, 0)); }
6
-
7
- describe("Window actions", () => {
8
- let app, root;
9
-
10
- beforeEach(async () => {
11
- document.body.innerHTML = "";
12
- root = document.createElement("div");
13
- document.body.appendChild(root);
14
- app = Application.start(root);
15
- await nextTick();
16
- });
17
-
18
- it("handles multiple window events via array", async () => {
19
- root.innerHTML = `<div data-controller="demo"></div>`;
20
- const hits = { resize: 0, scroll: 0 };
21
-
22
- class DemoController extends Controller {
23
- connect() {
24
- useActions(this, { window: ["resize->onResize", "scroll->onScroll"] });
25
- }
26
- onResize() { hits.resize++; }
27
- onScroll() { hits.scroll++; }
28
- }
29
-
30
- app.register("demo", DemoController);
31
- await nextTick();
32
-
33
- window.dispatchEvent(new Event("resize"));
34
- window.dispatchEvent(new Event("scroll"));
35
-
36
- expect(hits.resize).toBe(1);
37
- expect(hits.scroll).toBe(1);
38
- });
39
- });
40
-
package/vitest.config.mjs DELETED
@@ -1,18 +0,0 @@
1
- import { defineConfig } from "vitest/config";
2
-
3
- export default defineConfig({
4
- test: {
5
- environment: "happy-dom",
6
- coverage: {
7
- provider: "v8",
8
- reportsDirectory: "./coverage",
9
- reporter: ["text", "lcov", "json-summary", "html"],
10
- thresholds: {
11
- lines: 100,
12
- functions: 100,
13
- branches: 100,
14
- statements: 100,
15
- },
16
- },
17
- },
18
- });