@alwatr/action 9.26.0 → 9.28.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.
@@ -0,0 +1,397 @@
1
+ import {createLogger} from '@alwatr/logger';
2
+ import {createChannelSignal} from '@alwatr/signal';
3
+ import type {SubscribeResult} from '@alwatr/signal';
4
+ import type {Awaitable, VoidFunc} from '@alwatr/type-helper';
5
+
6
+ import type {Action, ActionRecord, DispatchParam, ModifierHandler, PayloadResolver} from './type.js';
7
+
8
+ /**
9
+ * Parsed representation of an action attribute descriptor.
10
+ * @internal
11
+ */
12
+ interface ActionDescriptor {
13
+ readonly modifiers: ReadonlySet<string>;
14
+ readonly actionId: string;
15
+ readonly payload: string | undefined;
16
+ }
17
+
18
+ /**
19
+ * Regex parser for the `on-<eventType>` attribute syntax.
20
+ * Syntax: `actionId[:payload][; modifier1,modifier2,...]`
21
+ */
22
+ const syntaxRegex = /^(ui_[a-z0-9_-]+)(?::([^;]+))?(?:;\s*([a-z0-9_,-]+))?$/;
23
+
24
+ /**
25
+ * Service to manage declarative DOM actions, programmatic dispatch,
26
+ * modifiers, payload resolvers, and global event delegation.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * import {ActionService} from '@alwatr/action';
31
+ *
32
+ * const customActionService = new ActionService();
33
+ * ```
34
+ */
35
+ export class ActionService {
36
+ /**
37
+ * Default DOM event types that cover the vast majority of interactive elements.
38
+ */
39
+ static readonly DEFAULT_DELEGATED_EVENTS: readonly string[] = ['click', 'submit', 'input', 'change'];
40
+
41
+ protected readonly logger_ = createLogger('action-service');
42
+
43
+ /**
44
+ * Internal ChannelSignal used for routing dispatched actions.
45
+ * @protected
46
+ */
47
+ protected readonly internalChannel_ = createChannelSignal<Record<string, Action>>({name: 'action-service'});
48
+
49
+ /**
50
+ * Registry mapping custom modifiers to their handlers.
51
+ * @protected
52
+ */
53
+ protected readonly modifierRegistry_ = new Map<string, ModifierHandler>();
54
+
55
+ /**
56
+ * Registry mapping custom payload resolvers to their functions.
57
+ * @protected
58
+ */
59
+ protected readonly payloadRegistry_ = new Map<string, PayloadResolver>();
60
+
61
+ /**
62
+ * Cache of parsed action descriptors to prevent redundant regex evaluation.
63
+ * @protected
64
+ */
65
+ protected readonly descriptorCache_ = new Map<string, ActionDescriptor | null>();
66
+
67
+ /**
68
+ * Tracked event types currently delegated to `document.body`.
69
+ * @protected
70
+ */
71
+ protected readonly delegatedEventTypes_ = new Set<string>();
72
+
73
+ /**
74
+ * Bound delegation handler for add/removeEventListener.
75
+ * @private
76
+ */
77
+ private readonly handleDelegatedEventBound__ = this.handleDelegatedEvent_.bind(this);
78
+
79
+ constructor() {
80
+ this.logger_.logMethod?.('constructor');
81
+ this.registerDefaultModifiersAndResolvers__();
82
+ }
83
+
84
+ /**
85
+ * Subscribes to a named action dispatched anywhere in the application.
86
+ *
87
+ * @template K - A key of ActionRecord.
88
+ * @param type - Action type or array of action types to subscribe to.
89
+ * @param handler - Callback invoked with the full Action object.
90
+ * @returns SubscribeResult containing an `unsubscribe` method.
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * // Subscribe to a single action
95
+ * const sub1 = actionService.on('ui_open_drawer', (action) => {
96
+ * console.log(action.payload);
97
+ * });
98
+ *
99
+ * // Subscribe to multiple action types
100
+ * const sub2 = actionService.on(['ui_open_drawer', 'ui_close_drawer'], (action) => {
101
+ * console.log(action.type, action.payload);
102
+ * });
103
+ * ```
104
+ */
105
+ on<K extends keyof ActionRecord>(type: K | K[], handler: (action: Action<K>) => Awaitable<void>): SubscribeResult {
106
+ this.logger_.logMethodArgs?.('on', {type});
107
+ if (Array.isArray(type)) {
108
+ const typeList = type as K[];
109
+ const unsubscribeList: VoidFunc[] = [];
110
+ for (const type_ of typeList) {
111
+ unsubscribeList.push(
112
+ this.internalChannel_.on(type_, handler as (action: Action) => Awaitable<void>).unsubscribe,
113
+ );
114
+ }
115
+ return {
116
+ unsubscribe: () => {
117
+ this.logger_.logMethod?.('unsubscribe');
118
+ for (const unsubscribe of unsubscribeList) {
119
+ unsubscribe();
120
+ }
121
+ unsubscribeList.length = 0;
122
+ },
123
+ };
124
+ }
125
+ return this.internalChannel_.on(type, handler as (action: Action) => Awaitable<void>);
126
+ }
127
+
128
+ /**
129
+ * Dispatches an action to all subscribers matching `action.type`.
130
+ *
131
+ * @template K - A key of ActionRecord.
132
+ * @param action - Action object containing `type` and `payload`.
133
+ *
134
+ * @example
135
+ * ```ts
136
+ * // Dispatches a typed action (payload is required)
137
+ * actionService.dispatch({type: 'upload_complete', payload: 'file-123'});
138
+ *
139
+ * // Dispatches a void action (payload can be omitted)
140
+ * actionService.dispatch({type: 'auth_expired'});
141
+ * ```
142
+ */
143
+ dispatch<K extends keyof ActionRecord>(action: DispatchParam<K>): void {
144
+ this.logger_.logMethodArgs?.('dispatch', action);
145
+ this.internalChannel_.dispatch(action.type, action as Action<K>);
146
+ }
147
+
148
+ /**
149
+ * Registers a custom modifier to enrich or filter actions before dispatch.
150
+ *
151
+ * @param name - Modifier name (lowercase, alphanumeric).
152
+ * @param handler - Function called when modifier is invoked.
153
+ *
154
+ * @example
155
+ * ```ts
156
+ * actionService.registerModifier('trace', (_event, _element, action) => {
157
+ * action.meta ??= {};
158
+ * action.meta['time'] = Date.now();
159
+ * return true;
160
+ * });
161
+ * ```
162
+ */
163
+ registerModifier(name: string, handler: ModifierHandler): void {
164
+ this.logger_.logMethodArgs?.('registerModifier', {name});
165
+ if (this.modifierRegistry_.has(name)) {
166
+ this.logger_.accident('registerModifier', 'modifier_already_registered', {name});
167
+ return;
168
+ }
169
+ this.modifierRegistry_.set(name, handler);
170
+ }
171
+
172
+ /**
173
+ * Registers a custom payload resolver to map DOM state to action payload.
174
+ *
175
+ * @param name - Resolver token (by convention starting with `$`).
176
+ * @param resolver - Function yielding payload from the event and element.
177
+ *
178
+ * @example
179
+ * ```ts
180
+ * actionService.registerPayloadResolver('$data-id', (_event, element) => {
181
+ * return element.dataset.id;
182
+ * });
183
+ * ```
184
+ */
185
+ registerPayloadResolver(name: string, resolver: PayloadResolver): void {
186
+ this.logger_.logMethodArgs?.('registerPayloadResolver', {name});
187
+ if (this.payloadRegistry_.has(name)) {
188
+ this.logger_.accident('registerPayloadResolver', 'payload_resolver_already_registered', {name});
189
+ return;
190
+ }
191
+ this.payloadRegistry_.set(name, resolver);
192
+ }
193
+
194
+ /**
195
+ * Registers global event delegation listeners on `document.body`.
196
+ *
197
+ * @param eventTypes - List of event types to delegate. Defaults to ActionService.DEFAULT_DELEGATED_EVENTS.
198
+ *
199
+ * @example
200
+ * ```ts
201
+ * actionService.setupDelegation();
202
+ * ```
203
+ */
204
+ setupDelegation(eventTypes: readonly string[] = ActionService.DEFAULT_DELEGATED_EVENTS): void {
205
+ this.logger_.logMethodArgs?.('setupDelegation', {eventTypes});
206
+ if (typeof document === 'undefined' || !document.body) {
207
+ this.logger_.incident?.('setupDelegation', 'document_body_not_found');
208
+ return;
209
+ }
210
+
211
+ for (const eventType of eventTypes) {
212
+ if (this.delegatedEventTypes_.has(eventType)) continue;
213
+ this.delegatedEventTypes_.add(eventType);
214
+ document.body.addEventListener(eventType, this.handleDelegatedEventBound__, {capture: true});
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Unregisters all global event delegation listeners.
220
+ *
221
+ * @example
222
+ * ```ts
223
+ * actionService.teardownDelegation();
224
+ * ```
225
+ */
226
+ teardownDelegation(): void {
227
+ this.logger_.logMethod?.('teardownDelegation');
228
+ if (typeof document === 'undefined' || !document.body) {
229
+ return;
230
+ }
231
+ for (const eventType of this.delegatedEventTypes_) {
232
+ document.body.removeEventListener(eventType, this.handleDelegatedEventBound__, {capture: true});
233
+ }
234
+ this.delegatedEventTypes_.clear();
235
+ this.descriptorCache_.clear();
236
+ }
237
+
238
+ /**
239
+ * Parses attribute values into action descriptor, utilizing the internal cache.
240
+ * @protected
241
+ */
242
+ protected parseDescriptor_(attributeValue: string): ActionDescriptor | null {
243
+ this.logger_.logMethodArgs?.('parseDescriptor_', {attributeValue});
244
+
245
+ const cached = this.descriptorCache_.get(attributeValue);
246
+ if (cached !== undefined) return cached;
247
+
248
+ const match = attributeValue.match(syntaxRegex);
249
+ if (!match) {
250
+ this.logger_.accident('parseDescriptor_', 'invalid_syntax', {attributeValue});
251
+ this.descriptorCache_.set(attributeValue, null);
252
+ return null;
253
+ }
254
+
255
+ const actionId = match[1]!;
256
+ const payload = match[2];
257
+ const modifierString = match[3];
258
+ const modifiers = modifierString ? new Set(modifierString.split(',').filter(Boolean)) : new Set<string>();
259
+
260
+ const descriptor: ActionDescriptor = {modifiers, actionId, payload};
261
+ this.descriptorCache_.set(attributeValue, descriptor);
262
+ return descriptor;
263
+ }
264
+
265
+ /**
266
+ * Global event delegation handler.
267
+ * @protected
268
+ */
269
+ protected handleDelegatedEvent_(event: Event): void {
270
+ const eventType = event.type;
271
+ this.logger_.logMethodArgs?.('handleDelegatedEvent_', {eventType});
272
+
273
+ const target = event.target as Element | null;
274
+ if (!target) return;
275
+
276
+ const actionAttrib = `on-${eventType}`;
277
+ const actionElement = target.closest?.(`[${actionAttrib}]`);
278
+ if (!actionElement) return;
279
+
280
+ const attributeValue = actionElement.getAttribute?.(actionAttrib)?.trim();
281
+ if (!attributeValue) {
282
+ this.logger_.accident('handleDelegatedEvent_', 'empty_attribute', {eventType, actionElement});
283
+ return;
284
+ }
285
+
286
+ if (!(actionElement instanceof HTMLElement)) {
287
+ this.logger_.accident('handleDelegatedEvent_', 'target_not_html_element', {eventType, actionElement});
288
+ return;
289
+ }
290
+
291
+ const descriptor = this.parseDescriptor_(attributeValue);
292
+ if (!descriptor) return;
293
+
294
+ this.logger_.logMethodArgs?.('handleDelegatedEvent_.action', {eventType, descriptor});
295
+
296
+ if (descriptor.modifiers.has('once')) {
297
+ actionElement.removeAttribute(actionAttrib);
298
+ }
299
+
300
+ const actionContext = actionElement.closest('[action-context]')?.getAttribute('action-context') ?? undefined;
301
+
302
+ const action: Action = {
303
+ type: descriptor.actionId as keyof ActionRecord,
304
+ context: actionContext,
305
+ payload: descriptor.payload as ActionRecord[keyof ActionRecord],
306
+ };
307
+
308
+ for (const modifier of descriptor.modifiers) {
309
+ if (modifier === 'once') continue;
310
+ const handler = this.modifierRegistry_.get(modifier);
311
+ if (!handler) {
312
+ this.logger_.accident('handleDelegatedEvent_', 'unknown_modifier', {
313
+ eventType,
314
+ modifier,
315
+ attributeValue,
316
+ descriptor,
317
+ });
318
+ return;
319
+ }
320
+ try {
321
+ if (handler(event, actionElement, action) === false) return;
322
+ } catch (error) {
323
+ this.logger_.accident('handleDelegatedEvent_', 'modifier_execution_failed', {
324
+ modifier,
325
+ error,
326
+ });
327
+ return;
328
+ }
329
+ }
330
+
331
+ if (descriptor.payload) {
332
+ const resolver = this.payloadRegistry_.get(descriptor.payload);
333
+ if (resolver) {
334
+ try {
335
+ (action as {payload: unknown}).payload = resolver(event, actionElement);
336
+ } catch (error) {
337
+ this.logger_.accident('handleDelegatedEvent_', 'payload_resolver_failed', {
338
+ resolver: descriptor.payload,
339
+ error,
340
+ });
341
+ return;
342
+ }
343
+ }
344
+ } else {
345
+ (action as {payload: unknown}).payload = undefined;
346
+ }
347
+
348
+ this.internalChannel_.dispatch(action.type, action);
349
+ }
350
+
351
+ /**
352
+ * Registers default modifiers and resolvers.
353
+ * @private
354
+ */
355
+ private registerDefaultModifiersAndResolvers__(): void {
356
+ this.logger_.logMethod?.('registerDefaultModifiersAndResolvers__');
357
+
358
+ // Built-in modifiers
359
+ this.registerModifier('prevent', (event) => {
360
+ event.preventDefault();
361
+ return true;
362
+ });
363
+
364
+ this.registerModifier('validate', (_event, element) => {
365
+ const form = element instanceof HTMLFormElement ? element : element.closest('form');
366
+ if (!form) return false;
367
+ return form.checkValidity();
368
+ });
369
+
370
+ // Built-in resolvers
371
+ this.registerPayloadResolver('$value', (_event, element) => {
372
+ return 'value' in element ? (element as {value: unknown}).value : null;
373
+ });
374
+
375
+ this.registerPayloadResolver('$formdata', (_event, element) => {
376
+ const form = element instanceof HTMLFormElement ? element : element.closest('form');
377
+ return form ? Object.fromEntries(new FormData(form)) : null;
378
+ });
379
+
380
+ this.registerPayloadResolver('$checked', (_event, element) => {
381
+ return 'checked' in element ? (element as HTMLInputElement).checked : null;
382
+ });
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Singleton instance of the ActionService.
388
+ * Ready for immediate use.
389
+ *
390
+ * @example
391
+ * ```ts
392
+ * import {actionService} from '@alwatr/action';
393
+ *
394
+ * actionService.setupDelegation();
395
+ * ```
396
+ */
397
+ export const actionService = new ActionService();
package/src/main.ts CHANGED
@@ -1,100 +1,147 @@
1
+ import type {SubscribeResult} from '@alwatr/signal';
2
+ import type {Awaitable} from '@alwatr/type-helper';
3
+
4
+ import {actionService, ActionService} from './action-service.js';
5
+ import type {Action, ActionRecord, DispatchParam, ModifierHandler, PayloadResolver} from './type.js';
6
+
7
+ export {actionService, ActionService};
8
+ export type {Action, ActionRecord, DispatchParam, ModifierHandler, PayloadResolver};
9
+
1
10
  /**
2
- * @alwatr/action Declarative DOM action-dispatch for Unidirectional Data Flow.
11
+ * Default DOM event types that cover the vast majority of interactive elements.
3
12
  *
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
13
+ * - `click` buttons, links, checkboxes, custom interactive elements
14
+ * - `submit` form submission
15
+ * - `input` live text input, range sliders
16
+ * - `change` select boxes, checkboxes, radio buttons (fires on commit)
17
+ */
18
+ export const DEFAULT_DELEGATED_EVENTS = ActionService.DEFAULT_DELEGATED_EVENTS;
19
+
20
+ /**
21
+ * Subscribes to a named action dispatched anywhere in the application.
11
22
  *
12
- * Call `setupActionDelegation()` once at bootstrap. A single capture-phase
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.
23
+ * @template K - A key of ActionRecord.
24
+ * @param type - Action type or array of action types to subscribe to.
25
+ * @param handler - Callback invoked with the full Action object.
26
+ * @returns SubscribeResult containing an `unsubscribe` method.
15
27
  *
28
+ * @example
16
29
  * ```ts
17
- * import {setupActionDelegation, onAction} from '@alwatr/action';
30
+ * import {onAction} from '@alwatr/action';
18
31
  *
19
- * setupActionDelegation();
20
- * onAction('ui_open_drawer', (action) => openDrawer(action.payload));
32
+ * // Subscribe to multiple action types
33
+ * const sub = onAction(['ui_open_drawer', 'ui_close_drawer'], (action) => {
34
+ * console.log(action.type, action.payload);
35
+ * });
36
+ * sub.unsubscribe();
21
37
  * ```
22
38
  *
23
- * ## Attribute syntax
24
- *
25
- * ```
26
- * on-<eventType>="actionId[:payload][; modifier1,modifier2,…]"
27
- * ```
39
+ * @deprecated Use `actionService.on` instead.
40
+ */
41
+ export function onAction<K extends keyof ActionRecord>(
42
+ type: K | K[],
43
+ handler: (action: Action<K>) => Awaitable<void>,
44
+ ): SubscribeResult {
45
+ return actionService.on(type, handler);
46
+ }
47
+
48
+ /**
49
+ * Dispatches an action to all subscribers matching `action.type`.
28
50
  *
29
- * ```html
30
- * <button on-click="ui_open_drawer:main">Open</button>
31
- * <input on-input="ui_search_query:$value" />
32
- * <form on-submit="ui_submit_form:$formdata; prevent,validate" novalidate>…</form>
33
- * ```
51
+ * @template K - A key of ActionRecord.
52
+ * @param action - Action object containing `type` and `payload`.
34
53
  *
35
- * ## Context scoping
54
+ * @example
55
+ * ```ts
56
+ * import {dispatchAction} from '@alwatr/action';
36
57
  *
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`:
58
+ * // Dispatches a typed action (payload is required)
59
+ * dispatchAction({type: 'upload_complete', payload: 'file-123'});
40
60
  *
41
- * ```html
42
- * <section action-context="product-list">
43
- * <button on-click="ui_add_to_cart:42">Add</button>
44
- * </section>
61
+ * // Dispatches a void action (payload can be omitted)
62
+ * dispatchAction({type: 'auth_expired'});
45
63
  * ```
46
64
  *
65
+ * @deprecated Use `actionService.dispatch` instead.
66
+ */
67
+ export function dispatchAction<K extends keyof ActionRecord>(action: DispatchParam<K>): void {
68
+ actionService.dispatch(action);
69
+ }
70
+
71
+ /**
72
+ * Registers global event delegation listeners on `document.body`.
73
+ *
74
+ * @param eventTypes - List of event types to delegate. Defaults to DEFAULT_DELEGATED_EVENTS.
75
+ *
76
+ * @example
47
77
  * ```ts
48
- * onAction('ui_add_to_cart', (action) => {
49
- * console.log(action.context); // 'product-list'
50
- * console.log(action.payload); // '42'
51
- * });
52
- * ```
78
+ * import {setupActionDelegation} from '@alwatr/action';
53
79
  *
54
- * ## Programmatic dispatch
80
+ * setupActionDelegation();
81
+ * ```
55
82
  *
56
- * Code-originated actions should not use the `ui_` prefix — that prefix is
57
- * reserved for DOM-originated actions dispatched via HTML attributes.
83
+ * @deprecated Use `actionService.setupDelegation` instead.
84
+ */
85
+ export function setupActionDelegation(eventTypes?: readonly string[]): void {
86
+ actionService.setupDelegation(eventTypes);
87
+ }
88
+
89
+ /**
90
+ * Unregisters all global event delegation listeners.
58
91
  *
92
+ * @example
59
93
  * ```ts
60
- * import {dispatchAction} from '@alwatr/action';
94
+ * import {teardownActionDelegation} from '@alwatr/action';
61
95
  *
62
- * dispatchAction({type: 'navigate', payload: '/dashboard'});
96
+ * teardownActionDelegation();
63
97
  * ```
64
98
  *
65
- * ## Registering typed actions
99
+ * @deprecated Use `actionService.teardownDelegation` instead.
100
+ */
101
+ export function teardownActionDelegation(): void {
102
+ actionService.teardownDelegation();
103
+ }
104
+
105
+ /**
106
+ * Registers a custom modifier to enrich or filter actions before dispatch.
66
107
  *
67
- * Extend `ActionRecord` via declaration merging to get full type safety:
108
+ * @param name - Modifier name (lowercase, alphanumeric).
109
+ * @param handler - Function called when modifier is invoked.
68
110
  *
111
+ * @example
69
112
  * ```ts
70
- * // src/action-record.ts
71
- * declare module '@alwatr/action' {
72
- * interface ActionRecord {
73
- * // UI-originated actions — must start with 'ui_'
74
- * 'ui_open_drawer': string;
75
- * 'ui_add_to_cart': {productId: number; qty: number};
76
- * 'ui_logout': void;
77
- *
78
- * // Code-originated actions — no 'ui_' prefix
79
- * 'upload_complete': string;
80
- * 'auth_expired': void;
81
- * }
82
- * }
113
+ * import {registerModifier} from '@alwatr/action';
114
+ *
115
+ * registerModifier('trace', (_event, _element, action) => {
116
+ * action.meta ??= {};
117
+ * action.meta['time'] = Date.now();
118
+ * return true;
119
+ * });
83
120
  * ```
84
121
  *
85
- * ## Public API
122
+ * @deprecated Use `actionService.registerModifier` instead.
123
+ */
124
+ export function registerModifier(name: string, handler: ModifierHandler): void {
125
+ actionService.registerModifier(name, handler);
126
+ }
127
+
128
+ /**
129
+ * Registers a custom payload resolver to map DOM state to action payload.
130
+ *
131
+ * @param name - Resolver token (by convention starting with `$`).
132
+ * @param resolver - Function yielding payload from the event and element.
86
133
  *
87
- * - `Action` — the AFSA object interface (`type`, `payload`, `context`, `meta`)
88
- * - `ActionRecord` — extend this interface to register typed actions
89
- * - `setupActionDelegation` / `teardownActionDelegation` — global delegation lifecycle
90
- * - `DEFAULT_DELEGATED_EVENTS` — default event types covered by delegation
91
- * - `onAction` / `dispatchAction` — subscribe to and dispatch named actions
92
- * - `registerModifier` / `registerPayloadResolver` — extend the attribute syntax
134
+ * @example
135
+ * ```ts
136
+ * import {registerPayloadResolver} from '@alwatr/action';
93
137
  *
94
- * ## Page identity
138
+ * registerPayloadResolver('$data-id', (_event, element) => {
139
+ * return element.dataset.id;
140
+ * });
141
+ * ```
95
142
  *
96
- * For page-ready signals in SSG/SSR apps, use `@alwatr/page-ready` instead.
143
+ * @deprecated Use `actionService.registerPayloadResolver` instead.
97
144
  */
98
- export * from './delegate.js';
99
- export * from './method.js';
100
- export type * from './type.js';
145
+ export function registerPayloadResolver(name: string, resolver: PayloadResolver): void {
146
+ actionService.registerPayloadResolver(name, resolver);
147
+ }