jssm 5.136.0 → 5.138.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.
@@ -1,6 +1,208 @@
1
1
  import { css, LitElement, html } from 'lit';
2
2
  import { sm } from 'jssm';
3
3
 
4
+ const VALID_KINDS = new Set([
5
+ 'hook',
6
+ 'named',
7
+ 'any transition',
8
+ 'standard transition',
9
+ 'main transition',
10
+ 'forced transition',
11
+ 'entry',
12
+ 'exit',
13
+ 'any action',
14
+ 'global action',
15
+ ]);
16
+ /**
17
+ * Build a {@link JssmHookProxy} that wraps an arbitrary hook context object.
18
+ *
19
+ * The context shape varies by hook kind (`from`/`to`/`action` may be absent
20
+ * for transition-kind hooks), so this normalizes the shape via optional
21
+ * fields and exposes mutable `data` while keeping the rest read-only.
22
+ *
23
+ * The `machine` parameter is used only for `state()`, so unit tests can
24
+ * substitute any object with a `state(): unknown` method.
25
+ *
26
+ * @param ctx - Raw hook context passed by jssm.
27
+ * @param machine - The owning machine; used for the `state()` accessor.
28
+ * @returns A proxy object suitable for passing to a user handler.
29
+ */
30
+ function make_hook_proxy(ctx, machine) {
31
+ return {
32
+ get data() {
33
+ return ctx.data;
34
+ },
35
+ set data(next) {
36
+ ctx.data = next;
37
+ },
38
+ get from() {
39
+ return ctx.from;
40
+ },
41
+ get to() {
42
+ return ctx.to;
43
+ },
44
+ get action() {
45
+ return ctx.action;
46
+ },
47
+ state() {
48
+ return String(machine.state());
49
+ },
50
+ };
51
+ }
52
+ /**
53
+ * Compile a textContent body into a callable user handler.
54
+ *
55
+ * Uses dynamic function construction — the same primitive browsers use
56
+ * internally for `<a onclick="...">` and `setTimeout(stringBody, ms)`.
57
+ * Strict CSP without `'unsafe-eval'` blocks this and the call will throw;
58
+ * consumers should fall back to the `handler="name"` form there.
59
+ *
60
+ * Prepends a `//# sourceURL=` comment so devtools surface a meaningful name
61
+ * in stack traces instead of `anonymous`.
62
+ *
63
+ * @param body - Trimmed textContent of the `<jssm-hook>` element.
64
+ * @param debug_id - Identifier appended to the synthetic sourceURL.
65
+ * @returns The compiled handler.
66
+ */
67
+ function compile_inline_body(body, debug_id) {
68
+ const annotated = `//# sourceURL=jssm-hook:${debug_id}\n${body}`;
69
+ const ctor = Function;
70
+ return new ctor('m', annotated);
71
+ }
72
+ /**
73
+ * Resolve a `handler="name"` attribute to a callable by consulting first the
74
+ * optional in-WC registry, then `globalThis[name]`. Throws a clear error if
75
+ * neither resolves.
76
+ *
77
+ * @param name - The handler name from the `handler=""` attribute.
78
+ * @param registry - Optional in-WC registry to consult first.
79
+ * @returns The resolved handler.
80
+ * @throws Error - If no callable of that name is found in either location.
81
+ */
82
+ function resolve_named_handler(name, registry) {
83
+ if (registry !== undefined) {
84
+ const registered = registry.get(name);
85
+ if (registered !== undefined) {
86
+ return registered;
87
+ }
88
+ }
89
+ const global = globalThis[name];
90
+ if (typeof global === 'function') {
91
+ return global;
92
+ }
93
+ throw new Error(`<jssm-hook handler="${name}">: handler not found in registry or globalThis`);
94
+ }
95
+ /**
96
+ * Validate and normalize a `<jssm-hook kind="...">` value, defaulting to
97
+ * `"hook"` when the attribute is absent. Throws on unknown kinds rather
98
+ * than silently doing nothing later.
99
+ *
100
+ * @param raw - The raw attribute value, or null if not present.
101
+ * @returns The normalized {@link JssmHookKind}.
102
+ * @throws Error - On an unknown kind.
103
+ */
104
+ function normalize_hook_kind(raw) {
105
+ if (raw === null || raw === undefined || raw === '') {
106
+ return 'hook';
107
+ }
108
+ if (!VALID_KINDS.has(raw)) {
109
+ throw new Error(`<jssm-hook kind="${raw}">: unknown hook kind (expected one of: ${[...VALID_KINDS].join(', ')})`);
110
+ }
111
+ return raw;
112
+ }
113
+ /**
114
+ * Parse a single `<jssm-hook>` element into a {@link JssmHookInstallSpec}.
115
+ *
116
+ * Validates the mutual-exclusion rule between `handler="name"` and inline
117
+ * body, defaults `kind` to `"hook"`, resolves named handlers against the
118
+ * optional registry then `globalThis`, and compiles inline bodies via
119
+ * dynamic function construction. Conditional-required attributes (e.g.
120
+ * `from`/`to` for `kind="hook"`) are NOT validated here — `set_hook` will
121
+ * throw with its own clear errors on missing pieces, which keeps the
122
+ * error surface single-sourced.
123
+ *
124
+ * @param el - The `<jssm-hook>` element to parse.
125
+ * @param debug_id - Identifier used in the inline body's sourceURL.
126
+ * @param registry - Optional in-WC registry of named handlers.
127
+ * @returns A {@link JssmHookInstallSpec} describing what to install.
128
+ * @throws Error - On mutual-exclusion violation, unknown kind, or unresolved name.
129
+ */
130
+ function parse_hook_element(el, debug_id, registry) {
131
+ var _a, _b, _c, _d;
132
+ const handler_attr = el.getAttribute('handler');
133
+ const raw_text = el.textContent;
134
+ const body_text = (raw_text === null ? '' : raw_text).trim();
135
+ if (handler_attr !== null && body_text.length > 0) {
136
+ throw new Error('<jssm-hook>: specify handler="name" OR inline body, not both');
137
+ }
138
+ if (handler_attr === null && body_text.length === 0) {
139
+ throw new Error('<jssm-hook>: must specify either handler="name" attribute or an inline body');
140
+ }
141
+ const user_handler = handler_attr !== null
142
+ ? resolve_named_handler(handler_attr, registry)
143
+ : compile_inline_body(body_text, debug_id);
144
+ const kind = normalize_hook_kind(el.getAttribute('kind'));
145
+ // Convert null → undefined so downstream descriptors omit absent keys.
146
+ const from = (_a = el.getAttribute('from')) !== null && _a !== void 0 ? _a : undefined;
147
+ const to = (_b = el.getAttribute('to')) !== null && _b !== void 0 ? _b : undefined;
148
+ const action = (_c = el.getAttribute('action')) !== null && _c !== void 0 ? _c : undefined;
149
+ const name = (_d = el.getAttribute('name')) !== null && _d !== void 0 ? _d : undefined;
150
+ return { kind, name, from, to, action, user_handler };
151
+ }
152
+ /**
153
+ * Wrap a {@link JssmHookUserHandler} so that jssm's native hook contract is
154
+ * satisfied: the user gets a friendly proxy, the proxy's mutated `data`
155
+ * becomes the `HookComplexResult.data`, and an explicit `false` return
156
+ * cancels the transition.
157
+ *
158
+ * Any non-`false` return — including `undefined`, `true`, or an arbitrary
159
+ * object — allows the transition. This matches the contract spelled out
160
+ * in the issue (#641): "return false cancels; anything else allows".
161
+ *
162
+ * @param spec - The parsed install spec carrying the user handler.
163
+ * @param machine - The owning machine; used by the proxy's `state()`.
164
+ * @returns A wrapped handler suitable for `set_hook`.
165
+ */
166
+ function wrap_user_handler(spec, machine) {
167
+ const user = spec.user_handler;
168
+ return (ctx) => {
169
+ const proxy = make_hook_proxy(ctx, machine);
170
+ const result = user(proxy);
171
+ if (result === false) {
172
+ return false;
173
+ }
174
+ return { pass: true, data: proxy.data };
175
+ };
176
+ }
177
+ /**
178
+ * Build the typed descriptor object passed to `machine.set_hook` (and later
179
+ * to `machine.remove_hook` for cleanup) from a parsed {@link JssmHookInstallSpec}
180
+ * and the wrapped handler.
181
+ *
182
+ * For kinds that need `from`/`to`/`action`, the descriptor includes those.
183
+ * Missing required keys produce `undefined` here; jssm's `set_hook` will
184
+ * surface the error with its own clear message so we don't duplicate
185
+ * validation.
186
+ *
187
+ * Return type is `unknown` because jssm's `HookDescription` is a
188
+ * discriminated union and our runtime-discriminator value can't be tracked
189
+ * by TypeScript across the build. The WC casts at the `set_hook` call site.
190
+ *
191
+ * @param spec - The parsed install spec.
192
+ * @param wrapped - The wrapped (friendly-proxy) handler from {@link wrap_user_handler}.
193
+ * @returns A descriptor object for `set_hook`/`remove_hook`.
194
+ */
195
+ function build_hook_descriptor(spec, wrapped) {
196
+ const base = { kind: spec.kind, handler: wrapped };
197
+ if (spec.from !== undefined)
198
+ base.from = spec.from;
199
+ if (spec.to !== undefined)
200
+ base.to = spec.to;
201
+ if (spec.action !== undefined)
202
+ base.action = spec.action;
203
+ return base;
204
+ }
205
+
4
206
  /**
5
207
  * Resolve a `<jssm-instance>`'s FSL source from the three legal channels:
6
208
  * the `fsl=""` attribute, a child `<script type="text/fsl">`, and the
@@ -116,6 +318,46 @@ class JssmInstance extends LitElement {
116
318
  * connection.
117
319
  */
118
320
  this._machine = undefined;
321
+ /**
322
+ * Per-instance registry of named hook handlers consulted before
323
+ * `globalThis` when resolving `<jssm-hook handler="name">`.
324
+ *
325
+ * Initialized to an empty `Map`; consumers may populate it before the
326
+ * element connects to provide handlers without polluting global scope —
327
+ * useful for module-scoped SPAs where strict CSP blocks inline-body hooks.
328
+ *
329
+ * @see {@link parse_hook_element}
330
+ */
331
+ this.registry = new Map();
332
+ /**
333
+ * Descriptors for hooks this WC installed at connect time, used in
334
+ * `disconnectedCallback` to call `remove_hook` for each so the underlying
335
+ * machine doesn't leak handlers when the element is detached.
336
+ *
337
+ * Captured at install time because `remove_hook` matches by descriptor
338
+ * shape (not handler identity), and we need to record the wrapped handler
339
+ * we passed to `set_hook` to undo the registration cleanly. Stored as
340
+ * `unknown[]` and cast at the call site because jssm's `HookDescription`
341
+ * is a discriminated union whose discriminator is only known at runtime.
342
+ */
343
+ this._installed_hooks = [];
344
+ /**
345
+ * Counter used to give each compiled inline-body hook a unique debug id
346
+ * for its `//# sourceURL=jssm-hook:N` annotation. Per-instance so that
347
+ * multiple `<jssm-instance>` elements on a page don't share numbering.
348
+ */
349
+ this._hook_debug_counter = 0;
350
+ /**
351
+ * Records every DOM listener installed by `<jssm-action>` / `data-jssm-action`
352
+ * discovery so {@link disconnectedCallback} can remove each one with the
353
+ * same handler reference originally passed to `addEventListener`.
354
+ *
355
+ * Listeners installed via the dedicated `<jssm-action>` tag form may target
356
+ * elements outside the host (its `selector` is resolved against the host,
357
+ * but matching elements live in the document tree), so cleanup must be
358
+ * explicit — relying on the host's GC is not sufficient.
359
+ */
360
+ this._action_listeners = [];
119
361
  }
120
362
  /**
121
363
  * Raw machine accessor. Returns the owned {@link Machine} instance.
@@ -180,22 +422,191 @@ class JssmInstance extends LitElement {
180
422
  this.requestUpdate();
181
423
  // TODO #638: subscribe to machine.on('transition', ...) once available
182
424
  // and dispatch DOM CustomEvents from this element.
183
- // TODO #641: <jssm-hook> discovery happens here.
425
+ // #641: <jssm-hook> declarative discovery.
426
+ this._install_declarative_hooks();
184
427
  // TODO #643: <jssm-on> discovery happens here.
185
- // TODO #640: <jssm-action> discovery happens here.
428
+ this._discover_jssm_actions();
186
429
  // TODO #645: <jssm-bind> discovery happens here.
187
430
  }
188
431
  /**
189
- * Lifecycle hook. Cleans up any installed subscriptions. Currently a
190
- * no-op (no subscriptions are installed in the base scaffolding) — the
191
- * empty body is intentional so the future tickets #638/#641/#643/#645
192
- * have a single canonical hook to extend.
432
+ * Discover every direct-child `<jssm-hook>` element and install each
433
+ * against the owned machine. Handlers are wrapped with the friendly-proxy
434
+ * adapter that lets user code write `m.data = ...` and return `false` to
435
+ * cancel see {@link make_hook_proxy} and the issue (#641) doc-comment
436
+ * for the full contract.
437
+ *
438
+ * Direct children only (the `:scope > jssm-hook` selector) so that nested
439
+ * `<jssm-instance>` elements don't have their child hooks installed on
440
+ * the outer machine.
441
+ *
442
+ * Tracks every installed descriptor in `_installed_hooks` so that
443
+ * `disconnectedCallback` can remove them on detach.
444
+ *
445
+ * @throws Error - On a malformed `<jssm-hook>` (mutual-exclusion violation,
446
+ * unknown kind, unresolved name, or jssm's own missing-key
447
+ * errors from `set_hook`).
448
+ */
449
+ _install_declarative_hooks() {
450
+ const machine = this._machine;
451
+ const hook_els = this.querySelectorAll(':scope > jssm-hook');
452
+ for (const el of Array.from(hook_els)) {
453
+ const debug_id = `${this._hook_id_prefix()}${++this._hook_debug_counter}`;
454
+ const spec = parse_hook_element(el, debug_id, this.registry);
455
+ const wrapped = wrap_user_handler(spec, machine);
456
+ const desc = build_hook_descriptor(spec, wrapped);
457
+ // `desc` is shaped from runtime kind discrimination; jssm's typed
458
+ // `HookDescription` is a static discriminated union that TS can't
459
+ // unify with our runtime-built object, hence the cast.
460
+ machine.set_hook(desc);
461
+ this._installed_hooks.push(desc);
462
+ }
463
+ }
464
+ /**
465
+ * Prefix used in synthetic `//# sourceURL=jssm-hook:<prefix><n>` annotations
466
+ * for inline-body hooks compiled by this element. Includes the element's
467
+ * `id` when present so multi-instance pages can tell sources apart in
468
+ * devtools.
469
+ */
470
+ _hook_id_prefix() {
471
+ const host_id = this.getAttribute('id');
472
+ return host_id !== null && host_id.length > 0 ? `${host_id}-` : '';
473
+ }
474
+ /**
475
+ * Lifecycle hook. Removes every hook this WC installed via
476
+ * `<jssm-hook>` discovery so the underlying machine doesn't leak handlers
477
+ * when the element detaches. Called automatically by the browser; the
478
+ * machine itself is not destroyed (consumers can reuse it).
479
+ *
480
+ * Future tickets #638/#643/#645 will extend this to drop other
481
+ * subscriptions / listeners installed by their respective tags.
193
482
  */
194
483
  disconnectedCallback() {
195
484
  super.disconnectedCallback();
196
485
  // TODO #638: unsubscribe from machine.on(...) handlers.
197
- // TODO #641: remove installed hooks.
486
+ // #641: remove installed hooks.
487
+ if (this._machine !== undefined) {
488
+ const machine = this._machine;
489
+ for (const desc of this._installed_hooks) {
490
+ machine.remove_hook(desc);
491
+ }
492
+ }
493
+ this._installed_hooks = [];
198
494
  // TODO #643/#645: remove installed listeners / bindings.
495
+ // Remove every listener installed during `<jssm-action>` / `data-jssm-action`
496
+ // discovery. Using the original handler reference ensures `removeEventListener`
497
+ // actually unbinds — anonymous re-creation here would silently leak.
498
+ for (const entry of this._action_listeners) {
499
+ entry.target.removeEventListener(entry.event, entry.handler);
500
+ }
501
+ this._action_listeners = [];
502
+ }
503
+ /**
504
+ * Wire DOM events to machine actions, using the two declarative forms from
505
+ * issue #640:
506
+ *
507
+ * 1. Inline attribute form: every descendant of the host carrying a
508
+ * `data-jssm-action="<name>"` attribute receives a listener on the
509
+ * event named by `data-jssm-event` (default `click`).
510
+ * 2. Dedicated tag form: each direct `<jssm-action>` child of the host
511
+ * supplies a CSS `selector` (scoped to the host), an `action`, and an
512
+ * optional `event` (default `click`); every matching descendant
513
+ * receives a listener configured by the tag's attributes.
514
+ *
515
+ * Both forms support optional `from-state` guards (dispatch only when the
516
+ * machine's current state matches), `from-property` data extraction (pass
517
+ * the source element's named property as the action's data argument), and
518
+ * `prevent-default` / `stop-propagation` modifiers.
519
+ *
520
+ * Every installed listener is recorded in {@link _action_listeners} so
521
+ * {@link disconnectedCallback} can detach them cleanly.
522
+ */
523
+ _discover_jssm_actions() {
524
+ var _a, _b, _c, _d;
525
+ // Inline attribute form: `[data-jssm-action]` descendants. Per the
526
+ // ticket, we scan the host's light DOM (not the shadow tree, which is
527
+ // owned by us) and skip any element living inside a `<jssm-action>` tag
528
+ // — those tags are pure data markup, never the source of an event.
529
+ const inline_targets = Array.from(this.querySelectorAll('[data-jssm-action]')).filter(el => el.closest('jssm-action') === null);
530
+ for (const el of inline_targets) {
531
+ this._install_action_listener({
532
+ source: el,
533
+ event_name: (_a = el.dataset['jssmEvent']) !== null && _a !== void 0 ? _a : 'click',
534
+ action_name: el.dataset['jssmAction'],
535
+ from_state: el.dataset['jssmFromState'],
536
+ from_property: el.dataset['jssmFromProperty'],
537
+ prevent_default: 'jssmPreventDefault' in el.dataset,
538
+ stop_propagation: 'jssmStopPropagation' in el.dataset,
539
+ });
540
+ }
541
+ // Dedicated tag form: direct `<jssm-action>` children of the host.
542
+ // `:scope >` keeps a nested `<jssm-instance>`'s actions from being
543
+ // claimed by an outer host.
544
+ const tags = this.querySelectorAll(':scope > jssm-action');
545
+ for (const tag of Array.from(tags)) {
546
+ const selector = tag.getAttribute('selector');
547
+ const action_name = tag.getAttribute('action');
548
+ if (selector === null || action_name === null) {
549
+ // Required attrs missing — skip, but don't throw: a malformed tag
550
+ // shouldn't break the rest of the host's wiring.
551
+ continue;
552
+ }
553
+ const event_name = (_b = tag.getAttribute('event')) !== null && _b !== void 0 ? _b : 'click';
554
+ const from_state = (_c = tag.getAttribute('from-state')) !== null && _c !== void 0 ? _c : undefined;
555
+ const from_property = (_d = tag.getAttribute('from-property')) !== null && _d !== void 0 ? _d : undefined;
556
+ const prevent_default = tag.hasAttribute('prevent-default');
557
+ const stop_propagation = tag.hasAttribute('stop-propagation');
558
+ const sources = this.querySelectorAll(selector);
559
+ for (const src of Array.from(sources)) {
560
+ this._install_action_listener({
561
+ source: src,
562
+ event_name,
563
+ action_name,
564
+ from_state,
565
+ from_property,
566
+ prevent_default,
567
+ stop_propagation,
568
+ });
569
+ }
570
+ }
571
+ }
572
+ /**
573
+ * Attach one DOM listener that translates a DOM event into a
574
+ * `machine.action(...)` call, honoring the configured modifiers. The
575
+ * listener is recorded in {@link _action_listeners} so it can be removed
576
+ * on disconnect.
577
+ *
578
+ * @param config - Listener configuration.
579
+ * @param config.source - Element to attach the listener to.
580
+ * @param config.event_name - DOM event to listen for.
581
+ * @param config.action_name - Action to dispatch on the machine.
582
+ * @param config.from_state - If set, only fire when `machine.state() === from_state`.
583
+ * @param config.from_property - If set, pass `source[from_property]` as the action's data argument.
584
+ * @param config.prevent_default - If true, call `e.preventDefault()` before checking the guard.
585
+ * @param config.stop_propagation - If true, call `e.stopPropagation()` before checking the guard.
586
+ */
587
+ _install_action_listener(config) {
588
+ const handler = (e) => {
589
+ if (config.prevent_default) {
590
+ e.preventDefault();
591
+ }
592
+ if (config.stop_propagation) {
593
+ e.stopPropagation();
594
+ }
595
+ // Guard: skip dispatch when the machine isn't in the required state.
596
+ if (config.from_state !== undefined && this.state() !== config.from_state) {
597
+ return;
598
+ }
599
+ const data = config.from_property !== undefined
600
+ ? config.source[config.from_property]
601
+ : undefined;
602
+ this.do(config.action_name, data);
603
+ };
604
+ config.source.addEventListener(config.event_name, handler);
605
+ this._action_listeners.push({
606
+ target: config.source,
607
+ event: config.event_name,
608
+ handler,
609
+ });
199
610
  }
200
611
  /**
201
612
  * Reflect machine state onto host attributes and CSS custom properties.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jssm",
3
- "version": "5.136.0",
3
+ "version": "5.138.0",
4
4
  "engines": {
5
5
  "node": ">=10.0.0"
6
6
  },