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.
- package/README.md +110 -0
- package/index.js +118 -1
- 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
|
+
[](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
|
-
|
|
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.
|
|
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": "
|
|
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
|
}
|