@jinntec/fore
Version:
Fore - declarative user interfaces in plain HTML
1,165 lines (949 loc) • 34.4 kB
JavaScript
import './fx-repeatitem.js';
import { Fore } from '../fore.js';
import ForeElementMixin from '../ForeElementMixin.js';
import { evaluateXPath } from '../xpath-evaluation.js';
import getInScopeContext from '../getInScopeContext.js';
import { XPathUtil } from '../xpath-util.js';
import { withDraggability } from '../withDraggability.js';
import { UIElement } from './UIElement.js';
import { getPath } from '../xpath-path.js';
import { FxModel } from '../fx-model.js';
import { FxBind } from '../fx-bind.js';
/**
* `fx-repeat`
*
* Repeats its template for each node in its' bound nodeset.
*
* Template is a standard HTML `<template>` element. Once instanciated the template
* is moved to the shadowDOM of the repeat for safe re-use.
*
* @customElement
* @extends {ForeElementMixin}
*/
export class FxRepeat extends withDraggability(UIElement, false) {
static get properties() {
return {
...super.properties,
index: {
type: Number,
},
template: {
type: Object,
},
focusOnCreate: {
type: String,
},
initDone: {
type: Boolean,
},
repeatIndex: {
type: Number,
},
nodeset: {
type: Array,
},
};
}
constructor() {
super();
this.ref = '';
this.dataTemplate = [];
this.isDraggable = null;
this.dropTarget = null;
this.focusOnCreate = '';
this.initDone = false;
this.repeatIndex = 1;
this.nodeset = [];
this.inited = false;
this.index = 1;
this.repeatSize = 0;
this.attachShadow({ mode: 'open', delegatesFocus: true });
this.opNum = 0; // global number of operations
this.handleInsertHandler = null;
this.handleDeleteHandler = null;
// Tracks ModelItems we observe due to JSON lens lookups inside predicate expressions
// (e.g. instance('data')?ui?query). Needed so the repeat refreshes when the query changes.
this._jsonPredicateDeps = new Set();
this._jsonPredicateDepsObserved = false;
// Flag used to suppress "programmatic index changed" notifications when setIndex()
// is called as a direct reaction to a repeatitem's item-changed event.
this._settingIndexFromItemChanged = false;
}
// ------------------------------------------------------------
// JSON ref helpers (for routing insert/delete events correctly)
// ------------------------------------------------------------
_stripJsonRefToContainer(ref) {
let s = String(ref || '').trim();
if (!s) return '';
// If this is a repeat nodeset ref like: instance('data')?movies?*[...]
// strip everything from "?*" onward => instance('data')?movies
const starPos = s.indexOf('?*');
if (starPos >= 0) s = s.slice(0, starPos).trim();
// Also strip trailing predicates if someone wrote ...?movies[...]
// (not common for lens refs, but keep it safe)
s = s.replace(/\[[\s\S]*\]\s*$/g, '').trim();
return s;
}
_inferArrayKeyFromRef() {
const r = String(this.ref || this.getAttribute('ref') || '').trim();
// instance('data')?movies?*...
let m = r.match(/\?([^?\[\]]+)\?\*\s*/);
if (m) return m[1];
// instance('data')?movies (no ?*)
m = r.match(/\?([^?\[\]]+)\s*$/);
if (m) return m[1];
return null;
}
_sameJsonContainer(detailRef) {
const myContainer = this._stripJsonRefToContainer(this.ref);
const evContainer = this._stripJsonRefToContainer(detailRef);
if (!myContainer || !evContainer) return false;
return myContainer === evContainer;
}
_matchesJsonParent(detail) {
// Fallback routing based on insertedParent / insertedNodes.parent
const parent =
detail?.insertedParent ||
detail?.insertedNodes?.parent ||
detail?.insertedNodes?.insertedParent ||
null;
if (!parent || !parent.__jsonlens__) return false;
const myKey = this._inferArrayKeyFromRef();
if (myKey && String(parent.keyOrIndex) !== String(myKey)) return false;
// If instance is known on both sides, ensure it matches
const myInstanceId = XPathUtil.resolveInstance(this, this.ref);
if (myInstanceId && parent.instanceId && String(myInstanceId) !== String(parent.instanceId)) {
return false;
}
return true;
}
connectedCallback() {
super.connectedCallback();
this.template = this.querySelector('template');
this.ref = this.getAttribute('ref');
this.dependencies.addXPath(this.ref);
this.addEventListener('item-changed', e => {
// IMPORTANT: when *we* emit item-changed from the repeat (programmatic setIndex),
// we must not react to it (would recurse).
if (e && e.target === this) return;
if (e?.detail?.source === 'repeat') return;
const { item } = e.detail;
this._settingIndexFromItemChanged = true;
try {
this.setIndex(item.index);
} finally {
this._settingIndexFromItemChanged = false;
}
});
// ----------------
// INSERT handler
// ----------------
this.handleInsertHandler = event => {
const { detail } = event;
const myForeId = this.getOwnerForm().id;
if (myForeId !== detail.foreId) return;
const fore = this.getOwnerForm();
// Detect JSON insert (robust)
const insertedParent = detail?.insertedParent;
const insertedNode = detail?.insertedNodes;
const isJson =
!!detail?.isJson ||
!!insertedParent?.__jsonlens__ ||
!!insertedNode?.__jsonlens__ ||
!!insertedNode?.parent?.__jsonlens__;
if (isJson) {
// IMPORTANT FIX:
// The old code compared detail.ref strictly to a computed container ref.
// For repeats like instance('data')?movies?*[predicate] the container is "instance('data')?movies"
// while detail.ref is usually exactly that container. But if we keep the predicate in this.ref,
// a strict string compare will FAIL and the insert never updates the DOM -> stays at 12.
//
// We accept the event if either:
// 1) detail.ref matches our container (predicate stripped), OR
// 2) insertedParent / insertedNode.parent matches our array key + instance.
const okByRef = this._sameJsonContainer(detail?.ref);
const okByParent = this._matchesJsonParent(detail);
if (!okByRef && !okByParent) return;
this._handleJsonInserted(detail);
return;
}
// ----------------
// XML insert: keep existing behavior
// ----------------
const oldNodesetLength = this.nodeset.length;
this._evalNodeset();
const newNodesetLength = this.nodeset.length;
if (oldNodesetLength === newNodesetLength) return;
const inserted = detail.insertedNodes;
const insertionIndex = this.nodeset.indexOf(inserted) + 1; // 1-based
const repeatItems = Array.from(
this.querySelectorAll(
':scope > fx-repeat-item, :scope > fx-repeatitem, :scope > .repeat-item',
),
);
const newRepeatItem = this._createNewRepeatItem();
const beforeNode = repeatItems[insertionIndex - 1] ?? null;
this.insertBefore(newRepeatItem, beforeNode);
newRepeatItem.index = insertionIndex;
this._initVariables(newRepeatItem);
newRepeatItem.nodeset = inserted;
for (let i = insertionIndex - 1; i < repeatItems.length; ++i) {
repeatItems[i].index += 1;
}
this.setIndex(insertionIndex);
this.opNum++;
let parentModelItem = FxBind.createModelItem(this.ref, inserted, newRepeatItem, this.opNum);
// IMPORTANT: registerModelItem may return an existing canonical ModelItem for the same path.
// Always keep using the returned instance to avoid "ghost" ModelItems that still notify.
parentModelItem = this.getModel().registerModelItem(parentModelItem);
newRepeatItem.modelItem = parentModelItem;
this._createModelItemsRecursively(newRepeatItem, parentModelItem);
fore.scanForNewTemplateExpressionsNextRefresh();
fore.addToBatchedNotifications(newRepeatItem);
};
// ----------------
// DELETE handler
// ----------------
this.handleDeleteHandler = event => {
const { detail } = event;
if (!detail || !detail.deletedNodes || detail.deletedNodes.length === 0) return;
const fore = this.getOwnerForm();
const myForeId = fore?.id;
if (detail.foreId && myForeId !== detail.foreId) return;
const deletedNodes = Array.from(detail.deletedNodes || []);
const first = deletedNodes[0];
const isJson =
!!detail.isJson ||
!!first?.__jsonlens__ ||
!!first?.parent?.__jsonlens__ ||
deletedNodes.some(n => n?.__jsonlens__ || n?.parent?.__jsonlens__);
if (isJson) {
// Route by parent array container, NOT by detail.ref string.
const parent =
(detail.parent && Array.isArray(detail.parent.value) && detail.parent) ||
(first?.parent && Array.isArray(first.parent.value) ? first.parent : null);
if (!parent) return;
const myKey = this._inferArrayKeyFromRef();
if (myKey && String(parent.keyOrIndex) !== String(myKey)) return;
if (
detail.instanceId &&
parent.instanceId &&
String(detail.instanceId) !== String(parent.instanceId)
) {
return;
}
this._handleJsonDeleted(detail);
return;
}
// XML delete: keep existing behavior
detail.deletedNodes.forEach(node => {
this.handleDelete(node);
});
fore?.addToBatchedNotifications?.(this);
};
document.addEventListener('insert', this.handleInsertHandler, true);
document.addEventListener('deleted', this.handleDeleteHandler, true);
// ----------------
// Mutation observer (XML only)
// ----------------
let bufferedMutationRecords = [];
let debouncedOnMutations = null;
this.mutationObserver = new MutationObserver(mutations => {
bufferedMutationRecords.push(...mutations);
if (!debouncedOnMutations) {
debouncedOnMutations = new Promise(() => {
debouncedOnMutations = null;
const records = bufferedMutationRecords;
bufferedMutationRecords = [];
for (const mutation of records) {
if (mutation.type === 'childList') {
const added = mutation.addedNodes[0];
if (added) {
const instance = XPathUtil.resolveInstance(this, this.ref);
const path = getPath(added, instance);
Fore.dispatch(this, 'path-mutated', { path, index: this.index });
}
}
}
});
}
});
this.getOwnerForm().registerLazyElement(this);
const style = `
:host{ }
.fade-out-bottom {
-webkit-animation: fade-out-bottom 0.7s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
animation: fade-out-bottom 0.7s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
}
`;
const html = `
<slot name="header"></slot>
<slot></slot>
`;
this.shadowRoot.innerHTML = `
<style>${style}</style>
${html}
`;
}
/**
* JSON insert (incremental):
* - re-evaluate nodeset once to get authoritative post-insert ordering
* - insert one new repeatitem at the correct position (using detail.index)
* - rebind only shifted repeatitems (pos..end) and refresh them
*/
_handleJsonInserted(detail) {
const fore = this.getOwnerForm();
const repeatItems = () =>
Array.from(
this.querySelectorAll(
':scope > fx-repeat-item, :scope > fx-repeatitem, :scope > .repeat-item',
),
);
// 1) Determine insertion index (1-based) from the event.
// Do NOT use indexOf(insertedNodes) for JSON.
let insertionIndex1 = Number(detail.index);
if (!Number.isFinite(insertionIndex1) || insertionIndex1 < 1) {
const ki = detail.insertedNodes?.keyOrIndex;
if (typeof ki === 'number') insertionIndex1 = ki + 1;
}
if (!Number.isFinite(insertionIndex1) || insertionIndex1 < 1) insertionIndex1 = 1;
// 2) Re-evaluate nodeset AFTER mutation to get correct order.
const oldLen = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
this._evalNodeset();
const newLen = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
if (newLen === oldLen) return;
// Clamp
insertionIndex1 = Math.max(1, Math.min(insertionIndex1, newLen));
const pos0 = insertionIndex1 - 1;
// 3) Insert DOM row: set nodeset/index BEFORE inserting into DOM.
const before = repeatItems();
const beforeNode = before[pos0] ?? null;
const newRepeatItem = this._createNewRepeatItem();
newRepeatItem.index = pos0 + 1;
this._initVariables(newRepeatItem);
newRepeatItem.nodeset = this.nodeset[pos0];
this.insertBefore(newRepeatItem, beforeNode);
// Ensure it gets initialized/rendered
fore.registerLazyElement(newRepeatItem);
if (fore.createNodes) {
fore.initData(newRepeatItem);
}
fore.scanForNewTemplateExpressionsNextRefresh();
fore.addToBatchedNotifications(newRepeatItem);
// 4) Rebind shifted rows (pos0+1..end) and refresh only those.
const after = repeatItems();
for (let i = pos0 + 1; i < after.length; i++) {
const ri = after[i];
ri.index = i + 1;
const newNode = this.nodeset[i];
if (ri.nodeset !== newNode) {
ri.nodeset = newNode;
if (fore.createNodes) fore.initData(ri);
}
fore.addToBatchedNotifications(ri);
}
// Select inserted row
this.setIndex(pos0 + 1);
}
disconnectedCallback() {
// Ensure UIElement cleanup runs (removes observer for primary binding, etc.)
if (super.disconnectedCallback) super.disconnectedCallback();
// Remove observers that were added for predicate dependencies
if (this._jsonPredicateDeps && this._jsonPredicateDeps.size) {
for (const mi of this._jsonPredicateDeps) {
if (mi && typeof mi.removeObserver === 'function') {
mi.removeObserver(this);
}
}
this._jsonPredicateDeps.clear();
}
this._jsonPredicateDepsObserved = false;
document.removeEventListener('deleted', this.handleDeleteHandler, true);
document.removeEventListener('insert', this.handleInsertHandler, true);
}
get repeatSize() {
return this.querySelectorAll(':scope > fx-repeatitem').length;
}
set repeatSize(size) {
this.size = size;
}
setIndex(index) {
this.index = index;
const rItems = this.querySelectorAll(':scope > fx-repeatitem');
const selected = rItems[this.index - 1];
this.applyIndex(selected);
// If setIndex is called programmatically (insert/delete), we must notify dependents
// (fx-group/fx-control/fx-output with index('repeatId') in ref).
//
// When setIndex is invoked as a reaction to a repeatitem click/focus,
// the repeatitem already dispatched item-changed and dependents already react.
if (!this._settingIndexFromItemChanged) {
this.dispatchEvent(
new CustomEvent('item-changed', {
composed: false,
bubbles: false,
detail: { item: selected || null, index: this.index, source: 'repeat' },
}),
);
}
}
applyIndex(repeatItem) {
this._removeIndexMarker();
if (repeatItem) {
repeatItem.setAttribute('repeat-index', '');
}
}
get index() {
return parseInt(this.getAttribute('index'), 10);
}
set index(idx) {
this.setAttribute('index', idx);
}
_getRef() {
return this.getAttribute('ref');
}
_createModelItemsRecursively(parentNode, parentModelItem) {
const parentWithDewey = parentModelItem?.path || null;
const __applyDeweyRewrite = mi => {
if (!mi || typeof mi.path !== 'string' || !parentModelItem?.path) return;
const pWith = parentModelItem.path;
const opMatch = pWith.match(/_(\d+)$/);
if (!opMatch) return;
const op = opMatch[1];
const toDollar = s => s.replace(/^instance\('([^']+)'\)\//, (_m, g1) => `$${g1}/`);
const parentBaseNorm = toDollar(pWith).replace(/_\d+$/, '');
const childNorm = toDollar(mi.path);
if (!childNorm.startsWith(parentBaseNorm)) return;
const childUsesInstanceFn = /^instance\('/.test(mi.path);
const parentBaseInChildStyle = childUsesInstanceFn
? parentBaseNorm.replace(/^\$([A-Za-z0-9_-]+)\//, `instance('$1')/`)
: parentBaseNorm;
if (mi.path.startsWith(`${parentBaseInChildStyle}_`)) return;
mi.path = `${parentBaseInChildStyle}_${op}${mi.path.slice(parentBaseInChildStyle.length)}`;
};
Array.from(parentNode.children).forEach(child => {
const nextParentMI = parentModelItem;
const isWidgetEl =
child &&
((child.classList && child.classList.contains('widget')) ||
(typeof Fore !== 'undefined' && Fore.isWidget && Fore.isWidget(child)) ||
(child.tagName &&
['INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'DATALIST'].includes(child.tagName)));
if (!isWidgetEl && child.hasAttribute('ref')) {
const ref = child.getAttribute('ref').trim();
if (ref && ref !== '.') {
let node = evaluateXPath(ref, parentModelItem.node, this);
if (Array.isArray(node)) node = node[0];
if (node) {
let modelItem = this.getModel().getModelItem(node);
if (!modelItem) {
modelItem = FxBind.createModelItem(ref, node, child, null);
modelItem.parentModelItem = parentModelItem;
// IMPORTANT: keep using the canonical instance returned by registerModelItem.
// Otherwise a throwaway ModelItem can leak into observer graphs and be notified.
modelItem = this.getModel().registerModelItem(modelItem);
}
__applyDeweyRewrite(modelItem);
child.nodeset = node;
if (child.attachObserver) child.attachObserver();
}
}
}
if (!isWidgetEl) this._createModelItemsRecursively(child, nextParentMI);
});
}
_handleJsonDeleted(detail) {
const fore = this.getOwnerForm();
const repeatItems = () =>
Array.from(
this.querySelectorAll(
':scope > fx-repeat-item, :scope > fx-repeatitem, :scope > .repeat-item',
),
);
let indices0 = Array.isArray(detail.deletedIndexes0)
? detail.deletedIndexes0.slice()
: Array.from(detail.deletedNodes || [])
.map(n => (n && typeof n.keyOrIndex === 'number' ? n.keyOrIndex : -1))
.filter(i => i >= 0);
indices0 = Array.from(new Set(indices0)).sort((a, b) => b - a);
if (indices0.length === 0) return;
let currentIndex1 = Number(this.getAttribute('index') || this.index || 1);
if (!Number.isFinite(currentIndex1) || currentIndex1 < 1) currentIndex1 = 1;
const deletedIdx1Asc = indices0.map(i0 => i0 + 1).sort((a, b) => a - b);
let nextIndex1 = currentIndex1;
for (const d1 of deletedIdx1Asc) {
if (nextIndex1 > d1) nextIndex1 -= 1;
}
const before = repeatItems();
for (const idx0 of indices0) {
const itemEl = before[idx0];
if (!itemEl) continue;
try {
fore?.unRegisterLazyElement?.(itemEl);
} catch (_e) {}
itemEl.remove();
}
this._evalNodeset();
const start0 = Math.min(...indices0);
const after = repeatItems();
for (let i = start0; i < after.length; i++) {
const ri = after[i];
ri.index = i + 1;
const newNode = Array.isArray(this.nodeset) ? this.nodeset[i] : null;
if (ri.nodeset !== newNode) {
ri.nodeset = newNode;
if (fore?.createNodes) fore.initData(ri);
}
fore?.addToBatchedNotifications?.(ri);
}
const newLen = after.length;
if (newLen === 0) {
this.setAttribute('index', '0');
this.index = 0;
this._removeIndexMarker?.();
return;
}
nextIndex1 = Math.max(1, Math.min(nextIndex1, newLen));
this.setIndex(nextIndex1);
}
handleDelete(deleted) {
const items = Array.from(
this.querySelectorAll(
':scope > fx-repeat-item, :scope > fx-repeatitem, :scope > .repeat-item',
),
);
this._evalNodeset();
const indexToRemove = items.findIndex(item => item.nodeset === deleted);
if (indexToRemove === -1) {
return;
}
const itemToRemove = items[indexToRemove];
itemToRemove.remove();
const newLength = this.querySelectorAll(
':scope > fx-repeat-item, :scope > fx-repeatitem, :scope > .repeat-item',
).length;
let nextIndex = indexToRemove + 1;
if (newLength === 0) {
nextIndex = 0;
} else if (nextIndex > newLength) {
nextIndex = newLength;
}
this.setIndex(nextIndex);
}
_createNewRepeatItem() {
const newItem = document.createElement('fx-repeatitem');
if (this.isDraggable) {
newItem.setAttribute('draggable', 'true');
newItem.setAttribute('tabindex', 0);
}
const clone = this._clone();
newItem.appendChild(clone);
return newItem;
}
init() {
this._evalNodeset();
this._initTemplate();
this._initRepeatItems();
this.setAttribute('index', this.index);
this.inited = true;
}
_observeJsonPredicateDependencies(contextNode) {
if (this._jsonPredicateDepsObserved) return;
const ref = String(this.ref || this.getAttribute('ref') || '').trim();
if (!ref || !ref.includes('[') || !ref.includes('?')) return;
const model = this.getModel && this.getModel();
if (!model) return;
// Collect predicate bodies [...]
const predicates = [];
const predRe = /\[([\s\S]+?)\]/g;
let pm;
while ((pm = predRe.exec(ref)) !== null) {
if (pm[1]) predicates.push(pm[1]);
}
if (predicates.length === 0) return;
const isBoundary = ch =>
ch === undefined ||
ch === null ||
/\s/.test(ch) ||
ch === ',' ||
ch === ')' ||
ch === ']' ||
ch === '+' ||
ch === '-' ||
ch === '*' ||
ch === '=' ||
ch === '>' ||
ch === '<' ||
ch === '!' ||
ch === '|' ||
ch === '&';
const lookups = new Set();
const readInstanceLensAt = (src, start) => {
if (!src.slice(start).match(/^instance\s*\(/)) return null;
let j = start;
let inS = false;
let inD = false;
let depth = 0;
while (j < src.length) {
const ch = src[j];
if (ch === "'" && !inD) {
inS = !inS;
j += 1;
continue;
}
if (ch === '"' && !inS) {
inD = !inD;
j += 1;
continue;
}
if (inS || inD) {
j += 1;
continue;
}
if (ch === '(') depth += 1;
else if (ch === ')') {
depth -= 1;
if (depth === 0) break;
}
j += 1;
}
if (j >= src.length) return null;
let k = j + 1;
while (k < src.length && /\s/.test(src[k])) k += 1;
if (src[k] !== '?') return null;
k += 1;
let bracketDepth = 0;
inS = false;
inD = false;
while (k < src.length) {
const ch = src[k];
if (ch === "'" && !inD) {
inS = !inS;
k += 1;
continue;
}
if (ch === '"' && !inS) {
inD = !inD;
k += 1;
continue;
}
if (inS || inD) {
k += 1;
continue;
}
if (ch === '[') bracketDepth += 1;
else if (ch === ']') {
if (bracketDepth > 0) bracketDepth -= 1;
else break;
}
if (bracketDepth === 0 && isBoundary(ch)) break;
k += 1;
}
return { raw: src.slice(start, k), end: k };
};
const readVarLensAt = (src, start) => {
if (src[start] !== '$') return null;
let j = start + 1;
if (!/[A-Za-z_]/.test(src[j] || '')) return null;
j += 1;
while (j < src.length && /[\w.-]/.test(src[j])) j += 1;
const varName = src.slice(start + 1, j);
let k = j;
while (k < src.length && /\s/.test(src[k])) k += 1;
// We only care if a lookup tail follows: ?foo?bar OR /foo/bar
const next = src[k];
if (next !== '?' && next !== '.' && next !== '/') {
return { raw: src.slice(start, j), end: j, varName, tail: '' };
}
// normalize .?foo?bar => ?foo?bar
if (next === '.' && src[k + 1] === '?') k += 1;
// read until boundary
let p = k;
let bracketDepth = 0;
let inS = false;
let inD = false;
while (p < src.length) {
const ch = src[p];
if (ch === "'" && !inD) {
inS = !inS;
p += 1;
continue;
}
if (ch === '"' && !inS) {
inD = !inD;
p += 1;
continue;
}
if (inS || inD) {
p += 1;
continue;
}
if (ch === '[') bracketDepth += 1;
else if (ch === ']') {
if (bracketDepth > 0) bracketDepth -= 1;
else break;
}
if (bracketDepth === 0 && p !== k && isBoundary(ch)) break;
p += 1;
}
return {
raw: src.slice(start, p),
end: p,
varName,
tail: src.slice(k, p),
};
};
// Collect lookups used inside predicates
for (const predicate of predicates) {
const src = String(predicate ?? '');
let inSingle = false;
let inDouble = false;
for (let i = 0; i < src.length; i += 1) {
const ch = src[i];
if (ch === "'" && !inDouble) {
inSingle = !inSingle;
continue;
}
if (ch === '"' && !inSingle) {
inDouble = !inDouble;
continue;
}
if (inSingle || inDouble) continue;
// instance('x')?foo?bar
const instLens = readInstanceLensAt(src, i);
if (instLens && instLens.raw && instLens.raw.includes('?')) {
lookups.add(instLens.raw.trim());
i = instLens.end - 1;
continue;
}
// $default?ui?query or $foo?bar (rewrite to instance() / instance('foo'))
const varLens = readVarLensAt(src, i);
if (
varLens &&
varLens.tail &&
(varLens.tail.startsWith('?') || varLens.tail.startsWith('/'))
) {
const v = varLens.varName;
const { tail } = varLens;
// $default must follow your semantics: first instance in doc order => instance()
const rewritten =
v === 'default'
? `instance()${tail.startsWith('?') ? tail : tail}`
: `instance('${v}')${tail}`;
lookups.add(rewritten.trim());
i = varLens.end - 1;
}
}
}
if (lookups.size === 0) return;
// Resolve lookups to actual nodes and observe them
for (const lookup of lookups) {
try {
const resolved = evaluateXPath(lookup, contextNode, this);
let node = null;
if (Array.isArray(resolved)) {
const first = resolved[0];
node = Array.isArray(first) ? first[0] : first;
} else {
node = resolved;
}
if (!node) continue;
const mi = FxModel.lazyCreateModelItem(model, lookup, node, this);
if (mi && typeof mi.addObserver === 'function') {
if (!this._jsonPredicateDeps.has(mi)) {
mi.addObserver(this);
this._jsonPredicateDeps.add(mi);
}
}
} catch (_e) {
// ignore
}
}
this._jsonPredicateDepsObserved = true;
}
_evalNodeset() {
const inscope = getInScopeContext(this.getAttributeNode('ref') || this, this.ref);
if (!inscope) return;
if (this.mutationObserver && inscope.nodeName) {
this.mutationObserver.observe(inscope, {
childList: true,
subtree: true,
});
}
const rawNodeset = evaluateXPath(this.ref, inscope, this);
this._observeJsonPredicateDependencies(inscope);
if (rawNodeset.length === 1 && Array.isArray(rawNodeset[0])) {
this.nodeset = rawNodeset[0];
return;
}
this.nodeset = rawNodeset;
}
/**
* Observer callback for ModelItem notifications.
* When a predicate dependency changes (eg. $default?ui?query),
* schedule this repeat for refresh so its nodeset/predicate is re-evaluated.
*/
update(_modelItem) {
const fore = this.getOwnerForm && this.getOwnerForm();
if (fore && typeof fore.addToBatchedNotifications === 'function') {
fore.addToBatchedNotifications(this);
return;
}
// Fallback (should rarely happen)
this.refresh(true);
}
async refresh(force) {
if (!this.inited) this.init();
this._evalNodeset();
let repeatItems = this.querySelectorAll(':scope > fx-repeatitem');
let repeatItemCount = repeatItems.length;
let nodeCount = 1;
if (Array.isArray(this.nodeset)) {
nodeCount = this.nodeset.length;
}
const contextSize = nodeCount;
if (contextSize < repeatItemCount) {
for (let position = repeatItemCount; position > contextSize; position -= 1) {
const itemToRemove = repeatItems[position - 1];
itemToRemove.parentNode.removeChild(itemToRemove);
this.getOwnerForm().unRegisterLazyElement(itemToRemove);
}
}
// DOM changed: re-query repeatitems
repeatItems = this.querySelectorAll(':scope > fx-repeatitem');
repeatItemCount = repeatItems.length;
if (contextSize > repeatItemCount) {
for (let position = repeatItemCount + 1; position <= contextSize; position += 1) {
const newItem = this._createNewRepeatItem();
this.appendChild(newItem);
this._initVariables(newItem);
newItem.nodeset = this.nodeset[position - 1];
newItem.index = position;
if (this.getOwnerForm().createNodes) {
this.getOwnerForm().initData(newItem);
}
this.getOwnerForm().scanForNewTemplateExpressionsNextRefresh();
newItem.refresh(true);
}
}
// DOM changed: re-query repeatitems
repeatItems = this.querySelectorAll(':scope > fx-repeatitem');
repeatItemCount = repeatItems.length;
for (let position = 0; position < repeatItemCount; position += 1) {
const item = repeatItems[position];
this.getOwnerForm().registerLazyElement(item);
if (item.nodeset !== this.nodeset[position]) {
item.nodeset = this.nodeset[position];
if (this.getOwnerForm().createNodes) {
this.getOwnerForm().initData(item);
}
}
}
const fore = this.getOwnerForm();
if (!fore.lazyRefresh || force) {
Fore.refreshChildren(this, force);
}
this.setIndex(this.index);
}
_initTemplate() {
// Template can be missing during early init (slot timing / nested repeats).
// Never dereference it before we have it.
if (!this.template) {
// Prefer a direct child template, then any descendant template.
this.template =
Array.from(this.children).find(c => c && c.localName === 'template') ||
this.querySelector('template') ||
(this.shadowRoot && this.shadowRoot.querySelector('template')) ||
null;
}
if (this.template === null) {
this.dispatchEvent(
new CustomEvent('no-template-error', {
composed: true,
bubbles: true,
detail: { message: `no template found for repeat:${this.id}` },
}),
);
return;
}
this.dropTarget = this.template.getAttribute('drop-target');
this.isDraggable = this.template.hasAttribute('draggable')
? this.template.getAttribute('draggable')
: null;
// Move template to shadow for safe reuse.
// If it's already in the shadowRoot, don't append again.
if (this.template.parentNode !== this.shadowRoot) {
this.shadowRoot.appendChild(this.template);
}
}
_initRepeatItems() {
this.nodeset.forEach((item, index) => {
const repeatItem = this._createNewRepeatItem();
repeatItem.nodeset = this.nodeset[index];
repeatItem.index = index + 1;
this.appendChild(repeatItem);
if (this.getOwnerForm().createNodes) {
this.getOwnerForm().initData(repeatItem);
if (repeatItem.nodeset.nodeType) {
const repeatItemClone = repeatItem.nodeset.cloneNode(true);
this.clearTextValues(repeatItemClone);
this.createdNodeset = repeatItemClone;
}
}
if (repeatItem.index === 1) {
this.applyIndex(repeatItem);
}
Fore.dispatch(this, 'item-created', { nodeset: repeatItem.nodeset, pos: index + 1 });
this._initVariables(repeatItem);
});
}
clearTextValues(node) {
if (!node) return;
if (node.nodeType === Node.TEXT_NODE) {
node.nodeValue = '';
}
if (node.nodeType === Node.ELEMENT_NODE) {
for (const attr of Array.from(node.attributes)) {
attr.value = '';
}
}
for (const child of node.childNodes) {
this.clearTextValues(child);
}
}
_initVariables(newRepeatItem) {
const inScopeVariables = new Map(this.inScopeVariables);
newRepeatItem.setInScopeVariables(inScopeVariables);
(function registerVariables(node) {
for (const child of node.children) {
if ('setInScopeVariables' in child) {
child.setInScopeVariables(inScopeVariables);
}
registerVariables(child);
}
})(newRepeatItem);
}
_clone() {
const tpl =
this.template ||
(this.shadowRoot && this.shadowRoot.querySelector('template')) ||
this.querySelector('template');
if (!tpl) {
console.error(`[fx-repeat] ${this.id || ''}: no <template> found when cloning`);
return document.createDocumentFragment();
}
const content = tpl.content.cloneNode(true);
return document.importNode(content, true);
}
_removeIndexMarker() {
Array.from(this.children).forEach(item => {
item.removeAttribute('repeat-index');
});
}
setInScopeVariables(inScopeVariables) {
this.inScopeVariables = new Map(inScopeVariables);
}
}
if (!customElements.get('fx-repeat')) {
window.customElements.define('fx-repeat', FxRepeat);
}