@alwatr/action 9.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ import {lazyDirective, Directive} from '@alwatr/directive';
2
+ import {modifierRegistry, payloadRegistry} from './registry.js';
3
+ import {dispatchAction} from './method.js';
4
+
5
+ // ─── Attribute Syntax Parser ──────────────────────────────────────────────────
6
+
7
+ /**
8
+ * Regex that parses the `on-action` attribute value into its three segments.
9
+ *
10
+ * Full syntax: `eventType[.modifier…]->actionId[:payload]`
11
+ *
12
+ * | Capture group | Matches | Example |
13
+ * | ------------- | ------------------------------------------- | -------------------- |
14
+ * | 1 | Event type + optional dot-chained modifiers | `click.prevent.once` |
15
+ * | 2 | Action identifier | `open-drawer` |
16
+ * | 3 | Optional payload token or literal | `main` / `$value` |
17
+ *
18
+ * @example
19
+ * ```
20
+ * 'click.prevent.once->open-drawer:main' → ['click.prevent.once', 'open-drawer', 'main']
21
+ * 'input->search-query:$value' → ['input', 'search-query', '$value']
22
+ * 'submit.prevent->submit-form' → ['submit.prevent', 'submit-form', undefined]
23
+ * ```
24
+ */
25
+ const syntaxRegex = /^([a-z0-9.-]+)->([a-z0-9-]+)(?::(.+))?$/;
26
+
27
+ // ─── Directive Class ──────────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Directive that bridges a DOM event to a typed action signal.
31
+ *
32
+ * Activated automatically by the `on-action` HTML attribute when
33
+ * `registerActionDirective()` has been called before `bootstrapDirectives()`.
34
+ * You rarely need to reference this class directly.
35
+ *
36
+ * **Attribute syntax**
37
+ * ```
38
+ * on-action="eventType[.modifier…]->actionId[:payload]"
39
+ * ```
40
+ *
41
+ * - `eventType` — any standard DOM event name (e.g. `click`, `input`, `submit`).
42
+ * - `modifier` — dot-chained tokens processed before dispatch
43
+ * (`prevent`, `stop`, `validate`, `once`, `passive`, or custom).
44
+ * - `actionId` — the identifier passed to `onAction` subscribers.
45
+ * - `payload` — an optional literal string or a `$`-prefixed resolver token
46
+ * (e.g. `$value`, `$formdata`, or custom).
47
+ *
48
+ * @example
49
+ * ```html
50
+ * <!-- Dispatches 'open-drawer' with payload 'settings' on click -->
51
+ * <button on-action="click->open-drawer:settings">Settings</button>
52
+ *
53
+ * <!-- Dispatches 'search-query' with the live input value on every keystroke -->
54
+ * <input on-action="input->search-query:$value" />
55
+ *
56
+ * <!-- Prevents default, validates, then dispatches 'submit-form' with all field values -->
57
+ * <form on-action="submit.prevent.validate->submit-form:$formdata" novalidate>…</form>
58
+ * ```
59
+ */
60
+ export class ActionDirective extends Directive {
61
+ /**
62
+ * Parsed and validated representation of the `on-action` attribute value.
63
+ *
64
+ * Set during `init_()` after the attribute is successfully parsed against
65
+ * `syntaxRegex`. Remains `undefined` when the attribute value is invalid,
66
+ * which prevents `dispatch_` from running.
67
+ */
68
+ protected actionContext_?: {
69
+ /** The DOM event type to listen for (e.g. `'click'`, `'input'`). */
70
+ eventType: string;
71
+ /** Set of active modifier names (e.g. `{'prevent', 'once'}`). */
72
+ modifiers: ReadonlySet<string>;
73
+ /** The action identifier dispatched to `onAction` subscribers. */
74
+ actionId: string;
75
+ /** Raw payload token from the attribute (literal string or `$`-resolver key). */
76
+ payload?: string;
77
+ };
78
+
79
+ /**
80
+ * Parses the `on-action` attribute, validates modifiers, and attaches the
81
+ * DOM event listener.
82
+ *
83
+ * Called once by `Directive` after one macrotask following element discovery.
84
+ * If the attribute value is malformed or references an unknown modifier,
85
+ * an accident is logged and the directive becomes a no-op.
86
+ */
87
+ protected override init_(): void {
88
+ this.logger_.logMethodArgs?.('init_', {attributeValue: this.attributeValue});
89
+
90
+ const match = this.attributeValue.trim().match(syntaxRegex);
91
+
92
+ if (!match) {
93
+ this.logger_.accident('init_', 'invalid_syntax', {attributeValue: this.attributeValue});
94
+ return;
95
+ }
96
+
97
+ const [eventType, ...modifierList] = match[1].split('.');
98
+ const actionId = match[2];
99
+ const payload = match[3] as string | undefined;
100
+
101
+ if (!eventType) {
102
+ this.logger_.accident('init_', 'invalid_syntax', {attributeValue: this.attributeValue});
103
+ return;
104
+ }
105
+
106
+ // Validate every modifier token against the registry (built-in native
107
+ // options 'once' and 'passive' are handled separately by the listener).
108
+ const modifiers = new Set<string>();
109
+ for (const modifier of modifierList) {
110
+ if (!modifierRegistry.has(modifier) && modifier !== 'once' && modifier !== 'passive') {
111
+ this.logger_.accident('init_', 'invalid_modifier', {attributeValue: this.attributeValue, modifier});
112
+ return;
113
+ }
114
+ modifiers.add(modifier);
115
+ }
116
+
117
+ // 'prevent' and 'passive' are mutually exclusive: a passive listener cannot
118
+ // call preventDefault(). Log an accident but continue — 'prevent' wins.
119
+ if (modifiers.has('prevent') && modifiers.has('passive')) {
120
+ this.logger_.accident('init_', 'conflicting_modifiers_prevent_passive', {attributeValue: this.attributeValue});
121
+ }
122
+
123
+ this.actionContext_ = {eventType, modifiers, actionId, payload};
124
+
125
+ // Bind once so the same function reference is used for both add and remove.
126
+ const listenerOptions: AddEventListenerOptions = {
127
+ once: modifiers.has('once'),
128
+ // 'passive' is only meaningful when 'prevent' is absent.
129
+ passive: modifiers.has('passive') && !modifiers.has('prevent'),
130
+ };
131
+
132
+ const boundDispatch = this.dispatch_.bind(this);
133
+ this.element_.addEventListener(eventType, boundDispatch, listenerOptions);
134
+ // Register cleanup so the listener is removed when the directive is destroyed
135
+ // (e.g. when the element is removed from the DOM via autoDestructDirectives).
136
+ this.addDestroyHook(() => {
137
+ this.element_.removeEventListener(eventType, boundDispatch, listenerOptions);
138
+ });
139
+ }
140
+
141
+ /**
142
+ * DOM event handler: runs modifiers, resolves the payload, and dispatches
143
+ * the action signal.
144
+ *
145
+ * Execution order:
146
+ * 1. Each modifier in `actionContext_.modifiers` is called in insertion order.
147
+ * If any returns `false` the method returns early — no action is dispatched.
148
+ * 2. The raw payload token is looked up in `payloadRegistry`. If a resolver
149
+ * is found it is called and its return value replaces the token.
150
+ * 3. `dispatchAction` is called with the resolved payload.
151
+ *
152
+ * @param event - The DOM event that triggered this handler.
153
+ */
154
+ protected dispatch_(event: Event): void {
155
+ this.logger_.logMethodArgs?.('dispatch_', {eventType: event.type, actionId: this.actionContext_?.actionId});
156
+
157
+ const context = this.actionContext_!;
158
+
159
+ // Step 1 — run modifiers; any returning false cancels the dispatch.
160
+ for (const mod of context.modifiers) {
161
+ const handler = modifierRegistry.get(mod);
162
+ if (handler && handler.call(this, event) === false) return;
163
+ }
164
+
165
+ // Step 2 — resolve dynamic payload tokens (e.g. '$value', '$formdata').
166
+ let payload: unknown = context.payload;
167
+ if (payload) {
168
+ const resolver = payloadRegistry.get(payload as string);
169
+ if (resolver) payload = resolver.call(this, event);
170
+ }
171
+
172
+ // Step 3 — dispatch the action to all onAction subscribers.
173
+ dispatchAction(context.actionId, payload);
174
+ }
175
+ }
176
+
177
+ // ─── Lazy Registration ────────────────────────────────────────────────────────
178
+
179
+ /**
180
+ * Registers `ActionDirective` under the `on-action` attribute name.
181
+ *
182
+ * This is a **lazy** registration: calling this function is the only way to
183
+ * opt-in to `on-action` support. If it is never called, the entire directive
184
+ * module (including `ActionDirective`) is tree-shaken from the bundle.
185
+ *
186
+ * Call it once, before `bootstrapDirectives()`, at your application entry point.
187
+ *
188
+ * @example
189
+ * ```ts
190
+ * import {registerActionDirective} from '@alwatr/action';
191
+ * import {bootstrapDirectives} from '@alwatr/directive';
192
+ *
193
+ * registerActionDirective();
194
+ * bootstrapDirectives();
195
+ * ```
196
+ */
197
+ export const registerActionDirective = lazyDirective('on-action', ActionDirective);
package/src/lib.ts ADDED
@@ -0,0 +1,47 @@
1
+ import {createLogger} from '@alwatr/logger';
2
+ import {createEventSignal} from '@alwatr/signal';
3
+
4
+ /**
5
+ * The shape of every payload carried by the internal action signal.
6
+ *
7
+ * `actionId` identifies which action was dispatched (e.g. `'open-drawer'`).
8
+ * `actionPayload` is the optional value attached to the action — defaults to
9
+ * `string` but can be narrowed to any type via the generic parameter `T`.
10
+ *
11
+ * @template T The type of the action payload. Defaults to `string`.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * // Typed payload for a cart action
16
+ * const payload: ActionSignalPayload<{productId: number; qty: number}> = {
17
+ * actionId: 'add-to-cart',
18
+ * actionPayload: {productId: 42, qty: 1},
19
+ * };
20
+ * ```
21
+ */
22
+ export interface ActionSignalPayload<T = string> {
23
+ actionId: string;
24
+ actionPayload?: T;
25
+ }
26
+
27
+ /**
28
+ * Module-scoped logger for `@alwatr/action`.
29
+ * Scoped to `'alwatr-action'` so log lines are easy to filter in the console.
30
+ *
31
+ * @internal
32
+ */
33
+ export const logger_ = createLogger('alwatr-action');
34
+
35
+ /**
36
+ * The single shared event signal that carries every dispatched action.
37
+ *
38
+ * All `ActionDirective` instances write to this signal via `dispatchAction`,
39
+ * and all `onAction` subscriptions read from it. Using one central signal keeps
40
+ * the pub/sub wiring minimal and makes the action flow easy to trace.
41
+ *
42
+ * The payload is typed as `ActionSignalPayload<unknown>` at the signal level;
43
+ * individual subscribers narrow the type through the `onAction` generic.
44
+ *
45
+ * @internal — not part of the public API; use `onAction` / `dispatchAction` instead.
46
+ */
47
+ export const internalSignal_ = createEventSignal<ActionSignalPayload<unknown>>({name: 'alwatr-action'});
package/src/main.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @alwatr/action — Declarative DOM action-dispatch for Unidirectional Data Flow.
3
+ *
4
+ * Public API surface:
5
+ * - `onAction` / `dispatchAction` — subscribe to and dispatch named actions
6
+ * - `registerActionDirective` — opt-in to `on-action` HTML attribute support
7
+ * - `registerPageIdDirective` — opt-in to `page-id` HTML attribute support
8
+ * - `registerModifier` / `registerPayloadResolver` — extend the directive syntax
9
+ * - `ActionDirective` / `PageIdDirective` — directive classes (advanced use)
10
+ * - `ActionSignalPayload` — payload type carried by the internal signal
11
+ */
12
+ export * from './method.js';
13
+ export * from './directive.js';
14
+ export * from './page-id.js';
15
+ export type {ActionSignalPayload} from './lib.js';
package/src/method.ts ADDED
@@ -0,0 +1,174 @@
1
+ import {internalSignal_, logger_} from './lib.js';
2
+ import type {SubscribeResult} from '@alwatr/signal';
3
+ import {modifierRegistry, payloadRegistry, type ModifierHandler, type PayloadResolver} from './registry.js';
4
+
5
+ // Re-export extension types so consumers can import them from the package root.
6
+ export type {ModifierHandler, PayloadResolver};
7
+
8
+ // ─── Core Action API ──────────────────────────────────────────────────────────
9
+
10
+ /**
11
+ * Subscribes to a named action dispatched anywhere in the application.
12
+ *
13
+ * The handler is invoked every time `dispatchAction(actionId, payload)` is
14
+ * called — whether from an `on-action` directive or from code — and the
15
+ * `actionId` matches. Multiple subscribers for the same `actionId` are all
16
+ * notified in subscription order.
17
+ *
18
+ * The generic parameter `T` narrows the type of the received payload.
19
+ * Defaults to `string`, which covers the common case of attribute-driven
20
+ * literal payloads.
21
+ *
22
+ * @param actionId - The action identifier to listen for (e.g. `'open-drawer'`).
23
+ * @param handler - Callback invoked with the resolved payload each time the
24
+ * action is dispatched. `payload` is `undefined` when the
25
+ * action was dispatched without a value.
26
+ * @returns A `SubscribeResult` with an `unsubscribe()` method for cleanup.
27
+ *
28
+ * @example — basic string payload
29
+ * ```ts
30
+ * import {onAction} from '@alwatr/action';
31
+ *
32
+ * const sub = onAction('open-drawer', (panel) => {
33
+ * openDrawer(panel); // panel === 'settings'
34
+ * });
35
+ *
36
+ * // Stop listening when the component is destroyed
37
+ * sub.unsubscribe();
38
+ * ```
39
+ *
40
+ * @example — typed object payload
41
+ * ```ts
42
+ * import {onAction} from '@alwatr/action';
43
+ *
44
+ * onAction<{productId: number; qty: number}>('add-to-cart', (item) => {
45
+ * cartService.add(item!.productId, item!.qty);
46
+ * });
47
+ * ```
48
+ */
49
+ export function onAction<T = string>(actionId: string, handler: (payload?: T) => void): SubscribeResult {
50
+ logger_.logMethodArgs?.('onAction', {actionId});
51
+ return internalSignal_.subscribe((signal) => {
52
+ if (signal.actionId === actionId) {
53
+ logger_.logMethodArgs?.('onAction.invoke', {actionId, payload: signal.actionPayload});
54
+ handler(signal.actionPayload as T);
55
+ }
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Dispatches a named action to all `onAction` subscribers with a matching `actionId`.
61
+ *
62
+ * This is the programmatic counterpart to the `on-action` HTML attribute.
63
+ * Use it when you need to trigger an action from code rather than from a DOM
64
+ * event (e.g. after an async operation completes, or from a service layer).
65
+ *
66
+ * The generic parameter `T` types the payload. Omit it to default to `string`.
67
+ *
68
+ * @param actionId - The action identifier (e.g. `'navigate'`).
69
+ * @param actionPayload - Optional value passed to every matching subscriber.
70
+ *
71
+ * @example — dispatch without payload
72
+ * ```ts
73
+ * import {dispatchAction} from '@alwatr/action';
74
+ *
75
+ * dispatchAction('logout');
76
+ * ```
77
+ *
78
+ * @example — dispatch with a typed payload
79
+ * ```ts
80
+ * import {dispatchAction} from '@alwatr/action';
81
+ *
82
+ * dispatchAction('navigate', '/dashboard');
83
+ * dispatchAction<{code: number}>('show-error', {code: 404});
84
+ * ```
85
+ */
86
+ export function dispatchAction<T = string>(actionId: string, actionPayload?: T): void {
87
+ logger_.logMethodArgs?.('dispatchAction', {actionId, actionPayload});
88
+ internalSignal_.dispatch({actionId, actionPayload});
89
+ }
90
+
91
+ // ─── Extension API ────────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Registers a custom modifier that can be used in `on-action` attribute syntax.
95
+ *
96
+ * A modifier is a dot-chained token placed after the event type
97
+ * (e.g. `click.mymod->action-id`). Its handler runs before the payload is
98
+ * resolved and the action is dispatched. Returning `false` cancels the dispatch.
99
+ *
100
+ * Built-in modifiers (`prevent`, `stop`, `validate`, `once`, `passive`) are
101
+ * always available. This function lets you add domain-specific ones.
102
+ *
103
+ * Registering the same name twice logs an accident and overwrites the previous
104
+ * handler — avoid duplicate registrations in production code.
105
+ *
106
+ * @param name - The modifier token (lowercase, no dots or arrows).
107
+ * @param handler - The `ModifierHandler` function bound to the directive instance.
108
+ *
109
+ * @example — a `confirm` modifier that shows a browser dialog
110
+ * ```ts
111
+ * import {registerModifier} from '@alwatr/action';
112
+ *
113
+ * registerModifier('confirm', function () {
114
+ * return window.confirm('Are you sure?');
115
+ * });
116
+ * ```
117
+ * ```html
118
+ * <button on-action="click.confirm->delete-item:42">Delete</button>
119
+ * ```
120
+ */
121
+ export function registerModifier(name: string, handler: ModifierHandler): void {
122
+ logger_.logMethodArgs?.('registerModifier', {name});
123
+ if (modifierRegistry.has(name)) {
124
+ logger_.accident('registerModifier', 'modifier_already_registered', {name});
125
+ }
126
+ modifierRegistry.set(name, handler);
127
+ }
128
+
129
+ /**
130
+ * Registers a custom payload resolver that can be used in `on-action` attribute syntax.
131
+ *
132
+ * A payload resolver is a colon-suffixed token in the attribute value
133
+ * (e.g. `click->action-id:$mytoken`). Its function is called at dispatch time
134
+ * with the directive instance as `this` and the DOM event as the argument.
135
+ * The return value becomes the `actionPayload` passed to `onAction` subscribers.
136
+ *
137
+ * Built-in resolvers (`$value`, `$formdata`) are always available. This function
138
+ * lets you add domain-specific ones.
139
+ *
140
+ * Registering the same name twice logs an accident and overwrites the previous
141
+ * resolver — avoid duplicate registrations in production code.
142
+ *
143
+ * @param name - The resolver token (should start with `$` by convention).
144
+ * @param resolver - The `PayloadResolver` function bound to the directive instance.
145
+ *
146
+ * @example — a `$checked` resolver for checkbox state
147
+ * ```ts
148
+ * import {registerPayloadResolver} from '@alwatr/action';
149
+ *
150
+ * registerPayloadResolver('$checked', function () {
151
+ * return (this.element_ as HTMLInputElement).checked;
152
+ * });
153
+ * ```
154
+ * ```html
155
+ * <input type="checkbox" on-action="change->toggle-feature:$checked" />
156
+ * ```
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
+ */
168
+ export function registerPayloadResolver(name: string, resolver: PayloadResolver): void {
169
+ logger_.logMethodArgs?.('registerPayloadResolver', {name});
170
+ if (payloadRegistry.has(name)) {
171
+ logger_.accident('registerPayloadResolver', 'payload_resolver_already_registered', {name});
172
+ }
173
+ payloadRegistry.set(name, resolver);
174
+ }
package/src/page-id.ts ADDED
@@ -0,0 +1,74 @@
1
+ import {Directive, lazyDirective} from '@alwatr/directive';
2
+ import {dispatchAction} from './method.js';
3
+
4
+ // ─── Directive Class ──────────────────────────────────────────────────────────
5
+
6
+ /**
7
+ * Directive that announces the current page identity as an action signal.
8
+ *
9
+ * Activated by the `page-id` HTML attribute. On bootstrap the directive reads
10
+ * the attribute value as the page identifier, dispatches a `'page-ready'`
11
+ * action with that value as the payload, and immediately self-destructs — no
12
+ * persistent listener is registered.
13
+ *
14
+ * Typical placement is on the `<body>` or the top-level page container so that
15
+ * any part of the application can react to route changes by subscribing to the
16
+ * `'page-ready'` action via `onAction`.
17
+ *
18
+ * @example
19
+ * ```html
20
+ * <!-- Dispatches dispatchAction('page-ready', 'home') on bootstrap -->
21
+ * <body page-id="home">…</body>
22
+ * ```
23
+ */
24
+ export class PageIdDirective extends Directive {
25
+ /**
26
+ * Reads the `page-id` attribute value, dispatches `'page-ready'` with it as
27
+ * the payload, then destroys the directive.
28
+ *
29
+ * Logs an accident and returns early if the attribute value is empty.
30
+ */
31
+ protected override init_(): void {
32
+ const pageId = this.attributeValue.trim();
33
+ this.logger_.logMethodArgs?.('init_', {pageId});
34
+
35
+ if (!pageId) {
36
+ this.logger_.accident('init_', 'empty_page_id');
37
+ return;
38
+ }
39
+
40
+ dispatchAction('page-ready', pageId);
41
+ this.destroy();
42
+ }
43
+ }
44
+
45
+ // ─── Lazy Registration ────────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * Registers `PageIdDirective` under the `page-id` attribute name.
49
+ *
50
+ * This is a **lazy** registration: calling this function is the only way to
51
+ * opt-in to `page-id` support. If it is never called, the entire directive
52
+ * module is tree-shaken from the bundle.
53
+ *
54
+ * Call it once, before `bootstrapDirectives()`, at your application entry point.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * import {registerPageIdDirective, onAction} from '@alwatr/action';
59
+ * import {bootstrapDirectives} from '@alwatr/directive';
60
+ *
61
+ * registerPageIdDirective();
62
+ * bootstrapDirectives();
63
+ *
64
+ * // React to every page change
65
+ * onAction('page-ready', (pageId) => {
66
+ * console.log('navigated to:', pageId); // e.g. 'home', 'about', 'product-detail'
67
+ * });
68
+ * ```
69
+ *
70
+ * ```html
71
+ * <body page-id="home">…</body>
72
+ * ```
73
+ */
74
+ export const registerPageIdDirective = lazyDirective('page-id', PageIdDirective);
@@ -0,0 +1,145 @@
1
+ import type {ActionDirective} from './directive.js';
2
+
3
+ // ─── Type Definitions ────────────────────────────────────────────────────────
4
+
5
+ /**
6
+ * A modifier handler attached to an `on-action` directive.
7
+ *
8
+ * Called with the directive instance as `this` and the triggering DOM `event`.
9
+ * Return `true` to allow the action to proceed, or `false` to cancel it.
10
+ * Returning `false` is the only way a modifier can veto a dispatch.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * // A modifier that only allows the action when the element is not disabled
15
+ * const notDisabledHandler: ModifierHandler = function () {
16
+ * return !(this.element_ as HTMLButtonElement).disabled;
17
+ * };
18
+ * ```
19
+ */
20
+ export type ModifierHandler = (this: ActionDirective, event: Event) => boolean;
21
+
22
+ /**
23
+ * A payload resolver attached to an `on-action` directive.
24
+ *
25
+ * Called with the directive instance as `this` and the triggering DOM `event`
26
+ * at dispatch time. The return value becomes the `actionPayload` of the
27
+ * dispatched action. Use this to compute dynamic payloads from the DOM state.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * // A resolver that returns the element's dataset id
32
+ * const dataIdResolver: PayloadResolver = function () {
33
+ * return (this.element_ as HTMLElement).dataset.id ?? null;
34
+ * };
35
+ * ```
36
+ */
37
+ export type PayloadResolver = (this: ActionDirective, event: Event) => unknown;
38
+
39
+ // ─── Registries ──────────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Registry of all named modifier handlers.
43
+ *
44
+ * Keys are modifier names used in the `on-action` attribute syntax
45
+ * (e.g. `click.prevent->action-id`). Values are `ModifierHandler` functions.
46
+ * Populated at module load with built-in modifiers; extended at runtime via
47
+ * `registerModifier`.
48
+ *
49
+ * @internal
50
+ */
51
+ export const modifierRegistry = new Map<string, ModifierHandler>();
52
+
53
+ /**
54
+ * Registry of all named payload resolvers.
55
+ *
56
+ * Keys are resolver tokens used in the `on-action` attribute syntax
57
+ * (e.g. `click->action-id:$value`). Values are `PayloadResolver` functions.
58
+ * Populated at module load with built-in resolvers; extended at runtime via
59
+ * `registerPayloadResolver`.
60
+ *
61
+ * @internal
62
+ */
63
+ export const payloadRegistry = new Map<string, PayloadResolver>();
64
+
65
+ // ─── Built-in Modifiers ───────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * `prevent` — calls `event.preventDefault()` before dispatching.
69
+ *
70
+ * Use it to suppress the browser's default behaviour (e.g. form submission,
71
+ * link navigation, context menu).
72
+ *
73
+ * @example `<form on-action="submit.prevent->submit-form">`
74
+ */
75
+ modifierRegistry.set('prevent', (event) => {
76
+ event.preventDefault();
77
+ return true;
78
+ });
79
+
80
+ /**
81
+ * `stop` — calls `event.stopPropagation()` before dispatching.
82
+ *
83
+ * Prevents the event from bubbling further up the DOM tree. Useful when a
84
+ * child element should handle a click without triggering a parent's listener.
85
+ *
86
+ * @example `<button on-action="click.stop->select-item:42">`
87
+ */
88
+ modifierRegistry.set('stop', (event) => {
89
+ event.stopPropagation();
90
+ return true;
91
+ });
92
+
93
+ /**
94
+ * `validate` — cancels the dispatch if the nearest `<form>` fails validation.
95
+ *
96
+ * Looks for a `<form>` ancestor (or the element itself if it is a form) and
97
+ * calls `checkValidity()`. If the form is invalid the action is not dispatched,
98
+ * allowing native constraint-validation UI to surface errors. If no form is
99
+ * found the dispatch is also cancelled and an accident is logged.
100
+ *
101
+ * Pair with `.prevent` on `submit` events to avoid page reloads:
102
+ *
103
+ * @example `<form on-action="submit.prevent.validate->submit-form" novalidate>`
104
+ */
105
+ modifierRegistry.set('validate', function () {
106
+ const form = this.element_ instanceof HTMLFormElement ? this.element_ : this.element_.closest('form');
107
+ if (!form) {
108
+ this.logger_.accident('validate_modifier', 'no_form_found', {element: this.element_});
109
+ return false;
110
+ }
111
+ return form.checkValidity();
112
+ });
113
+
114
+ // ─── Built-in Payload Resolvers ───────────────────────────────────────────────
115
+
116
+ /**
117
+ * `$value` — resolves to the element's `.value` property at dispatch time.
118
+ *
119
+ * Works with any element that exposes a `value` property: `<input>`,
120
+ * `<textarea>`, `<select>`. Returns `null` for elements without `.value`.
121
+ *
122
+ * @example `<input on-action="input->search-query:$value" />`
123
+ */
124
+ payloadRegistry.set('$value', function () {
125
+ return 'value' in this.element_ ? (this.element_ as {value: unknown}).value : null;
126
+ });
127
+
128
+ /**
129
+ * `$formdata` — resolves to a plain object of all fields in the nearest `<form>`.
130
+ *
131
+ * Collects entries via `FormData` and converts them to a `Record<string, FormDataEntryValue>`.
132
+ * Looks for a `<form>` ancestor (or the element itself). Returns `null` when no
133
+ * form is found.
134
+ *
135
+ * @example `<form on-action="submit.prevent.validate->submit-form">`
136
+ * ```ts
137
+ * onAction<Record<string, FormDataEntryValue>>('submit-form', (data) => {
138
+ * console.log(data); // {username: 'ali', password: '…'}
139
+ * });
140
+ * ```
141
+ */
142
+ payloadRegistry.set('$formdata', function () {
143
+ const form = this.element_ instanceof HTMLFormElement ? this.element_ : this.element_.closest('form');
144
+ return form ? Object.fromEntries(new FormData(form).entries()) : null;
145
+ });