@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 @@
1
+ {"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs \n"}
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs \n"}
@@ -0,0 +1 @@
1
+ Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 34.68670299999985
3
+ }
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs \n"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 40.970797999999945
3
+ }
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs \n"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 103.8570830000001
3
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "../../packages/listenable-element": "../../packages/listenable-element:BJKew/mHnmlPBxEYPPeGLJkt0CCyD91MqKloZmOhCis=:",
3
+ "throttle-debounce@5.0.2": "throttle-debounce@5.0.2:oG//pKDQJzc/QivfHcDLO/4t5iPoUclwOJpr/9ugrSc=:"
4
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
3
+ "rigPackageName": "@excom/heft-rig",
4
+ "rigProfile": "default"
5
+ }
6
+
package/index.ts ADDED
@@ -0,0 +1,282 @@
1
+ import { selectOne } from "../kit-utils/dom";
2
+ import { Neutron, TokenList } from "@excom/neutron";
3
+ import { debounce } from "throttle-debounce";
4
+
5
+ /**
6
+ * Composition base that wires event and lifecycle listening. Subclasses implement `actionHandler`. Used by `<spa-a>`, `<event-handler>`, and similar interactive elements.
7
+ *
8
+ * @summary Declarative event / lifecycle listening for Neutron elements.
9
+ */
10
+ export const ListenableElement = Neutron({
11
+ tag: "noop-tag",
12
+ props: {
13
+ /**
14
+ * @option
15
+ * Listen on another element / `window` / `document` — e.g. Escape to
16
+ * dismiss a dialog from a global `keydown`. Defaults to `:scope`. Used
17
+ * with `listen-for`. Not compatible with `listen-for-lifecycle`. The
18
+ * selector MUST resolve when `host-ref` is set — it will not wait for a
19
+ * match to appear.
20
+ * @values <CSS Selector> | window | document | html | body | head
21
+ */
22
+ hostRef: String,
23
+ /**
24
+ * @option
25
+ * Space-separated event names to listen for. Defaults to `click` when unset (and no lifecycle list is set).
26
+ * @values <EventName>…
27
+ */
28
+ listenFor: TokenList,
29
+ /**
30
+ * @option
31
+ * Space-separated element lifecycles to handle.
32
+ * @values connected | disconnected | adopted
33
+ */
34
+ listenForLifecycle: TokenList,
35
+ /**
36
+ * @option
37
+ * Handle each distinct event name / lifecycle at most once.
38
+ */
39
+ listenOnce: Boolean,
40
+ // TODO support :scope in the selector below
41
+ /**
42
+ * @option
43
+ * Only handle events whose `event.target` matches this CSS selector. Does not support `:scope` in the selector.
44
+ * @values <CSS Selector>
45
+ */
46
+ selectorFilter: String,
47
+ /**
48
+ * @option
49
+ * Space-separated key filters (OR). Join modifiers with `+` (AND, any
50
+ * order): `shift+k tab` → Shift+K or Tab. Modifiers: `shift`, `alt`,
51
+ * `ctrl`/`control`, `meta`. Case-insensitive.
52
+ * @values <key|mod+key>…
53
+ */
54
+ keycodeFilter: TokenList,
55
+ /**
56
+ * @option
57
+ * Only handle when `location.pathname` is one of these values — route-aware behaviors without a separate router element.
58
+ * @values <pathname>…
59
+ */
60
+ pathnameFilter: TokenList,
61
+ /**
62
+ * @option
63
+ * Call `preventDefault()` on matched events (ignored for lifecycles).
64
+ */
65
+ preventDefault: Boolean,
66
+ /**
67
+ * @option
68
+ * Call `stopPropagation()` on matched events (ignored for lifecycles).
69
+ */
70
+ stopPropagation: Boolean,
71
+ /**
72
+ * @option
73
+ * Call `stopImmediatePropagation()` on matched events (ignored for lifecycles).
74
+ */
75
+ stopImmediatePropagation: Boolean,
76
+ /**
77
+ * @option
78
+ * Vibrate on handle (`navigator.vibrate`). Empty / `0` uses a 20ms pulse.
79
+ * @default 20 (when attribute is present with no value)
80
+ */
81
+ vibrateMs: Number,
82
+ /**
83
+ * @option
84
+ * Delay handling by this many milliseconds.
85
+ */
86
+ delayMs: Number,
87
+ /**
88
+ * @option
89
+ * With `delay-ms`, coalesce bursts into one trailing call (debounce).
90
+ */
91
+ isDebounced: Boolean,
92
+ // Subclass hooks, not attributes (the analyzer skips Function-typed props).
93
+ debouncedActionHandler: Function,
94
+ actionHandler: Function,
95
+ // private state
96
+ _lifecycleTracker: {
97
+ type: Array<"connected" | "disconnected" | "adopted">,
98
+ defaultValue: () => [] as Array<"connected" | "disconnected" | "adopted">,
99
+ },
100
+ _hostElement: {
101
+ // EventTarget (not Element) so `window` / `document` are allowed.
102
+ type: EventTarget,
103
+ store: "weak",
104
+ notify: "prop",
105
+ },
106
+ },
107
+ })
108
+ .defineMethods({
109
+ handleEvent: (
110
+ element,
111
+ e: Event | "connected" | "disconnected" | "adopted"
112
+ ) => {
113
+ const {
114
+ preventDefault,
115
+ stopPropagation,
116
+ stopImmediatePropagation,
117
+ vibrateMs,
118
+ } = element;
119
+ const isLifecycle = typeof e === "string";
120
+ if (
121
+ isLifecycle ||
122
+ (matchesKeycode(element, e) &&
123
+ matchesSelector(element, e) &&
124
+ matchesPathname(element))
125
+ ) {
126
+ if (!isLifecycle && preventDefault) {
127
+ e.preventDefault();
128
+ }
129
+ if (!isLifecycle && stopPropagation) {
130
+ e.stopPropagation();
131
+ }
132
+ if (!isLifecycle && stopImmediatePropagation) {
133
+ e.stopImmediatePropagation();
134
+ }
135
+ // Boolean or number. A boolean attr (`vibrate=""`) is 0, so default to 20.
136
+ if (typeof vibrateMs === "number") {
137
+ navigator.vibrate?.(vibrateMs || 20);
138
+ }
139
+ if (typeof element.delayMs === "number") {
140
+ if (element.debouncedActionHandler) {
141
+ element.debouncedActionHandler(e);
142
+ } else {
143
+ setTimeout(() => element.actionHandler?.(e), element.delayMs);
144
+ }
145
+ } else {
146
+ element.actionHandler?.(e);
147
+ }
148
+ }
149
+ },
150
+ shouldLifecycleOnce: (
151
+ { listenForLifecycle, listenOnce, _lifecycleTracker },
152
+ lc: "connected" | "adopted" | "disconnected"
153
+ ) => {
154
+ const willRun =
155
+ listenForLifecycle?.includes(lc) &&
156
+ (!listenOnce || !_lifecycleTracker.includes(lc));
157
+ return [
158
+ willRun && {
159
+ // Mark this lifecycle as already run (`listen-once`)
160
+ _lifecycleTracker: [..._lifecycleTracker, lc],
161
+ },
162
+ {
163
+ returns: willRun,
164
+ },
165
+ ];
166
+ },
167
+ })
168
+ .onConnected(
169
+ ({ isMoving, shouldLifecycleOnce }) =>
170
+ !isMoving &&
171
+ shouldLifecycleOnce("connected") && { handleEvent: ["connected"] }
172
+ )
173
+ .onAdopted(
174
+ ({ shouldLifecycleOnce }) =>
175
+ shouldLifecycleOnce("adopted") && { handleEvent: ["adopted"] }
176
+ )
177
+ .onDisconnected(
178
+ ({ isMoving, shouldLifecycleOnce }) =>
179
+ !isMoving &&
180
+ // Don't dispatch after disconnect; the element is gone.
181
+ shouldLifecycleOnce("disconnected") && { handleEvent: ["disconnected"] }
182
+ )
183
+ .onPropChanged("hostRef", (element) => {
184
+ if (!element.hostRef) return { _hostElement: null };
185
+ const hostElement = selectOne(element.hostRef, {
186
+ scope: element,
187
+ enableRootRefs: true,
188
+ });
189
+ if (!hostElement)
190
+ throw new Error(`Host element not found for ${element.hostRef}`);
191
+ return { _hostElement: hostElement };
192
+ })
193
+ .onPropChanged(
194
+ ["_hostElement", "listenFor"],
195
+ ({ _hostElement, listenFor, handleEvent, listenOnce }, previous) => {
196
+ // `previous` only has changed props; fall back to current values.
197
+ const removeHost =
198
+ previous && "_hostElement" in previous
199
+ ? previous._hostElement
200
+ : _hostElement;
201
+ const removeArgs = removeHost ? { target: removeHost } : {};
202
+ const addArgs = _hostElement ? { target: _hostElement } : {};
203
+ const prevEvents =
204
+ previous && "listenFor" in previous
205
+ ? previous.listenFor?.length
206
+ ? previous.listenFor
207
+ : ["click"]
208
+ : listenFor?.length
209
+ ? listenFor
210
+ : ["click"];
211
+ const nextEvents = listenFor?.length ? listenFor : ["click"];
212
+ return [
213
+ {
214
+ removeListeners: prevEvents.map((k) => [k, handleEvent, removeArgs]),
215
+ },
216
+ {
217
+ addListeners: nextEvents.map((k) => [
218
+ k,
219
+ handleEvent,
220
+ listenOnce ? { once: true, ...addArgs } : addArgs,
221
+ ]),
222
+ },
223
+ ];
224
+ }
225
+ )
226
+ .onPropChanged("isDebounced", ({ isDebounced, delayMs, actionHandler }) => {
227
+ return {
228
+ debouncedActionHandler: isDebounced
229
+ ? debounce(delayMs || 10, actionHandler)
230
+ : null,
231
+ };
232
+ });
233
+
234
+ const KEY_MODIFIERS: Record<string, (e: KeyboardEvent) => boolean> = {
235
+ shift: (e) => e.shiftKey,
236
+ alt: (e) => e.altKey,
237
+ ctrl: (e) => e.ctrlKey,
238
+ control: (e) => e.ctrlKey,
239
+ meta: (e) => e.metaKey,
240
+ };
241
+
242
+ function modifierKeyName(mod: string): string {
243
+ return mod === "ctrl" || mod === "control" ? "control" : mod;
244
+ }
245
+
246
+ /** One filter token: `k`, `shift`, or `shift+k` / `k+shift`. */
247
+ function matchesKeycodeToken(token: string, e: KeyboardEvent): boolean {
248
+ const parts = token.toLowerCase().split("+").filter(Boolean);
249
+ if (!parts.length) return false;
250
+
251
+ const mods = parts.filter((p) => p in KEY_MODIFIERS);
252
+ const keys = parts.filter((p) => !(p in KEY_MODIFIERS));
253
+ if (!mods.every((m) => KEY_MODIFIERS[m](e))) return false;
254
+
255
+ const key = e.key?.toLowerCase();
256
+ if (!key) return false;
257
+ if (keys.length) return keys.every((k) => key === k);
258
+ // Modifier-only token (e.g. `shift`): match that modifier keydown.
259
+ return mods.some((m) => key === modifierKeyName(m));
260
+ }
261
+
262
+ function matchesKeycode(element: any, e: Event) {
263
+ if (!element.keycodeFilter?.length) return true;
264
+ const ke = e as KeyboardEvent;
265
+ return [...element.keycodeFilter].some((token: string) =>
266
+ matchesKeycodeToken(token, ke)
267
+ );
268
+ }
269
+
270
+ function matchesSelector(element: any, e: Event) {
271
+ return (
272
+ !element.selectorFilter ||
273
+ (e.target as HTMLElement).matches(element.selectorFilter)
274
+ );
275
+ }
276
+
277
+ function matchesPathname(element: any) {
278
+ return (
279
+ !element.pathnameFilter?.length ||
280
+ element.pathnameFilter.includes(location.pathname)
281
+ );
282
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@excom/listenable-element",
3
+ "version": "0.1.0",
4
+ "description": "ListenableElement base for Neutron elements",
5
+ "license": "MIT",
6
+ "engines": {
7
+ "node": ">=24.13.0"
8
+ },
9
+ "type": "module",
10
+ "dependencies": {
11
+ "throttle-debounce": "^5.0.2",
12
+ "@excom/neutron": "^0.1.0"
13
+ },
14
+ "peerDependencies": {},
15
+ "devDependencies": {
16
+ "@excom/heft-rig": "^0.1.0"
17
+ },
18
+ "repository": {
19
+ "url": "excom-dev/nucleus",
20
+ "directory": "packages/listenable-element"
21
+ },
22
+ "homepage": "https://github.com/excom-dev/nucleus/tree/main/packages/listenable-element/support/docs/README.md",
23
+ "bugs": "https://github.com/excom-dev/nucleus/issues",
24
+ "keywords": [
25
+ "ListenableElement",
26
+ "listenable-element",
27
+ "neutron",
28
+ "kit-element-base",
29
+ "custom-elements"
30
+ ],
31
+ "excom": {
32
+ "packageType": "element-base"
33
+ },
34
+ "scripts": {
35
+ "build": "node node_modules/@excom/heft-rig/scripts/vite-build.mjs",
36
+ "build:watch": "node node_modules/@excom/heft-rig/scripts/vite-build-watch.mjs",
37
+ "format": "node node_modules/@excom/heft-rig/scripts/format.mjs",
38
+ "test": "node node_modules/@excom/heft-rig/scripts/vitest.mjs",
39
+ "coverage": "node node_modules/@excom/heft-rig/scripts/coverage.mjs",
40
+ "dev": "node node_modules/@excom/heft-rig/scripts/vite-dev.mjs",
41
+ "preview": "node node_modules/@excom/heft-rig/scripts/vite-preview.mjs",
42
+ "build:package-metas": "node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs",
43
+ "build:docs": "node node_modules/@excom/heft-rig/scripts/build-docs.mjs"
44
+ }
45
+ }
@@ -0,0 +1 @@
1
+ Caching has been disabled for this project's "apply-exports" command.
@@ -0,0 +1 @@
1
+ Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
@@ -0,0 +1 @@
1
+ This project does not define the caching behavior of the "build:docs" command, so caching has been disabled.
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs
@@ -0,0 +1 @@
1
+ This project does not define the caching behavior of the "build:package-metas" command, so caching has been disabled.
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs
package/src/index.css ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Pointer affordance when listening for click / mouse (or default click).
3
+ * @element noop-tag
4
+ */
5
+
6
+ @define-mixin listenable-element {
7
+ &[listen-for*="click"],
8
+ &[listen-for*="mouse"],
9
+ &:not([listen-for]):not([listen-for-lifecycle]) {
10
+ cursor: pointer;
11
+ }
12
+ }