@elenajs/core 1.0.0-rc.3 → 1.0.0-rc.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elenajs/core",
3
- "version": "1.0.0-rc.3",
3
+ "version": "1.0.0-rc.4",
4
4
  "description": "Elena is a simple, tiny library for building Progressive Web Components.",
5
5
  "author": "Elena <hi@elenajs.com>",
6
6
  "homepage": "https://elenajs.com/",
@@ -16,6 +16,7 @@
16
16
  "publishConfig": {
17
17
  "access": "public"
18
18
  },
19
+ "source": "./src/elena.js",
19
20
  "main": "./dist/elena.js",
20
21
  "types": "./dist/elena.d.ts",
21
22
  "type": "module",
@@ -27,7 +28,8 @@
27
28
  "./bundle": "./dist/bundle.js"
28
29
  },
29
30
  "files": [
30
- "dist"
31
+ "dist",
32
+ "src"
31
33
  ],
32
34
  "scripts": {
33
35
  "prebuild": "npm run -s clean",
@@ -54,5 +56,5 @@
54
56
  "typescript": "5.9.3",
55
57
  "vitest": "4.1.0"
56
58
  },
57
- "gitHead": "18e13f305c8823f7633c739f2ec61cec2420267b"
59
+ "gitHead": "33e1f2c6a4f2a6693a557edf495ec48579269def"
58
60
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Get the value of the Elena Element property.
3
+ *
4
+ * @param {string} type
5
+ * @param {any} value
6
+ * @param {"toAttribute" | "toProp"} [transform]
7
+ */
8
+ export function getPropValue(type, value, transform) {
9
+ value = type === "boolean" && typeof value !== "boolean" ? value !== null : value;
10
+
11
+ if (!transform) {
12
+ return value;
13
+ } else if (transform === "toAttribute") {
14
+ switch (type) {
15
+ case "object":
16
+ case "array":
17
+ return value === null ? null : JSON.stringify(value);
18
+ case "boolean":
19
+ return value ? "" : null;
20
+ case "number":
21
+ return value === null ? null : value;
22
+ default:
23
+ return value === "" ? null : value;
24
+ }
25
+ } else {
26
+ switch (type) {
27
+ case "object":
28
+ case "array":
29
+ if (!value) {
30
+ return value;
31
+ }
32
+ try {
33
+ return JSON.parse(value);
34
+ } catch {
35
+ console.warn("░█ [ELENA]: Invalid JSON: " + value);
36
+ return null;
37
+ }
38
+ case "boolean":
39
+ return value; // conversion already handled above
40
+ case "number":
41
+ return value !== null ? +value : value;
42
+ default:
43
+ return value ?? "";
44
+ }
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Set or remove an attribute on an Elena Element.
50
+ *
51
+ * @param {Element} element - Target element
52
+ * @param {string} name - Attribute name
53
+ * @param {string | null} value - Attribute value, or null to remove
54
+ */
55
+ export function syncAttribute(element, name, value) {
56
+ if (!element) {
57
+ console.warn("░█ [ELENA]: Cannot sync attrs.");
58
+ return;
59
+ }
60
+ if (value === null) {
61
+ element.removeAttribute(name);
62
+ } else {
63
+ element.setAttribute(name, value);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Define prop getters/setters on the prototype once
69
+ * at class-creation time. Values are stored per-instance
70
+ * via a `_props` Map that is lazily created.
71
+ *
72
+ * @param {Function} proto - The class prototype
73
+ * @param {string[]} propNames - Prop names to define
74
+ * @param {Set<string>} [noReflect] - Props that should not reflect to attributes
75
+ */
76
+ export function setProps(proto, propNames, noReflect) {
77
+ for (const prop of propNames) {
78
+ const reflects = !noReflect || !noReflect.has(prop);
79
+ Object.defineProperty(proto, prop, {
80
+ configurable: true,
81
+ enumerable: true,
82
+ get() {
83
+ return this._props ? this._props.get(prop) : undefined;
84
+ },
85
+ set(value) {
86
+ if (!this._props) {
87
+ this._props = new Map();
88
+ }
89
+ if (value === this._props.get(prop)) {
90
+ return;
91
+ }
92
+
93
+ this._props.set(prop, value);
94
+ if (!this.isConnected) {
95
+ return;
96
+ }
97
+
98
+ if (reflects) {
99
+ // Skip reflection when called from attributeChangedCallback. The
100
+ // attribute is already at the new value, setting it again is redundant
101
+ // and would fire an extra attributeChangedCallback with identical values.
102
+ if (!this._syncing) {
103
+ const attrValue = getPropValue(typeof value, value, "toAttribute");
104
+ syncAttribute(this, prop, attrValue);
105
+ }
106
+ } else if (this._hydrated && !this._isRendering) {
107
+ this._safeRender();
108
+ }
109
+ },
110
+ });
111
+ }
112
+ }
113
+
114
+ /**
115
+ * We need to update the internals of the Elena Element
116
+ * when props on the host element are changed.
117
+ *
118
+ * @param {object} context
119
+ * @param {string} name
120
+ * @param {any} oldValue
121
+ * @param {any} newValue
122
+ */
123
+ export function getProps(context, name, oldValue, newValue) {
124
+ if (oldValue !== newValue) {
125
+ const type = typeof context[name];
126
+ if (type === "undefined") {
127
+ console.warn(`░█ [ELENA]: Prop "${name}" has no default.`);
128
+ }
129
+ const newAttr = getPropValue(type, newValue, "toProp");
130
+ context[name] = newAttr;
131
+ }
132
+ }
@@ -0,0 +1,230 @@
1
+ import { collapseWhitespace, isRaw, resolveValue, toPlainText } from "./utils.js";
2
+
3
+ const stringsCache = new WeakMap();
4
+ const markerKey = "e" + Math.random().toString(36).slice(2, 6);
5
+
6
+ /**
7
+ * Render a tagged template into an Elena Element with DOM diffing.
8
+ * Returns true if the DOM was fully rebuilt, false if only text
9
+ * nodes were patched in place.
10
+ *
11
+ * @param {HTMLElement} element
12
+ * @param {TemplateStringsArray} strings - Static parts of the tagged template
13
+ * @param {Array} values - Dynamic interpolated values
14
+ * @returns {boolean}
15
+ */
16
+ export function renderTemplate(element, strings, values) {
17
+ if (patchTextNodes(element, strings, values)) {
18
+ return false;
19
+ }
20
+ fullRender(element, strings, values);
21
+ return true;
22
+ }
23
+
24
+ /**
25
+ * Fast path: patch only the text nodes whose values changed.
26
+ *
27
+ * @param {HTMLElement} element - The host element with cached template state
28
+ * @param {TemplateStringsArray} strings - Static parts of the tagged template
29
+ * @param {Array} values - Dynamic interpolated values
30
+ * @returns {boolean} Whether patching was sufficient (false = full render)
31
+ */
32
+ function patchTextNodes(element, strings, values) {
33
+ // Only works when re-rendering the same template shape
34
+ if (element._tplStrings !== strings || !element._tplParts) {
35
+ return false;
36
+ }
37
+
38
+ for (let i = 0; i < values.length; i++) {
39
+ const v = values[i];
40
+ const comparable = Array.isArray(v) ? toPlainText(v) : v;
41
+
42
+ if (comparable === element._tplValues[i]) {
43
+ continue;
44
+ }
45
+
46
+ if (isRaw(v) || !element._tplParts[i]) {
47
+ return false;
48
+ }
49
+
50
+ element._tplValues[i] = comparable;
51
+ element._tplParts[i].textContent = toPlainText(v);
52
+ }
53
+
54
+ return true;
55
+ }
56
+
57
+ /**
58
+ * Cold path: clone a cached <template> and patch in values.
59
+ *
60
+ * @param {HTMLElement} element - The host element to render into
61
+ * @param {TemplateStringsArray} strings - Static parts of the tagged template
62
+ * @param {Array} values - Dynamic interpolated values
63
+ */
64
+ function fullRender(element, strings, values) {
65
+ let entry = stringsCache.get(strings);
66
+
67
+ if (!entry) {
68
+ const processedStrings = Array.from(strings, collapseWhitespace);
69
+ entry = {
70
+ processedStrings,
71
+ template: values.length > 0 ? createTemplate(processedStrings, values.length) : null,
72
+ };
73
+ stringsCache.set(strings, entry);
74
+ }
75
+
76
+ if (entry.template) {
77
+ element._tplParts = cloneAndPatch(element, entry.template, values);
78
+ } else {
79
+ // Fallback for attribute-position values or static templates.
80
+ // White space collapsing here protects against Vue SSR mismatches.
81
+ const renderedValues = values.map(value => resolveValue(value));
82
+ const markup = entry.processedStrings
83
+ .reduce((out, str, i) => out + str + (renderedValues[i] ?? ""), "")
84
+ .replace(/>\s+</g, "><")
85
+ .trim();
86
+
87
+ // Morph existing DOM to match new markup instead of replacing it.
88
+ const tpl = document.createElement("template");
89
+ tpl.innerHTML = markup;
90
+ morphContent(element, tpl.content.childNodes);
91
+ element._tplParts = null;
92
+ }
93
+
94
+ element._tplStrings = strings;
95
+ element._tplValues = values.map(v => (Array.isArray(v) ? toPlainText(v) : v));
96
+ }
97
+
98
+ /**
99
+ * Build a <template> element with comment markers.
100
+ *
101
+ * @param {string[]} processedStrings - Whitespace-collapsed static parts
102
+ * @param {number} valueCount - Number of dynamic values
103
+ * @returns {HTMLTemplateElement | null}
104
+ */
105
+ function createTemplate(processedStrings, valueCount) {
106
+ const marker = `<!--${markerKey}-->`;
107
+ const markup = processedStrings
108
+ .reduce((out, str, i) => out + str + (i < valueCount ? marker : ""), "")
109
+ .trim();
110
+
111
+ const tpl = document.createElement("template");
112
+ tpl.innerHTML = markup;
113
+
114
+ // Mismatch means this template shape cannot use the clone path.
115
+ const walker = document.createTreeWalker(tpl.content, NodeFilter.SHOW_COMMENT);
116
+ let count = 0;
117
+
118
+ while (walker.nextNode()) {
119
+ if (walker.currentNode.data === markerKey) {
120
+ count++;
121
+ }
122
+ }
123
+
124
+ return count === valueCount ? tpl : null;
125
+ }
126
+
127
+ /**
128
+ * Clone a cached template and replace comment markers
129
+ * with actual content.
130
+ *
131
+ * @param {HTMLElement} element - The host element to render into
132
+ * @param {HTMLTemplateElement} template - Cached template with markers
133
+ * @param {Array} values - Raw interpolated values
134
+ * @returns {Array<Text | undefined>} Text node map for fast-path patching
135
+ */
136
+ function cloneAndPatch(element, template, values) {
137
+ const clone = template.content.cloneNode(true);
138
+ const walker = document.createTreeWalker(clone, NodeFilter.SHOW_COMMENT);
139
+ const parts = new Array(values.length);
140
+ const markers = [];
141
+ let node;
142
+
143
+ // Collect markers before modifying the tree
144
+ while ((node = walker.nextNode())) {
145
+ if (node.data === markerKey) {
146
+ markers.push(node);
147
+ }
148
+ }
149
+
150
+ for (let i = 0; i < markers.length; i++) {
151
+ const value = values[i];
152
+
153
+ if (isRaw(value)) {
154
+ // Raw HTML: parse and insert as fragment
155
+ const tmp = document.createElement("template");
156
+ tmp.innerHTML = resolveValue(value);
157
+ markers[i].parentNode.replaceChild(tmp.content, markers[i]);
158
+
159
+ // Raw values can't be fast-patched; leave parts undefined
160
+ } else {
161
+ // Create text node with unescaped content
162
+ const textNode = document.createTextNode(toPlainText(value));
163
+ markers[i].parentNode.replaceChild(textNode, markers[i]);
164
+ parts[i] = textNode;
165
+ }
166
+ }
167
+
168
+ element.replaceChildren(clone);
169
+ return parts;
170
+ }
171
+
172
+ /**
173
+ * Patches attributes and text content in-place when structure is stable,
174
+ * preserving element identity and focus state across re-renders.
175
+ *
176
+ * @param {Node} parent
177
+ * @param {NodeList} nextNodes - The desired child nodes from the new render
178
+ */
179
+ function morphContent(parent, nextNodes) {
180
+ const current = Array.from(parent.childNodes);
181
+ const next = Array.from(nextNodes);
182
+ const len = Math.max(current.length, next.length);
183
+
184
+ for (let i = 0; i < len; i++) {
185
+ const cur = current[i];
186
+ const nxt = next[i];
187
+
188
+ if (!cur) {
189
+ parent.appendChild(nxt);
190
+ } else if (!nxt) {
191
+ parent.removeChild(cur);
192
+ } else if (
193
+ cur.nodeType !== nxt.nodeType ||
194
+ (cur.nodeType === Node.ELEMENT_NODE && cur.tagName !== nxt.tagName)
195
+ ) {
196
+ parent.replaceChild(nxt, cur);
197
+ } else if (cur.nodeType === Node.TEXT_NODE) {
198
+ if (cur.textContent !== nxt.textContent) {
199
+ cur.textContent = nxt.textContent;
200
+ }
201
+ } else if (cur.nodeType === Node.ELEMENT_NODE) {
202
+ morphAttributes(cur, nxt);
203
+ morphContent(cur, nxt.childNodes);
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Morhp element’s attributes without rebuilding the DOM.
210
+ *
211
+ * @param {Element} current - The current existing DOM element
212
+ * @param {Element} next - The desired element from the new render
213
+ */
214
+ function morphAttributes(current, next) {
215
+ for (let i = current.attributes.length - 1; i >= 0; i--) {
216
+ const { name } = current.attributes[i];
217
+
218
+ if (!next.hasAttribute(name)) {
219
+ current.removeAttribute(name);
220
+ }
221
+ }
222
+
223
+ for (let i = 0; i < next.attributes.length; i++) {
224
+ const { name, value } = next.attributes[i];
225
+
226
+ if (current.getAttribute(name) !== value) {
227
+ current.setAttribute(name, value);
228
+ }
229
+ }
230
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Register the Elena Element if the browser supports it.
3
+ *
4
+ * @param {string} tagName
5
+ * @param {Function} Element
6
+ */
7
+ export function defineElement(tagName, Element) {
8
+ if (typeof window !== "undefined" && "customElements" in window) {
9
+ if (!window.customElements.get(tagName)) {
10
+ window.customElements.define(tagName, Element);
11
+ }
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Escape a string for safe insertion into HTML.
17
+ *
18
+ * @param {string} str
19
+ * @returns {string}
20
+ */
21
+ const Escape = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
22
+ export function escapeHtml(str) {
23
+ return String(str).replace(/[&<>"']/g, c => Escape[c]);
24
+ }
25
+
26
+ /**
27
+ * Resolve an interpolated template value to its
28
+ * HTML string representation.
29
+ *
30
+ * @param {*} value
31
+ * @returns {string}
32
+ */
33
+ export function resolveValue(value) {
34
+ if (Array.isArray(value)) {
35
+ return value.map(item => resolveItem(item)).join("");
36
+ }
37
+ return resolveItem(value);
38
+ }
39
+
40
+ /**
41
+ * Resolve a single value to its HTML string
42
+ * representation.
43
+ *
44
+ * @param {*} value
45
+ * @returns {string}
46
+ */
47
+ function resolveItem(value) {
48
+ return value?.__raw ? String(value) : escapeHtml(String(value ?? ""));
49
+ }
50
+
51
+ /**
52
+ * Tagged template for trusted HTML. Use as the return value
53
+ * of render(), or for sub-fragments inside render methods.
54
+ *
55
+ * @param {TemplateStringsArray} strings
56
+ * @param {...*} values
57
+ * @returns {{ __raw: true, strings: TemplateStringsArray, values: Array, toString(): string }}
58
+ */
59
+ export function html(strings, ...values) {
60
+ let str;
61
+ return {
62
+ __raw: true,
63
+ strings,
64
+ values,
65
+ toString: () => {
66
+ if (str === undefined) {
67
+ str = strings.reduce((acc, s, i) => {
68
+ return acc + s + resolveValue(values[i]);
69
+ }, "");
70
+ }
71
+ return str;
72
+ },
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Renders a string as HTML rather than text.
78
+ *
79
+ * @param {string} str - The raw HTML string to trust.
80
+ * @returns {{ __raw: true, toString(): string }}
81
+ */
82
+ export function unsafeHTML(str) {
83
+ return { __raw: true, toString: () => str ?? "" };
84
+ }
85
+
86
+ /**
87
+ * A placeholder you can return from a conditional expression
88
+ * inside a template to render nothing.
89
+ *
90
+ * @type {{ __raw: true, toString(): string }}
91
+ */
92
+ export const nothing = Object.freeze({ __raw: true, toString: () => "" });
93
+
94
+ /**
95
+ * Check if a value contains trusted HTML fragments.
96
+ *
97
+ * @param {*} value
98
+ * @returns {boolean}
99
+ */
100
+ export const isRaw = value =>
101
+ Array.isArray(value) ? value.some(item => item?.__raw) : value?.__raw;
102
+
103
+ /**
104
+ * Convert a value to its plain text string.
105
+ *
106
+ * @param {*} value
107
+ * @returns {string}
108
+ */
109
+ export const toPlainText = value =>
110
+ Array.isArray(value) ? value.map(item => String(item ?? "")).join("") : String(value ?? "");
111
+
112
+ /**
113
+ * Collapse whitespace from a static string part.
114
+ *
115
+ * @param {string} string
116
+ * @returns {string}
117
+ */
118
+ export function collapseWhitespace(string) {
119
+ return string
120
+ .replace(/>\n\s*/g, ">") // newline after tag close
121
+ .replace(/\n\s*</g, "<") // newline before tag open
122
+ .replace(/\n\s*/g, " ") // newline in text content, preserve word boundary
123
+ .replace(/>\s+</g, "><"); // whitespace between tags
124
+ }
package/src/elena.js ADDED
@@ -0,0 +1,562 @@
1
+ /**
2
+ * ██████████ ████
3
+ * ░░███░░░░░█░░███
4
+ * ░███ █ ░ ░███ ██████ ████████ ██████
5
+ * ░██████ ░███ ███░░███░░███░░███ ░░░░░███
6
+ * ░███░░█ ░███ ░███████ ░███ ░███ ███████
7
+ * ░███ ░ █ ░███ ░███░░░ ░███ ░███ ███░░███
8
+ * ██████████ █████░░██████ ████ █████░░████████
9
+ * ░░░░░░░░░░ ░░░░░ ░░░░░░ ░░░░ ░░░░░ ░░░░░░░░
10
+ *
11
+ * Elena Progressive Web Components
12
+ * https://elenajs.com
13
+ */
14
+
15
+ import { setProps, getProps, getPropValue, syncAttribute } from "./common/props.js";
16
+ import { defineElement, html, unsafeHTML, nothing } from "./common/utils.js";
17
+ import { renderTemplate } from "./common/render.js";
18
+
19
+ export { html, unsafeHTML, nothing };
20
+
21
+ /**
22
+ * Returns a function that finds the inner element using the given selector.
23
+ * Built once per component class to avoid repeated work.
24
+ *
25
+ * - No selector: uses firstElementChild
26
+ * - Any string: uses querySelector
27
+ *
28
+ * @param {string | undefined} selector
29
+ * @returns {(host: HTMLElement) => HTMLElement | null}
30
+ */
31
+ function elementResolver(selector) {
32
+ if (!selector) {
33
+ return host => host.firstElementChild;
34
+ }
35
+ return host => host.querySelector(selector);
36
+ }
37
+
38
+ /**
39
+ * @typedef {new (...args: any[]) => HTMLElement} ElenaConstructor
40
+ */
41
+
42
+ /**
43
+ * @typedef {{ text: string, element: HTMLElement | null, render(): void, willUpdate(): void, firstUpdated(): void, updated(): void, connectedCallback(): void, disconnectedCallback(): void }} ElenaInstanceMembers
44
+ */
45
+
46
+ /**
47
+ * @typedef {{ name: string, reflect?: boolean }} ElenaPropObject
48
+ */
49
+
50
+ /**
51
+ * @typedef {(new (...args: any[]) => HTMLElement & ElenaInstanceMembers) & {
52
+ * define(): void,
53
+ * readonly observedAttributes: string[],
54
+ * tagName?: string,
55
+ * props?: (string | ElenaPropObject)[],
56
+ * events?: string[],
57
+ * element?: string,
58
+ * shadow?: "open" | "closed",
59
+ * styles?: CSSStyleSheet | string | (CSSStyleSheet | string)[],
60
+ * }} ElenaElementConstructor
61
+ */
62
+
63
+ // Tracks which component classes have already been set up.
64
+ const setupRegistry = new WeakSet();
65
+
66
+ /**
67
+ * Creates an Elena component class by extending `superClass`.
68
+ *
69
+ * Adds rendering, props, and event handling to your component.
70
+ * Configure it using static class fields: `static tagName`,
71
+ * `static props`, `static events`, and `static element`.
72
+ *
73
+ * @param {ElenaConstructor} superClass - The base class to extend (usually `HTMLElement`).
74
+ * @returns {ElenaElementConstructor} A class ready to be defined as a custom element.
75
+ */
76
+ export function Elena(superClass) {
77
+ /**
78
+ * The base Elena element class with all built-in behavior.
79
+ */
80
+ class ElenaElement extends superClass {
81
+ /**
82
+ * The inner element rendered by this component.
83
+ *
84
+ * @type {HTMLElement | null}
85
+ */
86
+ element = null;
87
+
88
+ /**
89
+ * Called by the browser when an observed attribute changes.
90
+ * Updates the matching prop and re-renders if needed.
91
+ *
92
+ * @param {string} prop
93
+ * @param {string} oldValue
94
+ * @param {string} newValue
95
+ */
96
+ attributeChangedCallback(prop, oldValue, newValue) {
97
+ super.attributeChangedCallback?.(prop, oldValue, newValue);
98
+
99
+ if (prop === "text") {
100
+ this.text = newValue ?? "";
101
+ return;
102
+ }
103
+
104
+ // Set flag so the property setter skips redundant attribute reflection:
105
+ // the attribute is already at the new value, no need to set it again.
106
+ this._syncing = true;
107
+ getProps(this, prop, oldValue, newValue);
108
+ this._syncing = false;
109
+
110
+ // Re-render when attributes change (after initial render).
111
+ // Guard against re-entrant renders: if render() itself mutates an observed
112
+ // attribute, skip the recursive call to prevent an infinite loop.
113
+ if (this._hydrated && oldValue !== newValue && !this._isRendering) {
114
+ this._safeRender();
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Lists the attributes Elena watches for changes.
120
+ * Reads from the subclass’s `static props` field.
121
+ */
122
+ static get observedAttributes() {
123
+ if (this._observedAttrs) {
124
+ return this._observedAttrs;
125
+ }
126
+
127
+ const propNames = (this.props || []).map(p => (typeof p === "string" ? p : p.name));
128
+ this._observedAttrs = [...propNames, "text"];
129
+ return this._observedAttrs;
130
+ }
131
+
132
+ /**
133
+ * Called by the browser each time the element is added to the page.
134
+ */
135
+ connectedCallback() {
136
+ super.connectedCallback?.();
137
+ this._setupStaticProps();
138
+ this._captureClassFieldDefaults();
139
+ this._captureText();
140
+ this._attachShadow();
141
+ this.willUpdate();
142
+ this._applyRender();
143
+ this._syncProps();
144
+ this._delegateEvents();
145
+ if (!this._hydrated) {
146
+ this._hydrated = true;
147
+ this.setAttribute("hydrated", "");
148
+ this.firstUpdated();
149
+ }
150
+ this.updated();
151
+ }
152
+
153
+ /**
154
+ * Sets up props, events, and the element selector once per component class.
155
+ * Runs the first time an instance of a given class connects to the page.
156
+ *
157
+ * @internal
158
+ */
159
+ _setupStaticProps() {
160
+ const component = this.constructor;
161
+
162
+ if (setupRegistry.has(component)) {
163
+ return;
164
+ }
165
+
166
+ // Props with reflect: false
167
+ const noRef = new Set();
168
+ const names = [];
169
+
170
+ if (component.props) {
171
+ for (const p of component.props) {
172
+ if (typeof p === "string") {
173
+ names.push(p);
174
+ } else {
175
+ names.push(p.name);
176
+
177
+ if (p.reflect === false) {
178
+ noRef.add(p.name);
179
+ }
180
+ }
181
+ }
182
+
183
+ if (names.includes("text")) {
184
+ console.warn('░█ [ELENA]: "text" is reserved.');
185
+ }
186
+
187
+ setProps(component.prototype, names, noRef);
188
+ }
189
+
190
+ component._propNames = names;
191
+ component._noReflect = noRef;
192
+ component._elenaEvents = component.events || null;
193
+
194
+ if (component._elenaEvents) {
195
+ for (const e of component._elenaEvents) {
196
+ if (!Object.prototype.hasOwnProperty.call(component.prototype, e)) {
197
+ component.prototype[e] = function (...args) {
198
+ return this.element[e](...args);
199
+ };
200
+ }
201
+ }
202
+ }
203
+
204
+ component._resolver = elementResolver(component.element);
205
+ setupRegistry.add(component);
206
+ }
207
+
208
+ /**
209
+ * Moves class field defaults into Elena’s internal props store
210
+ * so that getters and setters work correctly.
211
+ *
212
+ * @internal
213
+ */
214
+ _captureClassFieldDefaults() {
215
+ this._syncing = true;
216
+
217
+ for (const name of this.constructor._propNames) {
218
+ if (Object.prototype.hasOwnProperty.call(this, name)) {
219
+ const value = this[name];
220
+ delete this[name];
221
+ this[name] = value;
222
+ }
223
+ }
224
+
225
+ this._syncing = false;
226
+ }
227
+
228
+ /**
229
+ * Saves any text inside the element before the first render.
230
+ *
231
+ * @internal
232
+ */
233
+ _captureText() {
234
+ if (!this._hydrated && this._text === undefined) {
235
+ this.text = this.textContent.trim();
236
+ }
237
+ }
238
+
239
+ /**
240
+ * The root node to render into. Returns the shadow root when shadow mode
241
+ * is enabled, otherwise the host element itself.
242
+ *
243
+ * @type {ShadowRoot | HTMLElement}
244
+ */
245
+ get _renderRoot() {
246
+ return this._shadow ?? this.shadowRoot ?? this;
247
+ }
248
+
249
+ /**
250
+ * Attaches a shadow root and adopts styles on first connect.
251
+ * Only runs when `static shadow` is set on the component class.
252
+ *
253
+ * @internal
254
+ */
255
+ _attachShadow() {
256
+ const component = this.constructor;
257
+
258
+ if (!component.shadow) {
259
+ return;
260
+ }
261
+
262
+ // A shadow root may already exist if Declarative Shadow DOM was used.
263
+ // In that case skip attachShadow() but still adopt styles below.
264
+ // Store the reference so closed shadow roots remain accessible.
265
+ if (!this._shadow && !this.shadowRoot) {
266
+ this._shadow = this.attachShadow({ mode: component.shadow });
267
+ }
268
+
269
+ const shadowRoot = this._shadow ?? this.shadowRoot;
270
+
271
+ if (!component.styles) {
272
+ return;
273
+ }
274
+
275
+ // Normalize to array and cache converted CSSStyleSheet instances on the class.
276
+ // Avoids re-parsing CSS strings on every element instance.
277
+ if (!component._adoptedSheets) {
278
+ const stylesList = Array.isArray(component.styles) ? component.styles : [component.styles];
279
+
280
+ component._adoptedSheets = stylesList.map(s => {
281
+ if (typeof s === "string") {
282
+ const sheet = new CSSStyleSheet();
283
+ sheet.replaceSync(s);
284
+ return sheet;
285
+ }
286
+ return s;
287
+ });
288
+ }
289
+
290
+ shadowRoot.adoptedStyleSheets = component._adoptedSheets;
291
+ }
292
+
293
+ /**
294
+ * Calls render() and updates the DOM with the result.
295
+ * Also resolves the inner element reference.
296
+ *
297
+ * @internal
298
+ */
299
+ _applyRender() {
300
+ const result = this.render();
301
+
302
+ if (result && result.strings) {
303
+ const root = this._renderRoot;
304
+ const rebuilt = renderTemplate(root, result.strings, result.values);
305
+
306
+ // Re-resolve element ref when the DOM was fully rebuilt.
307
+ // Fast-path text node patching leaves the DOM structure intact,
308
+ // so the existing ref is still valid.
309
+ if (rebuilt) {
310
+ const oldElement = this.element;
311
+ this.element = this.constructor._resolver(root);
312
+
313
+ // Re-bind event listeners when the inner element was replaced.
314
+ if (this._events && oldElement && this.element !== oldElement) {
315
+ const events = this.constructor._elenaEvents;
316
+
317
+ for (const e of events) {
318
+ oldElement.removeEventListener(e, this);
319
+ this.element.addEventListener(e, this);
320
+ }
321
+ }
322
+ }
323
+ }
324
+
325
+ // Resolve inner element on first render
326
+ if (!this.element) {
327
+ const root = this._renderRoot;
328
+ this.element = this.constructor._resolver(root);
329
+
330
+ if (!this.element) {
331
+ if (this.constructor.element) {
332
+ console.warn("░█ [ELENA]: Element not found.");
333
+ }
334
+ this.element = root.firstElementChild;
335
+ }
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Syncs any props that were set before the element
341
+ * connected to the page.
342
+ *
343
+ * @internal
344
+ */
345
+ _syncProps() {
346
+ if (this._props) {
347
+ const noReflect = this.constructor._noReflect;
348
+
349
+ for (const [prop, value] of this._props) {
350
+ if (noReflect.has(prop)) {
351
+ continue;
352
+ }
353
+
354
+ const attrValue = getPropValue(typeof value, value, "toAttribute");
355
+
356
+ if (attrValue === null && !this.hasAttribute(prop)) {
357
+ continue;
358
+ }
359
+
360
+ syncAttribute(this, prop, attrValue);
361
+ }
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Forwards events from the inner element
367
+ * up to the host element.
368
+ *
369
+ * @internal
370
+ */
371
+ _delegateEvents() {
372
+ const events = this.constructor._elenaEvents;
373
+
374
+ if (!this._events && events?.length) {
375
+ if (!this.element) {
376
+ console.warn("░█ [ELENA]: Cannot add events.");
377
+ } else {
378
+ this._events = true;
379
+
380
+ for (const e of events) {
381
+ this.element.addEventListener(e, this);
382
+ }
383
+ }
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Define the element’s HTML here. Return an `html`
389
+ * tagged template. If not overridden, the element connects
390
+ * to the page without rendering anything.
391
+ */
392
+ render() {}
393
+
394
+ /**
395
+ * Called before every render.
396
+ * Override to prepare state before the template runs.
397
+ */
398
+ willUpdate() {}
399
+
400
+ /**
401
+ * Called once after the element’s first render.
402
+ * Override to run setup that needs the DOM.
403
+ */
404
+ firstUpdated() {}
405
+
406
+ /**
407
+ * Called after every render.
408
+ * Override to react to changes.
409
+ */
410
+ updated() {}
411
+
412
+ /**
413
+ * Called by the browser when the element is moved
414
+ * to a new document via `adoptNode()`.
415
+ */
416
+ adoptedCallback() {
417
+ super.adoptedCallback?.();
418
+ }
419
+
420
+ /**
421
+ * Called by the browser each time the element
422
+ * is removed from the page.
423
+ */
424
+ disconnectedCallback() {
425
+ super.disconnectedCallback?.();
426
+ if (this._events) {
427
+ this._events = false;
428
+
429
+ for (const e of this.constructor._elenaEvents) {
430
+ this.element?.removeEventListener(e, this);
431
+ }
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Forwards events that cannot reach the host naturally:
437
+ * non-bubbling events (focus, blur) and non-composed
438
+ * events in Shadow DOM (change, submit, reset).
439
+ * Composed bubbling events (click, input) pass through on their own.
440
+ *
441
+ * @internal
442
+ */
443
+ handleEvent(event) {
444
+ if (!this.constructor._elenaEvents?.includes(event.type)) {
445
+ return;
446
+ }
447
+
448
+ if (!event.bubbles || (!event.composed && this._renderRoot !== this)) {
449
+ /** @internal */
450
+ this.dispatchEvent(new Event(event.type, { bubbles: event.bubbles }));
451
+ }
452
+ }
453
+
454
+ /**
455
+ * The text content of the element. Elena reads this
456
+ * from the element’s children before the first render.
457
+ * Updating it triggers a re-render.
458
+ *
459
+ * @type {string}
460
+ */
461
+ get text() {
462
+ return this._text ?? "";
463
+ }
464
+
465
+ set text(value) {
466
+ const old = this._text;
467
+ this._text = value;
468
+
469
+ if (this._hydrated && old !== value && !this._isRendering) {
470
+ this._safeRender();
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Registers the component as a custom element using `static tagName`.
476
+ * Call this on your component class after the class body is defined,
477
+ * not on the Elena mixin itself.
478
+ */
479
+ static define() {
480
+ if (this.tagName) {
481
+ defineElement(this.tagName, this);
482
+ } else {
483
+ console.warn("░█ [ELENA]: define() without a tagName.");
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Schedules a re-render via microtask. If called multiple times
489
+ * before the microtask fires, only one render runs.
490
+ *
491
+ * @internal
492
+ */
493
+ _safeRender() {
494
+ if (this._isRendering) {
495
+ return;
496
+ }
497
+ if (!this._renderPending) {
498
+ this._renderPending = true;
499
+ this._updateComplete = new Promise(resolve => {
500
+ this._resolveUpdate = resolve;
501
+ });
502
+ queueMicrotask(() => {
503
+ try {
504
+ this._performUpdate();
505
+ } catch (e) {
506
+ console.error("░█ [ELENA]:", e);
507
+ }
508
+ });
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Runs the batched update cycle.
514
+ * Called by the microtask in _safeRender().
515
+ *
516
+ * @internal
517
+ */
518
+ _performUpdate() {
519
+ this._renderPending = false;
520
+ const resolve = this._resolveUpdate;
521
+ this._resolveUpdate = null;
522
+ try {
523
+ try {
524
+ this.willUpdate();
525
+ this._isRendering = true;
526
+ this._applyRender();
527
+ } finally {
528
+ this._isRendering = false;
529
+ }
530
+ this.updated();
531
+ } finally {
532
+ this._updateComplete = null;
533
+ resolve();
534
+ }
535
+ }
536
+
537
+ /**
538
+ * A Promise that resolves after the render completes.
539
+ * Resolves immediately if no render is scheduled.
540
+ *
541
+ * @type {Promise<void>}
542
+ */
543
+ get updateComplete() {
544
+ if (this._updateComplete) {
545
+ return this._updateComplete;
546
+ }
547
+ return Promise.resolve();
548
+ }
549
+
550
+ /**
551
+ * Schedules a re-render. Use this to manually trigger an
552
+ * update when Elena cannot detect the change automatically.
553
+ */
554
+ requestUpdate() {
555
+ if (this._hydrated && !this._isRendering) {
556
+ this._safeRender();
557
+ }
558
+ }
559
+ }
560
+
561
+ return ElenaElement;
562
+ }