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.
Files changed (2) hide show
  1. package/dist/index.js +154 -28
  2. 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.getAttribute("data-key");
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.getAttribute("data-key");
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.getAttribute("data-key") : null;
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.getAttribute("data-key");
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) return;
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
- const preserved = /* @__PURE__ */ new Map();
1789
- const listShrunk = newSlotMap.size < prevMountedCount;
1790
- for (const [id, entry] of mountedSlots) {
1791
- if (!newSlotMap.has(id)) continue;
1792
- if (!entry.el.isConnected) continue;
1793
- if (listShrunk && id.startsWith("p:")) {
1794
- teardownMountedSlot(id, entry);
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
- if (preserved.size > 0) {
1810
- const rehomeIndex = buildSlotIndex();
1811
- for (const [id, liveEl] of preserved) {
1812
- const stub = rehomeIndex.get(id);
1813
- if (!stub) continue;
1814
- stub.replaceWith(liveEl);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ilha",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "description": "A tiny, framework-free island architecture library",
5
5
  "keywords": [
6
6
  "framework-free",