@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/method.ts CHANGED
@@ -1,25 +1,30 @@
1
1
  import type {Awaitable} from '@alwatr/type-helper';
2
- import {internalChannel_, logger_} from './lib.js';
3
2
  import type {SubscribeResult} from '@alwatr/signal';
3
+
4
+ import {internalChannel_, logger_} from './lib.js';
4
5
  import {modifierRegistry, payloadRegistry, type ModifierHandler, type PayloadResolver} from './registry.js';
5
6
  import type {ActionRecord} from './action-record.js';
7
+ import type {Action} from './action.js';
6
8
 
7
9
  // Re-export extension types so consumers can import them from the package root.
8
10
  export type {ModifierHandler, PayloadResolver};
11
+ export type {Action};
9
12
 
10
13
  // ─── Core Action API ──────────────────────────────────────────────────────────
11
14
 
12
15
  /**
13
16
  * Subscribes to a named action dispatched anywhere in the application.
14
17
  *
15
- * `actionId` must be a key of `ActionRecord`. The handler's `payload` parameter
16
- * is automatically typed to the corresponding `ActionRecord` value — no manual
17
- * generic annotation needed:
18
+ * `type` must be a key of `ActionRecord`. The handler receives the full
19
+ * `Action<K>` object giving access to `payload`, `context`, and `meta`
20
+ * in one place. No manual generic annotation is needed; the compiler infers
21
+ * the correct `payload` type from `ActionRecord`:
18
22
  *
19
23
  * ```ts
20
- * // 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
24
+ * // ActionRecord declares: 'add_to_cart': {productId: number; qty: number}
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
  *
@@ -30,7 +35,7 @@ export type {ModifierHandler, PayloadResolver};
30
35
  * // src/action-record.ts
31
36
  * declare module '@alwatr/action' {
32
37
  * interface ActionRecord {
33
- * 'open-drawer': string;
38
+ * 'open_drawer': string;
34
39
  * }
35
40
  * }
36
41
  * ```
@@ -38,99 +43,103 @@ export type {ModifierHandler, PayloadResolver};
38
43
  * Internally delegates to `ChannelSignal.on()` for **O(1) routing** — dispatching
39
44
  * action `'A'` never invokes handlers registered for action `'B'`.
40
45
  *
41
- * @param actionId - A key of `ActionRecord`.
42
- * @param handler - Callback invoked with the typed payload on each dispatch.
46
+ * @param type - A key of `ActionRecord`.
47
+ * @param handler - Callback invoked with the full `Action<K>` on each dispatch.
43
48
  * @returns A `SubscribeResult` with an `unsubscribe()` method for cleanup.
44
49
  *
45
50
  * @example
46
51
  * ```ts
47
52
  * import {onAction} from '@alwatr/action';
48
53
  *
49
- * const sub = onAction('page-ready', (pageId) => {
50
- * router.setPage(pageId); // pageId: string — inferred from ActionRecord
54
+ * const sub = onAction('page_ready', (action) => {
55
+ * router.setPage(action.payload); // payload: string — inferred from ActionRecord
51
56
  * });
52
57
  *
53
58
  * sub.unsubscribe(); // stop listening when no longer needed
54
59
  * ```
55
60
  */
56
61
  export function onAction<K extends keyof ActionRecord>(
57
- actionId: K,
58
- handler: (payload: ActionRecord[K]) => Awaitable<void>,
62
+ type: K,
63
+ handler: (action: Action<K>) => Awaitable<void>,
59
64
  ): SubscribeResult {
60
- logger_.logMethodArgs?.('onAction', {actionId});
61
- return internalChannel_.on(actionId, handler as (payload: unknown) => Awaitable<void>);
65
+ logger_.logMethodArgs?.('onAction', {type});
66
+ // The internal channel stores Action<any>; we cast to Action<K> here because
67
+ // the channel key guarantees the type matches — only Action<K> objects are
68
+ // ever dispatched under key K.
69
+ return internalChannel_.on(type, handler as (action: Action) => Awaitable<void>);
62
70
  }
63
71
 
64
72
  /**
65
- * Dispatches a named action to all `onAction` subscribers with a matching `actionId`.
73
+ * Dispatches an action to all `onAction` subscribers with a matching `type`.
66
74
  *
67
- * `actionId` must be a key of `ActionRecord`. The `payload` parameter is
68
- * automatically typed — passing the wrong type is a **compile error**:
75
+ * Accepts a full `Action<K>` object. The `payload` field is automatically
76
+ * typed from `ActionRecord[K]` — passing the wrong shape is a **compile error**:
69
77
  *
70
78
  * ```ts
71
- * // 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
79
+ * // ActionRecord declares: 'add_to_cart': {productId: number; qty: number}
80
+ * dispatchAction({type: 'add_to_cart', payload: {productId: 42, qty: 1}}); // ✅
81
+ * dispatchAction({type: 'add_to_cart', payload: 'wrong'}); // ❌ compile error
82
+ * dispatchAction({type: 'unknown_action', payload: 'x'}); // ❌ compile error
75
83
  * ```
76
84
  *
77
- * Register new actions by extending `ActionRecord` via declaration merging:
78
- *
79
- * ```ts
80
- * // src/action-record.ts
81
- * declare module '@alwatr/action' {
82
- * interface ActionRecord {
83
- * 'navigate': string;
84
- * 'logout': void;
85
- * }
86
- * }
87
- * ```
85
+ * The `context` and `meta` fields are optional. When dispatching from code
86
+ * (not from the DOM), omit `context` — it is only meaningful for DOM-originated
87
+ * actions where an `[action-context]` ancestor exists.
88
88
  *
89
89
  * Use `dispatchAction` when triggering an action from code — e.g. after an
90
90
  * async operation, from a service layer, or in tests. For DOM-driven actions,
91
- * use the `on-action` HTML attribute with `setupActionDelegation`.
91
+ * use the `on-<eventType>` HTML attribute with `setupActionDelegation`.
92
92
  *
93
- * @param actionId - A key of `ActionRecord`.
94
- * @param actionPayload - The payload; type is enforced by `ActionRecord`.
93
+ * @param action - A full `Action<K>` object with at minimum `type` and `payload`.
95
94
  *
96
95
  * @example — with payload
97
96
  * ```ts
98
97
  * import {dispatchAction} from '@alwatr/action';
99
98
  *
100
- * dispatchAction('page-ready', 'home');
101
- * dispatchAction('navigate', '/dashboard');
99
+ * dispatchAction({type: 'navigate', payload: '/dashboard'});
100
+ * dispatchAction({type: 'add_to_cart', payload: {productId: 42, qty: 1}});
102
101
  * ```
103
102
  *
104
- * @example — void payload (no second argument)
103
+ * @example — void payload
105
104
  * ```ts
106
- * dispatchAction('logout');
105
+ * dispatchAction({type: 'logout', payload: undefined});
106
+ * ```
107
+ *
108
+ * @example — with context and meta
109
+ * ```ts
110
+ * dispatchAction({
111
+ * type: 'slider_change',
112
+ * payload: 75,
113
+ * context: 'volume_slider',
114
+ * meta: {traceId: 'abc-123'},
115
+ * });
107
116
  * ```
108
117
  */
109
- export function dispatchAction<K extends keyof ActionRecord>(
110
- ...args: ActionRecord[K] extends void | undefined ? [actionId: K] : [actionId: K, actionPayload: ActionRecord[K]]
111
- ): void {
112
- const [actionId, actionPayload] = args;
113
- logger_.logMethodArgs?.('dispatchAction', {actionId, actionPayload});
114
- internalChannel_.dispatch(actionId, actionPayload);
118
+ export function dispatchAction<K extends keyof ActionRecord>(action: Action<K>): void {
119
+ logger_.logMethodArgs?.('dispatchAction', action);
120
+ internalChannel_.dispatch(action.type, action);
115
121
  }
116
122
 
117
123
  // ─── Extension API ────────────────────────────────────────────────────────────
118
124
 
119
125
  /**
120
- * Registers a custom modifier that can be used in `on-action` attribute syntax.
126
+ * Registers a custom modifier that can be used in `on-<eventType>` attribute syntax.
121
127
  *
122
- * A modifier is a dot-chained token placed after the event type
123
- * (e.g. `click.mymod->action-id`). Its handler runs before the payload is
128
+ * A modifier is a comma-separated token placed after the `;` separator
129
+ * (e.g. `on-click="action-id; mymod"`). Its handler runs before the payload is
124
130
  * resolved and the action is dispatched. Returning `false` cancels the dispatch.
125
131
  *
126
- * Built-in modifiers (`prevent`, `stop`, `validate`, `once`) are always
127
- * available. This function lets you add domain-specific ones.
132
+ * The handler also receives the **mutable** `action` object being built, so it
133
+ * can attach data to `action.meta` before the action reaches subscribers.
134
+ *
135
+ * Built-in modifiers (`prevent`, `validate`, `once`) are always available.
136
+ * This function lets you add domain-specific ones.
128
137
  *
129
138
  * Registering the same name twice logs an accident and overwrites the previous
130
139
  * handler — avoid duplicate registrations in production code.
131
140
  *
132
- * @param name - The modifier token (lowercase, no dots or arrows).
133
- * @param handler - A `ModifierHandler` receiving `(event, element)`.
141
+ * @param name - The modifier token (lowercase, no special characters).
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
@@ -139,7 +148,16 @@ export function dispatchAction<K extends keyof ActionRecord>(
139
148
  * registerModifier('confirm', () => window.confirm('Are you sure?'));
140
149
  * ```
141
150
  * ```html
142
- * <button on-action="click.confirm->delete-item:42">Delete</button>
151
+ * <button on-click="delete_item:42; confirm">Delete</button>
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
+ * });
143
161
  * ```
144
162
  */
145
163
  export function registerModifier(name: string, handler: ModifierHandler): void {
@@ -151,15 +169,15 @@ export function registerModifier(name: string, handler: ModifierHandler): void {
151
169
  }
152
170
 
153
171
  /**
154
- * Registers a custom payload resolver that can be used in `on-action` attribute syntax.
172
+ * Registers a custom payload resolver that can be used in `on-<eventType>` attribute syntax.
155
173
  *
156
- * A payload resolver is a colon-suffixed token in the attribute value
157
- * (e.g. `click->action-id:$mytoken`). Its function is called at dispatch time
158
- * with an `ActionContext` as `this` and the DOM event as the argument.
159
- * The return value becomes the `actionPayload` passed to `onAction` subscribers.
174
+ * A payload resolver is a colon-prefixed token in the attribute value
175
+ * (e.g. `on-click="action-id:$mytoken"`). Its function is called at dispatch time
176
+ * with the DOM event and the element. The return value becomes the `payload`
177
+ * field of the `Action` object passed to `onAction` subscribers.
160
178
  *
161
- * Built-in resolvers (`$value`, `$formdata`) are always available. This function
162
- * lets you add domain-specific ones.
179
+ * Built-in resolvers (`$value`, `$formdata`, `$checked`) are always available.
180
+ * This function lets you add domain-specific ones.
163
181
  *
164
182
  * Registering the same name twice logs an accident and overwrites the previous
165
183
  * resolver — avoid duplicate registrations in production code.
@@ -167,16 +185,16 @@ export function registerModifier(name: string, handler: ModifierHandler): void {
167
185
  * @param name - The resolver token (should start with `$` by convention).
168
186
  * @param resolver - A `PayloadResolver` receiving `(event, element)`.
169
187
  *
170
- * @example — a `$checked` resolver for checkbox state
188
+ * @example — a `$data-id` resolver that reads a data attribute
171
189
  * ```ts
172
190
  * import {registerPayloadResolver} from '@alwatr/action';
173
191
  *
174
- * registerPayloadResolver('$checked', (_event, element) => {
175
- * return (element as HTMLInputElement).checked;
192
+ * registerPayloadResolver('$data-id', (_event, element) => {
193
+ * return (element as HTMLElement).dataset.id ?? null;
176
194
  * });
177
195
  * ```
178
196
  * ```html
179
- * <input type="checkbox" on-action="change->toggle-feature:$checked" />
197
+ * <button on-click="select_item:$data-id" data-id="42">Select</button>
180
198
  * ```
181
199
  */
182
200
  export function registerPayloadResolver(name: string, resolver: PayloadResolver): void {
package/src/registry.ts CHANGED
@@ -1,38 +1,51 @@
1
+ import type {Action} from './action.js';
2
+
1
3
  // ─── Type Definitions ────────────────────────────────────────────────────────
2
4
 
3
5
  /**
4
- * A modifier handler used in `on-action` attribute syntax.
6
+ * A modifier handler used in `on-<eventType>` attribute syntax.
5
7
  *
6
- * Receives the triggering DOM `event` and the `element` that owns the
7
- * `on-action` attribute. Return `true` (or any truthy value) to allow the
8
- * action to proceed, or `false` to cancel the dispatch.
8
+ * Receives the triggering DOM `event`, the `element` that owns the
9
+ * `on-<eventType>` attribute, and the **mutable** `action` object being built.
10
+ * The handler may mutate `action.meta` to attach cross-cutting data (e.g. a
11
+ * trace ID, a timestamp, or an A/B flag) before the action reaches subscribers.
12
+ *
13
+ * Return `true` (or any truthy value) to allow the action to proceed, or
14
+ * `false` to cancel the dispatch entirely.
9
15
  *
10
16
  * Using explicit parameters instead of `this` binding makes handlers
11
17
  * compatible with arrow functions and easier to test in isolation.
12
18
  *
13
- * @example
19
+ * @example — a modifier that stamps a timestamp into meta
20
+ * ```ts
21
+ * const timestampHandler: ModifierHandler = (_event, _element, action) => {
22
+ * action.meta ??= {};
23
+ * action.meta['timestamp'] = Date.now();
24
+ * return true;
25
+ * };
26
+ * ```
27
+ *
28
+ * @example — a modifier that cancels dispatch when the element is disabled
14
29
  * ```ts
15
- * // A modifier that only allows the action when the element is not disabled
16
30
  * const notDisabledHandler: ModifierHandler = (_event, element) => {
17
31
  * return !(element as HTMLButtonElement).disabled;
18
32
  * };
19
33
  * ```
20
34
  */
21
- export type ModifierHandler = (event: Event, element: HTMLElement) => boolean;
35
+ export type ModifierHandler = (event: Event, element: HTMLElement, action: Action) => boolean;
22
36
 
23
37
  /**
24
- * A payload resolver used in `on-action` attribute syntax.
38
+ * A payload resolver used in `on-<eventType>` attribute syntax.
25
39
  *
26
40
  * Receives the triggering DOM `event` and the `element` that owns the
27
- * `on-action` attribute. The return value becomes the `actionPayload` passed
28
- * to `onAction` subscribers. Use this to compute dynamic payloads from DOM state.
41
+ * `on-<eventType>` attribute. The return value becomes the `payload` field of
42
+ * the `Action` object passed to `onAction` subscribers.
29
43
  *
30
44
  * Using explicit parameters instead of `this` binding makes resolvers
31
45
  * compatible with arrow functions and easier to test in isolation.
32
46
  *
33
- * @example
47
+ * @example — a resolver that returns the element's dataset id
34
48
  * ```ts
35
- * // A resolver that returns the element's dataset id
36
49
  * const dataIdResolver: PayloadResolver = (_event, element) => {
37
50
  * return (element as HTMLElement).dataset.id ?? null;
38
51
  * };
@@ -45,8 +58,8 @@ export type PayloadResolver = (event: Event, element: HTMLElement) => unknown;
45
58
  /**
46
59
  * Registry of all named modifier handlers.
47
60
  *
48
- * Keys are modifier names used in the `on-action` attribute syntax
49
- * (e.g. `click.prevent->action-id`). Values are `ModifierHandler` functions.
61
+ * Keys are modifier names used in the `on-<eventType>` attribute syntax
62
+ * (e.g. `on-click="action-id; prevent"`). Values are `ModifierHandler` functions.
50
63
  * Populated at module load with built-in modifiers; extended at runtime via
51
64
  * `registerModifier`.
52
65
  *
@@ -57,8 +70,8 @@ export const modifierRegistry = new Map<string, ModifierHandler>();
57
70
  /**
58
71
  * Registry of all named payload resolvers.
59
72
  *
60
- * Keys are resolver tokens used in the `on-action` attribute syntax
61
- * (e.g. `click->action-id:$value`). Values are `PayloadResolver` functions.
73
+ * Keys are resolver tokens used in the `on-<eventType>` attribute syntax
74
+ * (e.g. `on-input="search_query:$value"`). Values are `PayloadResolver` functions.
62
75
  * Populated at module load with built-in resolvers; extended at runtime via
63
76
  * `registerPayloadResolver`.
64
77
  *
@@ -74,7 +87,7 @@ export const payloadRegistry = new Map<string, PayloadResolver>();
74
87
  * Use it to suppress the browser's default behaviour (e.g. form submission,
75
88
  * link navigation, context menu).
76
89
  *
77
- * @example `<form on-action="submit.prevent->submit-form">`
90
+ * @example `<form on-submit="submit-form; prevent">`
78
91
  */
79
92
  modifierRegistry.set('prevent', (event) => {
80
93
  event.preventDefault();
@@ -89,9 +102,9 @@ modifierRegistry.set('prevent', (event) => {
89
102
  * allowing native constraint-validation UI to surface errors. If no form is
90
103
  * found the dispatch is also cancelled.
91
104
  *
92
- * Pair with `.prevent` on `submit` events to avoid page reloads:
105
+ * Pair with `prevent` on `submit` events to avoid page reloads:
93
106
  *
94
- * @example `<form on-action="submit.prevent.validate->submit-form:$formdata" novalidate>`
107
+ * @example `<form on-submit="submit_form:$formdata; prevent,validate" novalidate>`
95
108
  */
96
109
  modifierRegistry.set('validate', (_event, element) => {
97
110
  const form = element instanceof HTMLFormElement ? element : element.closest('form');
@@ -107,7 +120,7 @@ modifierRegistry.set('validate', (_event, element) => {
107
120
  * Works with any element that exposes a `value` property: `<input>`,
108
121
  * `<textarea>`, `<select>`. Returns `null` for elements without `.value`.
109
122
  *
110
- * @example `<input on-action="input->search-query:$value" />`
123
+ * @example `<input on-input="search_query:$value" />`
111
124
  */
112
125
  payloadRegistry.set('$value', (_event, element) => {
113
126
  return 'value' in element ? (element as {value: unknown}).value : null;
@@ -120,14 +133,32 @@ payloadRegistry.set('$value', (_event, element) => {
120
133
  * Looks for a `<form>` ancestor (or the element itself). Returns `null` when no
121
134
  * form is found.
122
135
  *
123
- * @example `<form on-action="submit.prevent.validate->submit-form:$formdata">`
136
+ * @example `<form on-submit="submit_form:$formdata; prevent,validate">`
124
137
  * ```ts
125
- * onAction('submit-form', (data) => {
126
- * console.log(data); // {username: 'ali', password: '…'}
138
+ * onAction('submit_form', (action) => {
139
+ * console.log(action.payload); // {username: 'ali', password: '…'}
127
140
  * });
128
141
  * ```
129
142
  */
130
143
  payloadRegistry.set('$formdata', (_event, element) => {
131
144
  const form = element instanceof HTMLFormElement ? element : element.closest('form');
132
- return form ? Object.fromEntries(new FormData(form).entries()) : null;
145
+ return form ? Object.fromEntries(new FormData(form)) : null;
146
+ });
147
+
148
+ /**
149
+ * `$checked` — resolves to the `.checked` boolean property of a checkbox or radio input.
150
+ *
151
+ * Works with `<input type="checkbox">` and `<input type="radio">`.
152
+ * Returns `null` for elements that do not have a `checked` property.
153
+ *
154
+ * @example `<input type="checkbox" on-change="toggle_feature:$checked" />`
155
+ * ```ts
156
+ * onAction('toggle_feature', (action) => {
157
+ * console.log(action.payload); // true or false
158
+ * featureSignal.set(action.payload);
159
+ * });
160
+ * ```
161
+ */
162
+ payloadRegistry.set('$checked', (_event, element) => {
163
+ return 'checked' in element ? (element as HTMLInputElement).checked : null;
133
164
  });