ilha 0.9.3 → 0.9.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/dist/index.js +154 -28
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -121,6 +121,7 @@ function isStableInlineSlotMount(initialHtml, renderedHtml, slotIds) {
|
|
|
121
121
|
}
|
|
122
122
|
return true;
|
|
123
123
|
}
|
|
124
|
+
/** Serialized form of slot props, matching the decoded `data-ilha-props` attr. */
|
|
124
125
|
function serializeSlotProps(props) {
|
|
125
126
|
return props === void 0 ? "" : JSON.stringify(props);
|
|
126
127
|
}
|
|
@@ -138,7 +139,19 @@ const MORPH_CONTROLLER_ATTRS = /* @__PURE__ */ new Set([
|
|
|
138
139
|
"data-selected",
|
|
139
140
|
"data-panel-open"
|
|
140
141
|
]);
|
|
142
|
+
/**
|
|
143
|
+
* Opt-in contract for controller-owned attributes: an element carrying
|
|
144
|
+
* `data-morph-preserve="attr-a attr-b class"` keeps those attributes exactly
|
|
145
|
+
* as they are in the live DOM — the morph neither overwrites nor removes them.
|
|
146
|
+
* The Areia list above stays as a built-in default for `[data-slot]` elements.
|
|
147
|
+
*/
|
|
148
|
+
const MORPH_PRESERVE_ATTR = "data-morph-preserve";
|
|
141
149
|
function shouldPreserveMorphAttr(el, name) {
|
|
150
|
+
if (name === MORPH_PRESERVE_ATTR) return el.hasAttribute(MORPH_PRESERVE_ATTR);
|
|
151
|
+
const custom = el.getAttribute(MORPH_PRESERVE_ATTR);
|
|
152
|
+
if (custom !== null) {
|
|
153
|
+
for (const token of custom.split(/\s+/)) if (token === name) return true;
|
|
154
|
+
}
|
|
142
155
|
return el.matches(MORPH_CONTROLLER_SLOT_SELECTOR) && MORPH_CONTROLLER_ATTRS.has(name);
|
|
143
156
|
}
|
|
144
157
|
function syncAttributes(from, to) {
|
|
@@ -151,18 +164,30 @@ function syncAttributes(from, to) {
|
|
|
151
164
|
if (!to.hasAttribute(name)) from.removeAttribute(name);
|
|
152
165
|
}
|
|
153
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Morph identity key: explicit `data-key`, or the child-island slot id.
|
|
169
|
+
* Slot hosts are matched by id like keyed list items so a mounted child
|
|
170
|
+
* island's host element is reconciled IN PLACE on parent re-renders — never
|
|
171
|
+
* detached and reinserted, which would blur a focused element inside it.
|
|
172
|
+
*/
|
|
173
|
+
function morphKeyOf(el) {
|
|
174
|
+
const k = el.getAttribute("data-key");
|
|
175
|
+
if (k !== null) return `k:${k}`;
|
|
176
|
+
const s = el.getAttribute(SLOT_ATTR);
|
|
177
|
+
return s === null ? null : `s:${s}`;
|
|
178
|
+
}
|
|
154
179
|
function morphChildren(fromParent, toParent) {
|
|
155
180
|
const toNodes = Array.from(toParent.childNodes);
|
|
156
181
|
let fromKeyed = null;
|
|
157
182
|
for (const child of fromParent.children) {
|
|
158
|
-
const k = child
|
|
183
|
+
const k = morphKeyOf(child);
|
|
159
184
|
if (k !== null && !(fromKeyed ??= /* @__PURE__ */ new Map()).has(k)) fromKeyed.set(k, child);
|
|
160
185
|
}
|
|
161
186
|
let toKeys = null;
|
|
162
187
|
if (fromKeyed !== null) {
|
|
163
188
|
toKeys = /* @__PURE__ */ new Set();
|
|
164
189
|
for (const child of toParent.children) {
|
|
165
|
-
const k = child
|
|
190
|
+
const k = morphKeyOf(child);
|
|
166
191
|
if (k !== null) toKeys.add(k);
|
|
167
192
|
}
|
|
168
193
|
}
|
|
@@ -170,7 +195,7 @@ function morphChildren(fromParent, toParent) {
|
|
|
170
195
|
const toNode = toNodes[i];
|
|
171
196
|
let fromNode = fromParent.childNodes[i];
|
|
172
197
|
if (fromKeyed !== null) {
|
|
173
|
-
const toKey = toNode.nodeType === 1 ? toNode
|
|
198
|
+
const toKey = toNode.nodeType === 1 ? morphKeyOf(toNode) : null;
|
|
174
199
|
if (toKey !== null) {
|
|
175
200
|
const match = fromKeyed.get(toKey);
|
|
176
201
|
if (match) {
|
|
@@ -182,7 +207,7 @@ function morphChildren(fromParent, toParent) {
|
|
|
182
207
|
}
|
|
183
208
|
}
|
|
184
209
|
if (fromNode instanceof Element) {
|
|
185
|
-
const fromKey = fromNode
|
|
210
|
+
const fromKey = morphKeyOf(fromNode);
|
|
186
211
|
if (fromKey !== null && fromKey !== toKey && toKeys.has(fromKey)) {
|
|
187
212
|
fromParent.insertBefore(toNode.cloneNode(true), fromNode);
|
|
188
213
|
continue;
|
|
@@ -208,6 +233,18 @@ function morphChildren(fromParent, toParent) {
|
|
|
208
233
|
fromParent.replaceChild(toEl.cloneNode(true), fromEl);
|
|
209
234
|
continue;
|
|
210
235
|
}
|
|
236
|
+
if (fromEl.hasAttribute(LEAVING_ATTR)) {
|
|
237
|
+
fromParent.replaceChild(toEl.cloneNode(true), fromEl);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
{
|
|
241
|
+
const slotId = toEl.getAttribute(SLOT_ATTR);
|
|
242
|
+
if (slotId !== null && fromEl.getAttribute(SLOT_ATTR) === slotId) {
|
|
243
|
+
const props = toEl.getAttribute(PROPS_ATTR);
|
|
244
|
+
if (props !== null && fromEl.getAttribute(PROPS_ATTR) !== props) fromEl.setAttribute(PROPS_ATTR, props);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
211
248
|
if (fromEl.localName === "input" && fromEl.type !== toEl.type) {
|
|
212
249
|
fromParent.replaceChild(toEl.cloneNode(true), fromEl);
|
|
213
250
|
continue;
|
|
@@ -222,6 +259,22 @@ function morphChildren(fromParent, toParent) {
|
|
|
222
259
|
if (newValue !== hadValue) fromEl.value = newValue ?? "";
|
|
223
260
|
continue;
|
|
224
261
|
}
|
|
262
|
+
if (fromEl.localName === "select") {
|
|
263
|
+
const before = /* @__PURE__ */ new Map();
|
|
264
|
+
for (const o of fromEl.options) before.set(o, {
|
|
265
|
+
attr: o.hasAttribute("selected"),
|
|
266
|
+
live: o.selected
|
|
267
|
+
});
|
|
268
|
+
syncAttributes(fromEl, toEl);
|
|
269
|
+
morphChildren(fromEl, toEl);
|
|
270
|
+
const options = Array.from(fromEl.options);
|
|
271
|
+
if (options.some((o) => o.hasAttribute("selected") !== (before.get(o)?.attr ?? false))) for (const o of options) o.selected = o.hasAttribute("selected");
|
|
272
|
+
else for (const o of options) {
|
|
273
|
+
const prev = before.get(o);
|
|
274
|
+
if (prev && o.selected !== prev.live) o.selected = prev.live;
|
|
275
|
+
}
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
225
278
|
syncAttributes(fromEl, toEl);
|
|
226
279
|
if (fromEl.localName === "textarea") {
|
|
227
280
|
const newText = toEl.textContent ?? "";
|
|
@@ -234,9 +287,62 @@ function morphChildren(fromParent, toParent) {
|
|
|
234
287
|
}
|
|
235
288
|
while (fromParent.childNodes.length > toNodes.length) fromParent.lastChild.remove();
|
|
236
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Invariant: the morph must never lose focus for an element that survives it.
|
|
292
|
+
* Surviving slot hosts and keyed elements are patched in place, but a genuine
|
|
293
|
+
* reorder still detaches-and-reinserts an ancestor of `document.activeElement`,
|
|
294
|
+
* which blurs it in real engines — snapshot before, restore after.
|
|
295
|
+
*/
|
|
296
|
+
function snapshotFocusForMorph() {
|
|
297
|
+
if (typeof document === "undefined") return null;
|
|
298
|
+
const active = document.activeElement;
|
|
299
|
+
if (!(active instanceof HTMLElement) || active === document.body) return null;
|
|
300
|
+
let selection = null;
|
|
301
|
+
let range = null;
|
|
302
|
+
try {
|
|
303
|
+
if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) selection = {
|
|
304
|
+
start: active.selectionStart,
|
|
305
|
+
end: active.selectionEnd,
|
|
306
|
+
dir: active.selectionDirection
|
|
307
|
+
};
|
|
308
|
+
else if (active.isContentEditable) {
|
|
309
|
+
const sel = window.getSelection();
|
|
310
|
+
if (sel && sel.rangeCount > 0) range = sel.getRangeAt(0).cloneRange();
|
|
311
|
+
}
|
|
312
|
+
} catch {}
|
|
313
|
+
return {
|
|
314
|
+
active,
|
|
315
|
+
selection,
|
|
316
|
+
range
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function restoreFocusAfterMorph(snapshot) {
|
|
320
|
+
if (!snapshot) return;
|
|
321
|
+
const { active, selection, range } = snapshot;
|
|
322
|
+
if (!active.isConnected) return;
|
|
323
|
+
try {
|
|
324
|
+
if (document.activeElement !== active) active.focus({ preventScroll: true });
|
|
325
|
+
if (selection && selection.start !== null) {
|
|
326
|
+
const el = active;
|
|
327
|
+
if (el.selectionStart !== selection.start || el.selectionEnd !== selection.end) el.setSelectionRange(selection.start, selection.end, selection.dir ?? "none");
|
|
328
|
+
}
|
|
329
|
+
if (range && range.startContainer.isConnected) {
|
|
330
|
+
const sel = window.getSelection();
|
|
331
|
+
if (sel) {
|
|
332
|
+
const current = sel.rangeCount > 0 ? sel.getRangeAt(0) : null;
|
|
333
|
+
if (!(current !== null && current.startContainer === range.startContainer && current.startOffset === range.startOffset && current.endContainer === range.endContainer && current.endOffset === range.endOffset)) {
|
|
334
|
+
sel.removeAllRanges();
|
|
335
|
+
sel.addRange(range);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
} catch {}
|
|
340
|
+
}
|
|
237
341
|
function morphInner(from, to) {
|
|
238
342
|
if (from.localName !== to.localName || from.namespaceURI !== to.namespaceURI) throw new Error("[ilha] morph: elements must match");
|
|
343
|
+
const focus = snapshotFocusForMorph();
|
|
239
344
|
morphChildren(from, to);
|
|
345
|
+
restoreFocusAfterMorph(focus);
|
|
240
346
|
}
|
|
241
347
|
function isStandardSchema(value) {
|
|
242
348
|
if (value == null || typeof value !== "object") return false;
|
|
@@ -283,6 +389,11 @@ const ISLAND_SLOT_TAG = Symbol.for("ilha.islandSlotTag");
|
|
|
283
389
|
* on unmount. Not part of the public surface. */
|
|
284
390
|
const ISLAND_MOUNT_HANDLES = /* @__PURE__ */ new WeakMap();
|
|
285
391
|
const SLOT_ATTR = "data-ilha-slot";
|
|
392
|
+
/** Marks a slot host whose child island is mid leave-transition: it has been
|
|
393
|
+
* unmounted (and its slot id stripped) but stays connected so the leave
|
|
394
|
+
* animation can paint. The morph replaces such elements instead of matching
|
|
395
|
+
* or mutating them. */
|
|
396
|
+
const LEAVING_ATTR = "data-ilha-leaving";
|
|
286
397
|
const PROPS_ATTR = "data-ilha-props";
|
|
287
398
|
const STATE_ATTR = "data-ilha-state";
|
|
288
399
|
const CSS_ATTR = "data-ilha-css";
|
|
@@ -1521,6 +1632,8 @@ var IlhaBuilder = class IlhaBuilder {
|
|
|
1521
1632
|
leavingSlots.delete(id);
|
|
1522
1633
|
};
|
|
1523
1634
|
if (result instanceof Promise) {
|
|
1635
|
+
entry.el.removeAttribute(SLOT_ATTR);
|
|
1636
|
+
entry.el.setAttribute(LEAVING_ATTR, "");
|
|
1524
1637
|
leavingSlots.set(id, {
|
|
1525
1638
|
el: entry.el,
|
|
1526
1639
|
token
|
|
@@ -1747,6 +1860,7 @@ var IlhaBuilder = class IlhaBuilder {
|
|
|
1747
1860
|
cleanups.push(stopDerived);
|
|
1748
1861
|
let initialized = false;
|
|
1749
1862
|
const initialRenderedHtml = initial.html;
|
|
1863
|
+
let lastRendered = null;
|
|
1750
1864
|
let renderEpoch = 0;
|
|
1751
1865
|
const stopRender = effect(() => {
|
|
1752
1866
|
const epoch = ++renderEpoch;
|
|
@@ -1763,11 +1877,24 @@ var IlhaBuilder = class IlhaBuilder {
|
|
|
1763
1877
|
const next = newSlotMap.get(id);
|
|
1764
1878
|
if (next) entry.updateProps(next.props);
|
|
1765
1879
|
}
|
|
1766
|
-
if (rendered === initialRenderedHtml)
|
|
1880
|
+
if (rendered === initialRenderedHtml) {
|
|
1881
|
+
lastRendered = rendered;
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
if (rendered === initialRenderedHtml) {
|
|
1886
|
+
lastRendered = rendered;
|
|
1887
|
+
return;
|
|
1767
1888
|
}
|
|
1768
|
-
if (rendered === initialRenderedHtml) return;
|
|
1769
1889
|
if (mountedSlots.size > 0 && mountedSlots.size === newSlotMap.size && [...newSlotMap.keys()].every((id) => mountedSlots.has(id)) && isStableInlineSlotMount(initialRenderedHtml, rendered, newSlotMap.keys())) return;
|
|
1770
1890
|
}
|
|
1891
|
+
if (rendered === lastRendered && mountedSlots.size === newSlotMap.size && [...newSlotMap].every(([id, next]) => mountedSlots.get(id)?.island === next.island)) {
|
|
1892
|
+
for (const [id, entry] of mountedSlots) {
|
|
1893
|
+
const next = newSlotMap.get(id);
|
|
1894
|
+
if (next) entry.updateProps(next.props);
|
|
1895
|
+
}
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1771
1898
|
detachListeners();
|
|
1772
1899
|
stopBindings();
|
|
1773
1900
|
const prevMountedCount = mountedSlots.size;
|
|
@@ -1785,34 +1912,33 @@ var IlhaBuilder = class IlhaBuilder {
|
|
|
1785
1912
|
}
|
|
1786
1913
|
const applyMorph = () => {
|
|
1787
1914
|
if (epoch !== renderEpoch) return;
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
continue;
|
|
1915
|
+
if (newSlotMap.size < prevMountedCount) {
|
|
1916
|
+
let divergeFrom = Infinity;
|
|
1917
|
+
for (const [id, entry] of mountedSlots) {
|
|
1918
|
+
if (!id.startsWith("p:")) continue;
|
|
1919
|
+
const next = newSlotMap.get(id);
|
|
1920
|
+
if (!next) continue;
|
|
1921
|
+
const index = Number(id.slice(2));
|
|
1922
|
+
if (Number.isNaN(index) || index >= divergeFrom) continue;
|
|
1923
|
+
if (serializeSlotProps(next.props) !== (entry.el.getAttribute(PROPS_ATTR) ?? "")) divergeFrom = index;
|
|
1924
|
+
}
|
|
1925
|
+
for (const [id, entry] of mountedSlots) {
|
|
1926
|
+
if (!newSlotMap.has(id)) continue;
|
|
1927
|
+
if (!entry.el.isConnected) continue;
|
|
1928
|
+
if (id.startsWith("p:") && Number(id.slice(2)) >= divergeFrom) teardownMountedSlot(id, entry);
|
|
1796
1929
|
}
|
|
1797
|
-
const stubTag = entry.el.tagName.toLowerCase();
|
|
1798
|
-
const stub = document.createElement(stubTag);
|
|
1799
|
-
stub.setAttribute(SLOT_ATTR, id);
|
|
1800
|
-
const incomingProps = serializeSlotProps(newSlotMap.get(id)?.props);
|
|
1801
|
-
if (incomingProps !== "") stub.setAttribute(PROPS_ATTR, incomingProps);
|
|
1802
|
-
entry.el.replaceWith(stub);
|
|
1803
|
-
preserved.set(id, entry.el);
|
|
1804
1930
|
}
|
|
1805
1931
|
const tpl = document.createElement("template");
|
|
1806
1932
|
const morphRootTag = host.tagName.toLowerCase();
|
|
1807
1933
|
tpl.innerHTML = `<${morphRootTag}>${html}</${morphRootTag}>`;
|
|
1808
1934
|
morphInner(host, tpl.content.firstElementChild);
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1935
|
+
lastRendered = rendered;
|
|
1936
|
+
let rehomeIndex = null;
|
|
1937
|
+
for (const [id, entry] of mountedSlots) {
|
|
1938
|
+
if (!newSlotMap.has(id) || entry.el.isConnected) continue;
|
|
1939
|
+
rehomeIndex ??= buildSlotIndex();
|
|
1940
|
+
const stub = rehomeIndex.get(id);
|
|
1941
|
+
if (stub && stub !== entry.el) stub.replaceWith(entry.el);
|
|
1816
1942
|
}
|
|
1817
1943
|
attachListeners();
|
|
1818
1944
|
stopBindings = applyTemplateBindings(host, newBinds);
|