@elenajs/core 1.0.0-rc.7 → 1.0.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.
package/src/elena.js CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { setProps, getProps, getPropValue, syncAttribute } from "./common/props.js";
16
- import { defineElement, html, unsafeHTML, nothing } from "./common/utils.js";
16
+ import { defineElement, html, unsafeHTML, nothing, warn, prefix } from "./common/utils.js";
17
17
  import { renderTemplate } from "./common/render.js";
18
18
 
19
19
  export { html, unsafeHTML, nothing };
@@ -40,7 +40,7 @@ function elementResolver(selector) {
40
40
  */
41
41
 
42
42
  /**
43
- * @typedef {{ text: string, element: HTMLElement | null, render(): void, willUpdate(): void, firstUpdated(): void, updated(): void, connectedCallback(): void, disconnectedCallback(): void }} ElenaInstanceMembers
43
+ * @typedef {{ text: string, element: HTMLElement | null, updateComplete: Promise<void>, render(): void, willUpdate(): void, firstUpdated(): void, updated(): void, requestUpdate(): void, connectedCallback(): void, disconnectedCallback(): void, adoptedCallback(): void, attributeChangedCallback(prop: string, oldValue: string | null, newValue: string | null): void }} ElenaInstanceMembers
44
44
  */
45
45
 
46
46
  /**
@@ -49,7 +49,7 @@ function elementResolver(selector) {
49
49
 
50
50
  /**
51
51
  * @typedef {(new (...args: any[]) => HTMLElement & ElenaInstanceMembers) & {
52
- * define(): void,
52
+ * define(registry?: CustomElementRegistry): void,
53
53
  * readonly observedAttributes: string[],
54
54
  * tagName?: string,
55
55
  * props?: (string | ElenaPropObject)[],
@@ -57,11 +57,13 @@ function elementResolver(selector) {
57
57
  * element?: string,
58
58
  * shadow?: "open" | "closed",
59
59
  * styles?: CSSStyleSheet | string | (CSSStyleSheet | string)[],
60
+ * registry?: CustomElementRegistry,
60
61
  * }} ElenaElementConstructor
61
62
  */
62
63
 
63
64
  // Tracks which component classes have already been set up.
64
65
  const setupRegistry = new WeakSet();
66
+ const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
65
67
 
66
68
  /**
67
69
  * Creates an Elena component class by extending `superClass`.
@@ -90,8 +92,8 @@ export function Elena(superClass) {
90
92
  * Updates the matching prop and re-renders if needed.
91
93
  *
92
94
  * @param {string} prop
93
- * @param {string} oldValue
94
- * @param {string} newValue
95
+ * @param {string | null} oldValue
96
+ * @param {string | null} newValue
95
97
  */
96
98
  attributeChangedCallback(prop, oldValue, newValue) {
97
99
  super.attributeChangedCallback?.(prop, oldValue, newValue);
@@ -101,17 +103,29 @@ export function Elena(superClass) {
101
103
  return;
102
104
  }
103
105
 
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;
106
+ if (oldValue === newValue) {
107
+ return;
108
+ }
109
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) {
110
+ if (this._hydrated && !this._isRendering) {
111
+ // The attribute is already set and we just need the coerced
112
+ // prop value stored for the next render.
113
+ const current = this._props.get(prop);
114
+ const type = typeof current;
115
+ const coerced =
116
+ type === "string" ? (newValue ?? "") : getPropValue(type, newValue, "toProp");
117
+
118
+ if (coerced !== current) {
119
+ this._props.set(prop, coerced);
120
+ }
114
121
  this._safeRender();
122
+
123
+ // Runs pre-hydration or during render.
124
+ // Goes through the setter so _props is initialized correctly.
125
+ } else {
126
+ this._syncing = true;
127
+ getProps(this, prop, oldValue, newValue);
128
+ this._syncing = false;
115
129
  }
116
130
  }
117
131
 
@@ -136,8 +150,20 @@ export function Elena(superClass) {
136
150
  super.connectedCallback?.();
137
151
  this._setupStaticProps();
138
152
  this._captureClassFieldDefaults();
139
- this._captureText();
153
+ if (!this._hydrated && this._text === undefined) {
154
+ this.text = this.textContent.trim();
155
+ }
140
156
  this._attachShadow();
157
+ this._root = this._shadow ?? this.shadowRoot ?? this;
158
+
159
+ this._runUpdate ??= () => {
160
+ try {
161
+ this._performUpdate();
162
+ } catch (e) {
163
+ console.error(prefix, e);
164
+ }
165
+ };
166
+
141
167
  this.willUpdate();
142
168
  this._applyRender();
143
169
  this._syncProps();
@@ -181,7 +207,7 @@ export function Elena(superClass) {
181
207
  }
182
208
 
183
209
  if (names.includes("text")) {
184
- console.warn('░█ [ELENA]: "text" is reserved.');
210
+ warn('"text" is reserved.');
185
211
  }
186
212
 
187
213
  setProps(component.prototype, names, noRef);
@@ -193,7 +219,7 @@ export function Elena(superClass) {
193
219
 
194
220
  if (component._elenaEvents) {
195
221
  for (const e of component._elenaEvents) {
196
- if (!Object.prototype.hasOwnProperty.call(component.prototype, e)) {
222
+ if (!hasOwn(component.prototype, e)) {
197
223
  component.prototype[e] = function (...args) {
198
224
  return this.element[e](...args);
199
225
  };
@@ -215,7 +241,7 @@ export function Elena(superClass) {
215
241
  this._syncing = true;
216
242
 
217
243
  for (const name of this.constructor._propNames) {
218
- if (Object.prototype.hasOwnProperty.call(this, name)) {
244
+ if (hasOwn(this, name)) {
219
245
  const value = this[name];
220
246
  delete this[name];
221
247
  this[name] = value;
@@ -225,27 +251,6 @@ export function Elena(superClass) {
225
251
  this._syncing = false;
226
252
  }
227
253
 
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
254
  /**
250
255
  * Attaches a shadow root and adopts styles on first connect.
251
256
  * Only runs when `static shadow` is set on the component class.
@@ -263,7 +268,11 @@ export function Elena(superClass) {
263
268
  // In that case skip attachShadow() but still adopt styles below.
264
269
  // Store the reference so closed shadow roots remain accessible.
265
270
  if (!this._shadow && !this.shadowRoot) {
266
- this._shadow = this.attachShadow({ mode: component.shadow });
271
+ const options = { mode: component.shadow };
272
+ if (component.registry) {
273
+ options.customElementRegistry = component.registry;
274
+ }
275
+ this._shadow = this.attachShadow(options);
267
276
  }
268
277
 
269
278
  const shadowRoot = this._shadow ?? this.shadowRoot;
@@ -275,7 +284,7 @@ export function Elena(superClass) {
275
284
  // Normalize to array and cache converted CSSStyleSheet instances on the class.
276
285
  // Avoids re-parsing CSS strings on every element instance.
277
286
  if (!component._adoptedSheets) {
278
- const stylesList = Array.isArray(component.styles) ? component.styles : [component.styles];
287
+ const stylesList = [component.styles].flat();
279
288
 
280
289
  component._adoptedSheets = stylesList.map(s => {
281
290
  if (typeof s === "string") {
@@ -297,22 +306,23 @@ export function Elena(superClass) {
297
306
  * @internal
298
307
  */
299
308
  _applyRender() {
309
+ const constructor = this.constructor;
310
+ const root = this._root;
300
311
  const result = this.render();
301
312
 
302
313
  if (result && result.strings) {
303
- const root = this._renderRoot;
304
314
  const rebuilt = renderTemplate(root, result.strings, result.values);
305
315
 
306
316
  // Re-resolve element ref when the DOM was fully rebuilt.
307
- // Fast-path text node patching leaves the DOM structure intact,
317
+ // patch() and morph() leave the DOM structure intact,
308
318
  // so the existing ref is still valid.
309
319
  if (rebuilt) {
310
320
  const oldElement = this.element;
311
- this.element = this.constructor._resolver(root);
321
+ this.element = constructor._resolver(root);
312
322
 
313
323
  // Re-bind event listeners when the inner element was replaced.
314
324
  if (this._events && oldElement && this.element !== oldElement) {
315
- const events = this.constructor._elenaEvents;
325
+ const events = constructor._elenaEvents;
316
326
 
317
327
  for (const e of events) {
318
328
  oldElement.removeEventListener(e, this);
@@ -324,12 +334,11 @@ export function Elena(superClass) {
324
334
 
325
335
  // Resolve inner element on first render
326
336
  if (!this.element) {
327
- const root = this._renderRoot;
328
- this.element = this.constructor._resolver(root);
337
+ this.element = constructor._resolver(root);
329
338
 
330
339
  if (!this.element) {
331
- if (this.constructor.element) {
332
- console.warn("░█ [ELENA]: Element not found.");
340
+ if (constructor.element) {
341
+ warn("Element not found.");
333
342
  }
334
343
  this.element = root.firstElementChild;
335
344
  }
@@ -373,7 +382,7 @@ export function Elena(superClass) {
373
382
 
374
383
  if (!this._events && events?.length) {
375
384
  if (!this.element) {
376
- console.warn("░█ [ELENA]: Cannot add events.");
385
+ warn("Cannot add events.");
377
386
  } else {
378
387
  this._events = true;
379
388
 
@@ -438,6 +447,7 @@ export function Elena(superClass) {
438
447
  * events in Shadow DOM (change, submit, reset).
439
448
  * Composed bubbling events (click, input) pass through on their own.
440
449
  *
450
+ * @param {Event} event
441
451
  * @internal
442
452
  */
443
453
  handleEvent(event) {
@@ -445,7 +455,7 @@ export function Elena(superClass) {
445
455
  return;
446
456
  }
447
457
 
448
- if (!event.bubbles || (!event.composed && this._renderRoot !== this)) {
458
+ if (!event.bubbles || (!event.composed && this._root !== this)) {
449
459
  /** @internal */
450
460
  this.dispatchEvent(new Event(event.type, { bubbles: event.bubbles }));
451
461
  }
@@ -475,12 +485,16 @@ export function Elena(superClass) {
475
485
  * Registers the component as a custom element using `static tagName`.
476
486
  * Call this on your component class after the class body is defined,
477
487
  * not on the Elena mixin itself.
488
+ *
489
+ * @param {CustomElementRegistry} [registry] - A scoped registry to register in.
490
+ * When omitted, registers in the global `customElements` registry.
478
491
  */
479
- static define() {
480
- if (this.tagName) {
481
- defineElement(this.tagName, this);
492
+ static define(registry) {
493
+ const tag = this.tagName;
494
+ if (tag) {
495
+ defineElement(tag, this, registry);
482
496
  } else {
483
- console.warn("░█ [ELENA]: define() without a tagName.");
497
+ warn("define() without a tagName.");
484
498
  }
485
499
  }
486
500
 
@@ -496,16 +510,7 @@ export function Elena(superClass) {
496
510
  }
497
511
  if (!this._renderPending) {
498
512
  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
- });
513
+ queueMicrotask(this._runUpdate);
509
514
  }
510
515
  }
511
516
 
@@ -530,7 +535,7 @@ export function Elena(superClass) {
530
535
  this.updated();
531
536
  } finally {
532
537
  this._updateComplete = null;
533
- resolve();
538
+ resolve?.();
534
539
  }
535
540
  }
536
541
 
@@ -541,10 +546,15 @@ export function Elena(superClass) {
541
546
  * @type {Promise<void>}
542
547
  */
543
548
  get updateComplete() {
544
- if (this._updateComplete) {
545
- return this._updateComplete;
549
+ if (!this._renderPending) {
550
+ return Promise.resolve();
551
+ }
552
+ if (!this._updateComplete) {
553
+ this._updateComplete = new Promise(resolve => {
554
+ this._resolveUpdate = resolve;
555
+ });
546
556
  }
547
- return Promise.resolve();
557
+ return this._updateComplete;
548
558
  }
549
559
 
550
560
  /**