@excom/listenable-element 0.1.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.
Files changed (30) hide show
  1. package/.rush/temp/chunked-rush-logs/listenable-element.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/listenable-element.build_docs.chunks.jsonl +1 -0
  3. package/.rush/temp/chunked-rush-logs/listenable-element.build_package-metas.chunks.jsonl +1 -0
  4. package/.rush/temp/operation/apply-exports/all.log +1 -0
  5. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  6. package/.rush/temp/operation/apply-exports/state.json +3 -0
  7. package/.rush/temp/operation/build_docs/all.log +1 -0
  8. package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
  9. package/.rush/temp/operation/build_docs/state.json +3 -0
  10. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  11. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  12. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  13. package/.rush/temp/shrinkwrap-deps.json +4 -0
  14. package/config/rig.json +6 -0
  15. package/index.ts +282 -0
  16. package/package.json +45 -0
  17. package/rush-logs/listenable-element.apply-exports.cache.log +1 -0
  18. package/rush-logs/listenable-element.apply-exports.log +1 -0
  19. package/rush-logs/listenable-element.build_docs.cache.log +1 -0
  20. package/rush-logs/listenable-element.build_docs.log +1 -0
  21. package/rush-logs/listenable-element.build_package-metas.cache.log +1 -0
  22. package/rush-logs/listenable-element.build_package-metas.log +1 -0
  23. package/src/index.css +12 -0
  24. package/support/custom-elements.json +332 -0
  25. package/support/dist-docs/listenable-element.md +122 -0
  26. package/support/docs/README.md +82 -0
  27. package/support/package-meta.json +148 -0
  28. package/support/tests/listenable-element.matrix.test.ts +300 -0
  29. package/support/tests/listenable-element.test.ts +455 -0
  30. package/tsconfig.json +5 -0
@@ -0,0 +1,148 @@
1
+ {
2
+ "shortName": "listenable-element",
3
+ "package": {
4
+ "name": "@excom/listenable-element",
5
+ "version": "0.1.0",
6
+ "description": "ListenableElement base for Neutron elements",
7
+ "peerDependencies": {},
8
+ "excom": {
9
+ "packageType": "element-base"
10
+ }
11
+ },
12
+ "demos": {},
13
+ "readme": "<h1 id=\"md-listenable-element\">listenable-element</h1>\n<p>Declarative event and lifecycle listening for Neutron elements —\nfilter, debounce, vibrate, and hand off to your <code>actionHandler</code>.</p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><strong>Event / lifecycle hooks</strong> Listen for DOM events or <code>connected</code> /\n<code>disconnected</code> / <code>adopted</code></li>\n<li><strong>Host retarget</strong> <code>host-ref=&quot;window&quot;</code> / <code>document</code> / any selector —\nEscape to dismiss, shortcuts outside the bubble path</li>\n<li><strong>Target filters</strong> Selector, keycode (<code>shift+k</code> chords), and pathname gates</li>\n<li><strong>Debounce / delay</strong> Coalesce noisy input</li>\n<li><strong>Event hygiene</strong> <code>prevent-default</code> / <code>stop-propagation</code> /\n<code>stop-immediate-propagation</code></li>\n<li><strong>Haptic pulse</strong> Optional <code>vibrate-ms</code> on handle</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<p>Compose <code>ListenableElement</code> and implement <code>actionHandler</code>. Concrete\nconsumers include <code>&lt;spa-a&gt;</code> and <code>&lt;event-handler&gt;</code>.</p>\n<include-content data-language=\"ts\"><template>import { Neutron } from \"@excom/neutron\";\nimport { ListenableElement } from \"@excom/listenable-element\";\n\nexport const TapLog = Neutron.compose([\n ListenableElement,\n Neutron({ tag: \"tap-log\" }),\n])\n .defineMethods({\n actionHandler: (_el, e) =&gt; {\n console.log(\"handled\", e.type);\n },\n });\n\nTapLog.define();</template></include-content>\n<include-content data-language=\"html\"><template>&lt;tap-log listen-for=\"click keydown\" keycode-filter=\"enter\"&gt;\n Tap or Enter\n&lt;/tap-log&gt;</template></include-content>\n<p><code>host-ref</code> moves listening off <code>:scope</code> — e.g. <code>host-ref=&quot;window&quot;</code> for\nglobal keydown. See <code>&lt;event-handler&gt;</code> for Escape-to-dismiss examples.</p>\n<h3 id=\"md-api-reference\">API Reference</h3>\n<p><include-content is-active template-ref=\"/views/api-reference/api-reference.html\"></include-content></p>\n<h3 id=\"md-examples\">Examples</h3>\n<h4 id=\"md-default-click-navigate\">Default click → navigate</h4>\n<p><code>&lt;spa-a&gt;</code> inherits this base and defaults to click when <code>listen-for</code>\nis unset:</p>\n<include-content data-language=\"html\"><template>&lt;spa-a route-href=\"/pricing\"&gt;Pricing&lt;/spa-a&gt;</template></include-content>\n<h4 id=\"md-filter-amp-debounce\">Filter &amp; debounce</h4>\n<include-content data-language=\"html\"><template>&lt;event-handler\n listen-for=\"input\"\n delay-ms=\"200\"\n is-debounced\n fire-event=\"search-query\"\n&gt;\n &lt;input name=\"q\" /&gt;\n&lt;/event-handler&gt;</template></include-content>\n<p>See <code>&lt;event-handler&gt;</code> and <code>&lt;spa-a&gt;</code> package docs for more examples\nbuilt on this mixin.</p>\n",
14
+ "docs": {
15
+ "readme": "<h1 id=\"md-listenable-element\">listenable-element</h1>\n<p>Declarative event and lifecycle listening for Neutron elements —\nfilter, debounce, vibrate, and hand off to your <code>actionHandler</code>.</p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><strong>Event / lifecycle hooks</strong> Listen for DOM events or <code>connected</code> /\n<code>disconnected</code> / <code>adopted</code></li>\n<li><strong>Host retarget</strong> <code>host-ref=&quot;window&quot;</code> / <code>document</code> / any selector —\nEscape to dismiss, shortcuts outside the bubble path</li>\n<li><strong>Target filters</strong> Selector, keycode (<code>shift+k</code> chords), and pathname gates</li>\n<li><strong>Debounce / delay</strong> Coalesce noisy input</li>\n<li><strong>Event hygiene</strong> <code>prevent-default</code> / <code>stop-propagation</code> /\n<code>stop-immediate-propagation</code></li>\n<li><strong>Haptic pulse</strong> Optional <code>vibrate-ms</code> on handle</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<p>Compose <code>ListenableElement</code> and implement <code>actionHandler</code>. Concrete\nconsumers include <code>&lt;spa-a&gt;</code> and <code>&lt;event-handler&gt;</code>.</p>\n<include-content data-language=\"ts\"><template>import { Neutron } from \"@excom/neutron\";\nimport { ListenableElement } from \"@excom/listenable-element\";\n\nexport const TapLog = Neutron.compose([\n ListenableElement,\n Neutron({ tag: \"tap-log\" }),\n])\n .defineMethods({\n actionHandler: (_el, e) =&gt; {\n console.log(\"handled\", e.type);\n },\n });\n\nTapLog.define();</template></include-content>\n<include-content data-language=\"html\"><template>&lt;tap-log listen-for=\"click keydown\" keycode-filter=\"enter\"&gt;\n Tap or Enter\n&lt;/tap-log&gt;</template></include-content>\n<p><code>host-ref</code> moves listening off <code>:scope</code> — e.g. <code>host-ref=&quot;window&quot;</code> for\nglobal keydown. See <code>&lt;event-handler&gt;</code> for Escape-to-dismiss examples.</p>\n<h3 id=\"md-api-reference\">API Reference</h3>\n<p><include-content is-active template-ref=\"/views/api-reference/api-reference.html\"></include-content></p>\n<h3 id=\"md-examples\">Examples</h3>\n<h4 id=\"md-default-click-navigate\">Default click → navigate</h4>\n<p><code>&lt;spa-a&gt;</code> inherits this base and defaults to click when <code>listen-for</code>\nis unset:</p>\n<include-content data-language=\"html\"><template>&lt;spa-a route-href=\"/pricing\"&gt;Pricing&lt;/spa-a&gt;</template></include-content>\n<h4 id=\"md-filter-amp-debounce\">Filter &amp; debounce</h4>\n<include-content data-language=\"html\"><template>&lt;event-handler\n listen-for=\"input\"\n delay-ms=\"200\"\n is-debounced\n fire-event=\"search-query\"\n&gt;\n &lt;input name=\"q\" /&gt;\n&lt;/event-handler&gt;</template></include-content>\n<p>See <code>&lt;event-handler&gt;</code> and <code>&lt;spa-a&gt;</code> package docs for more examples\nbuilt on this mixin.</p>\n"
16
+ },
17
+ "installation": {
18
+ "name": "@excom/listenable-element",
19
+ "shortName": "listenable-element",
20
+ "version": "0.1.0",
21
+ "description": "ListenableElement base for Neutron elements",
22
+ "packageType": "element-base",
23
+ "install": {
24
+ "npm": "npm install @excom/listenable-element"
25
+ },
26
+ "imports": {
27
+ "js": "import { /* … */ } from \"@excom/listenable-element\";"
28
+ },
29
+ "peerDependencies": []
30
+ },
31
+ "elementApis": [
32
+ {
33
+ "summary": "Declarative event / lifecycle listening for Neutron elements.",
34
+ "kind": "mixin",
35
+ "attributes": [
36
+ {
37
+ "name": "delay-ms",
38
+ "type": "number",
39
+ "description": "Delay handling by this many milliseconds.",
40
+ "fieldName": "delayMs",
41
+ "surface": "option"
42
+ },
43
+ {
44
+ "name": "host-ref",
45
+ "type": "string",
46
+ "description": "Listen on another element / <code>window</code> / <code>document</code> — e.g. Escape to dismiss a dialog from a global <code>keydown</code>. Defaults to <code>:scope</code>. Used with <code>listen-for</code>. Not compatible with <code>listen-for-lifecycle</code>. The selector MUST resolve when <code>host-ref</code> is set — it will not wait for a match to appear.",
47
+ "fieldName": "hostRef",
48
+ "surface": "option",
49
+ "values": "<CSS Selector> | \"window\" | \"document\" | \"html\" | \"body\" | \"head\""
50
+ },
51
+ {
52
+ "name": "is-debounced",
53
+ "type": "boolean",
54
+ "description": "With <code>delay-ms</code>, coalesce bursts into one trailing call (debounce).",
55
+ "fieldName": "isDebounced",
56
+ "surface": "option"
57
+ },
58
+ {
59
+ "name": "keycode-filter",
60
+ "type": "tokenlist",
61
+ "description": "Space-separated key filters (OR). Join modifiers with <code>+</code> (AND, any order): <code>shift+k tab</code> → Shift+K or Tab. Modifiers: <code>shift</code>, <code>alt</code>, <code>ctrl</code>/<code>control</code>, <code>meta</code>. Case-insensitive.",
62
+ "fieldName": "keycodeFilter",
63
+ "surface": "option",
64
+ "values": "<key | mod+key>…"
65
+ },
66
+ {
67
+ "name": "listen-for",
68
+ "type": "tokenlist",
69
+ "description": "Space-separated event names to listen for. Defaults to <code>click</code> when unset (and no lifecycle list is set).",
70
+ "fieldName": "listenFor",
71
+ "surface": "option",
72
+ "values": "<EventName>…"
73
+ },
74
+ {
75
+ "name": "listen-for-lifecycle",
76
+ "type": "tokenlist",
77
+ "description": "Space-separated element lifecycles to handle.",
78
+ "fieldName": "listenForLifecycle",
79
+ "surface": "option",
80
+ "values": "\"connected\" | \"disconnected\" | \"adopted\""
81
+ },
82
+ {
83
+ "name": "listen-once",
84
+ "type": "boolean",
85
+ "description": "Handle each distinct event name / lifecycle at most once.",
86
+ "fieldName": "listenOnce",
87
+ "surface": "option"
88
+ },
89
+ {
90
+ "name": "pathname-filter",
91
+ "type": "tokenlist",
92
+ "description": "Only handle when <code>location.pathname</code> is one of these values — route-aware behaviors without a separate router element.",
93
+ "fieldName": "pathnameFilter",
94
+ "surface": "option",
95
+ "values": "<pathname>…"
96
+ },
97
+ {
98
+ "name": "prevent-default",
99
+ "type": "boolean",
100
+ "description": "Call <code>preventDefault()</code> on matched events (ignored for lifecycles).",
101
+ "fieldName": "preventDefault",
102
+ "surface": "option"
103
+ },
104
+ {
105
+ "name": "selector-filter",
106
+ "type": "string",
107
+ "description": "Only handle events whose <code>event.target</code> matches this CSS selector. Does not support <code>:scope</code> in the selector.",
108
+ "fieldName": "selectorFilter",
109
+ "surface": "option",
110
+ "values": "<CSS Selector>"
111
+ },
112
+ {
113
+ "name": "stop-immediate-propagation",
114
+ "type": "boolean",
115
+ "description": "Call <code>stopImmediatePropagation()</code> on matched events (ignored for lifecycles).",
116
+ "fieldName": "stopImmediatePropagation",
117
+ "surface": "option"
118
+ },
119
+ {
120
+ "name": "stop-propagation",
121
+ "type": "boolean",
122
+ "description": "Call <code>stopPropagation()</code> on matched events (ignored for lifecycles).",
123
+ "fieldName": "stopPropagation",
124
+ "surface": "option"
125
+ },
126
+ {
127
+ "name": "vibrate-ms",
128
+ "type": "number",
129
+ "description": "Vibrate on handle (<code>navigator.vibrate</code>). Empty / <code>0</code> uses a 20ms pulse.",
130
+ "fieldName": "vibrateMs",
131
+ "surface": "option",
132
+ "default": "\"20 (when attribute is present with no value)\""
133
+ }
134
+ ],
135
+ "events": [],
136
+ "slots": [],
137
+ "cssProperties": [],
138
+ "cssClasses": [],
139
+ "cssAliases": [],
140
+ "listens": [],
141
+ "commands": [],
142
+ "defaultActions": [],
143
+ "expectedChildren": [],
144
+ "provisions": []
145
+ }
146
+ ],
147
+ "exportedFiles": {}
148
+ }
@@ -0,0 +1,300 @@
1
+ import { ListenableElement } from "../../index";
2
+ import { Neutron } from "@excom/neutron";
3
+ import {
4
+ afterEach,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ fixture,
9
+ it,
10
+ vi,
11
+ wait,
12
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
13
+
14
+ const TAG = "listenable-matrix-test";
15
+ if (!customElements.get(TAG)) {
16
+ ListenableElement.define(TAG);
17
+ }
18
+
19
+ /*
20
+ * Concrete consumer (README pattern) with `actionHandler` from construction,
21
+ * so connect-time lifecycles and `is-debounced` wrapping see a real handler,
22
+ * not one assigned after mount.
23
+ */
24
+ const COMPOSED_TAG = "listenable-composed-test";
25
+ const composedSpy = vi.fn();
26
+ if (!customElements.get(COMPOSED_TAG)) {
27
+ Neutron.compose([ListenableElement, Neutron({ tag: COMPOSED_TAG })])
28
+ .defineMethods({
29
+ actionHandler: (_el, e: Event | string) => {
30
+ composedSpy(e);
31
+ },
32
+ })
33
+ .define();
34
+ }
35
+
36
+ const key = (k: string, init: KeyboardEventInit = {}) =>
37
+ new KeyboardEvent("keydown", { key: k, ...init });
38
+
39
+ describe("ListenableElement keycode-filter matrix", () => {
40
+ afterEach(() => {
41
+ document.body.innerHTML = "";
42
+ vi.restoreAllMocks();
43
+ });
44
+
45
+ it("matches alt / ctrl / control / meta chords", () => {
46
+ const el = fixture<any>(
47
+ `<${TAG} keycode-filter="alt+a ctrl+b control+c meta+d"></${TAG}>`,
48
+ );
49
+ const handler = vi.fn();
50
+ el.actionHandler = handler;
51
+
52
+ el.handleEvent(key("a", { altKey: true }));
53
+ expect(handler).toHaveBeenCalledTimes(1);
54
+ el.handleEvent(key("a"));
55
+ expect(handler).toHaveBeenCalledTimes(1);
56
+
57
+ el.handleEvent(key("b", { ctrlKey: true }));
58
+ expect(handler).toHaveBeenCalledTimes(2);
59
+ el.handleEvent(key("c", { ctrlKey: true }));
60
+ expect(handler).toHaveBeenCalledTimes(3);
61
+ el.handleEvent(key("c", { shiftKey: true }));
62
+ expect(handler).toHaveBeenCalledTimes(3);
63
+
64
+ el.handleEvent(key("d", { metaKey: true }));
65
+ expect(handler).toHaveBeenCalledTimes(4);
66
+ el.handleEvent(key("d"));
67
+ expect(handler).toHaveBeenCalledTimes(4);
68
+ });
69
+
70
+ it("matches modifier-only tokens against the modifier keydown itself", () => {
71
+ const el = fixture<any>(
72
+ `<${TAG} keycode-filter="shift ctrl meta"></${TAG}>`,
73
+ );
74
+ const handler = vi.fn();
75
+ el.actionHandler = handler;
76
+
77
+ el.handleEvent(key("Shift", { shiftKey: true }));
78
+ expect(handler).toHaveBeenCalledTimes(1);
79
+ // `ctrl` is spelled `Control` on the event
80
+ el.handleEvent(key("Control", { ctrlKey: true }));
81
+ expect(handler).toHaveBeenCalledTimes(2);
82
+ el.handleEvent(key("Meta", { metaKey: true }));
83
+ expect(handler).toHaveBeenCalledTimes(3);
84
+ // a modifier that is not in the filter
85
+ el.handleEvent(key("Alt", { altKey: true }));
86
+ expect(handler).toHaveBeenCalledTimes(3);
87
+ // the modifier is held but a different key was pressed
88
+ el.handleEvent(key("x", { shiftKey: true }));
89
+ expect(handler).toHaveBeenCalledTimes(3);
90
+ });
91
+
92
+ it("accepts the `control` spelling for a modifier-only token", () => {
93
+ const el = fixture<any>(`<${TAG} keycode-filter="control"></${TAG}>`);
94
+ const handler = vi.fn();
95
+ el.actionHandler = handler;
96
+ el.handleEvent(key("Control", { ctrlKey: true }));
97
+ expect(handler).toHaveBeenCalledTimes(1);
98
+ el.handleEvent(key("Shift", { shiftKey: true }));
99
+ expect(handler).toHaveBeenCalledTimes(1);
100
+ });
101
+
102
+ it("never matches an empty token or a keyless event", () => {
103
+ const el = fixture<any>(`<${TAG} keycode-filter="+"></${TAG}>`);
104
+ const handler = vi.fn();
105
+ el.actionHandler = handler;
106
+ el.handleEvent(key("a"));
107
+ el.handleEvent(key("+"));
108
+ expect(handler).not.toHaveBeenCalled();
109
+
110
+ el.keycodeFilter = ["a"];
111
+ // no `key` at all (e.g. a synthetic event)
112
+ el.handleEvent(new KeyboardEvent("keydown"));
113
+ expect(handler).not.toHaveBeenCalled();
114
+ el.handleEvent(key("a"));
115
+ expect(handler).toHaveBeenCalledTimes(1);
116
+ });
117
+ });
118
+
119
+ describe("ListenableElement listen-for defaults", () => {
120
+ afterEach(() => {
121
+ document.body.innerHTML = "";
122
+ vi.restoreAllMocks();
123
+ });
124
+
125
+ it("falls back to click when listen-for is cleared later", () => {
126
+ // Bare base adds nothing until `listen-for` / `host-ref` changes.
127
+ // `<event-handler>` adds the connect-time click default.
128
+ const el = fixture<any>(`<${TAG}></${TAG}>`);
129
+ const handler = vi.fn();
130
+ el.actionHandler = handler;
131
+ expect(el).toContainListeners({ click: 0 });
132
+
133
+ el.listenFor = ["foo-event"];
134
+ expect(el).toContainListeners({ click: 0, "foo-event": 1 });
135
+ el.click();
136
+ expect(handler).not.toHaveBeenCalled();
137
+ el.dispatchEvent(new CustomEvent("foo-event"));
138
+ expect(handler).toHaveBeenCalledTimes(1);
139
+
140
+ // clearing the list falls back to the click default
141
+ el.listenFor = [];
142
+ expect(el).toContainListeners({ click: 1, "foo-event": 0 });
143
+ el.click();
144
+ expect(handler).toHaveBeenCalledTimes(2);
145
+
146
+ // and the click default is what gets removed on the next change
147
+ el.listenFor = ["bar-event"];
148
+ expect(el).toContainListeners({ click: 0, "bar-event": 1 });
149
+ el.click();
150
+ expect(handler).toHaveBeenCalledTimes(2);
151
+ });
152
+
153
+ it("uses the default click listener on a host-ref target", () => {
154
+ const button = fixture<HTMLButtonElement>(
155
+ `<button id="default-host"></button>`,
156
+ );
157
+ const el = fixture<any>(`<${TAG} host-ref="#default-host"></${TAG}>`);
158
+ const handler = vi.fn();
159
+ el.actionHandler = handler;
160
+ expect(button).toContainListeners({ click: 1 });
161
+ expect(el).toContainListeners({ click: 0 });
162
+
163
+ button.click();
164
+ expect(handler).toHaveBeenCalledTimes(1);
165
+
166
+ el.hostRef = null;
167
+ expect(button).toContainListeners({ click: 0 });
168
+ expect(el).toContainListeners({ click: 1 });
169
+ button.click();
170
+ expect(handler).toHaveBeenCalledTimes(1);
171
+ el.click();
172
+ expect(handler).toHaveBeenCalledTimes(2);
173
+ });
174
+ });
175
+
176
+ describe("ListenableElement is-debounced", () => {
177
+ beforeEach(() => composedSpy.mockClear());
178
+ afterEach(() => {
179
+ document.body.innerHTML = "";
180
+ vi.restoreAllMocks();
181
+ });
182
+
183
+ it("wraps actionHandler in a trailing debounce when delay-ms is set", async () => {
184
+ const el = fixture<any>(
185
+ `<${COMPOSED_TAG} listen-for="ping-event" delay-ms="40" is-debounced></${COMPOSED_TAG}>`,
186
+ );
187
+ expect(typeof el.debouncedActionHandler).toBe("function");
188
+
189
+ el.dispatchEvent(new CustomEvent("ping-event"));
190
+ el.dispatchEvent(new CustomEvent("ping-event"));
191
+ el.dispatchEvent(new CustomEvent("ping-event"));
192
+ expect(composedSpy).not.toHaveBeenCalled();
193
+ await wait(60);
194
+ expect(composedSpy).toHaveBeenCalledTimes(1);
195
+
196
+ /* Turning it off doesn't throw. Neutron treats an effect on a
197
+ Function-typed prop that already holds a function as a method
198
+ call, so the wrapper isn't actually replaced here. See the report. */
199
+ expect(() => {
200
+ el.isDebounced = false;
201
+ }).not.toThrow();
202
+ expect(el.isDebounced).toBe(false);
203
+ el.dispatchEvent(new CustomEvent("ping-event"));
204
+ expect(composedSpy).toHaveBeenCalledTimes(1);
205
+ await wait(60);
206
+ expect(composedSpy.mock.calls.length).toBeGreaterThanOrEqual(2);
207
+ });
208
+
209
+ it("falls back to a 10ms debounce window without delay-ms", async () => {
210
+ const el = fixture<any>(
211
+ `<${COMPOSED_TAG} listen-for="ping-event" is-debounced></${COMPOSED_TAG}>`,
212
+ );
213
+ expect(typeof el.debouncedActionHandler).toBe("function");
214
+ // without delay-ms the element handles synchronously (the wrapper is
215
+ // only consulted when a delay is configured)
216
+ el.dispatchEvent(new CustomEvent("ping-event"));
217
+ expect(composedSpy).toHaveBeenCalledTimes(1);
218
+ });
219
+ });
220
+
221
+ describe("ListenableElement lifecycles", () => {
222
+ beforeEach(() => composedSpy.mockClear());
223
+ afterEach(() => {
224
+ document.body.innerHTML = "";
225
+ vi.restoreAllMocks();
226
+ });
227
+
228
+ it("handles connected on mount, ignores synchronous moves, re-handles on reconnect", async () => {
229
+ const el = fixture<any>(
230
+ `<${COMPOSED_TAG} listen-for-lifecycle="connected"></${COMPOSED_TAG}>`,
231
+ );
232
+ expect(composedSpy).toHaveBeenCalledTimes(1);
233
+ expect(composedSpy).toHaveBeenCalledWith("connected");
234
+ expect(el._lifecycleTracker).toEqual(["connected"]);
235
+
236
+ // a synchronous re-parent is a move, not a fresh connection
237
+ const other = document.createElement("div");
238
+ document.body.appendChild(other);
239
+ other.appendChild(el);
240
+ await wait(0);
241
+ expect(composedSpy).toHaveBeenCalledTimes(1);
242
+
243
+ // a real disconnect followed by a later reconnect handles again
244
+ el.remove();
245
+ await wait(0);
246
+ document.body.appendChild(el);
247
+ await wait(0);
248
+ expect(composedSpy).toHaveBeenCalledTimes(2);
249
+ });
250
+
251
+ it("handles connected only once with listen-once", async () => {
252
+ const el = fixture<any>(
253
+ `<${COMPOSED_TAG} listen-for-lifecycle="connected" listen-once></${COMPOSED_TAG}>`,
254
+ );
255
+ expect(composedSpy).toHaveBeenCalledTimes(1);
256
+ el.remove();
257
+ await wait(0);
258
+ document.body.appendChild(el);
259
+ await wait(0);
260
+ expect(composedSpy).toHaveBeenCalledTimes(1);
261
+ });
262
+
263
+ it("handles the adopted lifecycle", async () => {
264
+ const el = fixture<any>(
265
+ `<${COMPOSED_TAG} listen-for-lifecycle="adopted"></${COMPOSED_TAG}>`,
266
+ );
267
+ expect(composedSpy).not.toHaveBeenCalled();
268
+
269
+ // happy-dom never runs `adoptedCallback` (`adoptNode` skips CE
270
+ // reactions), so drive the platform hook directly.
271
+ el.adoptedCallback();
272
+ await wait(0);
273
+ expect(composedSpy).toHaveBeenCalledTimes(1);
274
+ expect(composedSpy).toHaveBeenCalledWith("adopted");
275
+ expect(el._lifecycleTracker).toEqual(["adopted"]);
276
+
277
+ // `isAdopted` resets on disconnect, so a later adoption handles again
278
+ el.remove();
279
+ await wait(0);
280
+ document.body.appendChild(el);
281
+ await wait(0);
282
+ el.adoptedCallback();
283
+ await wait(0);
284
+ expect(composedSpy).toHaveBeenCalledTimes(2);
285
+ });
286
+
287
+ it("ignores lifecycles that are not listed", async () => {
288
+ const el = fixture<any>(
289
+ `<${COMPOSED_TAG} listen-for-lifecycle="disconnected"></${COMPOSED_TAG}>`,
290
+ );
291
+ expect(composedSpy).not.toHaveBeenCalled();
292
+ el.adoptedCallback();
293
+ await wait(0);
294
+ expect(composedSpy).not.toHaveBeenCalled();
295
+ el.remove();
296
+ await wait(0);
297
+ expect(composedSpy).toHaveBeenCalledTimes(1);
298
+ expect(composedSpy).toHaveBeenCalledWith("disconnected");
299
+ });
300
+ });