@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/README.md +249 -70
- package/dist/action.d.ts +93 -0
- package/dist/action.d.ts.map +1 -0
- package/dist/delegate.d.ts +28 -5
- package/dist/delegate.d.ts.map +1 -1
- package/dist/lib.d.ts +8 -6
- package/dist/lib.d.ts.map +1 -1
- package/dist/main.d.ts +30 -3
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +3 -3
- package/dist/main.js.map +6 -6
- package/dist/method.d.ts +62 -45
- package/dist/method.d.ts.map +1 -1
- package/dist/registry.d.ts +24 -12
- package/dist/registry.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/action.ts +97 -0
- package/src/delegate.ts +70 -19
- package/src/lib.ts +9 -6
- package/src/main.ts +30 -3
- package/src/method.ts +72 -54
- package/src/registry.ts +31 -17
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
|
-
* -
|
|
20
|
-
*
|
|
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_
|
|
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.
|
|
154
|
-
* 4.
|
|
155
|
-
* 5.
|
|
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:
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
214
|
-
internalChannel_.dispatch(
|
|
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 —
|
|
247
|
-
* resolution, and `dispatchAction` — happens inside that
|
|
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', (
|
|
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`
|
|
15
|
+
* The internal action channel — a `ChannelSignal` keyed by action `type`.
|
|
14
16
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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,
|
|
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', (
|
|
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
|
-
* - `
|
|
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
|
-
* `
|
|
16
|
-
*
|
|
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', (
|
|
22
|
-
* cartService.add(
|
|
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
|
|
42
|
-
* @param handler
|
|
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', (
|
|
50
|
-
* router.setPage(
|
|
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
|
-
|
|
58
|
-
handler: (
|
|
62
|
+
type: K,
|
|
63
|
+
handler: (action: Action<K>) => Awaitable<void>,
|
|
59
64
|
): SubscribeResult {
|
|
60
|
-
logger_.logMethodArgs?.('onAction', {
|
|
61
|
-
|
|
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
|
|
73
|
+
* Dispatches an action to all `onAction` subscribers with a matching `type`.
|
|
66
74
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
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('
|
|
101
|
-
* dispatchAction('
|
|
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
|
|
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
|
-
|
|
111
|
-
)
|
|
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
|
-
*
|
|
127
|
-
*
|
|
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
|
|
159
|
-
*
|
|
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.
|
|
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 `$
|
|
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('$
|
|
175
|
-
* return (element as
|
|
192
|
+
* registerPayloadResolver('$data-id', (_event, element) => {
|
|
193
|
+
* return (element as HTMLElement).dataset.id ?? null;
|
|
176
194
|
* });
|
|
177
195
|
* ```
|
|
178
196
|
* ```html
|
|
179
|
-
* <
|
|
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 {
|