@alwatr/action 9.14.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/delegate.ts CHANGED
@@ -10,14 +10,18 @@
10
10
  * time — O(N) initialization cost, O(N) memory for listener references, and
11
11
  * zero support for elements added after bootstrap.
12
12
  *
13
- * This module implements the **Qwik-inspired global delegation** pattern:
13
+ * This module implements the global delegation pattern:
14
14
  * - A single listener per event type is attached to `document.body` with
15
15
  * `capture: true` (so it fires even for non-bubbling events).
16
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.
17
+ * using `closest()` to find the nearest element with an `on-<eventType>`
18
+ * attribute (e.g. `on-click`, `on-submit`).
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
  *
@@ -35,74 +39,86 @@
35
39
  * - `stop` stops further bubbling but the delegation handler has already
36
40
  * captured the event at `body` level — it does not prevent other delegation
37
41
  * handlers from running on the same element.
38
- * - `once` is emulated by delete attribute elements after first fire.
42
+ * - `once` is emulated by removing the attribute after first fire.
39
43
  */
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
 
46
52
  /**
47
- * Parses the `on-action` attribute value into its three segments.
53
+ * Parses the `on-<eventType>` attribute value into its segments.
48
54
  *
49
- * Full syntax: `eventType[.modifier…]->actionId[:payload]`
55
+ * Syntax: `actionId[:payload][; modifier1,modifier2,…]`
50
56
  *
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` |
57
+ * The event type is encoded in the **attribute name** itself (`on-click`,
58
+ * `on-submit`, etc.) rather than inside the value. This makes the HTML more
59
+ * readable and aligns with native event attribute conventions.
60
+ *
61
+ * | Capture group | Matches | Example |
62
+ * | ------------- | -------------------------------------- | ---------------------- |
63
+ * | 1 | Action identifier | `open_drawer` |
64
+ * | 2 | Optional payload token or literal | `main_menu` / `$value` |
65
+ * | 3 | Optional comma-separated modifier list | `prevent,validate` |
56
66
  *
57
67
  * @example
58
68
  * ```
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]
69
+ * 'close_drawer'
70
+ * actionId='close_drawer', payload=undefined, modifiers={}
71
+ *
72
+ * 'open_drawer:main_menu'
73
+ * → actionId='open_drawer', payload='main_menu', modifiers={}
74
+ *
75
+ * 'my_submit_handler:$formdata; prevent,validate'
76
+ * → actionId='my_submit_handler', payload='$formdata', modifiers={'prevent','validate'}
62
77
  * ```
63
78
  */
64
- const syntaxRegex = /^([a-z0-9.-]+)->([a-z0-9-]+)(?::(.+))?$/;
79
+ const syntaxRegex = /^([a-z0-9_:-]+)(?::([^;]+))?(?:;\s*([a-z0-9_,-]+))?$/;
65
80
 
66
81
  // ─── Parsed Action Descriptor ─────────────────────────────────────────────────
67
82
 
68
83
  /**
69
- * Parsed and cached representation of a single `on-action` attribute value.
84
+ * Parsed and cached representation of a single `on-<eventType>` attribute value.
70
85
  *
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.
86
+ * Does not store `eventType` — the caller always has it from `event.type`,
87
+ * and the attribute name already encodes it (e.g. `on-click`), so storing it
88
+ * here would be redundant. This also keeps the cache key simple: just the raw
89
+ * attribute value string, with no composite key needed.
74
90
  */
75
91
  interface ActionDescriptor {
76
- /** The DOM event type to listen for (e.g. `'click'`, `'input'`). */
77
- readonly eventType: string;
78
92
  /** Set of active modifier names (e.g. `{'prevent', 'once'}`). */
79
93
  readonly modifiers: ReadonlySet<string>;
80
94
  /** The action identifier dispatched to `onAction` subscribers. */
81
95
  readonly actionId: string;
82
- /** Raw payload token from the attribute (literal string or `$`-resolver key). */
96
+ /** Raw payload token from the attribute (literal string or $-resolver key). */
83
97
  readonly payload: string | undefined;
84
98
  }
85
99
 
86
100
  /**
87
- * LRU-style cache for parsed `on-action` attribute values.
101
+ * Cache for parsed `on-<eventType>` attribute values.
88
102
  *
89
103
  * 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
104
+ * "add to cart" button shares the same `on-click` value). Caching the parsed
91
105
  * descriptor avoids redundant regex work on every event.
92
106
  *
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.
107
+ * The cache key is the raw attribute value string. No composite key with
108
+ * event type is needed because the attribute name already encodes the event
109
+ * type `on-click="open_drawer"` and `on-submit="open_drawer"` are two
110
+ * separate attributes with the same value string, but they are read from
111
+ * different attribute names and never collide in this cache.
96
112
  *
97
113
  * @internal
98
114
  */
99
115
  const descriptorCache__ = new Map<string, ActionDescriptor | null>();
100
116
 
101
117
  /**
102
- * Parses an `on-action` attribute value into an `ActionDescriptor`.
118
+ * Parses an `on-<eventType>` attribute value into an `ActionDescriptor`.
103
119
  *
104
120
  * 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).
121
+ * attribute value string so repeated calls for the same value are O(1).
106
122
  *
107
123
  * @internal
108
124
  */
@@ -120,23 +136,12 @@ function parseDescriptor__(attributeValue: string): ActionDescriptor | null {
120
136
  return null;
121
137
  }
122
138
 
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
- };
139
+ const actionId = match[1];
140
+ const payload: string | undefined = match[2];
141
+ // match[3] is the raw modifier list string, e.g. "prevent,validate"
142
+ const modifierString = match[3];
143
+ const modifiers: Set<string> = modifierString ? new Set(modifierString.split(',').filter(Boolean)) : new Set();
144
+ const descriptor: ActionDescriptor = {modifiers, actionId, payload};
140
145
 
141
146
  descriptorCache__.set(attributeValue, descriptor);
142
147
  return descriptor;
@@ -144,17 +149,19 @@ function parseDescriptor__(attributeValue: string): ActionDescriptor | null {
144
149
 
145
150
  // ─── Core Delegation Handler ──────────────────────────────────────────────────
146
151
 
147
- const onActionAttrib__ = 'on-action';
148
152
  /**
149
153
  * Central event handler attached to `document.body` for every delegated event type.
150
154
  *
151
155
  * Execution flow for each incoming event:
152
156
  * 1. Walk up from `event.target` to find the nearest element with an
153
- * `on-action` attribute whose event type matches the current event.
157
+ * `on-<eventType>` attribute (e.g. `on-click`, `on-submit`).
154
158
  * 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)`.
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.
158
165
  *
159
166
  * @internal
160
167
  */
@@ -162,62 +169,81 @@ function handleDelegatedEvent__(event: Event): void {
162
169
  const eventType = event.type;
163
170
  logger_.logMethodArgs?.('handleDelegatedEvent__', {eventType});
164
171
 
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
172
  const target = event.target as Element | null;
168
173
  if (!target) return;
169
174
 
170
- // Find the nearest ancestor (or self) that has an on-action attribute
171
- const actionElement = target.closest?.(`[${onActionAttrib__}^=${eventType}]`);
175
+ // Attribute name encodes the event type: on-click, on-submit, etc.
176
+ const actionAttrib = `on-${eventType}`;
177
+
178
+ // Walk up the DOM to find the closest element with the matching on-<eventType> attribute.
179
+ const actionElement = target.closest?.(`[${actionAttrib}]`);
172
180
  if (!actionElement) return;
173
181
 
174
- const attributeValue = actionElement.getAttribute?.(onActionAttrib__)?.trim();
182
+ const attributeValue = actionElement.getAttribute?.(actionAttrib)?.trim();
175
183
  if (!attributeValue) {
176
- logger_.accident('handleDelegatedEvent__', 'empty_attribute', {eventType, attributeValue, actionElement});
184
+ logger_.accident('handleDelegatedEvent__', 'empty_attribute', {eventType, actionElement});
177
185
  return;
178
186
  }
179
187
 
180
188
  if (!(actionElement instanceof HTMLElement)) {
181
- logger_.accident('handleDelegatedEvent__', 'target_not_html_element', {eventType, attributeValue, actionElement});
189
+ logger_.accident('handleDelegatedEvent__', 'target_not_html_element', {eventType, actionElement});
182
190
  return;
183
191
  }
184
192
 
185
193
  const descriptor = parseDescriptor__(attributeValue);
186
- if (!descriptor) {
187
- logger_.accident('handleDelegatedEvent__', 'invalid_attribute', {eventType, attributeValue, actionElement});
188
- return;
189
- }
194
+ if (!descriptor) return;
190
195
 
191
- if (descriptor.eventType !== eventType) return;
196
+ logger_.logMethodArgs?.('handleDelegatedEvent__.action', {eventType, descriptor});
192
197
 
193
- logger_.logMethodArgs?.('handleDelegatedEvent__.action', descriptor);
194
-
195
- // Step 1: handle once modifier
198
+ // Step 1: handle `once` modifier — remove attribute before running other modifiers
199
+ // so that even if a modifier aborts, the element will not fire again.
196
200
  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
201
+ actionElement.removeAttribute(actionAttrib);
199
202
  }
200
203
 
201
- // 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.
202
220
  for (const modifier of descriptor.modifiers) {
203
- if (modifier === 'once') continue; // handled separately
221
+ if (modifier === 'once') continue; // handled above
204
222
  const handler = modifierRegistry.get(modifier);
205
223
  if (!handler) {
206
- logger_.accident('handleDelegatedEvent__', 'unknown_modifier', {modifier, attributeValue});
207
- return; // unknown modifier — abort to avoid silent misbehaviour
224
+ logger_.accident('handleDelegatedEvent__', 'unknown_modifier', {eventType, modifier, attributeValue, descriptor});
225
+ return; // unknown modifier — abort to avoid silent misbehavior
208
226
  }
209
- if (handler(event, actionElement) === false) return;
227
+ if (handler(event, actionElement, action) === false) return;
210
228
  }
211
229
 
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);
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;
217
243
  }
218
244
 
219
- // Step 4: dispatch
220
- internalChannel_.dispatch(descriptor.actionId, payload);
245
+ // Step 6: dispatch the fully assembled Action object.
246
+ internalChannel_.dispatch(action.type, action);
221
247
  }
222
248
 
223
249
  // ─── Setup ────────────────────────────────────────────────────────────────────
@@ -246,11 +272,12 @@ const delegatedEventTypes__ = new Set<string>();
246
272
  export const DEFAULT_DELEGATED_EVENTS: readonly string[] = ['click', 'submit', 'input', 'change'];
247
273
 
248
274
  /**
249
- * Registers global event delegation for `on-action` attributes.
275
+ * Registers global event delegation for `on-<eventType>` attributes.
250
276
  *
251
277
  * 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.
278
+ * event type in `eventTypes`. All processing — context resolution, modifier
279
+ * execution, payload resolution, and `dispatchAction` — happens inside that
280
+ * one handler.
254
281
  *
255
282
  * **Call this once at application bootstrap**, before any user interaction.
256
283
  * Subsequent calls with the same event types are no-ops (idempotent).
@@ -267,6 +294,24 @@ export const DEFAULT_DELEGATED_EVENTS: readonly string[] = ['click', 'submit', '
267
294
  * after this call — via `innerHTML`, `lit-html`, a framework renderer, or
268
295
  * server-sent HTML — is automatically covered. No re-bootstrap is needed.
269
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
+ *
270
315
  * @param eventTypes - Event types to delegate. Defaults to `DEFAULT_DELEGATED_EVENTS`.
271
316
  *
272
317
  * @example — minimal bootstrap
@@ -276,7 +321,7 @@ export const DEFAULT_DELEGATED_EVENTS: readonly string[] = ['click', 'submit', '
276
321
  * // One call activates the entire page.
277
322
  * setupActionDelegation();
278
323
  *
279
- * onAction('open-drawer', (panel) => openDrawer(panel));
324
+ * onAction('open_drawer', (action) => openDrawer(action.payload));
280
325
  * ```
281
326
  *
282
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,17 +1,54 @@
1
1
  /**
2
2
  * @alwatr/action — Declarative DOM action-dispatch for Unidirectional Data Flow.
3
3
  *
4
- * ## Activating `on-action` attributes
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
+ *
10
+ * ## Activating `on-<eventType>` attributes
5
11
  *
6
12
  * 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.
13
+ * listener on `document.body` handles every `on-click`, `on-submit`, etc. element —
14
+ * including elements added dynamically after bootstrap — with O(1) initialization cost.
9
15
  *
10
16
  * ```ts
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));
21
+ * ```
22
+ *
23
+ * ## Attribute syntax
24
+ *
25
+ * ```
26
+ * on-<eventType>="actionId[:payload][; modifier1,modifier2,…]"
27
+ * ```
28
+ *
29
+ * ```html
30
+ * <button on-click="open_drawer:main">Open</button>
31
+ * <input on-input="search_query:$value" />
32
+ * <form on-submit="submit_form:$formdata; prevent,validate" novalidate>…</form>
33
+ * ```
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
+ * });
15
52
  * ```
16
53
  *
17
54
  * ## Programmatic dispatch
@@ -19,7 +56,7 @@
19
56
  * ```ts
20
57
  * import {dispatchAction} from '@alwatr/action';
21
58
  *
22
- * dispatchAction('navigate', '/dashboard');
59
+ * dispatchAction({type: 'navigate', payload: '/dashboard'});
23
60
  * ```
24
61
  *
25
62
  * ## Registering typed actions
@@ -30,8 +67,8 @@
30
67
  * // src/action-record.ts
31
68
  * declare module '@alwatr/action' {
32
69
  * interface ActionRecord {
33
- * 'open-drawer': string;
34
- * 'add-to-cart': {productId: number; qty: number};
70
+ * 'open_drawer': string;
71
+ * 'add_to_cart': {productId: number; qty: number};
35
72
  * 'logout': void;
36
73
  * }
37
74
  * }
@@ -39,7 +76,8 @@
39
76
  *
40
77
  * ## Public API
41
78
  *
42
- * - `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
43
81
  * - `setupActionDelegation` / `teardownActionDelegation` — global delegation lifecycle
44
82
  * - `DEFAULT_DELEGATED_EVENTS` — default event types covered by delegation
45
83
  * - `onAction` / `dispatchAction` — subscribe to and dispatch named actions
@@ -50,5 +88,6 @@
50
88
  * For page-ready signals in SSG/SSR apps, use `@alwatr/page-ready` instead.
51
89
  */
52
90
  export type {ActionRecord} from './action-record.js';
91
+ export type {Action} from './action.js';
53
92
  export * from './method.js';
54
93
  export * from './delegate.js';