@alwatr/action 9.16.0 → 9.17.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/src/action.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @file action.ts
3
+ *
4
+ * Defines the Alwatr Flux Standard Action (AFSA) — the single, unified data
5
+ * structure that flows through the entire action bus for both dispatch and
6
+ * subscription.
7
+ *
8
+ * ## Why a single Action object?
9
+ *
10
+ * Previously, `dispatchAction(id, payload)` and `onAction(id, handler(payload))`
11
+ * treated the action as two separate concerns: an identifier and a raw payload.
12
+ * This made it impossible to carry cross-cutting metadata (context, trace IDs,
13
+ * timestamps) without breaking every call site.
14
+ *
15
+ * AFSA wraps everything into one object:
16
+ * - `type` — the action identifier (replaces the first positional argument)
17
+ * - `payload` — the business data (replaces the second positional argument)
18
+ * - `context` — the DOM context extracted from the nearest `[action-context]`
19
+ * ancestor at delegation time; `undefined` for programmatic dispatches
20
+ * - `meta` — open-ended bag for future cross-cutting concerns (trace IDs,
21
+ * timestamps, A/B flags, etc.) without breaking the typed API
22
+ *
23
+ * Modifiers in the delegation pipeline can also mutate `meta` before the action
24
+ * reaches subscribers — e.g. a `trace` modifier could stamp a request ID.
25
+ */
26
+
27
+ import type {DictionaryOpt} from '@alwatr/type-helper';
28
+ import type {ActionRecord} from './action-record.js';
29
+
30
+ /**
31
+ * Alwatr Flux Standard Action (AFSA).
32
+ *
33
+ * The single, canonical object passed to every `dispatchAction` call and
34
+ * received by every `onAction` handler. Keeping all action data in one
35
+ * structure makes the bus extensible without breaking existing call sites.
36
+ *
37
+ * @template K - A key of `ActionRecord`; constrains `type` and `payload` together.
38
+ *
39
+ * @example — dispatching
40
+ * ```ts
41
+ * dispatchAction({type: 'add_to_cart', payload: {productId: 42, qty: 1}});
42
+ * ```
43
+ *
44
+ * @example — subscribing
45
+ * ```ts
46
+ * onAction('add_to_cart', (action) => {
47
+ * console.log(action.type); // 'add_to_cart'
48
+ * console.log(action.payload); // {productId: 42, qty: 1}
49
+ * console.log(action.context); // e.g. 'product-list' (from DOM) or undefined
50
+ * });
51
+ * ```
52
+ */
53
+ export interface Action<K extends keyof ActionRecord = keyof ActionRecord> {
54
+ /**
55
+ * Unique action identifier — must be a key of `ActionRecord`.
56
+ *
57
+ * @example 'cart:add-item', 'open_drawer', 'logout'
58
+ */
59
+ readonly type: K;
60
+
61
+ /**
62
+ * The DOM context in which the action was triggered.
63
+ *
64
+ * Extracted at delegation time from the nearest ancestor element that carries
65
+ * an `action-context` attribute. Useful for scoping the same action type to
66
+ * different UI regions (e.g. two sliders on the same page both dispatching
67
+ * `'slider:change'` but with different context values).
68
+ *
69
+ * `undefined` when the action is dispatched programmatically (no DOM involved)
70
+ * or when no `[action-context]` ancestor exists.
71
+ *
72
+ * @example 'slider-123', 'product-list', 'checkout-form'
73
+ */
74
+ readonly context?: string;
75
+
76
+ /**
77
+ * The pure business payload carried by this action.
78
+ *
79
+ * Type is inferred from `ActionRecord[K]` — the compiler enforces the correct
80
+ * shape at every call site. No manual generic annotation is needed.
81
+ */
82
+ readonly payload: ActionRecord[K];
83
+
84
+ /**
85
+ * Open-ended metadata bag for cross-cutting concerns.
86
+ *
87
+ * Intentionally untyped so that future infrastructure layers (tracing,
88
+ * analytics, A/B testing) can attach data without touching the typed API.
89
+ * Modifiers in the delegation pipeline may also write to `meta` before the
90
+ * action reaches subscribers.
91
+ *
92
+ * Treat values here as `unknown` and validate before use.
93
+ *
94
+ * @example {traceId: 'abc-123', timestamp: Date.now()}
95
+ */
96
+ meta?: DictionaryOpt<unknown>;
97
+ }
package/src/delegate.ts CHANGED
@@ -16,8 +16,12 @@
16
16
  * - When an event fires, the handler walks up the DOM from `event.target`
17
17
  * using `closest()` to find the nearest element with an `on-<eventType>`
18
18
  * attribute (e.g. `on-click`, `on-submit`).
19
- * - Modifiers and payload resolvers run in the same pipeline as before.
20
- * - `dispatchAction` is called with the resolved payload.
19
+ * - The nearest `[action-context]` ancestor is also resolved and attached to
20
+ * the `Action` object as `context` enabling the same action type to be
21
+ * scoped to different UI regions.
22
+ * - Modifiers run with access to the mutable `Action` object so they can
23
+ * enrich `meta` before the action reaches subscribers.
24
+ * - `dispatchAction` is called with the fully assembled `Action` object.
21
25
  *
22
26
  * ## Complexity
23
27
  *
@@ -40,6 +44,8 @@
40
44
 
41
45
  import {internalChannel_, logger_} from './lib.js';
42
46
  import {modifierRegistry, payloadRegistry} from './registry.js';
47
+ import type {Action} from './action.js';
48
+ import type {ActionRecord} from './action-record.js';
43
49
 
44
50
  // ─── Syntax Parser ────────────────────────────────────────────────────────────
45
51
 
@@ -70,7 +76,7 @@ import {modifierRegistry, payloadRegistry} from './registry.js';
70
76
  * → actionId='my_submit_handler', payload='$formdata', modifiers={'prevent','validate'}
71
77
  * ```
72
78
  */
73
- const syntaxRegex = /^([a-z0-9_-]+)(?::([^;]+))?(?:;\s*([a-z0-9_,-]+))?$/;
79
+ const syntaxRegex = /^([a-z0-9_:-]+)(?::([^;]+))?(?:;\s*([a-z0-9_,-]+))?$/;
74
80
 
75
81
  // ─── Parsed Action Descriptor ─────────────────────────────────────────────────
76
82
 
@@ -150,9 +156,12 @@ function parseDescriptor__(attributeValue: string): ActionDescriptor | null {
150
156
  * 1. Walk up from `event.target` to find the nearest element with an
151
157
  * `on-<eventType>` attribute (e.g. `on-click`, `on-submit`).
152
158
  * 2. Parse (or retrieve from cache) the `ActionDescriptor` for that attribute.
153
- * 3. Run each modifier in order; if any returns `false`, abort.
154
- * 4. Resolve the payload token (literal or $-resolver).
155
- * 5. Call `dispatchAction(actionId, payload)`.
159
+ * 3. Resolve `context` from the nearest `[action-context]` ancestor.
160
+ * 4. Build a mutable `Action` object with `type`, `payload` (raw), and `context`.
161
+ * 5. Run each modifier in order with access to the mutable `Action`; if any
162
+ * returns `false`, abort.
163
+ * 6. Resolve the payload token (literal or $-resolver) and assign to `action.payload`.
164
+ * 7. Call `dispatchAction(action)` with the fully assembled object.
156
165
  *
157
166
  * @internal
158
167
  */
@@ -186,13 +195,28 @@ function handleDelegatedEvent__(event: Event): void {
186
195
 
187
196
  logger_.logMethodArgs?.('handleDelegatedEvent__.action', {eventType, descriptor});
188
197
 
189
- // Step 1: handle once modifier — remove attribute before running other modifiers
198
+ // Step 1: handle `once` modifier — remove attribute before running other modifiers
190
199
  // so that even if a modifier aborts, the element will not fire again.
191
200
  if (descriptor.modifiers.has('once')) {
192
201
  actionElement.removeAttribute(actionAttrib);
193
202
  }
194
203
 
195
- // Step 2: run modifiers
204
+ // Step 2: resolve `context` from the nearest [action-context] ancestor.
205
+ // Walk up from the action element itself (inclusive) to find the context scope.
206
+ // This allows the action element itself to carry action-context if needed.
207
+ const actionContext = actionElement.closest('[action-context]')?.getAttribute('action-context') ?? undefined;
208
+
209
+ // Step 3: build the mutable Action object.
210
+ // `payload` starts as the raw token string; it will be resolved in step 5.
211
+ // Modifiers in step 4 may mutate `meta` to attach cross-cutting data.
212
+ const action: Action = {
213
+ type: descriptor.actionId as keyof ActionRecord,
214
+ context: actionContext,
215
+ // Payload is temporarily set to the raw token; resolved below after modifiers run.
216
+ payload: descriptor.payload as ActionRecord[keyof ActionRecord],
217
+ };
218
+
219
+ // Step 4: run modifiers — each receives the mutable action so it can enrich meta.
196
220
  for (const modifier of descriptor.modifiers) {
197
221
  if (modifier === 'once') continue; // handled above
198
222
  const handler = modifierRegistry.get(modifier);
@@ -200,18 +224,26 @@ function handleDelegatedEvent__(event: Event): void {
200
224
  logger_.accident('handleDelegatedEvent__', 'unknown_modifier', {eventType, modifier, attributeValue, descriptor});
201
225
  return; // unknown modifier — abort to avoid silent misbehavior
202
226
  }
203
- if (handler(event, actionElement) === false) return;
227
+ if (handler(event, actionElement, action) === false) return;
204
228
  }
205
229
 
206
- // Step 3: resolve payload
207
- let payload: unknown = descriptor.payload;
208
- if (payload) {
209
- const resolver = payloadRegistry.get(payload as string);
210
- if (resolver) payload = resolver(event, actionElement);
230
+ // Step 5: resolve payload — replace raw token with the actual value.
231
+ // If the raw token starts with '$', look it up in the payload resolver registry.
232
+ // Otherwise treat it as a literal string payload.
233
+ if (descriptor.payload) {
234
+ const resolver = payloadRegistry.get(descriptor.payload);
235
+ if (resolver) {
236
+ // Cast needed: payload is typed as ActionRecord[K] but we're building generically.
237
+ (action as {payload: unknown}).payload = resolver(event, actionElement);
238
+ }
239
+ // else: keep the literal string already set on action.payload
240
+ } else {
241
+ // No payload token in the attribute — set to undefined.
242
+ (action as {payload: unknown}).payload = undefined;
211
243
  }
212
244
 
213
- // Step 4: dispatch
214
- internalChannel_.dispatch(descriptor.actionId, payload);
245
+ // Step 6: dispatch the fully assembled Action object.
246
+ internalChannel_.dispatch(action.type, action);
215
247
  }
216
248
 
217
249
  // ─── Setup ────────────────────────────────────────────────────────────────────
@@ -243,8 +275,9 @@ export const DEFAULT_DELEGATED_EVENTS: readonly string[] = ['click', 'submit', '
243
275
  * Registers global event delegation for `on-<eventType>` attributes.
244
276
  *
245
277
  * Attaches a single `capture`-phase listener on `document.body` for each
246
- * event type in `eventTypes`. All processing — modifier execution, payload
247
- * resolution, and `dispatchAction` — happens inside that one handler.
278
+ * event type in `eventTypes`. All processing — context resolution, modifier
279
+ * execution, payload resolution, and `dispatchAction` — happens inside that
280
+ * one handler.
248
281
  *
249
282
  * **Call this once at application bootstrap**, before any user interaction.
250
283
  * Subsequent calls with the same event types are no-ops (idempotent).
@@ -261,6 +294,24 @@ export const DEFAULT_DELEGATED_EVENTS: readonly string[] = ['click', 'submit', '
261
294
  * after this call — via `innerHTML`, `lit-html`, a framework renderer, or
262
295
  * server-sent HTML — is automatically covered. No re-bootstrap is needed.
263
296
  *
297
+ * ### Context scoping
298
+ *
299
+ * Wrap a group of elements in a `[action-context]` container to scope their
300
+ * actions. The delegation handler automatically resolves the nearest ancestor
301
+ * and attaches its value to `action.context`:
302
+ *
303
+ * ```html
304
+ * <section action-context="product-list">
305
+ * <button on-click="add_to_cart:42">Add</button>
306
+ * </section>
307
+ * ```
308
+ *
309
+ * ```ts
310
+ * onAction('add_to_cart', (action) => {
311
+ * console.log(action.context); // 'product-list'
312
+ * });
313
+ * ```
314
+ *
264
315
  * @param eventTypes - Event types to delegate. Defaults to `DEFAULT_DELEGATED_EVENTS`.
265
316
  *
266
317
  * @example — minimal bootstrap
@@ -270,7 +321,7 @@ export const DEFAULT_DELEGATED_EVENTS: readonly string[] = ['click', 'submit', '
270
321
  * // One call activates the entire page.
271
322
  * setupActionDelegation();
272
323
  *
273
- * onAction('open_drawer', (panel) => openDrawer(panel));
324
+ * onAction('open_drawer', (action) => openDrawer(action.payload));
274
325
  * ```
275
326
  *
276
327
  * @example — with extra event types
package/src/lib.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import {createLogger} from '@alwatr/logger';
2
2
  import {createChannelSignal} from '@alwatr/signal';
3
3
 
4
+ import type {Action} from './action.js';
5
+
4
6
  /**
5
7
  * Module-scoped logger for `@alwatr/action`.
6
8
  * Scoped to `'alwatr-action'` so log lines are easy to filter in the console.
@@ -10,16 +12,17 @@ import {createChannelSignal} from '@alwatr/signal';
10
12
  export const logger_ = createLogger('alwatr-action');
11
13
 
12
14
  /**
13
- * The action channel — a `ChannelSignal` strictly typed by `ActionRecord`.
15
+ * The internal action channel — a `ChannelSignal` keyed by action `type`.
14
16
  *
15
- * Only action names declared in `ActionRecord` (via declaration merging) are
16
- * accepted at compile time. Passing an unknown action name to `onAction` or
17
- * `dispatchAction` is a **compile error** there is no string fallback.
17
+ * Each message on this channel is a full `Action` object (AFSA), not just a
18
+ * raw payload. Subscribers registered via `onAction('foo', handler)` receive
19
+ * the entire `Action<'foo'>` so they have access to `context`, `meta`, and
20
+ * `payload` in one place.
18
21
  *
19
22
  * Uses `ChannelSignal` for O(1) routing: dispatching action `'A'` performs a
20
23
  * single `Map.get('A')` lookup and invokes only the handlers registered for
21
- * that specific action — never handlers for `'B'`, `'C'`, etc.
24
+ * that specific type — never handlers for `'B'`, `'C'`, etc.
22
25
  *
23
26
  * @internal — not part of the public API; use `onAction` / `dispatchAction` instead.
24
27
  */
25
- export const internalChannel_ = createChannelSignal<Record<string, unknown>>({name: 'alwatr-action'});
28
+ export const internalChannel_ = createChannelSignal<Record<string, Action>>({name: 'alwatr-action'});
package/src/main.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * @alwatr/action — Declarative DOM action-dispatch for Unidirectional Data Flow.
3
3
  *
4
+ * Implements the **Alwatr Flux Standard Action (AFSA)** pattern: every action
5
+ * flowing through the bus is a single, typed `Action<K>` object carrying
6
+ * `type`, `payload`, `context`, and optional `meta`. This replaces the previous
7
+ * two-argument `(id, payload)` API with a unified structure that is extensible
8
+ * without breaking existing call sites.
9
+ *
4
10
  * ## Activating `on-<eventType>` attributes
5
11
  *
6
12
  * Call `setupActionDelegation()` once at bootstrap. A single capture-phase
@@ -11,7 +17,7 @@
11
17
  * import {setupActionDelegation, onAction} from '@alwatr/action';
12
18
  *
13
19
  * setupActionDelegation();
14
- * onAction('open_drawer', (panel) => openDrawer(panel));
20
+ * onAction('open_drawer', (action) => openDrawer(action.payload));
15
21
  * ```
16
22
  *
17
23
  * ## Attribute syntax
@@ -26,12 +32,31 @@
26
32
  * <form on-submit="submit_form:$formdata; prevent,validate" novalidate>…</form>
27
33
  * ```
28
34
  *
35
+ * ## Context scoping
36
+ *
37
+ * Wrap elements in an `[action-context]` container to scope their actions.
38
+ * The delegation handler resolves the nearest ancestor and attaches its value
39
+ * to `action.context`:
40
+ *
41
+ * ```html
42
+ * <section action-context="product-list">
43
+ * <button on-click="add_to_cart:42">Add</button>
44
+ * </section>
45
+ * ```
46
+ *
47
+ * ```ts
48
+ * onAction('add_to_cart', (action) => {
49
+ * console.log(action.context); // 'product-list'
50
+ * console.log(action.payload); // '42'
51
+ * });
52
+ * ```
53
+ *
29
54
  * ## Programmatic dispatch
30
55
  *
31
56
  * ```ts
32
57
  * import {dispatchAction} from '@alwatr/action';
33
58
  *
34
- * dispatchAction('navigate', '/dashboard');
59
+ * dispatchAction({type: 'navigate', payload: '/dashboard'});
35
60
  * ```
36
61
  *
37
62
  * ## Registering typed actions
@@ -51,7 +76,8 @@
51
76
  *
52
77
  * ## Public API
53
78
  *
54
- * - `ActionRecord` extend this interface to register typed actions
79
+ * - `Action` the AFSA object interface (`type`, `payload`, `context`, `meta`)
80
+ * - `ActionRecord` — extend this interface to register typed actions
55
81
  * - `setupActionDelegation` / `teardownActionDelegation` — global delegation lifecycle
56
82
  * - `DEFAULT_DELEGATED_EVENTS` — default event types covered by delegation
57
83
  * - `onAction` / `dispatchAction` — subscribe to and dispatch named actions
@@ -62,5 +88,6 @@
62
88
  * For page-ready signals in SSG/SSR apps, use `@alwatr/page-ready` instead.
63
89
  */
64
90
  export type {ActionRecord} from './action-record.js';
91
+ export type {Action} from './action.js';
65
92
  export * from './method.js';
66
93
  export * from './delegate.js';
package/src/method.ts CHANGED
@@ -1,25 +1,30 @@
1
1
  import type {Awaitable} from '@alwatr/type-helper';
2
- import {internalChannel_, logger_} from './lib.js';
3
2
  import type {SubscribeResult} from '@alwatr/signal';
3
+
4
+ import {internalChannel_, logger_} from './lib.js';
4
5
  import {modifierRegistry, payloadRegistry, type ModifierHandler, type PayloadResolver} from './registry.js';
5
6
  import type {ActionRecord} from './action-record.js';
7
+ import type {Action} from './action.js';
6
8
 
7
9
  // Re-export extension types so consumers can import them from the package root.
8
10
  export type {ModifierHandler, PayloadResolver};
11
+ export type {Action};
9
12
 
10
13
  // ─── Core Action API ──────────────────────────────────────────────────────────
11
14
 
12
15
  /**
13
16
  * Subscribes to a named action dispatched anywhere in the application.
14
17
  *
15
- * `actionId` must be a key of `ActionRecord`. The handler's `payload` parameter
16
- * is automatically typed to the corresponding `ActionRecord` value — no manual
17
- * generic annotation needed:
18
+ * `type` must be a key of `ActionRecord`. The handler receives the full
19
+ * `Action<K>` object giving access to `payload`, `context`, and `meta`
20
+ * in one place. No manual generic annotation is needed; the compiler infers
21
+ * the correct `payload` type from `ActionRecord`:
18
22
  *
19
23
  * ```ts
20
24
  * // ActionRecord declares: 'add_to_cart': {productId: number; qty: number}
21
- * onAction('add_to_cart', (item) => {
22
- * cartService.add(item.productId, item.qty); // fully typed, no `!` needed
25
+ * onAction('add_to_cart', (action) => {
26
+ * cartService.add(action.payload.productId, action.payload.qty); // fully typed
27
+ * console.log(action.context); // e.g. 'product-list' (from DOM) or undefined
23
28
  * });
24
29
  * ```
25
30
  *
@@ -38,80 +43,81 @@ export type {ModifierHandler, PayloadResolver};
38
43
  * Internally delegates to `ChannelSignal.on()` for **O(1) routing** — dispatching
39
44
  * action `'A'` never invokes handlers registered for action `'B'`.
40
45
  *
41
- * @param actionId - A key of `ActionRecord`.
42
- * @param handler - Callback invoked with the typed payload on each dispatch.
46
+ * @param type - A key of `ActionRecord`.
47
+ * @param handler - Callback invoked with the full `Action<K>` on each dispatch.
43
48
  * @returns A `SubscribeResult` with an `unsubscribe()` method for cleanup.
44
49
  *
45
50
  * @example
46
51
  * ```ts
47
52
  * import {onAction} from '@alwatr/action';
48
53
  *
49
- * const sub = onAction('page_ready', (pageId) => {
50
- * router.setPage(pageId); // pageId: string — inferred from ActionRecord
54
+ * const sub = onAction('page_ready', (action) => {
55
+ * router.setPage(action.payload); // payload: string — inferred from ActionRecord
51
56
  * });
52
57
  *
53
58
  * sub.unsubscribe(); // stop listening when no longer needed
54
59
  * ```
55
60
  */
56
61
  export function onAction<K extends keyof ActionRecord>(
57
- actionId: K,
58
- handler: (payload: ActionRecord[K]) => Awaitable<void>,
62
+ type: K,
63
+ handler: (action: Action<K>) => Awaitable<void>,
59
64
  ): SubscribeResult {
60
- logger_.logMethodArgs?.('onAction', {actionId});
61
- return internalChannel_.on(actionId, handler as (payload: unknown) => Awaitable<void>);
65
+ logger_.logMethodArgs?.('onAction', {type});
66
+ // The internal channel stores Action<any>; we cast to Action<K> here because
67
+ // the channel key guarantees the type matches — only Action<K> objects are
68
+ // ever dispatched under key K.
69
+ return internalChannel_.on(type, handler as (action: Action) => Awaitable<void>);
62
70
  }
63
71
 
64
72
  /**
65
- * Dispatches a named action to all `onAction` subscribers with a matching `actionId`.
73
+ * Dispatches an action to all `onAction` subscribers with a matching `type`.
66
74
  *
67
- * `actionId` must be a key of `ActionRecord`. The `payload` parameter is
68
- * automatically typed — passing the wrong type is a **compile error**:
75
+ * Accepts a full `Action<K>` object. The `payload` field is automatically
76
+ * typed from `ActionRecord[K]` — passing the wrong shape is a **compile error**:
69
77
  *
70
78
  * ```ts
71
79
  * // ActionRecord declares: 'add_to_cart': {productId: number; qty: number}
72
- * dispatchAction('add_to_cart', {productId: 42, qty: 1}); // ✅
73
- * dispatchAction('add_to_cart', 'wrong'); // ❌ compile error
74
- * dispatchAction('unknown_action', 'x'); // ❌ compile error
80
+ * dispatchAction({type: 'add_to_cart', payload: {productId: 42, qty: 1}}); // ✅
81
+ * dispatchAction({type: 'add_to_cart', payload: 'wrong'}); // ❌ compile error
82
+ * dispatchAction({type: 'unknown_action', payload: 'x'}); // ❌ compile error
75
83
  * ```
76
84
  *
77
- * Register new actions by extending `ActionRecord` via declaration merging:
78
- *
79
- * ```ts
80
- * // src/action-record.ts
81
- * declare module '@alwatr/action' {
82
- * interface ActionRecord {
83
- * 'navigate': string;
84
- * 'logout': void;
85
- * }
86
- * }
87
- * ```
85
+ * The `context` and `meta` fields are optional. When dispatching from code
86
+ * (not from the DOM), omit `context` — it is only meaningful for DOM-originated
87
+ * actions where an `[action-context]` ancestor exists.
88
88
  *
89
89
  * Use `dispatchAction` when triggering an action from code — e.g. after an
90
90
  * async operation, from a service layer, or in tests. For DOM-driven actions,
91
91
  * use the `on-<eventType>` HTML attribute with `setupActionDelegation`.
92
92
  *
93
- * @param actionId - A key of `ActionRecord`.
94
- * @param actionPayload - The payload; type is enforced by `ActionRecord`.
93
+ * @param action - A full `Action<K>` object with at minimum `type` and `payload`.
95
94
  *
96
95
  * @example — with payload
97
96
  * ```ts
98
97
  * import {dispatchAction} from '@alwatr/action';
99
98
  *
100
- * dispatchAction('page_ready', 'home');
101
- * dispatchAction('navigate', '/dashboard');
99
+ * dispatchAction({type: 'navigate', payload: '/dashboard'});
100
+ * dispatchAction({type: 'add_to_cart', payload: {productId: 42, qty: 1}});
102
101
  * ```
103
102
  *
104
- * @example — void payload (no second argument)
103
+ * @example — void payload
105
104
  * ```ts
106
- * dispatchAction('logout');
105
+ * dispatchAction({type: 'logout', payload: undefined});
106
+ * ```
107
+ *
108
+ * @example — with context and meta
109
+ * ```ts
110
+ * dispatchAction({
111
+ * type: 'slider_change',
112
+ * payload: 75,
113
+ * context: 'volume_slider',
114
+ * meta: {traceId: 'abc-123'},
115
+ * });
107
116
  * ```
108
117
  */
109
- export function dispatchAction<K extends keyof ActionRecord>(
110
- ...args: ActionRecord[K] extends void | undefined ? [actionId: K] : [actionId: K, actionPayload: ActionRecord[K]]
111
- ): void {
112
- const [actionId, actionPayload] = args;
113
- logger_.logMethodArgs?.('dispatchAction', {actionId, actionPayload});
114
- internalChannel_.dispatch(actionId, actionPayload);
118
+ export function dispatchAction<K extends keyof ActionRecord>(action: Action<K>): void {
119
+ logger_.logMethodArgs?.('dispatchAction', action);
120
+ internalChannel_.dispatch(action.type, action);
115
121
  }
116
122
 
117
123
  // ─── Extension API ────────────────────────────────────────────────────────────
@@ -123,14 +129,17 @@ export function dispatchAction<K extends keyof ActionRecord>(
123
129
  * (e.g. `on-click="action-id; mymod"`). Its handler runs before the payload is
124
130
  * resolved and the action is dispatched. Returning `false` cancels the dispatch.
125
131
  *
126
- * Built-in modifiers (`prevent`, `stop`, `validate`, `once`) are always
127
- * available. This function lets you add domain-specific ones.
132
+ * The handler also receives the **mutable** `action` object being built, so it
133
+ * can attach data to `action.meta` before the action reaches subscribers.
134
+ *
135
+ * Built-in modifiers (`prevent`, `validate`, `once`) are always available.
136
+ * This function lets you add domain-specific ones.
128
137
  *
129
138
  * Registering the same name twice logs an accident and overwrites the previous
130
139
  * handler — avoid duplicate registrations in production code.
131
140
  *
132
141
  * @param name - The modifier token (lowercase, no special characters).
133
- * @param handler - A `ModifierHandler` receiving `(event, element)`.
142
+ * @param handler - A `ModifierHandler` receiving `(event, element, action)`.
134
143
  *
135
144
  * @example — a `confirm` modifier that shows a browser dialog
136
145
  * ```ts
@@ -141,6 +150,15 @@ export function dispatchAction<K extends keyof ActionRecord>(
141
150
  * ```html
142
151
  * <button on-click="delete_item:42; confirm">Delete</button>
143
152
  * ```
153
+ *
154
+ * @example — a `trace` modifier that stamps a trace ID into meta
155
+ * ```ts
156
+ * registerModifier('trace', (_event, _element, action) => {
157
+ * action.meta ??= {};
158
+ * action.meta['traceId'] = crypto.randomUUID();
159
+ * return true;
160
+ * });
161
+ * ```
144
162
  */
145
163
  export function registerModifier(name: string, handler: ModifierHandler): void {
146
164
  logger_.logMethodArgs?.('registerModifier', {name});
@@ -155,11 +173,11 @@ export function registerModifier(name: string, handler: ModifierHandler): void {
155
173
  *
156
174
  * A payload resolver is a colon-prefixed token in the attribute value
157
175
  * (e.g. `on-click="action-id:$mytoken"`). Its function is called at dispatch time
158
- * with an `ActionContext` as `this` and the DOM event as the argument.
159
- * The return value becomes the `actionPayload` passed to `onAction` subscribers.
176
+ * with the DOM event and the element. The return value becomes the `payload`
177
+ * field of the `Action` object passed to `onAction` subscribers.
160
178
  *
161
- * Built-in resolvers (`$value`, `$formdata`) are always available. This function
162
- * lets you add domain-specific ones.
179
+ * Built-in resolvers (`$value`, `$formdata`, `$checked`) are always available.
180
+ * This function lets you add domain-specific ones.
163
181
  *
164
182
  * Registering the same name twice logs an accident and overwrites the previous
165
183
  * resolver — avoid duplicate registrations in production code.
@@ -167,16 +185,16 @@ export function registerModifier(name: string, handler: ModifierHandler): void {
167
185
  * @param name - The resolver token (should start with `$` by convention).
168
186
  * @param resolver - A `PayloadResolver` receiving `(event, element)`.
169
187
  *
170
- * @example — a `$checked` resolver for checkbox state
188
+ * @example — a `$data-id` resolver that reads a data attribute
171
189
  * ```ts
172
190
  * import {registerPayloadResolver} from '@alwatr/action';
173
191
  *
174
- * registerPayloadResolver('$checked', (_event, element) => {
175
- * return (element as HTMLInputElement).checked;
192
+ * registerPayloadResolver('$data-id', (_event, element) => {
193
+ * return (element as HTMLElement).dataset.id ?? null;
176
194
  * });
177
195
  * ```
178
196
  * ```html
179
- * <input type="checkbox" on-change="toggle_feature:$checked" />
197
+ * <button on-click="select_item:$data-id" data-id="42">Select</button>
180
198
  * ```
181
199
  */
182
200
  export function registerPayloadResolver(name: string, resolver: PayloadResolver): void {