lime-csr-js 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * @module bindings-events
3
+ * Event shortcut — `data-on-{event}="handlerName"` (the EVAL-FREE
4
+ * counterpart of Alpine's `x-on`).
5
+ *
6
+ * WHAT IT DOES
7
+ * <button data-on-click="addComment">Add</button>
8
+ * A HANDLER DICTIONARY is passed to the mount() call:
9
+ * mount("page", ctx, target, store, { handlers: { addComment(e, el) {...} } })
10
+ * On click, the framework looks up "addComment" by name in the dictionary and calls it.
11
+ *
12
+ * NO EVAL — NAME MATCHING (a DELIBERATE DIFFERENCE from Alpine):
13
+ * In Alpine, `x-on:click="count++"` is a JS EXPRESSION in the attribute
14
+ * value, and it gets executed (via a `new Function`-like mechanism). Here
15
+ * the attribute value ("addComment") is NOT an expression — it's only a
16
+ * KEY (string) looked up in the `handlers` dictionary. No code is ever
17
+ * generated from a string and executed; this is fully consistent with the
18
+ * project-wide "no new Function/eval" principle (see the same principle in
19
+ * template.js/conditionals.js).
20
+ *
21
+ * SUPPORTED EVENTS (SUPPORTED_EVENTS):
22
+ * click, input, change, submit, keydown — derived from the attribute name
23
+ * (`data-on-{event}`). If a type outside this list of 5 is used (e.g.
24
+ * `data-on-foo`), a dev-mode warning is issued and that attribute is ignored.
25
+ *
26
+ * MECHANISM — DELEGATION (not one listener per element):
27
+ * setupEventBindings sets up ONE listener PER USED EVENT TYPE
28
+ * (`root.addEventListener(type, ...)`), not per element — and only for the
29
+ * types actually used, not blindly for all 5 (see collectUsedEventTypes).
30
+ * When an event arrives, the real target is found via
31
+ * `event.target.closest('[data-on-{type}]')`, the handler name is read
32
+ * from the attribute, and looked up in the dictionary.
33
+ *
34
+ * ADVANTAGE OF DELEGATION: when reactive `<for data-live>`/`<if data-live>`
35
+ * adds new content and removes old content, there is NO NEED to set up a
36
+ * separate listener for those new elements — the single listener on `root`
37
+ * always catches them via event bubbling. Since `root` is a FIXED root for
38
+ * the duration of mount() that is UNAFFECTED by these reactive internal
39
+ * changes (only the nodes INSIDE it change, `root` itself never changes),
40
+ * this guarantee holds permanently — see the "delegation proof" test in
41
+ * examples/events-test.html.
42
+ *
43
+ * WHY root, NOT document: if the listener were attached to `document`, then
44
+ * with multiple mount() calls on the page, every `document` listener would
45
+ * listen to the ENTIRE page — one mount's handlers dictionary could
46
+ * mistakenly catch a data-on-* element belonging to another mount (which
47
+ * could have the same name). Using `root` (mount()'s `target`) isolates
48
+ * each mount's own delegation; the `root.contains(el)` check is an extra
49
+ * layer of guarantee.
50
+ *
51
+ * NO inLiveBlock EXCEPTION NEEDED (DIFFERENCE from bindings.js/bindings-model.js/bindings-show.js):
52
+ * Those modules walk every element ONE BY ONE AT RENDER TIME (before the
53
+ * fragment is added to the DOM) and set up a subscription — if they bound
54
+ * an element inside a not-yet-expanded live block too early, that
55
+ * subscription would leak when the block later re-renders via its own
56
+ * renderFn (staying attached to the old DOM). Event delegation, on the
57
+ * other hand, never binds any element individually — a single root-level
58
+ * listener is set up, and when an event is ACTUALLY CLICKED, closest() is
59
+ * used to search the CURRENT (up-to-date) DOM at that moment. The risk of
60
+ * "binding too early to the wrong context" is structurally absent; hence
61
+ * no need for the inLiveBlock filter at all.
62
+ *
63
+ * MODIFIER — ONLY data-on-submit → preventDefault (DECISION):
64
+ * Not reloading the page on form submit is the OVERWHELMINGLY common
65
+ * desired behavior; so data-on-submit ALWAYS calls preventDefault (no
66
+ * opt-in data-prevent attribute was deemed NECESSARY — KISS). preventDefault
67
+ * is applied EVEN IF the handler is not found (a typo): otherwise the
68
+ * form's native submit would kick in and reload the page — exactly what we
69
+ * want to prevent. Other modifiers (stop propagation, once, debounce) were
70
+ * NOT ADDED — TODO: could be addressed later via a separate data-*
71
+ * attribute (e.g. `data-once`).
72
+ *
73
+ * SCOPE DECISION — no context is INJECTED into the handler:
74
+ * Handlers are written in APPLICATION code and already have access to
75
+ * their own store reference (via closure); the framework doesn't need to
76
+ * pass a context separately. Only `(event, element)` is passed to the
77
+ * handler — `element.dataset` is used to find out which item it relates to
78
+ * (e.g. `data-id="42"` → `el.dataset.id`; see the "Events" section in README).
79
+ */
80
+
81
+ import { errors } from './errors.js';
82
+
83
+ /** @type {Set<string>} Event types supported as data-on-{event}. */
84
+ const SUPPORTED_EVENTS = new Set(['click', 'input', 'change', 'submit', 'keydown']);
85
+
86
+ const EVENT_ATTR_PATTERN = /^data-on-(.+)$/;
87
+
88
+ /**
89
+ * Scans ALL <template> contents on the page and collects the data-on-{event}
90
+ * types actually in use (intersected with SUPPORTED_EVENTS).
91
+ *
92
+ * WHY "ALL templates" (not just THIS mount's template):
93
+ * An <if data-live>'s else branch, a <for data-live>'s currently-empty loop
94
+ * body, or a <partial>'s OWN template may not YET be VISIBLE in the live
95
+ * DOM at mount TIME (they only appear once the relevant branch/item/partial
96
+ * is rendered). So scanning only the CURRENTLY rendered DOM would be
97
+ * insufficient — a data-on-* type that gets added LATER via reactivity
98
+ * would never have gotten a listener set up for it. The raw <template>
99
+ * sources (document.querySelectorAll('template')), however, are NEVER
100
+ * mutated (see template.js: the cache always returns cloneNode(true)) — so
101
+ * as written, regardless of which branch/loop/partial they belong to, they
102
+ * safely surface ALL data-on-* usages. If an unknown (outside
103
+ * SUPPORTED_EVENTS) type is found, it's warned about here (once, at scan time).
104
+ *
105
+ * @returns {Set<string>}
106
+ */
107
+ function collectUsedEventTypes() {
108
+ const types = new Set();
109
+
110
+ for (const tpl of document.querySelectorAll('template')) {
111
+ for (const el of tpl.content.querySelectorAll('*')) {
112
+ for (const attr of el.attributes) {
113
+ const match = EVENT_ATTR_PATTERN.exec(attr.name);
114
+ if (!match) continue;
115
+
116
+ const eventName = match[1];
117
+ if (SUPPORTED_EVENTS.has(eventName)) {
118
+ types.add(eventName);
119
+ } else {
120
+ errors.unknownEvent(eventName, Array.from(SUPPORTED_EVENTS), el);
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ return types;
127
+ }
128
+
129
+ /**
130
+ * Sets up a SINGLE delegation listener on root for each data-on-{event} type
131
+ * actually in use. A handler call whose name isn't found in the handlers
132
+ * dictionary is warned about in dev-mode (no crash).
133
+ *
134
+ * @param {Element} root - Delegation root (mount()'s target)
135
+ * @param {import('./store.js').Store} store - Currently UNUSED (no context is
136
+ * injected into handlers, see the module JSDoc — kept for signature
137
+ * consistency with the other setup*Bindings functions).
138
+ * @param {Object<string, function(Event, Element): void>} handlers
139
+ * @returns {function(): void} cleanup — removes all listeners that were set up
140
+ */
141
+ export function setupEventBindings(root, store, handlers) {
142
+ const usedTypes = collectUsedEventTypes();
143
+ const listeners = [];
144
+
145
+ for (const type of usedTypes) {
146
+ const attrName = `data-on-${type}`;
147
+
148
+ const onEvent = (event) => {
149
+ const el = event.target.closest?.(`[${attrName}]`);
150
+ if (!el || !root.contains(el)) return;
151
+
152
+ // data-on-submit ALWAYS calls preventDefault (see the "MODIFIER"
153
+ // section in the module JSDoc) — even if the handler isn't found, to
154
+ // prevent the native submit from reloading the page.
155
+ if (type === 'submit') event.preventDefault();
156
+
157
+ const handlerName = el.getAttribute(attrName);
158
+ const handler = Object.hasOwn(handlers, handlerName) ? handlers[handlerName] : undefined;
159
+
160
+ if (!handler) {
161
+ errors.handlerNotFound(handlerName, Object.keys(handlers), el);
162
+ return;
163
+ }
164
+
165
+ handler(event, el);
166
+ };
167
+
168
+ root.addEventListener(type, onEvent);
169
+ listeners.push({ type, onEvent });
170
+ }
171
+
172
+ return function cleanup() {
173
+ for (const { type, onEvent } of listeners) root.removeEventListener(type, onEvent);
174
+ listeners.length = 0; // guard against double cleanup calls
175
+ };
176
+ }