@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.
@@ -0,0 +1,315 @@
1
+ /**
2
+ * @file delegate.ts
3
+ *
4
+ * Global Event Delegation engine for `@alwatr/action`.
5
+ *
6
+ * ## Why delegation instead of per-element listeners?
7
+ *
8
+ * The classic directive approach attaches one `addEventListener` per element.
9
+ * With 100 buttons on a page, that means 100 listener registrations at boot
10
+ * time — O(N) initialization cost, O(N) memory for listener references, and
11
+ * zero support for elements added after bootstrap.
12
+ *
13
+ * This module implements the **Qwik-inspired global delegation** pattern:
14
+ * - A single listener per event type is attached to `document.body` with
15
+ * `capture: true` (so it fires even for non-bubbling events).
16
+ * - When an event fires, the handler walks up the DOM from `event.target`
17
+ * using `closest()` to find the nearest element with an `on-action`
18
+ * attribute whose event type matches.
19
+ * - Modifiers and payload resolvers run in the same pipeline as before.
20
+ * - `dispatchAction` is called with the resolved payload.
21
+ *
22
+ * ## Complexity
23
+ *
24
+ * | Metric | Per-element listeners | Global delegation |
25
+ * | --------------- | --------------------- | ----------------- |
26
+ * | Boot time | O(N elements) | O(1) — 1 loop |
27
+ * | Memory | O(N listeners) | O(1) — 1 handler |
28
+ * | Dynamic content | Requires re-bootstrap | Works out-of-box |
29
+ * | `once` modifier | Native option | Manual tracking |
30
+ *
31
+ * ## Trade-offs vs. the directive approach
32
+ *
33
+ * - `passive` is not supported as a per-element option (all delegated listeners
34
+ * are non-passive so that `prevent` can call `preventDefault()`).
35
+ * - `stop` stops further bubbling but the delegation handler has already
36
+ * captured the event at `body` level — it does not prevent other delegation
37
+ * handlers from running on the same element.
38
+ * - `once` is emulated by delete attribute elements after first fire.
39
+ */
40
+
41
+ import {internalChannel_, logger_} from './lib.js';
42
+ import {modifierRegistry, payloadRegistry} from './registry.js';
43
+
44
+ // ─── Syntax Parser ────────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Parses the `on-action` attribute value into its three segments.
48
+ *
49
+ * Full syntax: `eventType[.modifier…]->actionId[:payload]`
50
+ *
51
+ * | Capture group | Matches | Example |
52
+ * | ------------- | ------------------------------------------- | -------------------- |
53
+ * | 1 | Event type + optional dot-chained modifiers | `click.prevent.once` |
54
+ * | 2 | Action identifier | `open-drawer` |
55
+ * | 3 | Optional payload token or literal | `main` / `$value` |
56
+ *
57
+ * @example
58
+ * ```
59
+ * 'click.prevent.once->open-drawer:main' → ['click.prevent.once', 'open-drawer', 'main']
60
+ * 'input->search-query:$value' → ['input', 'search-query', '$value']
61
+ * 'submit.prevent->submit-form' → ['submit.prevent', 'submit-form', undefined]
62
+ * ```
63
+ */
64
+ const syntaxRegex = /^([a-z0-9.-]+)->([a-z0-9-]+)(?::(.+))?$/;
65
+
66
+ // ─── Parsed Action Descriptor ─────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Parsed and cached representation of a single `on-action` attribute value.
70
+ *
71
+ * Caching avoids re-parsing the same attribute string on every event fire.
72
+ * The cache is keyed by the raw attribute string so identical values share
73
+ * the same descriptor object.
74
+ */
75
+ interface ActionDescriptor {
76
+ /** The DOM event type to listen for (e.g. `'click'`, `'input'`). */
77
+ readonly eventType: string;
78
+ /** Set of active modifier names (e.g. `{'prevent', 'once'}`). */
79
+ readonly modifiers: ReadonlySet<string>;
80
+ /** The action identifier dispatched to `onAction` subscribers. */
81
+ readonly actionId: string;
82
+ /** Raw payload token from the attribute (literal string or `$`-resolver key). */
83
+ readonly payload: string | undefined;
84
+ }
85
+
86
+ /**
87
+ * LRU-style cache for parsed `on-action` attribute values.
88
+ *
89
+ * Attribute strings are typically repeated across many elements (e.g. every
90
+ * "add to cart" button shares the same `on-action` value). Caching the parsed
91
+ * descriptor avoids redundant regex work on every event.
92
+ *
93
+ * Using a plain `Map` here is intentional — attribute strings are short-lived
94
+ * keys and the map size is bounded by the number of distinct `on-action` values
95
+ * in the page, which is typically small.
96
+ *
97
+ * @internal
98
+ */
99
+ const descriptorCache__ = new Map<string, ActionDescriptor | null>();
100
+
101
+ /**
102
+ * Parses an `on-action` attribute value into an `ActionDescriptor`.
103
+ *
104
+ * Returns `null` when the syntax is invalid. Results are cached by the raw
105
+ * attribute string so repeated calls for the same value are O(1).
106
+ *
107
+ * @internal
108
+ */
109
+ function parseDescriptor__(attributeValue: string): ActionDescriptor | null {
110
+ logger_.logMethodArgs?.('parseDescriptor__', {attributeValue});
111
+
112
+ const cached = descriptorCache__.get(attributeValue);
113
+ // Explicit `undefined` check: `null` means "already parsed and invalid".
114
+ if (cached !== undefined) return cached;
115
+
116
+ const match = attributeValue.match(syntaxRegex);
117
+ if (!match) {
118
+ logger_.accident('parseDescriptor__', 'invalid_syntax', {attributeValue});
119
+ descriptorCache__.set(attributeValue, null);
120
+ return null;
121
+ }
122
+
123
+ const [eventType, ...modifierList] = match[1].split('.');
124
+ if (!eventType) {
125
+ logger_.accident('parseDescriptor__', 'missing_event_type', {attributeValue});
126
+ descriptorCache__.set(attributeValue, null);
127
+ return null;
128
+ }
129
+
130
+ const modifiers = new Set(modifierList);
131
+ const actionId = match[2];
132
+ const payload: string | undefined = match[3];
133
+
134
+ const descriptor: ActionDescriptor = {
135
+ eventType,
136
+ modifiers,
137
+ actionId,
138
+ payload,
139
+ };
140
+
141
+ descriptorCache__.set(attributeValue, descriptor);
142
+ return descriptor;
143
+ }
144
+
145
+ // ─── Core Delegation Handler ──────────────────────────────────────────────────
146
+
147
+ const onActionAttrib__ = 'on-action';
148
+ /**
149
+ * Central event handler attached to `document.body` for every delegated event type.
150
+ *
151
+ * Execution flow for each incoming event:
152
+ * 1. Walk up from `event.target` to find the nearest element with an
153
+ * `on-action` attribute whose event type matches the current event.
154
+ * 2. Parse (or retrieve from cache) the `ActionDescriptor` for that attribute.
155
+ * 3. Run each modifier in order; if any returns `false`, abort.
156
+ * 4. Resolve the payload token (literal or `$`-resolver).
157
+ * 5. Call `dispatchAction(actionId, payload)`.
158
+ *
159
+ * @internal
160
+ */
161
+ function handleDelegatedEvent__(event: Event): void {
162
+ const eventType = event.type;
163
+ logger_.logMethodArgs?.('handleDelegatedEvent__', {eventType});
164
+
165
+ // Walk up the DOM to find the closest element with a matching on-action attribute.
166
+ // We use `closest` on the composedPath target to support Shadow DOM correctly.
167
+ const target = event.target as Element | null;
168
+ if (!target) return;
169
+
170
+ // Find the nearest ancestor (or self) that has an on-action attribute
171
+ const actionElement = target.closest?.(`[${onActionAttrib__}^=${eventType}]`);
172
+ if (!actionElement) return;
173
+
174
+ const attributeValue = actionElement.getAttribute?.(onActionAttrib__)?.trim();
175
+ if (!attributeValue) {
176
+ logger_.accident('handleDelegatedEvent__', 'empty_attribute', {eventType, attributeValue, actionElement});
177
+ return;
178
+ }
179
+
180
+ if (!(actionElement instanceof HTMLElement)) {
181
+ logger_.accident('handleDelegatedEvent__', 'target_not_html_element', {eventType, attributeValue, actionElement});
182
+ return;
183
+ }
184
+
185
+ const descriptor = parseDescriptor__(attributeValue);
186
+ if (!descriptor) {
187
+ logger_.accident('handleDelegatedEvent__', 'invalid_attribute', {eventType, attributeValue, actionElement});
188
+ return;
189
+ }
190
+
191
+ if (descriptor.eventType !== eventType) return;
192
+
193
+ logger_.logMethodArgs?.('handleDelegatedEvent__.action', descriptor);
194
+
195
+ // Step 1: handle once modifier
196
+ if (descriptor.modifiers.has('once')) {
197
+ actionElement.removeAttribute(onActionAttrib__); // remove on-action to prevent repeat the action
198
+ descriptorCache__.delete(attributeValue); // free memory for once
199
+ }
200
+
201
+ // Step 2: run modifiers
202
+ for (const modifier of descriptor.modifiers) {
203
+ if (modifier === 'once') continue; // handled separately
204
+ const handler = modifierRegistry.get(modifier);
205
+ if (!handler) {
206
+ logger_.accident('handleDelegatedEvent__', 'unknown_modifier', {modifier, attributeValue});
207
+ return; // unknown modifier — abort to avoid silent misbehaviour
208
+ }
209
+ if (handler(event, actionElement) === false) return;
210
+ }
211
+
212
+ // Step 3: resolve payload
213
+ let payload: unknown = descriptor.payload;
214
+ if (payload) {
215
+ const resolver = payloadRegistry.get(payload as string);
216
+ if (resolver) payload = resolver(event, actionElement);
217
+ }
218
+
219
+ // Step 4: dispatch
220
+ internalChannel_.dispatch(descriptor.actionId, payload);
221
+ }
222
+
223
+ // ─── Setup ────────────────────────────────────────────────────────────────────
224
+
225
+ /**
226
+ * The set of event types currently delegated to `document.body`.
227
+ *
228
+ * Tracked so that `setupActionDelegation` is idempotent — calling it multiple
229
+ * times with the same event types does not register duplicate listeners.
230
+ *
231
+ * @internal
232
+ */
233
+ const delegatedEventTypes__ = new Set<string>();
234
+
235
+ /**
236
+ * Default DOM event types that cover the vast majority of interactive elements.
237
+ *
238
+ * - `click` — buttons, links, checkboxes, custom interactive elements
239
+ * - `submit` — form submission
240
+ * - `input` — live text input, range sliders
241
+ * - `change` — select boxes, checkboxes, radio buttons (fires on commit)
242
+ *
243
+ * Pass additional types to `setupActionDelegation` when your app uses other
244
+ * events (e.g. `'keydown'`, `'pointerup'`).
245
+ */
246
+ export const DEFAULT_DELEGATED_EVENTS: readonly string[] = ['click', 'submit', 'input', 'change'];
247
+
248
+ /**
249
+ * Registers global event delegation for `on-action` attributes.
250
+ *
251
+ * Attaches a single `capture`-phase listener on `document.body` for each
252
+ * event type in `eventTypes`. All `on-action` processing — modifier execution,
253
+ * payload resolution, and `dispatchAction` — happens inside that one handler.
254
+ *
255
+ * **Call this once at application bootstrap**, before any user interaction.
256
+ * Subsequent calls with the same event types are no-ops (idempotent).
257
+ *
258
+ * ### Why `capture: true`?
259
+ *
260
+ * Capture-phase listeners fire before bubble-phase listeners and also catch
261
+ * events that do not bubble (e.g. `focus`, `blur`). This ensures the delegation
262
+ * handler always runs, even when a child element calls `stopPropagation()`.
263
+ *
264
+ * ### Dynamic content
265
+ *
266
+ * Because the listener lives on `document.body`, any element added to the DOM
267
+ * after this call — via `innerHTML`, `lit-html`, a framework renderer, or
268
+ * server-sent HTML — is automatically covered. No re-bootstrap is needed.
269
+ *
270
+ * @param eventTypes - Event types to delegate. Defaults to `DEFAULT_DELEGATED_EVENTS`.
271
+ *
272
+ * @example — minimal bootstrap
273
+ * ```ts
274
+ * import {setupActionDelegation, onAction} from '@alwatr/action';
275
+ *
276
+ * // One call activates the entire page.
277
+ * setupActionDelegation();
278
+ *
279
+ * onAction('open-drawer', (panel) => openDrawer(panel));
280
+ * ```
281
+ *
282
+ * @example — with extra event types
283
+ * ```ts
284
+ * import {setupActionDelegation, DEFAULT_DELEGATED_EVENTS} from '@alwatr/action';
285
+ *
286
+ * setupActionDelegation([...DEFAULT_DELEGATED_EVENTS, 'keydown', 'pointerup']);
287
+ * ```
288
+ */
289
+ export function setupActionDelegation(eventTypes: readonly string[] = DEFAULT_DELEGATED_EVENTS): void {
290
+ logger_.logMethodArgs?.('setupActionDelegation', {eventTypes});
291
+
292
+ for (const eventType of eventTypes) {
293
+ if (delegatedEventTypes__.has(eventType)) continue; // already registered — skip
294
+ delegatedEventTypes__.add(eventType);
295
+ // capture: true — fires before bubble-phase listeners and catches non-bubbling events.
296
+ document.body.addEventListener(eventType, handleDelegatedEvent__, {capture: true});
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Removes all global delegation listeners registered by `setupActionDelegation`.
302
+ *
303
+ * Useful in test environments where each test needs a clean slate, or in
304
+ * micro-frontend setups where a sub-app is unmounted.
305
+ *
306
+ * After calling this, `setupActionDelegation` can be called again to re-register.
307
+ */
308
+ export function teardownActionDelegation(): void {
309
+ logger_.logMethod?.('teardownActionDelegation');
310
+ for (const eventType of delegatedEventTypes__) {
311
+ document.body.removeEventListener(eventType, handleDelegatedEvent__, {capture: true});
312
+ }
313
+ delegatedEventTypes__.clear();
314
+ descriptorCache__.clear();
315
+ }
package/src/lib.ts CHANGED
@@ -1,28 +1,5 @@
1
1
  import {createLogger} from '@alwatr/logger';
2
- import {createEventSignal} from '@alwatr/signal';
3
-
4
- /**
5
- * The shape of every payload carried by the internal action signal.
6
- *
7
- * `actionId` identifies which action was dispatched (e.g. `'open-drawer'`).
8
- * `actionPayload` is the optional value attached to the action — defaults to
9
- * `string` but can be narrowed to any type via the generic parameter `T`.
10
- *
11
- * @template T The type of the action payload. Defaults to `string`.
12
- *
13
- * @example
14
- * ```ts
15
- * // Typed payload for a cart action
16
- * const payload: ActionSignalPayload<{productId: number; qty: number}> = {
17
- * actionId: 'add-to-cart',
18
- * actionPayload: {productId: 42, qty: 1},
19
- * };
20
- * ```
21
- */
22
- export interface ActionSignalPayload<T = string> {
23
- actionId: string;
24
- actionPayload?: T;
25
- }
2
+ import {createChannelSignal} from '@alwatr/signal';
26
3
 
27
4
  /**
28
5
  * Module-scoped logger for `@alwatr/action`.
@@ -33,15 +10,16 @@ export interface ActionSignalPayload<T = string> {
33
10
  export const logger_ = createLogger('alwatr-action');
34
11
 
35
12
  /**
36
- * The single shared event signal that carries every dispatched action.
13
+ * The action channel a `ChannelSignal` strictly typed by `ActionRecord`.
37
14
  *
38
- * All `ActionDirective` instances write to this signal via `dispatchAction`,
39
- * and all `onAction` subscriptions read from it. Using one central signal keeps
40
- * the pub/sub wiring minimal and makes the action flow easy to trace.
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.
41
18
  *
42
- * The payload is typed as `ActionSignalPayload<unknown>` at the signal level;
43
- * individual subscribers narrow the type through the `onAction` generic.
19
+ * Uses `ChannelSignal` for O(1) routing: dispatching action `'A'` performs a
20
+ * single `Map.get('A')` lookup and invokes only the handlers registered for
21
+ * that specific action — never handlers for `'B'`, `'C'`, etc.
44
22
  *
45
23
  * @internal — not part of the public API; use `onAction` / `dispatchAction` instead.
46
24
  */
47
- export const internalSignal_ = createEventSignal<ActionSignalPayload<unknown>>({name: 'alwatr-action'});
25
+ export const internalChannel_ = createChannelSignal<Record<string, unknown>>({name: 'alwatr-action'});
package/src/main.ts CHANGED
@@ -1,15 +1,54 @@
1
1
  /**
2
2
  * @alwatr/action — Declarative DOM action-dispatch for Unidirectional Data Flow.
3
3
  *
4
- * Public API surface:
4
+ * ## Activating `on-action` attributes
5
+ *
6
+ * Call `setupActionDelegation()` once at bootstrap. A single capture-phase
7
+ * listener on `document.body` handles every `on-action` element — including
8
+ * elements added dynamically after bootstrap — with O(1) initialization cost.
9
+ *
10
+ * ```ts
11
+ * import {setupActionDelegation, onAction} from '@alwatr/action';
12
+ *
13
+ * setupActionDelegation();
14
+ * onAction('open-drawer', (panel) => openDrawer(panel));
15
+ * ```
16
+ *
17
+ * ## Programmatic dispatch
18
+ *
19
+ * ```ts
20
+ * import {dispatchAction} from '@alwatr/action';
21
+ *
22
+ * dispatchAction('navigate', '/dashboard');
23
+ * ```
24
+ *
25
+ * ## Registering typed actions
26
+ *
27
+ * Extend `ActionRecord` via declaration merging to get full type safety:
28
+ *
29
+ * ```ts
30
+ * // src/action-record.ts
31
+ * declare module '@alwatr/action' {
32
+ * interface ActionRecord {
33
+ * 'open-drawer': string;
34
+ * 'add-to-cart': {productId: number; qty: number};
35
+ * 'logout': void;
36
+ * }
37
+ * }
38
+ * ```
39
+ *
40
+ * ## Public API
41
+ *
42
+ * - `ActionRecord` — extend this interface to register typed actions
43
+ * - `setupActionDelegation` / `teardownActionDelegation` — global delegation lifecycle
44
+ * - `DEFAULT_DELEGATED_EVENTS` — default event types covered by delegation
5
45
  * - `onAction` / `dispatchAction` — subscribe to and dispatch named actions
6
- * - `registerActionDirective` opt-in to `on-action` HTML attribute support
7
- * - `registerPageIdDirective` — opt-in to `page-id` HTML attribute support
8
- * - `registerModifier` / `registerPayloadResolver` — extend the directive syntax
9
- * - `ActionDirective` / `PageIdDirective` — directive classes (advanced use)
10
- * - `ActionSignalPayload` payload type carried by the internal signal
46
+ * - `registerModifier` / `registerPayloadResolver` extend the attribute syntax
47
+ *
48
+ * ## Page identity
49
+ *
50
+ * For page-ready signals in SSG/SSR apps, use `@alwatr/page-ready` instead.
11
51
  */
52
+ export type {ActionRecord} from './action-record.js';
12
53
  export * from './method.js';
13
- export * from './directive.js';
14
- export * from './page-id.js';
15
- export type {ActionSignalPayload} from './lib.js';
54
+ export * from './delegate.js';
package/src/method.ts CHANGED
@@ -1,6 +1,8 @@
1
- import {internalSignal_, logger_} from './lib.js';
1
+ import type {Awaitable} from '@alwatr/type-helper';
2
+ import {internalChannel_, logger_} from './lib.js';
2
3
  import type {SubscribeResult} from '@alwatr/signal';
3
4
  import {modifierRegistry, payloadRegistry, type ModifierHandler, type PayloadResolver} from './registry.js';
5
+ import type {ActionRecord} from './action-record.js';
4
6
 
5
7
  // Re-export extension types so consumers can import them from the package root.
6
8
  export type {ModifierHandler, PayloadResolver};
@@ -10,82 +12,106 @@ export type {ModifierHandler, PayloadResolver};
10
12
  /**
11
13
  * Subscribes to a named action dispatched anywhere in the application.
12
14
  *
13
- * The handler is invoked every time `dispatchAction(actionId, payload)` is
14
- * called whether from an `on-action` directive or from code and the
15
- * `actionId` matches. Multiple subscribers for the same `actionId` are all
16
- * notified in subscription order.
15
+ * `actionId` must be a key of `ActionRecord`. The handler's `payload` parameter
16
+ * is automatically typed to the corresponding `ActionRecord` valueno manual
17
+ * generic annotation needed:
17
18
  *
18
- * The generic parameter `T` narrows the type of the received payload.
19
- * Defaults to `string`, which covers the common case of attribute-driven
20
- * literal payloads.
19
+ * ```ts
20
+ * // 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
23
+ * });
24
+ * ```
21
25
  *
22
- * @param actionId - The action identifier to listen for (e.g. `'open-drawer'`).
23
- * @param handler - Callback invoked with the resolved payload each time the
24
- * action is dispatched. `payload` is `undefined` when the
25
- * action was dispatched without a value.
26
- * @returns A `SubscribeResult` with an `unsubscribe()` method for cleanup.
26
+ * Passing an action name not declared in `ActionRecord` is a **compile error**.
27
+ * Register new actions by extending `ActionRecord` via declaration merging:
27
28
  *
28
- * @example — basic string payload
29
29
  * ```ts
30
- * import {onAction} from '@alwatr/action';
30
+ * // src/action-record.ts
31
+ * declare module '@alwatr/action' {
32
+ * interface ActionRecord {
33
+ * 'open-drawer': string;
34
+ * }
35
+ * }
36
+ * ```
31
37
  *
32
- * const sub = onAction('open-drawer', (panel) => {
33
- * openDrawer(panel); // panel === 'settings'
34
- * });
38
+ * Internally delegates to `ChannelSignal.on()` for **O(1) routing** — dispatching
39
+ * action `'A'` never invokes handlers registered for action `'B'`.
35
40
  *
36
- * // Stop listening when the component is destroyed
37
- * sub.unsubscribe();
38
- * ```
41
+ * @param actionId - A key of `ActionRecord`.
42
+ * @param handler - Callback invoked with the typed payload on each dispatch.
43
+ * @returns A `SubscribeResult` with an `unsubscribe()` method for cleanup.
39
44
  *
40
- * @example — typed object payload
45
+ * @example
41
46
  * ```ts
42
47
  * import {onAction} from '@alwatr/action';
43
48
  *
44
- * onAction<{productId: number; qty: number}>('add-to-cart', (item) => {
45
- * cartService.add(item!.productId, item!.qty);
49
+ * const sub = onAction('page-ready', (pageId) => {
50
+ * router.setPage(pageId); // pageId: string — inferred from ActionRecord
46
51
  * });
52
+ *
53
+ * sub.unsubscribe(); // stop listening when no longer needed
47
54
  * ```
48
55
  */
49
- export function onAction<T = string>(actionId: string, handler: (payload?: T) => void): SubscribeResult {
56
+ export function onAction<K extends keyof ActionRecord>(
57
+ actionId: K,
58
+ handler: (payload: ActionRecord[K]) => Awaitable<void>,
59
+ ): SubscribeResult {
50
60
  logger_.logMethodArgs?.('onAction', {actionId});
51
- return internalSignal_.subscribe((signal) => {
52
- if (signal.actionId === actionId) {
53
- logger_.logMethodArgs?.('onAction.invoke', {actionId, payload: signal.actionPayload});
54
- handler(signal.actionPayload as T);
55
- }
56
- });
61
+ return internalChannel_.on(actionId, handler as (payload: unknown) => Awaitable<void>);
57
62
  }
58
63
 
59
64
  /**
60
65
  * Dispatches a named action to all `onAction` subscribers with a matching `actionId`.
61
66
  *
62
- * This is the programmatic counterpart to the `on-action` HTML attribute.
63
- * Use it when you need to trigger an action from code rather than from a DOM
64
- * event (e.g. after an async operation completes, or from a service layer).
67
+ * `actionId` must be a key of `ActionRecord`. The `payload` parameter is
68
+ * automatically typed passing the wrong type is a **compile error**:
65
69
  *
66
- * The generic parameter `T` types the payload. Omit it to default to `string`.
70
+ * ```ts
71
+ * // 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
75
+ * ```
67
76
  *
68
- * @param actionId - The action identifier (e.g. `'navigate'`).
69
- * @param actionPayload - Optional value passed to every matching subscriber.
77
+ * Register new actions by extending `ActionRecord` via declaration merging:
70
78
  *
71
- * @example — dispatch without payload
72
79
  * ```ts
73
- * import {dispatchAction} from '@alwatr/action';
74
- *
75
- * dispatchAction('logout');
80
+ * // src/action-record.ts
81
+ * declare module '@alwatr/action' {
82
+ * interface ActionRecord {
83
+ * 'navigate': string;
84
+ * 'logout': void;
85
+ * }
86
+ * }
76
87
  * ```
77
88
  *
78
- * @example dispatch with a typed payload
89
+ * Use `dispatchAction` when triggering an action from code — e.g. after an
90
+ * async operation, from a service layer, or in tests. For DOM-driven actions,
91
+ * use the `on-action` HTML attribute with `setupActionDelegation`.
92
+ *
93
+ * @param actionId - A key of `ActionRecord`.
94
+ * @param actionPayload - The payload; type is enforced by `ActionRecord`.
95
+ *
96
+ * @example — with payload
79
97
  * ```ts
80
98
  * import {dispatchAction} from '@alwatr/action';
81
99
  *
100
+ * dispatchAction('page-ready', 'home');
82
101
  * dispatchAction('navigate', '/dashboard');
83
- * dispatchAction<{code: number}>('show-error', {code: 404});
102
+ * ```
103
+ *
104
+ * @example — void payload (no second argument)
105
+ * ```ts
106
+ * dispatchAction('logout');
84
107
  * ```
85
108
  */
86
- export function dispatchAction<T = string>(actionId: string, actionPayload?: T): void {
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;
87
113
  logger_.logMethodArgs?.('dispatchAction', {actionId, actionPayload});
88
- internalSignal_.dispatch({actionId, actionPayload});
114
+ internalChannel_.dispatch(actionId, actionPayload);
89
115
  }
90
116
 
91
117
  // ─── Extension API ────────────────────────────────────────────────────────────
@@ -97,22 +123,20 @@ export function dispatchAction<T = string>(actionId: string, actionPayload?: T):
97
123
  * (e.g. `click.mymod->action-id`). Its handler runs before the payload is
98
124
  * resolved and the action is dispatched. Returning `false` cancels the dispatch.
99
125
  *
100
- * Built-in modifiers (`prevent`, `stop`, `validate`, `once`, `passive`) are
101
- * always available. This function lets you add domain-specific ones.
126
+ * Built-in modifiers (`prevent`, `stop`, `validate`, `once`) are always
127
+ * available. This function lets you add domain-specific ones.
102
128
  *
103
129
  * Registering the same name twice logs an accident and overwrites the previous
104
130
  * handler — avoid duplicate registrations in production code.
105
131
  *
106
132
  * @param name - The modifier token (lowercase, no dots or arrows).
107
- * @param handler - The `ModifierHandler` function bound to the directive instance.
133
+ * @param handler - A `ModifierHandler` receiving `(event, element)`.
108
134
  *
109
135
  * @example — a `confirm` modifier that shows a browser dialog
110
136
  * ```ts
111
137
  * import {registerModifier} from '@alwatr/action';
112
138
  *
113
- * registerModifier('confirm', function () {
114
- * return window.confirm('Are you sure?');
115
- * });
139
+ * registerModifier('confirm', () => window.confirm('Are you sure?'));
116
140
  * ```
117
141
  * ```html
118
142
  * <button on-action="click.confirm->delete-item:42">Delete</button>
@@ -131,7 +155,7 @@ export function registerModifier(name: string, handler: ModifierHandler): void {
131
155
  *
132
156
  * A payload resolver is a colon-suffixed token in the attribute value
133
157
  * (e.g. `click->action-id:$mytoken`). Its function is called at dispatch time
134
- * with the directive instance as `this` and the DOM event as the argument.
158
+ * with an `ActionContext` as `this` and the DOM event as the argument.
135
159
  * The return value becomes the `actionPayload` passed to `onAction` subscribers.
136
160
  *
137
161
  * Built-in resolvers (`$value`, `$formdata`) are always available. This function
@@ -141,29 +165,19 @@ export function registerModifier(name: string, handler: ModifierHandler): void {
141
165
  * resolver — avoid duplicate registrations in production code.
142
166
  *
143
167
  * @param name - The resolver token (should start with `$` by convention).
144
- * @param resolver - The `PayloadResolver` function bound to the directive instance.
168
+ * @param resolver - A `PayloadResolver` receiving `(event, element)`.
145
169
  *
146
170
  * @example — a `$checked` resolver for checkbox state
147
171
  * ```ts
148
172
  * import {registerPayloadResolver} from '@alwatr/action';
149
173
  *
150
- * registerPayloadResolver('$checked', function () {
151
- * return (this.element_ as HTMLInputElement).checked;
174
+ * registerPayloadResolver('$checked', (_event, element) => {
175
+ * return (element as HTMLInputElement).checked;
152
176
  * });
153
177
  * ```
154
178
  * ```html
155
179
  * <input type="checkbox" on-action="change->toggle-feature:$checked" />
156
180
  * ```
157
- *
158
- * @example — a `$dataset-id` resolver for data attributes
159
- * ```ts
160
- * registerPayloadResolver('$dataset-id', function () {
161
- * return (this.element_ as HTMLElement).dataset.id ?? null;
162
- * });
163
- * ```
164
- * ```html
165
- * <li on-action="click->select-item:$dataset-id" data-id="42">Item</li>
166
- * ```
167
181
  */
168
182
  export function registerPayloadResolver(name: string, resolver: PayloadResolver): void {
169
183
  logger_.logMethodArgs?.('registerPayloadResolver', {name});