@xoong/data 0.0.42 → 0.0.43

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 (2) hide show
  1. package/package.json +2 -2
  2. package/src/view.js +47 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xoong/data",
3
- "version": "0.0.42",
3
+ "version": "0.0.43",
4
4
  "description": "xoong data layer: OpenAPI-driven client, auto cache, entity store, derived views, live updates. Depends only on @xoong/core.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -15,7 +15,7 @@
15
15
  "sideEffects": false,
16
16
  "license": "MIT",
17
17
  "dependencies": {
18
- "@xoong/core": "0.0.42"
18
+ "@xoong/core": "0.0.43"
19
19
  },
20
20
  "files": [
21
21
  "src"
package/src/view.js CHANGED
@@ -101,6 +101,7 @@ class XoView extends XoElement {
101
101
  /** @type {Node[] | null} */ #tpl = null;
102
102
  /** @type {(() => void)[]} */ #bound = [];
103
103
  #params = signal(/** @type {Record<string, any>} */ ({}));
104
+ #warned = new Set();
104
105
  #release = null;
105
106
  /** Fetch the selected query again (xo-refresh). */ refresh = () => {};
106
107
  /** @type {AbortController | null} */ #streamCtl = null;
@@ -254,6 +255,12 @@ class XoView extends XoElement {
254
255
  q.value = null;
255
256
  this.clearSelection(); // other rows are on screen now
256
257
  if (!ref) return;
258
+ try { // a path parameter without a value: say so once (the symptom is a request to /users/{id} and an empty screen)
259
+ for (const m of client.op(ref.startsWith('/') ? `GET ${ref}` : ref).path.matchAll(/\{([^}]+)\}/g)) {
260
+ const v = params[m[1]];
261
+ if ((v == null || v === '') && !this.#warned.has(m[1])) { this.#warned.add(m[1]); console.warn(`xoong: <xo-view get="${ref}"> has no value for {${m[1]}}: add p-${m[1]}="..." (or p-${m[1]}=":${m[1]}" from a route)`); }
262
+ }
263
+ } catch { /* an unknown operation is reported by the query below */ }
257
264
  try {
258
265
  current = client.query(ref.startsWith('/') ? `GET ${ref}` : ref, params); // "/users" or an operationId
259
266
  } catch (e) {
@@ -391,6 +398,16 @@ function bindNode(node, scope, ctx, disposers) {
391
398
  el.removeAttribute(name);
392
399
  continue;
393
400
  }
401
+ if (BOOLEAN_ATTRS.has(name) && parts.length === 1 && typeof parts[0] !== 'string') { // hidden="{closed}": present when true, absent when false
402
+ const path = parts[0].path;
403
+ disposers.push(effect(() => {
404
+ const v = lookup(scope, path);
405
+ const on = Array.isArray(v) ? v.length > 0 : !!v && v !== 'false';
406
+ el.toggleAttribute(name, on);
407
+ if ((name === 'checked' || name === 'selected') && name in el) /** @type {any} */ (el)[name] = on; // the attribute only sets the default state
408
+ }));
409
+ continue;
410
+ }
394
411
  disposers.push(effect(() => {
395
412
  let v = evalParts(parts, scope, lookup);
396
413
  if (URL_ATTRS.has(name) && !isSafeUrl(v)) v = 'about:blank';
@@ -398,9 +415,13 @@ function bindNode(node, scope, ctx, disposers) {
398
415
  }));
399
416
  }
400
417
  }
418
+ if (el.localName === 'xo-view') return; // a nested view binds its own children against its own data when it starts; the outer pass must not touch them (its `p-*` attributes above are bound to the outer scope)
401
419
  if (!translated) for (const child of [...el.childNodes]) bindNode(child, childScope, ctx, disposers); // a translated element's text is the message, not markup
402
420
  }
403
421
 
422
+ /** Boolean attributes: `{flag}` as the whole value turns them on and off (an attribute with the value "false" would still count as set). */
423
+ const BOOLEAN_ATTRS = new Set(['hidden', 'disabled', 'checked', 'selected', 'readonly', 'required', 'open', 'multiple', 'inert', 'autofocus', 'controls', 'loop', 'muted', 'reversed']);
424
+
404
425
  /** Attributes whose value is a URL: data must not smuggle in script schemes. */
405
426
  const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'poster', 'xlink:href', 'data', 'cite', 'srcset']);
406
427
  const isScriptAttr = (name) => name.startsWith('on') || name === 'srcdoc';
@@ -445,24 +466,29 @@ function bindIf(el, scope, ctx, disposers) {
445
466
 
446
467
  function bindEach(el, scope, ctx, disposers) {
447
468
  const path = el.getAttribute('xo-each').trim();
469
+ // `<template xo-each="blocks">...</template>` repeats its children with no element of its own (a list of mixed blocks: h2, p, ul, figure)
470
+ const fragmentRows = el.localName === 'template';
448
471
  const tpl = /** @type {Element} */ (el.cloneNode(true));
449
472
  tpl.removeAttribute('xo-each');
450
473
  const start = document.createComment('xo-each');
451
474
  const end = document.createComment('/xo-each');
452
475
  el.replaceWith(start, end);
453
- /** @type {Map<string, { node: Element, stops: (() => void)[], index: ReturnType<typeof signal>, current: ReturnType<typeof signal> }>} */
476
+ /** A row is the nodes from `first` to `last` (one element, or the children of a template between two comments). */
477
+ /** @typedef {{ first: Node, last: Node, stops: (() => void)[], index: ReturnType<typeof signal>, current: ReturnType<typeof signal>, frag?: DocumentFragment }} Row */
478
+ /** @type {Map<string, Row>} */
454
479
  let rows = new Map();
480
+ const nodesOf = (/** @type {Row} */ row) => { const out = [row.first]; for (let n = row.first; n !== row.last && n.nextSibling; ) { n = /** @type {Node} */ (n.nextSibling); out.push(n); } return out; };
481
+ const dropRow = (/** @type {Row} */ row) => { row.stops.forEach((s) => s()); for (const n of nodesOf(row)) n.remove(); };
455
482
  disposers.push(effect(() => {
456
483
  const list = ctx.lookup(scope, path);
457
484
  const items = (Array.isArray(list) ? list : []).filter((it) => !isRef(it) || ctx.client.store.has(it)); // drop deleted entities
458
485
  // Do not subscribe this effect to row internals: bind rows untracked by their own effects.
459
486
  const next = new Map();
460
- let cursor = start;
487
+ /** @type {Node} */ let cursor = start;
461
488
  items.forEach((item, i) => {
462
489
  const key = isRef(item) ? item.$ref : `#${i}`;
463
490
  let row = rows.get(key) ?? next.get(key);
464
491
  if (!row) {
465
- const node = /** @type {Element} */ (tpl.cloneNode(true));
466
492
  const index = signal(i);
467
493
  const current = signal(item);
468
494
  // `$index` is the nearest row; `$i0`, `$i1` ... are the rows of the enclosing `xo-each`s, outermost first (names of nested array fields)
@@ -470,17 +496,29 @@ function bindEach(el, scope, ctx, disposers) {
470
496
  for (let s = scope; s; s = s.parent) if (s.vars?.$index) depth++;
471
497
  const itemScope = { value: () => current.value, parent: scope, vars: { $index: () => index.value, $n: () => index.value + 1, [`$i${depth}`]: () => index.value } };
472
498
  const stops = [];
473
- batch(() => bindNode(node, itemScope, ctx, stops));
474
- row = { node, stops, index, current };
499
+ let first, last;
500
+ if (fragmentRows) {
501
+ const frag = document.createDocumentFragment();
502
+ first = document.createComment('xo-row');
503
+ last = document.createComment('/xo-row');
504
+ frag.append(first, .../** @type {HTMLTemplateElement} */ (tpl).content.cloneNode(true).childNodes, last);
505
+ batch(() => { for (const n of [...frag.childNodes]) if (n !== first && n !== last) bindNode(n, itemScope, ctx, stops); });
506
+ row = { first, last, stops, index, current, frag };
507
+ } else {
508
+ const node = /** @type {Element} */ (tpl.cloneNode(true));
509
+ batch(() => bindNode(node, itemScope, ctx, stops));
510
+ row = { first: node, last: node, stops, index, current };
511
+ }
475
512
  } else batch(() => { row.index.value = i; row.current.value = item; }); // reused row follows its new item
476
513
  next.set(key, row);
477
- if (cursor.nextSibling !== row.node) cursor.after(row.node);
478
- cursor = row.node;
514
+ if (cursor.nextSibling !== row.first) cursor.after(...(row.frag ? [row.frag] : nodesOf(row)));
515
+ row.frag = undefined;
516
+ cursor = row.last;
479
517
  });
480
- for (const [key, row] of rows) if (!next.has(key)) { row.stops.forEach((s) => s()); row.node.remove(); }
518
+ for (const [key, row] of rows) if (!next.has(key)) dropRow(row);
481
519
  rows = next;
482
520
  }));
483
- disposers.push(() => { for (const row of rows.values()) { row.stops.forEach((s) => s()); row.node.remove(); } rows.clear(); });
521
+ disposers.push(() => { for (const row of rows.values()) dropRow(row); rows.clear(); });
484
522
  }
485
523
 
486
524
  /** `@xoong/i18n` registers how `xo-t` elements are translated (the view supplies the scope for `{placeholders}`). */