@excom/quark 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 (130) hide show
  1. package/.rush/temp/chunked-rush-logs/quark.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/quark.build_package-metas.chunks.jsonl +1 -0
  3. package/.rush/temp/operation/apply-exports/all.log +1 -0
  4. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  5. package/.rush/temp/operation/apply-exports/state.json +3 -0
  6. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  7. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  8. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  9. package/.rush/temp/shrinkwrap-deps.json +3 -0
  10. package/config/rig.json +6 -0
  11. package/index.ts +4 -0
  12. package/language.ts +7 -0
  13. package/package.json +45 -0
  14. package/rush-logs/quark.apply-exports.cache.log +1 -0
  15. package/rush-logs/quark.apply-exports.log +1 -0
  16. package/rush-logs/quark.build_package-metas.cache.log +1 -0
  17. package/rush-logs/quark.build_package-metas.log +1 -0
  18. package/src/actions.ts +445 -0
  19. package/src/ast.ts +187 -0
  20. package/src/bindings.ts +115 -0
  21. package/src/builtin-modules.ts +401 -0
  22. package/src/constants.ts +47 -0
  23. package/src/cycle-check.ts +121 -0
  24. package/src/devtools-hook.ts +303 -0
  25. package/src/element-api.ts +109 -0
  26. package/src/evaluator.ts +317 -0
  27. package/src/form-controls.ts +69 -0
  28. package/src/language-tables.ts +137 -0
  29. package/src/language.ts +1225 -0
  30. package/src/observer.ts +110 -0
  31. package/src/paint.ts +305 -0
  32. package/src/properties.ts +818 -0
  33. package/src/props.ts +94 -0
  34. package/src/quark-element.ts +7 -0
  35. package/src/quark-internal.ts +365 -0
  36. package/src/quark.ts +817 -0
  37. package/src/resolvers.ts +867 -0
  38. package/src/rule.ts +800 -0
  39. package/src/scope-id.ts +47 -0
  40. package/src/selector-utils.ts +430 -0
  41. package/src/settle.ts +117 -0
  42. package/src/types.ts +155 -0
  43. package/src/utils.ts +79 -0
  44. package/src/variables.ts +359 -0
  45. package/support/demos/css-variables.html +18 -0
  46. package/support/demos/dispatch.html +20 -0
  47. package/support/demos/events.html +9 -0
  48. package/support/demos/iterate.html +13 -0
  49. package/support/demos/js-api.html +17 -0
  50. package/support/demos/provider-list.html +16 -0
  51. package/support/demos/toggle-content.html +14 -0
  52. package/support/demos/unset.html +12 -0
  53. package/support/demos/view-transition.html +36 -0
  54. package/support/docs/ATTRIBUTES.md +37 -0
  55. package/support/docs/AT_RULES.md +45 -0
  56. package/support/docs/BUILTINS.md +57 -0
  57. package/support/docs/CONTENT.md +40 -0
  58. package/support/docs/CSS_VARIABLES.md +16 -0
  59. package/support/docs/DECLARATIONS.md +26 -0
  60. package/support/docs/DELAY.md +37 -0
  61. package/support/docs/DIAGNOSTICS.md +32 -0
  62. package/support/docs/DISPATCH.md +81 -0
  63. package/support/docs/ELEMENT_PROPERTIES.md +34 -0
  64. package/support/docs/EXPRESSIONS.md +31 -0
  65. package/support/docs/INTERNAL.md +8 -0
  66. package/support/docs/JS_API.md +53 -0
  67. package/support/docs/JS_WRITES.md +36 -0
  68. package/support/docs/LIMITATIONS.md +26 -0
  69. package/support/docs/LOOP_GUARD.md +30 -0
  70. package/support/docs/METHODS.md +43 -0
  71. package/support/docs/MODULES.md +111 -0
  72. package/support/docs/NO_REVERSION.md +18 -0
  73. package/support/docs/ON.md +87 -0
  74. package/support/docs/REACTIVITY.md +24 -0
  75. package/support/docs/README.md +143 -0
  76. package/support/docs/SELECTORS.md +87 -0
  77. package/support/docs/SHEETS.md +67 -0
  78. package/support/docs/SYNTAX.md +36 -0
  79. package/support/docs/USE.md +24 -0
  80. package/support/docs/VALUES.md +27 -0
  81. package/support/docs/VARIABLES.md +47 -0
  82. package/support/docs/VIEW_TRANSITION.md +64 -0
  83. package/support/docs-sections.json +65 -0
  84. package/support/package-meta.json +208 -0
  85. package/support/scripts/build-language-docs.mjs +196 -0
  86. package/support/tests/__snapshots__/css-variables.view.test.ts.snap +23 -0
  87. package/support/tests/__snapshots__/dispatch.view.test.ts.snap +23 -0
  88. package/support/tests/__snapshots__/events.view.test.ts.snap +23 -0
  89. package/support/tests/__snapshots__/iterate.view.test.ts.snap +23 -0
  90. package/support/tests/__snapshots__/js-api.view.test.ts.snap +23 -0
  91. package/support/tests/__snapshots__/provider-list.view.test.ts.snap +23 -0
  92. package/support/tests/__snapshots__/quark-features.test.ts.snap +1079 -0
  93. package/support/tests/__snapshots__/quark-with-providers.test.ts.snap +89 -0
  94. package/support/tests/__snapshots__/quark.test.ts.snap +221 -0
  95. package/support/tests/__snapshots__/selectors.test.ts.snap +133 -0
  96. package/support/tests/__snapshots__/toggle-content.view.test.ts.snap +23 -0
  97. package/support/tests/__snapshots__/unset.view.test.ts.snap +23 -0
  98. package/support/tests/__snapshots__/view-transition.view.test.ts.snap +23 -0
  99. package/support/tests/actions.test.ts +437 -0
  100. package/support/tests/builtin-modules.test.ts +337 -0
  101. package/support/tests/builtins.test.ts +189 -0
  102. package/support/tests/css-variables.view.test.ts +35 -0
  103. package/support/tests/delay.test.ts +244 -0
  104. package/support/tests/devtools-hook.test.ts +496 -0
  105. package/support/tests/diagnostics.test.ts +224 -0
  106. package/support/tests/dispatch.view.test.ts +43 -0
  107. package/support/tests/element-api.test.ts +202 -0
  108. package/support/tests/evaluator.test.ts +237 -0
  109. package/support/tests/events.view.test.ts +40 -0
  110. package/support/tests/form-controls.test.ts +452 -0
  111. package/support/tests/helpers.ts +354 -0
  112. package/support/tests/internals.test.ts +568 -0
  113. package/support/tests/iterate.view.test.ts +40 -0
  114. package/support/tests/js-api.view.test.ts +45 -0
  115. package/support/tests/language-docs.test.ts +147 -0
  116. package/support/tests/lifecycle.test.ts +440 -0
  117. package/support/tests/loop-guard.test.ts +323 -0
  118. package/support/tests/provider-list.view.test.ts +47 -0
  119. package/support/tests/quark-features.test.ts +2887 -0
  120. package/support/tests/quark-with-providers.test.ts +169 -0
  121. package/support/tests/quark.test.ts +359 -0
  122. package/support/tests/resolvers.test.ts +346 -0
  123. package/support/tests/selector-analysis.test.ts +281 -0
  124. package/support/tests/selectors.test.ts +670 -0
  125. package/support/tests/toggle-content.view.test.ts +40 -0
  126. package/support/tests/unset.view.test.ts +42 -0
  127. package/support/tests/view-helpers.ts +81 -0
  128. package/support/tests/view-transition.test.ts +1069 -0
  129. package/support/tests/view-transition.view.test.ts +76 -0
  130. package/tsconfig.json +5 -0
@@ -0,0 +1,818 @@
1
+ export type * from "./types";
2
+ import type { DiagnosticLevel } from "./ast";
3
+ import { type ListenerOptionSource, listenerOptionsText } from "./ast";
4
+ import { writeBinding } from "./bindings";
5
+ import { ATTRIBUTE_BLACKLIST_REGEXES, isNoop } from "./constants";
6
+ import { publicize } from "./devtools-hook";
7
+ import {
8
+ collectAttrCalls,
9
+ collectPropCalls,
10
+ collectVariableNames,
11
+ getExpressionAst,
12
+ } from "./evaluator";
13
+ import { getQuarkInternal, TQuarkElement } from "./quark-internal";
14
+ import { reportDiagnostic, resolveExpression, resolveField } from "./resolvers";
15
+ import type { Rule } from "./rule";
16
+ import type { QuarkOptions, TransitionSpec } from "./types";
17
+ import { deref, QuarkLogger } from "./utils";
18
+ import { LoopGuard, tc } from "@excom/kit-utils";
19
+ import type { Expression } from "@excom/quark-parser";
20
+
21
+ /** How `QuarkInternal.propertyHasBeenSet` tells a property's kind apart. */
22
+ export type PropertyKind =
23
+ | "variable"
24
+ | "attribute"
25
+ | "listener"
26
+ | "diagnostic"
27
+ | "delay";
28
+
29
+ export class Property {
30
+ key: string;
31
+ value: string;
32
+ parsedValue: Expression | null;
33
+ /** `$name`s referenced by the expression (static analysis). */
34
+ referencedVarNames: string[] = [];
35
+ /** Literal `attr("x")` names referenced by the expression (static analysis). */
36
+ referencedAttrNames: string[] = [];
37
+ /**
38
+ * Literal `prop("x")` names in the expression. Subscribed on the
39
+ * matched element at first read (`props.ts`); change events re-run
40
+ * this property via the sheet's `propIndex`.
41
+ */
42
+ referencedPropNames: string[] = [];
43
+ parent: Rule;
44
+ /**
45
+ * Resolves from bindings ($vars) or `prop()`, so it re-runs on change
46
+ * events, not sync DOM runs. Reactivity is in the AST; no cross-sheet
47
+ * analysis needed.
48
+ */
49
+ isReactive: boolean;
50
+ /**
51
+ * The `@view-transition` block this write sits in, if any: its paints
52
+ * commit inside a view transition (see `paint.ts`).
53
+ */
54
+ transition: TransitionSpec | null;
55
+ constructor({
56
+ key,
57
+ value,
58
+ parent,
59
+ transition = null,
60
+ }: {
61
+ key: string;
62
+ value: string;
63
+ parent: Rule;
64
+ transition?: TransitionSpec | null;
65
+ }) {
66
+ this.key = key;
67
+ this.value = value;
68
+ this.parent = parent;
69
+ this.transition = transition;
70
+ if (transition) parent.quarkInstance.hasTransitions = true;
71
+ try {
72
+ // an `@on` block without prelude handlers has nothing to evaluate
73
+ this.parsedValue = value ? getExpressionAst(value) : null;
74
+ this.referencedVarNames = this.parsedValue
75
+ ? collectVariableNames(this.parsedValue)
76
+ : [];
77
+ const attrCalls = collectAttrCalls(this.parsedValue);
78
+ this.referencedAttrNames = attrCalls.names.filter(
79
+ (name) => !ATTRIBUTE_BLACKLIST_REGEXES.some((regex) => regex.test(name))
80
+ );
81
+ this.referencedAttrNames.forEach((name) =>
82
+ this.parent.observedAttrs.add(name)
83
+ );
84
+ if (attrCalls.hasNonLiteral) {
85
+ QuarkLogger.warn({
86
+ method: "property",
87
+ message: `Quark: attr() only observes static string names; "${key}: ${value}" will not re-run when a dynamic name changes`,
88
+ script: value,
89
+ });
90
+ }
91
+ const propCalls = collectPropCalls(this.parsedValue);
92
+ this.referencedPropNames = propCalls.names;
93
+ if (propCalls.hasNonLiteral) {
94
+ QuarkLogger.warn({
95
+ method: "property",
96
+ message: `Quark: prop() only observes static string names; "${key}: ${value}" will not re-run when a dynamic name changes`,
97
+ script: value,
98
+ });
99
+ }
100
+ } catch (error) {
101
+ this.parsedValue = null;
102
+ QuarkLogger.error({
103
+ method: "property",
104
+ message: `Quark: Invalid expression for "${key}"`,
105
+ script: value,
106
+ error: [error],
107
+ });
108
+ }
109
+ this.isReactive =
110
+ this.referencedVarNames.length > 0 || this.referencedPropNames.length > 0;
111
+ }
112
+
113
+ /**
114
+ * The storage kind `propertyHasBeenSet` checks. Diagnostics and delays
115
+ * record nothing on the element, so they count as new on every
116
+ * application (they gate themselves).
117
+ */
118
+ kind(): PropertyKind {
119
+ return this instanceof Variable
120
+ ? "variable"
121
+ : this instanceof Attribute
122
+ ? "attribute"
123
+ : "listener";
124
+ }
125
+
126
+ run(element: HTMLElement, options: QuarkOptions): void {
127
+ const elementInternal = getQuarkInternal(element);
128
+ const propertyIsNew = !elementInternal.propertyHasBeenSet(
129
+ this.parent.quarkInstance.hash,
130
+ this.parent.id,
131
+ this.kind(),
132
+ this.key,
133
+ this.value
134
+ );
135
+ /*
136
+ * Nearness: skip an event re-run when the changed owner is farther
137
+ * up than the owner this element last read. The nearer one shadows.
138
+ */
139
+ const { changedBinding } = options;
140
+ if (
141
+ !propertyIsNew &&
142
+ changedBinding &&
143
+ this.referencedVarNames.includes(changedBinding.name)
144
+ ) {
145
+ const known = elementInternal.varOwners[changedBinding.name]?.deref();
146
+ const origin = deref(changedBinding.origin);
147
+ if (known && origin && origin !== known && origin.contains(known)) {
148
+ return;
149
+ }
150
+ }
151
+ /*
152
+ * Deferred fan-out: this sheet wrote during the run and this
153
+ * property already ran on this element after it (new value). Nothing
154
+ * to refresh.
155
+ */
156
+ if (changedBinding?.trace) {
157
+ const ranAt = changedBinding.trace.ran.get(element)?.get(this);
158
+ if (ranAt !== undefined && ranAt > (changedBinding.sinceSeq ?? -1)) {
159
+ return;
160
+ }
161
+ }
162
+ const trace = this.parent.quarkInstance.runTrace;
163
+ if (trace) recordSeq(trace.visited, element, this, ++trace.seq);
164
+ const shouldRun =
165
+ propertyIsNew ||
166
+ (options.isAsyncRun
167
+ ? this.isReactive
168
+ : !this.isReactive ||
169
+ // a binding this reads was written earlier in this run: refresh
170
+ // inline instead of through the deferred change event
171
+ (!!trace &&
172
+ this.referencedVarNames.some((name) => trace.written.has(name))));
173
+ if (shouldRun) {
174
+ if (trace) recordSeq(trace.ran, element, this, trace.seq);
175
+ this._run(element as TQuarkElement, options);
176
+ }
177
+ }
178
+ _run(element: TQuarkElement, options: QuarkOptions) {
179
+ return resolveField({
180
+ element,
181
+ key: this.key,
182
+ value: this.value,
183
+ options: { ...options, rule: this.parent, property: this },
184
+ hash: this.parent.quarkInstance.hash,
185
+ });
186
+ }
187
+ }
188
+
189
+ /** Store `seq` for (element, property) in a run-trace map. */
190
+ export const recordSeq = (
191
+ map: Map<Element, Map<Variable | Attribute | Listener, number>>,
192
+ element: Element,
193
+ property: Variable | Attribute | Listener,
194
+ seq: number
195
+ ) => {
196
+ let byProperty = map.get(element);
197
+ if (!byProperty) map.set(element, (byProperty = new Map()));
198
+ byProperty.set(property, seq);
199
+ };
200
+
201
+ export class Variable extends Property {
202
+ _run(element: TQuarkElement, options: QuarkOptions) {
203
+ const resolved = super._run(element, options);
204
+ // `preserve` leaves the existing binding untouched
205
+ if (isNoop(resolved)) return false;
206
+ /*
207
+ * Bindings live on the element (`_q_.vars`), shared across sheets
208
+ * like CSS custom properties. `unset` deletes; a real change fires
209
+ * bubbling `quark-binding-change`, which drives re-runs.
210
+ */
211
+ const sheet = this.parent.quarkInstance;
212
+ return writeBinding(
213
+ element,
214
+ this.key,
215
+ resolved,
216
+ {
217
+ sheetId: sheet.id,
218
+ runId: options.runId,
219
+ isFirstRun: options.isFirstRun,
220
+ },
221
+ // mid-run writes are announced once the run completes
222
+ sheet.runTrace ? sheet.queueBindingChange : undefined
223
+ );
224
+ }
225
+ }
226
+
227
+ export class Attribute extends Property {}
228
+
229
+ /**
230
+ * `--custom-prop: expr` writes a CSS custom property on matched elements
231
+ * (see the `styleProperty` resolver). Values are Quark expressions, not
232
+ * CSS value grammar: quote CSS literals. Enforced here for the two
233
+ * shapes that would silently become strings (`#ccc` colors, `10px` unit
234
+ * numbers). Bare ids and space lists (`red`, `1px solid red`) already
235
+ * fail at eval.
236
+ */
237
+ export class StyleProperty extends Attribute {
238
+ private isValidValue: boolean;
239
+ constructor(args: ConstructorParameters<typeof Property>[0]) {
240
+ super(args);
241
+ const node = this.parsedValue as { type?: string; unit?: string } | null;
242
+ this.isValidValue = !(
243
+ node &&
244
+ (node.type === "color" || (node.type === "number" && node.unit))
245
+ );
246
+ if (!this.isValidValue) {
247
+ QuarkLogger.error({
248
+ method: "styleProperty",
249
+ message: `Quark: CSS literal values must be quoted — write ${this.key}: "${this.value}" (strings / expressions only)`,
250
+ });
251
+ }
252
+ }
253
+ _run(element: TQuarkElement, options: QuarkOptions) {
254
+ if (!this.isValidValue) return false;
255
+ return super._run(element, options);
256
+ }
257
+ }
258
+
259
+ /**
260
+ * `@warn` / `@debug` / `@error` statements: evaluate their value on the
261
+ * matched element and report it (console at that logger level, DevTools
262
+ * as `quark/diagnostic`). `@warn` and `@error` speak once per element and
263
+ * rule — a warning repeated on every re-run is noise; `@debug` speaks on
264
+ * every application, so it re-logs when a binding or `prop()` it reads
265
+ * changes. A top-level comma list (`@debug "size", attr("width")`)
266
+ * reports one value per item.
267
+ */
268
+ export class Diagnostic extends Property {
269
+ level: DiagnosticLevel;
270
+ /** Elements already warned about (not kept for `@debug`). */
271
+ private spoken = new WeakSet<Element>();
272
+ constructor({
273
+ level,
274
+ value,
275
+ parent,
276
+ transition = null,
277
+ }: {
278
+ level: DiagnosticLevel;
279
+ value: string;
280
+ parent: Rule;
281
+ transition?: TransitionSpec | null;
282
+ }) {
283
+ super({ key: `@${level}`, value, parent, transition });
284
+ this.level = level;
285
+ }
286
+ kind(): PropertyKind {
287
+ return "diagnostic";
288
+ }
289
+ _run(element: TQuarkElement, options: QuarkOptions) {
290
+ if (this.level !== "debug") {
291
+ if (this.spoken.has(element)) return false;
292
+ this.spoken.add(element);
293
+ }
294
+ const args = {
295
+ element,
296
+ key: this.key,
297
+ value: this.value,
298
+ options: { ...options, rule: this.parent, property: this },
299
+ hash: this.parent.quarkInstance.hash,
300
+ };
301
+ const resolved = resolveExpression(args);
302
+ // a failed expression already logged and published an error
303
+ if (isNoop(resolved)) return false;
304
+ const node = this.parsedValue as {
305
+ type?: string;
306
+ separator?: string;
307
+ } | null;
308
+ const values =
309
+ node?.type === "list" && node.separator === "," && Array.isArray(resolved)
310
+ ? resolved
311
+ : [resolved];
312
+ reportDiagnostic(args, this.level, values);
313
+ return true;
314
+ }
315
+ }
316
+
317
+ /**
318
+ * `@delay <ms> { … }`: applies its block to the matched element once the
319
+ * duration elapses, provided the element is still in the document, the
320
+ * sheet is still registered and the rule still matches. Applying the rule
321
+ * again restarts the timer (one per element). The block runs like an
322
+ * `@on` block — a one-shot, with `event` / `target` kept when scheduled
323
+ * from one — inside the loop-guard depth of the run that scheduled it, so
324
+ * two delays cannot bounce forever. The sheet clears pending timers on
325
+ * unregister.
326
+ */
327
+ export class Delay extends Property {
328
+ block: Rule;
329
+ private timers = new WeakMap<Element, ReturnType<typeof setTimeout>>();
330
+ constructor({
331
+ value,
332
+ parent,
333
+ transition = null,
334
+ block,
335
+ }: {
336
+ value: string;
337
+ parent: Rule;
338
+ transition?: TransitionSpec | null;
339
+ block: Rule;
340
+ }) {
341
+ super({ key: "@delay", value, parent, transition });
342
+ this.block = block;
343
+ }
344
+ kind(): PropertyKind {
345
+ return "delay";
346
+ }
347
+ /** Drop the pending timer for `element`, if any. */
348
+ cancel(element: Element) {
349
+ const timer = this.timers.get(element);
350
+ if (timer === undefined) return;
351
+ clearTimeout(timer);
352
+ this.timers.delete(element);
353
+ this.parent.quarkInstance.delayTimers.delete(timer);
354
+ }
355
+ _run(element: TQuarkElement, options: QuarkOptions) {
356
+ const sheet = this.parent.quarkInstance;
357
+ const resolved = resolveExpression({
358
+ element,
359
+ key: this.key,
360
+ value: this.value,
361
+ options: { ...options, rule: this.parent, property: this },
362
+ hash: sheet.hash,
363
+ });
364
+ if (isNoop(resolved)) return false;
365
+ const ms = typeof resolved === "string" ? parseFloat(resolved) : resolved;
366
+ if (typeof ms !== "number" || !(ms >= 0)) {
367
+ QuarkLogger.warn({
368
+ method: "delay",
369
+ message: `Quark: @delay ${this.value} needs a number of milliseconds (${this.parent.selector})`,
370
+ element,
371
+ });
372
+ return false;
373
+ }
374
+ this.cancel(element);
375
+ const publish = (phase: "scheduled" | "fired" | "dropped", extra = {}) =>
376
+ publicize(["quark", "delay"], {
377
+ weakElement: new WeakRef(element),
378
+ tag: element.localName,
379
+ selector: this.parent.selector,
380
+ ruleId: this.parent.id,
381
+ sheetId: sheet.id,
382
+ runId: options.runId ?? null,
383
+ expression: this.value,
384
+ ms,
385
+ phase,
386
+ ...extra,
387
+ });
388
+ // the timer carries the causal depth of the run that scheduled it
389
+ const depth = LoopGuard.current();
390
+ const elRef = new WeakRef(element);
391
+ const timer = setTimeout(() => {
392
+ this.timers.delete(element);
393
+ sheet.delayTimers.delete(timer);
394
+ const el = elRef.deref();
395
+ const host = sheet.host?.deref();
396
+ const reason =
397
+ !el || !el.isConnected
398
+ ? "disconnected"
399
+ : !sheet.isRegistered || !host
400
+ ? "unregistered"
401
+ : !stillMatches(this.parent, el, host)
402
+ ? "unmatched"
403
+ : null;
404
+ if (reason) {
405
+ publish("dropped", { reason });
406
+ return;
407
+ }
408
+ publish("fired");
409
+ LoopGuard.run(depth, () =>
410
+ this.block.runEvent(
411
+ el as HTMLElement,
412
+ options.event,
413
+ options,
414
+ options.eventTarget ?? null
415
+ )
416
+ );
417
+ }, ms);
418
+ this.timers.set(element, timer);
419
+ sheet.delayTimers.add(timer);
420
+ publish("scheduled");
421
+ return true;
422
+ }
423
+ }
424
+
425
+ /** Whether `rule` (its host compound included) still matches `element`. */
426
+ const stillMatches = (
427
+ rule: Rule,
428
+ element: HTMLElement,
429
+ host: HTMLElement
430
+ ): boolean =>
431
+ (rule.hostCompound === null || host.matches(rule.hostCompound)) &&
432
+ rule.matchesElement(element, host);
433
+
434
+ /**
435
+ * `@on` options that configure the registration or act on the event
436
+ * itself, resolved once per match (bare flags and the `host` word; see
437
+ * `resolveListenerOptions`). Everything else (`target`, `key`,
438
+ * `debounce`, `throttle`, `handle`) is evaluated when the event fires.
439
+ */
440
+ export interface ResolvedListenerOptions {
441
+ self?: boolean;
442
+ once?: boolean;
443
+ passive?: boolean;
444
+ capture?: boolean;
445
+ preventDefault?: boolean;
446
+ stopPropagation?: boolean;
447
+ stopImmediatePropagation?: boolean;
448
+ host?: "window" | "document";
449
+ }
450
+
451
+ /** Option names evaluated per event, in the block's scope. */
452
+ export const DYNAMIC_LISTENER_OPTIONS = [
453
+ "target",
454
+ "key",
455
+ "debounce",
456
+ "throttle",
457
+ "handle",
458
+ ] as const;
459
+ export type DynamicListenerOption = (typeof DYNAMIC_LISTENER_OPTIONS)[number];
460
+
461
+ /** What an option-aware listener needs at event time; refreshed per run. */
462
+ export interface ListenerState {
463
+ opts: ResolvedListenerOptions;
464
+ /** Per-event options as written (`target`, `key`, `debounce`, `throttle`, `handle`). */
465
+ dynamic: ListenerOptionSource[];
466
+ options: QuarkOptions;
467
+ hash: string;
468
+ ruleId: number;
469
+ internal: ReturnType<typeof getQuarkInternal>;
470
+ timer?: ReturnType<typeof setTimeout>;
471
+ last?: number;
472
+ }
473
+
474
+ const KEY_MODIFIERS: Record<string, (e: KeyboardEvent) => boolean> = {
475
+ shift: (e) => e.shiftKey,
476
+ alt: (e) => e.altKey,
477
+ ctrl: (e) => e.ctrlKey,
478
+ control: (e) => e.ctrlKey,
479
+ meta: (e) => e.metaKey,
480
+ cmd: (e) => e.metaKey,
481
+ };
482
+
483
+ const modifierKeyName = (mod: string): string =>
484
+ mod === "ctrl" || mod === "control"
485
+ ? "control"
486
+ : mod === "cmd"
487
+ ? "meta"
488
+ : mod;
489
+
490
+ /**
491
+ * One chord token: `k`, `shift`, or `shift+k` / `k+shift`. Listed
492
+ * modifiers must be held; a modifier-only token matches that key's
493
+ * own keydown. Same rules as `event-handler`'s `keycode-filter`.
494
+ */
495
+ const matchesKeyToken = (token: string, e: KeyboardEvent): boolean => {
496
+ const parts = token.toLowerCase().split("+").filter(Boolean);
497
+ if (!parts.length) return false;
498
+ const mods = parts.filter((p) => p in KEY_MODIFIERS);
499
+ const keys = parts.filter((p) => !(p in KEY_MODIFIERS));
500
+ if (!mods.every((m) => KEY_MODIFIERS[m](e))) return false;
501
+ const key = e.key?.toLowerCase();
502
+ if (!key) return false;
503
+ if (keys.length) return keys.every((k) => key === k);
504
+ return mods.some((m) => key === modifierKeyName(m));
505
+ };
506
+
507
+ /** `key: "Escape Shift+K"`, space-separated alternatives, any may match. */
508
+ export const matchesKey = (filter: string, e: Event): boolean =>
509
+ filter
510
+ .split(/\s+/)
511
+ .filter(Boolean)
512
+ .some((token) => matchesKeyToken(token, e as KeyboardEvent));
513
+
514
+ /** The EventTarget an `@on (host: …)` listener registers on. */
515
+ export const listenerHost = (
516
+ element: Element,
517
+ host: ResolvedListenerOptions["host"]
518
+ ): EventTarget =>
519
+ host === "window"
520
+ ? ((element.ownerDocument.defaultView as EventTarget) ?? window)
521
+ : host === "document"
522
+ ? element.ownerDocument
523
+ : element;
524
+
525
+ /**
526
+ * `@on <event>[, <event>] [(options)] { … }` / `@on <event> (options);`
527
+ * attaches listeners on matched elements. `key` is the display form
528
+ * (`@on click, submit (once, handle: save)`), unique per rule for one
529
+ * event list + options group and used as the storage slot; the same
530
+ * function is registered for every event type in the list.
531
+ *
532
+ * `block` is a rule from the block body (never run by sheet passes)
533
+ * applied to the matched element once per event. Without options the
534
+ * block listener is registered directly and created once per element
535
+ * (`blockFns`) so rule re-runs keep the same registration.
536
+ *
537
+ * With options, one per-element `wrapper` applies the filters (`self`,
538
+ * `target`, `key`), the event flags (`prevent-default`, …), timing
539
+ * (`debounce` / `throttle`), `once`, then runs the `handle` functions
540
+ * and the block. `target`, `key`, `debounce`, `throttle` and `handle` are
541
+ * expressions evaluated when the event fires, in the block's scope
542
+ * (`event`, `target`, `element`, current `$bindings`), so nothing about
543
+ * the listener is reactive: a re-run only refreshes `ListenerState`,
544
+ * never the DOM registration.
545
+ */
546
+ export class Listener extends Property {
547
+ eventTypes: string[];
548
+ optionSources: ListenerOptionSource[];
549
+ block: Rule | null;
550
+ /** The `handle:` option as written, for DevTools (`""` when absent). */
551
+ handleText: string;
552
+ /** Per-element block listener, stable across rule re-runs. */
553
+ private blockFns = new WeakMap<Element, (e: Event) => void>();
554
+ /** Per-element option-aware wrapper + the state it reads at event time. */
555
+ private wrappers = new WeakMap<
556
+ Element,
557
+ { fn: (e: Event) => void; state: ListenerState }
558
+ >();
559
+ /** Per-event option problems already reported, per element. */
560
+ private warned = new WeakMap<Element, Set<string>>();
561
+ constructor({
562
+ eventTypes,
563
+ eventsText,
564
+ optionSources = [],
565
+ parent,
566
+ block = null,
567
+ transition = null,
568
+ }: {
569
+ eventTypes: string[];
570
+ /** Event list as written (`click, "my:evt"`), for the display key. */
571
+ eventsText: string;
572
+ optionSources?: ListenerOptionSource[];
573
+ parent: Rule;
574
+ block?: Rule | null;
575
+ transition?: TransitionSpec | null;
576
+ }) {
577
+ super({
578
+ key: `@on ${eventsText}${listenerOptionsText(optionSources)}`,
579
+ value: "",
580
+ parent,
581
+ transition,
582
+ });
583
+ this.eventTypes = eventTypes;
584
+ this.optionSources = optionSources;
585
+ this.block = block;
586
+ this.handleText =
587
+ optionSources.find((o) => o.name === "handle")?.text ?? "";
588
+ }
589
+ get hasOptions(): boolean {
590
+ return this.optionSources.length > 0;
591
+ }
592
+ /** The listener that applies this `@on` block to `element`. */
593
+ blockListener(
594
+ element: TQuarkElement,
595
+ options: QuarkOptions
596
+ ): (e: Event) => void {
597
+ let fn = this.blockFns.get(element);
598
+ if (!fn) {
599
+ const block = this.block!;
600
+ fn = (e: Event) => block.runEvent(element, e, options);
601
+ this.blockFns.set(element, fn);
602
+ }
603
+ return fn;
604
+ }
605
+ private warnOnce(element: Element, topic: string, message: string) {
606
+ let topics = this.warned.get(element);
607
+ if (!topics) this.warned.set(element, (topics = new Set()));
608
+ if (topics.has(topic)) return;
609
+ topics.add(topic);
610
+ QuarkLogger.warn({
611
+ method: "listener",
612
+ message: `Quark: ${this.key} — ${message}`,
613
+ element,
614
+ });
615
+ }
616
+ /**
617
+ * Evaluate one per-event option in the block's scope: the event, the
618
+ * delegate (once known) and the element's current bindings.
619
+ */
620
+ private evaluateOption(
621
+ element: TQuarkElement,
622
+ state: ListenerState,
623
+ name: DynamicListenerOption,
624
+ e: Event,
625
+ delegate: Element | undefined
626
+ ): unknown {
627
+ const source = state.dynamic.find((o) => o.name === name);
628
+ if (!source) return undefined;
629
+ if (source.text === null) {
630
+ this.warnOnce(element, name, `option "${name}" needs a value`);
631
+ return undefined;
632
+ }
633
+ return resolveExpression({
634
+ element,
635
+ key: `${this.key} option`,
636
+ value: source.text,
637
+ options: {
638
+ ...state.options,
639
+ rule: this.parent,
640
+ property: this,
641
+ event: e,
642
+ eventTarget: delegate ?? (e.target as Element | null) ?? null,
643
+ },
644
+ hash: state.hash,
645
+ });
646
+ }
647
+ /** `debounce` / `throttle` as milliseconds, or `undefined` (warned once). */
648
+ private evaluateMs(
649
+ element: TQuarkElement,
650
+ state: ListenerState,
651
+ name: "debounce" | "throttle",
652
+ e: Event,
653
+ delegate: Element | undefined
654
+ ): number | undefined {
655
+ if (!state.dynamic.some((o) => o.name === name)) return undefined;
656
+ const value = this.evaluateOption(element, state, name, e, delegate);
657
+ if (isNoop(value)) return undefined;
658
+ const ms = typeof value === "string" ? parseFloat(value) : value;
659
+ if (typeof ms !== "number" || !(ms > 0)) {
660
+ this.warnOnce(
661
+ element,
662
+ name,
663
+ `option "${name}" needs a positive number of milliseconds`
664
+ );
665
+ return undefined;
666
+ }
667
+ return ms;
668
+ }
669
+ /** Call the `handle:` result(s) with the event; `this` is the element. */
670
+ private callHandlers(element: TQuarkElement, value: unknown, e: Event) {
671
+ if (value === undefined || value === null || isNoop(value)) return;
672
+ const handlers = Array.isArray(value) ? value.flat(Infinity) : [value];
673
+ for (const handler of handlers) {
674
+ if (typeof handler === "function") {
675
+ handler.call(element, e);
676
+ } else if (typeof handler === "string") {
677
+ this.warnOnce(
678
+ element,
679
+ "handle",
680
+ `handle: "${handler}" is a string — write the bare name of a function`
681
+ );
682
+ }
683
+ }
684
+ }
685
+ /**
686
+ * The one function registered for an `@on` with options. Created once
687
+ * per element; `state` is replaced each run so the wrapper always
688
+ * sees the latest flags, option sources and run context.
689
+ */
690
+ wrapper(element: TQuarkElement, state: ListenerState): (e: Event) => void {
691
+ const existing = this.wrappers.get(element);
692
+ if (existing) {
693
+ // keep timing bookkeeping across runs
694
+ state.timer = existing.state.timer;
695
+ state.last = existing.state.last;
696
+ existing.state = state;
697
+ return existing.fn;
698
+ }
699
+ const entry = { state, fn: (_e: Event) => {} };
700
+ const block = this.block;
701
+ const slot = this.key;
702
+ const run = (e: Event, delegate: Element | undefined) => {
703
+ const { options } = entry.state;
704
+ if (entry.state.dynamic.some((o) => o.name === "handle")) {
705
+ this.callHandlers(
706
+ element,
707
+ this.evaluateOption(element, entry.state, "handle", e, delegate),
708
+ e
709
+ );
710
+ }
711
+ if (block) block.runEvent(element, e, options, delegate ?? null);
712
+ };
713
+ entry.fn = (e: Event) => {
714
+ const { opts, internal, hash, ruleId } = entry.state;
715
+ const onHost = !!opts.host;
716
+ if (onHost && !element.isConnected) {
717
+ // the element left the document: drop the window / document listener
718
+ internal.removeAllListeners(hash, ruleId, slot);
719
+ return;
720
+ }
721
+ if (opts.self && e.target !== e.currentTarget) return;
722
+ let delegate: Element | undefined;
723
+ if (entry.state.dynamic.some((o) => o.name === "target")) {
724
+ const selector = this.evaluateOption(
725
+ element,
726
+ entry.state,
727
+ "target",
728
+ e,
729
+ undefined
730
+ );
731
+ if (isNoop(selector)) return;
732
+ if (typeof selector !== "string" || !selector.trim()) {
733
+ this.warnOnce(
734
+ element,
735
+ "target",
736
+ `option "target" needs a string value`
737
+ );
738
+ return;
739
+ }
740
+ const origin = e.target as Element | null;
741
+ const found =
742
+ origin && typeof origin.closest === "function"
743
+ ? tc(() => origin.closest(selector))
744
+ : null;
745
+ if (!found) return;
746
+ if (!onHost && !element.contains(found)) return;
747
+ delegate = found;
748
+ }
749
+ if (entry.state.dynamic.some((o) => o.name === "key")) {
750
+ const filter = this.evaluateOption(
751
+ element,
752
+ entry.state,
753
+ "key",
754
+ e,
755
+ delegate
756
+ );
757
+ if (isNoop(filter)) return;
758
+ if (typeof filter !== "string" || !filter.trim()) {
759
+ this.warnOnce(element, "key", `option "key" needs a string value`);
760
+ return;
761
+ }
762
+ if (!matchesKey(filter, e)) return;
763
+ }
764
+ if (opts.once) internal.removeAllListeners(hash, ruleId, slot);
765
+ // event flags act now, before any timing, so cancelation is synchronous
766
+ if (opts.preventDefault) e.preventDefault();
767
+ if (opts.stopPropagation) e.stopPropagation();
768
+ if (opts.stopImmediatePropagation) e.stopImmediatePropagation();
769
+ const debounce = this.evaluateMs(
770
+ element,
771
+ entry.state,
772
+ "debounce",
773
+ e,
774
+ delegate
775
+ );
776
+ if (debounce) {
777
+ clearTimeout(entry.state.timer);
778
+ entry.state.timer = setTimeout(() => run(e, delegate), debounce);
779
+ return;
780
+ }
781
+ const throttle = this.evaluateMs(
782
+ element,
783
+ entry.state,
784
+ "throttle",
785
+ e,
786
+ delegate
787
+ );
788
+ if (throttle) {
789
+ const now = Date.now();
790
+ const last = entry.state.last;
791
+ if (last !== undefined && now - last < throttle) return;
792
+ entry.state.last = now;
793
+ }
794
+ run(e, delegate);
795
+ };
796
+ // give the wrapper a readable name for DevTools / stack traces
797
+ Object.defineProperty(entry.fn, "name", {
798
+ value: `on:${this.eventTypes.join(",")}`,
799
+ });
800
+ this.wrappers.set(element, entry);
801
+ return entry.fn;
802
+ }
803
+ _run(element: TQuarkElement, options: QuarkOptions) {
804
+ return resolveField({
805
+ element,
806
+ key: this.key,
807
+ value: this.value,
808
+ options: { ...options, rule: this.parent, property: this },
809
+ hash: this.parent.quarkInstance.hash,
810
+ listener: {
811
+ eventTypes: this.eventTypes,
812
+ slot: this.key,
813
+ optionSources: this.optionSources,
814
+ block: this.block ?? undefined,
815
+ },
816
+ });
817
+ }
818
+ }