@webjsdev/core 0.7.6 → 0.7.7

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/index-browser.js CHANGED
@@ -48,8 +48,16 @@ export { WebjsFrame } from './src/webjs-frame.js';
48
48
  // Signals (TC39 Stage-1 shape), also available via '@webjsdev/core/signals'
49
49
  export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
50
50
 
51
- // Directives, also available via '@webjsdev/core/directives'
52
- export { unsafeHTML, isUnsafeHTML, live, isLive } from './src/directives.js';
51
+ // Directives, also available via '@webjsdev/core/directives'. The full
52
+ // lit-html-parity set is re-exported here so the dist browser bundle (which
53
+ // the `@webjsdev/core/directives` subpath collapses onto in dist mode) carries
54
+ // every directive, matching what src/directives.js exports in dev.
55
+ export {
56
+ unsafeHTML, isUnsafeHTML, live, isLive, keyed, isKeyed, guard, isGuard,
57
+ templateContent, isTemplateContent, ref, isRef, createRef, cache, isCache,
58
+ until, isUntil, asyncAppend, isAsyncAppend, asyncReplace, isAsyncReplace,
59
+ watch, isWatch,
60
+ } from './src/directives.js';
53
61
 
54
62
  // Context Protocol, also available via '@webjsdev/core/context'
55
63
  export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent } from './src/context.js';
package/index.js CHANGED
@@ -30,8 +30,15 @@ export { WebjsFrame } from './src/webjs-frame.js';
30
30
  // Signals (TC39 Stage-1 shape), also available via '@webjsdev/core/signals'
31
31
  export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
32
32
 
33
- // Directives, also available via '@webjsdev/core/directives'
34
- export { unsafeHTML, isUnsafeHTML, live, isLive } from './src/directives.js';
33
+ // Directives, also available via '@webjsdev/core/directives'. The full
34
+ // lit-html-parity set is re-exported so the bare specifier exposes the same
35
+ // directive surface in Node as the browser bundle does.
36
+ export {
37
+ unsafeHTML, isUnsafeHTML, live, isLive, keyed, isKeyed, guard, isGuard,
38
+ templateContent, isTemplateContent, ref, isRef, createRef, cache, isCache,
39
+ until, isUntil, asyncAppend, isAsyncAppend, asyncReplace, isAsyncReplace,
40
+ watch, isWatch,
41
+ } from './src/directives.js';
35
42
 
36
43
  // Context Protocol, also available via '@webjsdev/core/context'
37
44
  export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent } from './src/context.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
package/src/component.js CHANGED
@@ -260,6 +260,96 @@ class ServerElement {
260
260
  return [...this.__ssrAttrs.keys()];
261
261
  }
262
262
 
263
+ /**
264
+ * Minimal `Element.closest()` for SSR. The SSR walker threads the chain
265
+ * of enclosing custom-element instances into each instance
266
+ * (`__ssrAncestors`); this walks self-then-ancestors and returns the
267
+ * nearest whose tag matches. Only bare tag-name selectors are supported
268
+ * server-side (`closest('ui-tabs')`), which is what compound components
269
+ * need to read parent state for a correct first paint; anything more
270
+ * specific (class, attribute, or descendant selectors) returns null,
271
+ * matching the pre-shim behaviour. The browser runs the real `closest()`
272
+ * on hydration.
273
+ * @param {string} selector
274
+ * @returns {any}
275
+ */
276
+ closest(selector) {
277
+ const sel = String(selector).trim().toLowerCase();
278
+ // Tag-name selectors only at SSR; bail (null) on anything else.
279
+ if (!/^[a-z][a-z0-9-]*$/.test(sel)) return null;
280
+ if (this.__ssrTag === sel) return this;
281
+ const chain = this.__ssrAncestors;
282
+ if (!Array.isArray(chain)) return null;
283
+ for (let i = chain.length - 1; i >= 0; i--) {
284
+ if (chain[i] && chain[i].__ssrTag === sel) return chain[i];
285
+ }
286
+ return null;
287
+ }
288
+
289
+ /**
290
+ * `HTMLElement.dataset`: a live view over the element's `data-*`
291
+ * attributes, backed by the SSR attribute Map. Reading / writing
292
+ * `el.dataset.fooBar` maps to the `data-foo-bar` attribute (camelCase
293
+ * to kebab-case), so a `render()` that sets `this.dataset.state = 'on'`
294
+ * surfaces `data-state="on"` in the SSR'd host tag instead of crashing
295
+ * on an undefined `dataset`.
296
+ * @returns {Record<string, string>}
297
+ */
298
+ get dataset() {
299
+ if (this.__dataset) return this.__dataset;
300
+ const el = this;
301
+ const toAttr = (p) => 'data-' + String(p).replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
302
+ this.__dataset = new Proxy(/** @type {Record<string,string>} */ ({}), {
303
+ get(_t, prop) {
304
+ if (typeof prop !== 'string') return undefined;
305
+ const v = el.getAttribute(toAttr(prop));
306
+ return v === null ? undefined : v;
307
+ },
308
+ set(_t, prop, value) {
309
+ if (typeof prop === 'string') el.setAttribute(toAttr(prop), value);
310
+ return true;
311
+ },
312
+ has(_t, prop) {
313
+ return typeof prop === 'string' && el.hasAttribute(toAttr(prop));
314
+ },
315
+ deleteProperty(_t, prop) {
316
+ if (typeof prop === 'string') el.removeAttribute(toAttr(prop));
317
+ return true;
318
+ },
319
+ ownKeys() {
320
+ return el.getAttributeNames()
321
+ .filter((n) => n.startsWith('data-'))
322
+ .map((n) => n.slice(5).replace(/-([a-z])/g, (_m, c) => c.toUpperCase()));
323
+ },
324
+ getOwnPropertyDescriptor(_t, prop) {
325
+ if (typeof prop === 'string' && el.hasAttribute(toAttr(prop))) {
326
+ return { enumerable: true, configurable: true, value: el.getAttribute(toAttr(prop)) };
327
+ }
328
+ return undefined;
329
+ },
330
+ });
331
+ return this.__dataset;
332
+ }
333
+
334
+ // IDL properties that reflect to a content attribute. A render() that
335
+ // mutates these on the host (a light-DOM compound-component pattern, e.g.
336
+ // `this.className = ...`, `this.hidden = !active`) then surfaces the
337
+ // matching attribute in the SSR'd host tag, matching the browser.
338
+ get className() { return this.getAttribute('class') ?? ''; }
339
+ set className(v) { this.setAttribute('class', v); }
340
+ get hidden() { return this.hasAttribute('hidden'); }
341
+ set hidden(v) { this.toggleAttribute('hidden', !!v); }
342
+ get id() { return this.getAttribute('id') ?? ''; }
343
+ set id(v) { this.setAttribute('id', v); }
344
+ get title() { return this.getAttribute('title') ?? ''; }
345
+ set title(v) { this.setAttribute('title', v); }
346
+ get slot() { return this.getAttribute('slot') ?? ''; }
347
+ set slot(v) { this.setAttribute('slot', v); }
348
+ get role() { return this.getAttribute('role'); }
349
+ set role(v) { v == null ? this.removeAttribute('role') : this.setAttribute('role', v); }
350
+ get tabIndex() { const v = this.getAttribute('tabindex'); return v === null ? -1 : (Number.parseInt(v, 10) || 0); }
351
+ set tabIndex(v) { this.setAttribute('tabindex', String(v)); }
352
+
263
353
  // No server event loop: listeners never fire at SSR. The no-op keeps a
264
354
  // constructor that wires delegated listeners (a common lit pattern) from
265
355
  // crashing; the browser re-runs the constructor on hydration where the
@@ -282,6 +372,30 @@ class ServerElement {
282
372
  }
283
373
  }
284
374
 
375
+ // ARIAMixin IDL reflections (`el.ariaPressed = 'true'` writes aria-pressed).
376
+ // A render() that sets ARIA state via the IDL properties then surfaces the
377
+ // matching aria-* attribute in the SSR'd host tag, matching the browser. The
378
+ // IDL name maps to the content attribute by lowercasing the part after
379
+ // `aria` and prefixing `aria-` (ariaPressed -> aria-pressed).
380
+ const ARIA_IDL_PROPS = [
381
+ 'ariaAtomic', 'ariaAutoComplete', 'ariaBusy', 'ariaChecked', 'ariaColCount',
382
+ 'ariaColIndex', 'ariaColSpan', 'ariaCurrent', 'ariaDescription', 'ariaDisabled',
383
+ 'ariaExpanded', 'ariaHasPopup', 'ariaHidden', 'ariaInvalid', 'ariaKeyShortcuts',
384
+ 'ariaLabel', 'ariaLevel', 'ariaLive', 'ariaModal', 'ariaMultiLine',
385
+ 'ariaMultiSelectable', 'ariaOrientation', 'ariaPlaceholder', 'ariaPosInSet',
386
+ 'ariaPressed', 'ariaReadOnly', 'ariaRequired', 'ariaRoleDescription',
387
+ 'ariaRowCount', 'ariaRowIndex', 'ariaRowSpan', 'ariaSelected', 'ariaSetSize',
388
+ 'ariaSort', 'ariaValueMax', 'ariaValueMin', 'ariaValueNow', 'ariaValueText',
389
+ ];
390
+ for (const idl of ARIA_IDL_PROPS) {
391
+ const attr = 'aria-' + idl.slice(4).toLowerCase();
392
+ Object.defineProperty(ServerElement.prototype, idl, {
393
+ configurable: true,
394
+ get() { return this.getAttribute(attr); },
395
+ set(v) { v == null ? this.removeAttribute(attr) : this.setAttribute(attr, String(v)); },
396
+ });
397
+ }
398
+
285
399
  // Base class choice: real HTMLElement on the browser, the shim on the server.
286
400
  const Base = isBrowser ? HTMLElement : /** @type {any} */ (ServerElement);
287
401
 
package/src/directives.js CHANGED
@@ -12,11 +12,17 @@
12
12
  * } from '@webjsdev/core/directives';
13
13
  * ```
14
14
  *
15
- * `repeat()` lives in `./repeat.js` (re-exported from the package root).
15
+ * `repeat()` is implemented in `./repeat.js`; it is re-exported here so the
16
+ * `@webjsdev/core/directives` subpath exposes the whole documented directive
17
+ * table in src/dev mode too (the dist browser bundle already carries it).
16
18
  *
17
19
  * @module directives
18
20
  */
19
21
 
22
+ // Re-export so the `/directives` subpath surface matches the documented table
23
+ // in both src and dist mode. The implementation stays in repeat.js.
24
+ export { repeat, isRepeat } from './repeat.js';
25
+
20
26
  /* ================================================================
21
27
  * unsafeHTML
22
28
  * ================================================================ */
@@ -362,7 +362,7 @@ function browserMemberHint(e) {
362
362
  * @param {SuspenseCtx} [ctx]
363
363
  * @returns {Promise<string>}
364
364
  */
365
- async function injectDSD(html, ctx) {
365
+ async function injectDSD(html, ctx, ancestors = []) {
366
366
  const tags = allTags();
367
367
  if (!tags.length) return html;
368
368
  // Sort longest tag name first so the regex alternation tries the most
@@ -391,6 +391,12 @@ async function injectDSD(html, ctx) {
391
391
  try {
392
392
  const isShadow = /** @type any */ (Cls).shadow === true;
393
393
  const instance = new /** @type any */ (Cls)();
394
+ // Thread the ancestor chain (the enclosing custom-element instances)
395
+ // so the server element shim's closest() can resolve a parent at SSR.
396
+ // Set before performServerUpdate so a willUpdate() that reads a parent
397
+ // via closest() sees the chain. Each child recursion below extends it.
398
+ instance.__ssrTag = tag;
399
+ instance.__ssrAncestors = ancestors;
394
400
  const attrMap = parseAttrs(attrs);
395
401
  // Decode `data-webjs-prop-*` attributes first (rich-typed values
396
402
  // emitted for `.prop=${val}` bindings in the parent template),
@@ -416,13 +422,16 @@ async function injectDSD(html, ctx) {
416
422
  // SSR. WebComponent instances expose performServerUpdate; bare
417
423
  // Base-extending kit components (no lifecycle) do not, so it is guarded.
418
424
  if (typeof instance.performServerUpdate === 'function') instance.performServerUpdate();
419
- // Surface attributes the component set before render (reflected
420
- // reflect:true props, or an explicit this.setAttribute in the
421
- // constructor / willUpdate) that were not already in the source tag.
422
- // Appending keeps the original tag byte-identical when nothing changed.
423
- opening = appendReflectedAttrs(opening, instance, presentAttrNames);
424
425
  let tpl = instance.render ? instance.render() : '';
425
426
  if (tpl && typeof tpl.then === 'function') tpl = await tpl;
427
+ // Surface attributes the component set up to and including render()
428
+ // that were not already in the source tag: reflected reflect:true
429
+ // props, an explicit this.setAttribute in the constructor / willUpdate,
430
+ // or a host-attribute mutation inside render() itself (a light-DOM
431
+ // compound-component pattern, e.g. this.dataset.state / this.className /
432
+ // this.hidden on the host). Reading after render() captures all three.
433
+ // Appending keeps the original tag byte-identical when nothing changed.
434
+ opening = appendReflectedAttrs(opening, instance, presentAttrNames);
426
435
  // Render the template to HTML. injectDSD recurses on the result so
427
436
  // nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
428
437
  // get their own DSD pass.
@@ -432,7 +441,7 @@ async function injectDSD(html, ctx) {
432
441
  // Shadow DOM: native <slot> stays as-is in the DSD template. The
433
442
  // browser handles projection from the host's light-DOM children
434
443
  // into the shadow tree natively. No framework substitution here.
435
- const innerProcessed = await injectDSD(rawInner, ctx);
444
+ const innerProcessed = await injectDSD(rawInner, ctx, [...ancestors, instance]);
436
445
  const rawStyles = /** @type any */ (Cls).styles;
437
446
  const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
438
447
  const styleStr = stylesToString(styleList);
@@ -495,7 +504,7 @@ async function injectDSD(html, ctx) {
495
504
  }
496
505
  const partitioned = partitionAuthoredBySlot(authoredInner);
497
506
  const innerWithSlots = substituteSlotsInRender(rawInner, partitioned);
498
- const innerProcessed = await injectDSD(innerWithSlots, ctx);
507
+ const innerProcessed = await injectDSD(innerWithSlots, ctx, [...ancestors, instance]);
499
508
  edits.push({
500
509
  start: m.index,
501
510
  end: closeEnd,
@@ -523,6 +532,11 @@ async function injectDSD(html, ctx) {
523
532
  // independently against the original html, but those inner elements
524
533
  // are processed by the recursive injectDSD call on innerWithSlots.
525
534
  // Keeping both edits would double-process them and corrupt the output.
535
+ // A consequence: a nested instance's render() runs once per chain depth
536
+ // (the discarded top-level pass sees an empty ancestor chain, so its
537
+ // closest() reads null; the kept recursive pass has the real chain). The
538
+ // kept pass is the only output, and closest() is a read, so render() must
539
+ // stay pure at SSR (the standard SSR contract), not branch on side effects.
526
540
  edits.sort((a, b) => a.start - b.start);
527
541
  /** @type {{start:number, end:number, text:string}[]} */
528
542
  const filtered = [];