@alwatr/action 9.12.0 → 9.14.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
@@ -2,9 +2,7 @@
2
2
 
3
3
  **Declarative DOM action-dispatch — the Action layer for Unidirectional Data Flow.**
4
4
 
5
- `@alwatr/action` bridges HTML attributes to typed signal handlers. Add an `on-action` attribute to any element, and the library automatically listens for the specified DOM event, resolves the payload, and dispatches a named action signal. Subscribe to actions anywhere in your app with `onAction`.
6
-
7
- This package serves as the **Action layer** in a Unidirectional Data Flow (UDF) architecture: UI elements declare their intent via `on-action` attributes, actions flow upward to business logic via `dispatchAction`, and state flows back down to the UI through signals.
5
+ `@alwatr/action` bridges HTML `on-action` attributes to typed signal handlers using **global event delegation**. One listener on `document.body` covers every element on the page including elements added dynamically after bootstrap with O(1) initialization cost regardless of how many elements exist.
8
6
 
9
7
  ---
10
8
 
@@ -12,82 +10,93 @@ This package serves as the **Action layer** in a Unidirectional Data Flow (UDF)
12
10
 
13
11
  | Approach | Problem |
14
12
  | -------------------------------------- | -------------------------------------------------------- |
15
- | Inline `addEventListener` everywhere | Scattered, hard to trace, breaks on dynamic content |
13
+ | Inline `addEventListener` everywhere | O(N) boot cost, scattered, breaks on dynamic content |
16
14
  | Framework event bindings (React, Vue…) | Requires full framework buy-in |
17
15
  | Custom events + `dispatchEvent` | Verbose, no typed payload, no central subscription point |
18
- | **`@alwatr/action`** | ✅ Declarative, typed, zero-coupling, SPA-friendly |
16
+ | **`@alwatr/action`** | ✅ O(1) boot, declarative, typed, zero-coupling |
19
17
 
20
18
  ---
21
19
 
22
- ## Installation
20
+ ## How It Works
23
21
 
24
- ```bash
25
- bun add @alwatr/action
26
- # or
27
- npm i @alwatr/action
28
- ```
22
+ ### Action Bus
29
23
 
30
- ---
24
+ The action bus is powered by a [`ChannelSignal`](../signal/README.md) from `@alwatr/signal`. Dispatching action `'A'` performs a single `Map.get('A')` lookup and invokes only the handlers registered for that specific action — **O(1) per dispatch**, regardless of how many other actions are subscribed.
31
25
 
32
- ## Attribute Syntax
26
+ ### Global Event Delegation
27
+
28
+ A single capture-phase listener on `document.body` handles all `on-action` elements. When an event fires, the handler walks up from `event.target` using `closest('[on-action]')`, parses the attribute, runs modifiers, resolves the payload, and dispatches the action.
33
29
 
34
30
  ```
35
- on-action="eventType->actionId"
36
- on-action="eventType->actionId:payload"
31
+ User clicks a button
32
+
33
+
34
+ document.body capture listener (1 listener per event type)
35
+
36
+ └─ closest('[on-action^=click]') → finds element
37
+ parse attribute → 'click->add-to-cart:42'
38
+ run modifiers → none
39
+ resolve payload → '42'
40
+ internalChannel_.dispatch('add-to-cart', '42')
41
+
42
+ └─ Map.get('add-to-cart') → O(1) → invoke only matching handlers
37
43
  ```
38
44
 
39
- | Segment | Description | Example |
40
- | ----------- | ------------------------------------------------------------ | ----------------------------- |
41
- | `eventType` | Any standard DOM event name | `click`, `input`, `submit` |
42
- | `actionId` | Identifier your handler subscribes to | `open-drawer`, `search-query` |
43
- | `:payload` | Optional literal string, or `$value` to read `element.value` | `:main`, `:$value` |
45
+ ### Complexity
44
46
 
45
- ### Payload resolvers
47
+ | Metric | Per-element listeners | Global delegation |
48
+ | --------------- | --------------------- | -------------------- |
49
+ | Boot time | O(N elements) | **O(1)** |
50
+ | Memory | O(N listeners) | **O(1)** |
51
+ | Dynamic content | Requires re-bootstrap | **Works out-of-box** |
52
+ | `once` modifier | Native option | Remove attribute |
46
53
 
47
- | Token | Resolves to |
48
- | ------------ | -------------------------------------------------------------- |
49
- | `:$value` | `element.value` (for `<input>`, `<select>`, `<textarea>`) |
50
- | `:$formdata` | `Object.fromEntries(new FormData(form))` from nearest `<form>` |
54
+ ### `once` modifier
51
55
 
52
- ### Event modifiers
56
+ In delegation mode, `once` is implemented by removing the `on-action` attribute from the element after the first fire. This is simpler than a `WeakSet` cache and naturally handles element reuse — if the element is re-rendered with the attribute, it fires again.
53
57
 
54
- Modifiers are chained with `.` after the event type:
58
+ ---
55
59
 
56
- | Modifier | Behavior |
57
- | ----------- | -------------------------------------------------------------------- |
58
- | `.prevent` | Calls `event.preventDefault()` |
59
- | `.stop` | Calls `event.stopPropagation()` |
60
- | `.once` | Removes the listener after the first dispatch (native `once` option) |
61
- | `.passive` | Marks the listener as passive (cannot be combined with `.prevent`) |
62
- | `.validate` | Cancels dispatch if the nearest `<form>` fails `checkValidity()` |
60
+ ## Installation
61
+
62
+ ```bash
63
+ bun add @alwatr/action
64
+ # or
65
+ npm i @alwatr/action
66
+ ```
63
67
 
64
68
  ---
65
69
 
66
70
  ## Quick Start
67
71
 
68
- ### 1. Register the directive and bootstrap
72
+ ### 1. Register your action types
69
73
 
70
- ```typescript
71
- import {bootstrapDirectives} from '@alwatr/directive';
72
- import {registerActionDirective} from '@alwatr/action';
74
+ Extend `ActionRecord` via declaration merging. This gives you full type safety and IDE autocomplete — passing an undeclared action name is a **compile error**.
73
75
 
74
- registerActionDirective(); // registers ActionDirective under 'on-action'
75
- bootstrapDirectives();
76
+ ```ts
77
+ // src/action-record.ts
78
+ declare module '@alwatr/action' {
79
+ interface ActionRecord {
80
+ 'open-drawer': string;
81
+ 'search-query': string;
82
+ 'add-to-cart': {productId: number; qty: number};
83
+ 'logout': void;
84
+ }
85
+ }
76
86
  ```
77
87
 
78
- ### 2. Subscribe to actions
88
+ ### 2. Bootstrap delegation
79
89
 
80
- ```typescript
81
- import {onAction} from '@alwatr/action';
90
+ ```ts
91
+ import {setupActionDelegation, onAction} from '@alwatr/action';
92
+ import './action-record.js'; // ensure the declaration is loaded
82
93
 
83
- // Fires whenever any element with on-action="click->open-drawer:main" is clicked
84
- onAction('open-drawer', (payload) => {
85
- openDrawer(payload); // payload === 'main'
86
- });
94
+ setupActionDelegation();
87
95
 
88
- // Fires on every keystroke in an input with on-action="input->search-query:$value"
89
- onAction('search-query', (query) => {
90
- performSearch(query);
96
+ // Payload types are inferred from ActionRecord no generics needed.
97
+ onAction('open-drawer', (panel) => openDrawer(panel)); // panel: string
98
+ onAction('add-to-cart', (item) => {
99
+ cartService.add(item.productId, item.qty); // fully typed
91
100
  });
92
101
  ```
93
102
 
@@ -97,9 +106,6 @@ onAction('search-query', (query) => {
97
106
  <!-- Dispatches 'open-drawer' with payload 'main' on click -->
98
107
  <button on-action="click->open-drawer:main">Open Drawer</button>
99
108
 
100
- <!-- Dispatches 'open-drawer' with payload 'settings' on click -->
101
- <button on-action="click->open-drawer:settings">Settings</button>
102
-
103
109
  <!-- Dispatches 'search-query' with the input's live value -->
104
110
  <input
105
111
  type="search"
@@ -107,131 +113,183 @@ onAction('search-query', (query) => {
107
113
  placeholder="Search…"
108
114
  />
109
115
 
110
- <!-- Prevents default and validates form before dispatching all field values -->
116
+ <!-- Prevents default, validates, then dispatches all field values -->
111
117
  <form
112
118
  on-action="submit.prevent.validate->submit-form:$formdata"
113
119
  novalidate
114
120
  >
115
- <!-- ... -->
121
+ <input
122
+ name="username"
123
+ required
124
+ />
125
+ <button type="submit">Save</button>
116
126
  </form>
127
+
128
+ <!-- Fires only once — attribute is removed after first click -->
129
+ <button on-action="click.once->welcome-dismissed">Got it</button>
117
130
  ```
118
131
 
119
- ---
132
+ ### 4. Programmatic dispatch
120
133
 
121
- ## Programmatic Dispatch
134
+ ```ts
135
+ import {dispatchAction} from '@alwatr/action';
122
136
 
123
- Dispatch actions from code using `dispatchAction` — useful after async operations or from service layers:
137
+ await uploadFile(file);
138
+ dispatchAction('upload-complete', fileId);
124
139
 
125
- ```typescript
126
- import {dispatchAction} from '@alwatr/action';
140
+ dispatchAction('navigate', '/dashboard');
141
+ ```
142
+
143
+ ---
127
144
 
128
- dispatchAction('open-drawer', 'main');
129
- dispatchAction('navigate', '/home');
130
- dispatchAction<{code: number}>('show-error', {code: 404});
145
+ ## Attribute Syntax
146
+
147
+ ```
148
+ on-action="eventType[.modifier…]->actionId[:payload]"
131
149
  ```
132
150
 
151
+ | Segment | Description | Example |
152
+ | ----------- | --------------------------------------------------------- | ----------------------------- |
153
+ | `eventType` | Any standard DOM event name | `click`, `input`, `submit` |
154
+ | `modifier` | Optional dot-chained tokens processed before dispatch | `.prevent`, `.validate` |
155
+ | `actionId` | Identifier your handler subscribes to | `open-drawer`, `search-query` |
156
+ | `:payload` | Optional literal string, or a `$`-prefixed resolver token | `:main`, `:$value` |
157
+
158
+ ### Built-in modifiers
159
+
160
+ | Modifier | Behavior |
161
+ | ----------- | -------------------------------------------------------------------------------- |
162
+ | `.prevent` | Calls `event.preventDefault()` |
163
+ | `.once` | Removes the `on-action` attribute after first fire — action dispatches only once |
164
+ | `.validate` | Cancels dispatch if the nearest `<form>` fails `checkValidity()` |
165
+
166
+ ### Built-in payload resolvers
167
+
168
+ | Token | Resolves to |
169
+ | ------------ | -------------------------------------------------------------- |
170
+ | `:$value` | `element.value` (for `<input>`, `<select>`, `<textarea>`) |
171
+ | `:$formdata` | `Object.fromEntries(new FormData(form))` from nearest `<form>` |
172
+
133
173
  ---
134
174
 
135
175
  ## API Reference
136
176
 
137
- ### `onAction(actionId, handler)`
177
+ ### `ActionRecord` (interface)
138
178
 
139
- Subscribes to a named action dispatched by any `on-action` directive or `dispatchAction` call.
179
+ The global action type registry. Extend via declaration merging to register typed actions.
140
180
 
141
- ```typescript
142
- function onAction<T = string>(actionId: string, handler: (payload?: T) => void): SubscribeResult;
181
+ ```ts
182
+ declare module '@alwatr/action' {
183
+ interface ActionRecord {
184
+ 'open-drawer': string;
185
+ 'logout': void;
186
+ }
187
+ }
143
188
  ```
144
189
 
145
- | Parameter | Type | Description |
146
- | ---------- | ----------------------- | ----------------------------------- |
147
- | `actionId` | `string` | The action identifier to listen for |
148
- | `handler` | `(payload?: T) => void` | Called with the resolved payload |
190
+ ---
149
191
 
150
- Returns a `SubscribeResult` with an `unsubscribe()` method.
192
+ ### `setupActionDelegation(eventTypes?)`
151
193
 
152
- ```typescript
153
- const sub = onAction('open-drawer', (payload) => {
154
- /* … */
155
- });
194
+ Registers global event delegation on `document.body`. Call once at bootstrap. Idempotent.
156
195
 
157
- // Stop listening when no longer needed (prevents memory leaks)
158
- sub.unsubscribe();
196
+ ```ts
197
+ function setupActionDelegation(eventTypes?: readonly string[]): void;
159
198
  ```
160
199
 
161
- ---
200
+ Defaults to `DEFAULT_DELEGATED_EVENTS`: `['click', 'submit', 'input', 'change']`.
162
201
 
163
- ### `dispatchAction(actionId, payload?)`
164
-
165
- Dispatches a named action signal. Any `onAction` subscriber with a matching `actionId` will be invoked.
202
+ ```ts
203
+ import {setupActionDelegation, DEFAULT_DELEGATED_EVENTS} from '@alwatr/action';
166
204
 
167
- ```typescript
168
- function dispatchAction<T = string>(actionId: string, actionPayload?: T): void;
205
+ setupActionDelegation([...DEFAULT_DELEGATED_EVENTS, 'keydown']);
169
206
  ```
170
207
 
171
208
  ---
172
209
 
173
- ### `registerActionDirective()`
174
-
175
- Lazy registration for `ActionDirective`. Call once before `bootstrapDirectives()`.
176
- If never called, the entire directive module is tree-shaken from the bundle.
210
+ ### `teardownActionDelegation()`
177
211
 
178
- ```typescript
179
- import {registerActionDirective} from '@alwatr/action';
180
- import {bootstrapDirectives} from '@alwatr/directive';
212
+ Removes all delegation listeners and clears the descriptor cache. Useful in tests or micro-frontend teardown.
181
213
 
182
- registerActionDirective();
183
- bootstrapDirectives();
214
+ ```ts
215
+ function teardownActionDelegation(): void;
184
216
  ```
185
217
 
186
218
  ---
187
219
 
188
- ### `registerPageIdDirective()`
220
+ ### `onAction(actionId, handler)`
189
221
 
190
- Registers the `page-id` directive, which dispatches a `'page-ready'` action with the page ID as payload when the element is initialized.
222
+ Subscribes to a named action. O(1) routing via `ChannelSignal`.
191
223
 
192
- ```html
193
- <body page-id="home"></body>
224
+ ```ts
225
+ function onAction<K extends keyof ActionRecord>(
226
+ actionId: K,
227
+ handler: (payload: ActionRecord[K]) => void,
228
+ ): SubscribeResult;
194
229
  ```
195
230
 
196
- ```typescript
197
- import {registerPageIdDirective, onAction} from '@alwatr/action';
231
+ ```ts
232
+ const sub = onAction('open-drawer', (panel) => openDrawer(panel));
233
+ sub.unsubscribe(); // prevent memory leaks
234
+ ```
198
235
 
199
- registerPageIdDirective();
236
+ ---
200
237
 
201
- onAction('page-ready', (pageId) => {
202
- console.log('Page is ready:', pageId); // 'home'
203
- });
238
+ ### `dispatchAction(actionId, payload?)`
239
+
240
+ Dispatches a named action. Payload type is enforced by `ActionRecord`.
241
+
242
+ ```ts
243
+ // With payload
244
+ dispatchAction('open-drawer', 'settings');
245
+
246
+ // Void payload — no second argument
247
+ dispatchAction('logout');
204
248
  ```
205
249
 
206
250
  ---
207
251
 
208
252
  ### `registerModifier(name, handler)`
209
253
 
210
- Registers a custom modifier for use in `on-action` directives. Return `false` from the handler to cancel the dispatch.
254
+ Registers a custom modifier. Return `false` to cancel the dispatch.
255
+
256
+ Handler signature: `(event: Event, element: HTMLElement) => boolean`
211
257
 
212
- ```typescript
258
+ ```ts
213
259
  import {registerModifier} from '@alwatr/action';
214
260
 
215
- registerModifier('confirm', function () {
216
- return window.confirm('Are you sure?');
261
+ // Arrow function no `this` binding needed
262
+ registerModifier('not-disabled', (_event, element) => {
263
+ return !(element as HTMLButtonElement).disabled;
217
264
  });
218
265
  ```
219
266
 
220
267
  ```html
221
- <button on-action="click.confirm->delete-item:42">Delete</button>
268
+ <button
269
+ on-action="click.not-disabled->select-item:$data-id"
270
+ data-id="42"
271
+ >
272
+ Select
273
+ </button>
222
274
  ```
223
275
 
224
276
  ---
225
277
 
226
278
  ### `registerPayloadResolver(name, resolver)`
227
279
 
228
- Registers a custom payload resolver for use in `on-action` directives.
280
+ Registers a custom payload resolver.
281
+
282
+ Handler signature: `(event: Event, element: HTMLElement) => unknown`
229
283
 
230
- ```typescript
284
+ ```ts
231
285
  import {registerPayloadResolver} from '@alwatr/action';
232
286
 
233
- registerPayloadResolver('$checked', function () {
234
- return (this.element_ as HTMLInputElement).checked;
287
+ registerPayloadResolver('$checked', (_event, element) => {
288
+ return (element as HTMLInputElement).checked;
289
+ });
290
+
291
+ registerPayloadResolver('$data-id', (_event, element) => {
292
+ return (element as HTMLElement).dataset.id ?? null;
235
293
  });
236
294
  ```
237
295
 
@@ -240,18 +298,16 @@ registerPayloadResolver('$checked', function () {
240
298
  type="checkbox"
241
299
  on-action="change->toggle-feature:$checked"
242
300
  />
301
+ <li
302
+ on-action="click->select-item:$data-id"
303
+ data-id="42"
304
+ >
305
+ Item
306
+ </li>
243
307
  ```
244
308
 
245
309
  ---
246
310
 
247
- ### `ActionDirective`
248
-
249
- The directive class registered under the `on-action` attribute. Extends `Directive` from `@alwatr/directive`.
250
-
251
- You rarely need to interact with this class directly — use `registerActionDirective()` to register it.
252
-
253
- ---
254
-
255
311
  ## Unidirectional Data Flow
256
312
 
257
313
  ```
@@ -259,13 +315,15 @@ You rarely need to interact with this class directly — use `registerActionDire
259
315
  │ UI Layer │
260
316
  │ <button on-action="click->add-to-cart:42">Add</button> │
261
317
  └────────────────────────┬────────────────────────────────┘
262
- │ DOM event
318
+ │ DOM event bubbles to body
263
319
 
264
320
  ┌─────────────────────────────────────────────────────────┐
265
- Action Layer (@alwatr/action)
266
- dispatchAction('add-to-cart', '42')
321
+ Action Layer (@alwatr/action)
322
+ document.body capture listener (1 per event type)
323
+ │ → closest('[on-action]') → parse → modifiers │
324
+ │ → internalChannel_.dispatch('add-to-cart', '42') [O(1)]│
267
325
  └────────────────────────┬────────────────────────────────┘
268
- action signal
326
+ O(1) routing via ChannelSignal
269
327
 
270
328
  ┌─────────────────────────────────────────────────────────┐
271
329
  │ Business Logic Layer │
@@ -275,45 +333,55 @@ You rarely need to interact with this class directly — use `registerActionDire
275
333
 
276
334
  ┌─────────────────────────────────────────────────────────┐
277
335
  │ State Layer (@alwatr/signal) │
278
- │ cartSignal.dispatch(newCartState)
336
+ │ cartSignal.set(newCartState)
279
337
  └────────────────────────┬────────────────────────────────┘
280
338
  │ state flows down to UI
281
339
 
282
- UI Layer
340
+ UI re-renders
283
341
  ```
284
342
 
285
343
  ---
286
344
 
287
- ## Lifecycle
345
+ ## Page Identity
288
346
 
289
- ```
290
- bootstrapDirectives()
291
-
292
- └─ finds element with on-action="click->open-drawer:main"
293
-
294
- └─ new ActionDirective(element, 'on-action')
295
-
296
- └─ after one macrotask → init_()
297
-
298
- ├─ parse attributeValue with syntaxRegex
299
- ├─ if invalid → log accident, return
300
- └─ addEventListener(eventType, dispatch_)
301
- addDestroyHook(removeEventListener)
302
- ```
347
+ For page-ready signals in SSG/SSR apps (reading `page-id` attribute and notifying
348
+ page-specific handlers), use [`@alwatr/page-ready`](../page-ready/README.md) instead.
349
+ It is intentionally separate from the action bus — page identity is a routing/lifecycle
350
+ concern, not a user-interaction action.
303
351
 
304
352
  ---
305
353
 
306
- ## Cleanup & Memory Management
354
+ ## Migration from Previous Versions
307
355
 
308
- Every `addEventListener` registered by the directive has a corresponding `removeEventListener` in a destroy hook. Call `autoDestructDirectives()` periodically (or on route changes) to clean up directives whose elements have been removed from the DOM.
356
+ ### `ActionContext` removed
309
357
 
310
- ```typescript
311
- import {autoDestructDirectives} from '@alwatr/directive';
358
+ The `this` context in modifier and resolver handlers changed to explicit parameters:
312
359
 
313
- // Clean up on every SPA navigation
314
- router.on('navigate', autoDestructDirectives);
360
+ **Before:**
361
+
362
+ ```ts
363
+ registerModifier('not-disabled', function () {
364
+ return !(this.element as HTMLButtonElement).disabled;
365
+ });
366
+ ```
367
+
368
+ **After:**
369
+
370
+ ```ts
371
+ registerModifier('not-disabled', (_event, element) => {
372
+ return !(element as HTMLButtonElement).disabled;
373
+ });
315
374
  ```
316
375
 
376
+ ### `once` behavior changed
377
+
378
+ Previously tracked via `WeakSet`. Now removes the `on-action` attribute after first fire.
379
+ Behavior is equivalent for typical use cases.
380
+
381
+ ### `page-ready` moved to `@alwatr/page-ready`
382
+
383
+ `dispatchPageId` / `onPageReady` are no longer part of this package.
384
+
317
385
  ---
318
386
 
319
387
  ## Contributing
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @file action-record.ts
3
+ *
4
+ * Global action type registry via TypeScript declaration merging.
5
+ *
6
+ * ## How it works
7
+ *
8
+ * `ActionRecord` is an open interface — any package in the monorepo (or any
9
+ * consumer application) can extend it with their own action names and payload
10
+ * types using declaration merging, without modifying this file:
11
+ *
12
+ * ```ts
13
+ * // In your package: src/action-record.ts
14
+ * declare module '@alwatr/action' {
15
+ * interface ActionRecord {
16
+ * 'open-drawer': string;
17
+ * 'add-to-cart': {productId: number; qty: number};
18
+ * 'logout': void;
19
+ * }
20
+ * }
21
+ * ```
22
+ *
23
+ * Once declared, `onAction` and `dispatchAction` become fully type-safe for
24
+ * those action names — the compiler enforces the correct payload type at every
25
+ * call site and provides autocomplete for action identifiers.
26
+ *
27
+ * Only actions declared in `ActionRecord` are accepted. Passing an unknown
28
+ * action name is a **compile error** — there is no string fallback.
29
+ */
30
+ /**
31
+ * Global registry mapping action identifiers to their payload types.
32
+ *
33
+ * Extend this interface via declaration merging to register your application's
34
+ * actions and gain full type safety in `onAction` and `dispatchAction`.
35
+ *
36
+ * This interface is intentionally empty in the base package — all actions are
37
+ * application-specific and should be declared in a dedicated `action-record.ts`
38
+ * file within each feature package.
39
+ *
40
+ * @example — registering actions in a feature package
41
+ * ```ts
42
+ * // pkg/my-feature/src/action-record.ts
43
+ * declare module '@alwatr/action' {
44
+ * interface ActionRecord {
45
+ * 'open-drawer': string;
46
+ * 'add-to-cart': {productId: number; qty: number};
47
+ * 'logout': void;
48
+ * }
49
+ * }
50
+ * ```
51
+ */
52
+ export interface ActionRecord {
53
+ }
54
+ //# sourceMappingURL=action-record.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"action-record.d.ts","sourceRoot":"","sources":["../src/action-record.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,MAAM,WAAW,YAAY;CAAG"}