stimulus-use-actions 0.1.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.
Files changed (3) hide show
  1. package/README.md +110 -0
  2. package/index.js +118 -1
  3. package/package.json +15 -3
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # stimulus-use-actions
2
+
3
+ Declare Stimulus `data-action` bindings in JavaScript instead of HTML markup.
4
+
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
+
7
+ Requires Stimulus v3+ and ES modules.
8
+
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
+
14
+ ```js
15
+ import { Controller } from "stimulus-use-actions"
16
+
17
+ export default class extends Controller {
18
+ static targets = ["field", "checkbox"]
19
+
20
+ static actions = {
21
+ field: "input->update",
22
+ checkbox: "change->rerender",
23
+ window: "resize->layout",
24
+ }
25
+
26
+ update(event) { /* ... */ }
27
+ rerender(event) { /* ... */ }
28
+ layout(event) { /* ... */ }
29
+ }
30
+ ```
31
+
32
+ Actions are bound via **event delegation** on the controller element. This
33
+ means:
34
+
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
39
+
40
+ ### Keys
41
+
42
+ Each key in `static actions` is a **target name** matching an entry in
43
+ `static targets`, or the special key `window`.
44
+
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**:
53
+
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:
66
+
67
+ ```js
68
+ import { Controller } from "@hotwired/stimulus"
69
+ import useActions from "stimulus-use-actions"
70
+
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
+ }
80
+
81
+ submit() { /* ... */ }
82
+ preview() { /* ... */ }
83
+ reflow() { /* ... */ }
84
+ }
85
+ ```
86
+
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.
90
+
91
+ ### `useActions(controller, actions?)`
92
+
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`
100
+
101
+ A `withActions(BaseController)` wrapper is also available -- it returns a
102
+ subclass that auto-calls `useActions(this)` in `connect()`.
103
+
104
+ ## Notes
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,4 +1,10 @@
1
- export default function(controller, actions) {
1
+ import { Controller as StimulusController } from "@hotwired/stimulus"
2
+
3
+ // --- Direct binding via Stimulus internals (useActions, withActions) ---
4
+
5
+ function useActionsImpl(controller, actions) {
6
+ if (!actions) actions = controller?.constructor?.actions || controller?.actions;
7
+ if (!actions || typeof actions !== "object") return;
2
8
  const bindingObserver = controller.context.bindingObserver
3
9
 
4
10
  Object.entries(actions).forEach(([targetName, actionName]) => {
@@ -28,3 +34,114 @@ export default function(controller, actions) {
28
34
  }
29
35
  }
30
36
 
37
+ export default useActionsImpl
38
+
39
+ export function withActions(BaseController) {
40
+ return class WithActions extends BaseController {
41
+ connect() {
42
+ super.connect()
43
+ useActionsImpl(this)
44
+ }
45
+ }
46
+ }
47
+
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
+
137
+ export class Controller extends StimulusController {
138
+ connect() {
139
+ super.connect()
140
+ this._useActionListeners = bindDelegatedActions(this)
141
+ }
142
+ disconnect() {
143
+ super.disconnect()
144
+ unbindDelegatedActions(this._useActionListeners)
145
+ this._useActionListeners = []
146
+ }
147
+ }
package/package.json CHANGED
@@ -1,11 +1,23 @@
1
1
  {
2
2
  "name": "stimulus-use-actions",
3
- "version": "0.1.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
- "test": "echo \"Error: no test specified\" && exit 1"
8
+ "test": "vitest run",
9
+ "test:watch": "vitest",
10
+ "coverage": "vitest run --coverage",
11
+ "coverage:badge": "node scripts/generate-coverage-badge.js",
12
+ "ci": "vitest run --coverage && node scripts/generate-coverage-badge.js"
8
13
  },
9
14
  "author": "",
10
- "license": "ISC"
15
+ "license": "ISC",
16
+ "devDependencies": {
17
+ "@hotwired/stimulus": "^3.2.2",
18
+ "@vitest/coverage-v8": "^4.0.4",
19
+ "happy-dom": "^20.0.8",
20
+ "jsdom": "^27.0.1",
21
+ "vitest": "^4.0.4"
22
+ }
11
23
  }