@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 +215 -147
- package/dist/action-record.d.ts +54 -0
- package/dist/action-record.d.ts.map +1 -0
- package/dist/delegate.d.ts +103 -0
- package/dist/delegate.d.ts.map +1 -0
- package/dist/lib.d.ts +8 -29
- package/dist/lib.d.ts.map +1 -1
- package/dist/main.d.ts +48 -9
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +3 -3
- package/dist/main.js.map +7 -8
- package/dist/method.d.ts +70 -57
- package/dist/method.d.ts.map +1 -1
- package/dist/registry.d.ts +20 -15
- package/dist/registry.d.ts.map +1 -1
- package/package.json +7 -9
- package/src/action-record.ts +54 -0
- package/src/delegate.ts +315 -0
- package/src/lib.ts +9 -31
- package/src/main.ts +48 -9
- package/src/method.ts +79 -65
- package/src/registry.ts +31 -43
- package/dist/directive.d.ts +0 -94
- package/dist/directive.d.ts.map +0 -1
- package/dist/page-id.d.ts +0 -57
- package/dist/page-id.d.ts.map +0 -1
- package/src/directive.ts +0 -197
- package/src/page-id.ts +0 -74
package/src/delegate.ts
ADDED
|
@@ -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 {
|
|
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
|
|
13
|
+
* The action channel — a `ChannelSignal` strictly typed by `ActionRecord`.
|
|
37
14
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
*
|
|
43
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
* - `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
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 './
|
|
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 {
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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` value — no manual
|
|
17
|
+
* generic annotation needed:
|
|
17
18
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
30
|
+
* // src/action-record.ts
|
|
31
|
+
* declare module '@alwatr/action' {
|
|
32
|
+
* interface ActionRecord {
|
|
33
|
+
* 'open-drawer': string;
|
|
34
|
+
* }
|
|
35
|
+
* }
|
|
36
|
+
* ```
|
|
31
37
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
37
|
-
*
|
|
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
|
|
45
|
+
* @example
|
|
41
46
|
* ```ts
|
|
42
47
|
* import {onAction} from '@alwatr/action';
|
|
43
48
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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<
|
|
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
|
|
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
|
-
*
|
|
63
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* @example — void payload (no second argument)
|
|
105
|
+
* ```ts
|
|
106
|
+
* dispatchAction('logout');
|
|
84
107
|
* ```
|
|
85
108
|
*/
|
|
86
|
-
export function dispatchAction<
|
|
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
|
-
|
|
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
|
|
101
|
-
*
|
|
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 -
|
|
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',
|
|
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
|
|
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 -
|
|
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',
|
|
151
|
-
* return (
|
|
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});
|