dalila 1.9.5 → 1.9.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/README.md CHANGED
@@ -58,7 +58,7 @@ bind(document.getElementById('app')!, ctx);
58
58
 
59
59
  ### Runtime
60
60
 
61
- - [Template Binding](./docs/runtime/bind.md) — `bind()`, `mount()`, `configure()`, text interpolation, events
61
+ - [Template Binding](./docs/runtime/bind.md) — `bind()`, `mount()`, `configure()`, transitions, portal, text interpolation, events
62
62
  - [Components](./docs/runtime/component.md) — `defineComponent`, typed props/emits/refs, slots
63
63
  - [FOUC Prevention](./docs/runtime/fouc-prevention.md) — Automatic token hiding
64
64
 
@@ -76,7 +76,7 @@ bind(document.getElementById('app')!, ctx);
76
76
  - [when](./docs/core/when.md) — Conditional visibility
77
77
  - [match](./docs/core/match.md) — Switch-style rendering
78
78
  - [for](./docs/core/for.md) — List rendering with keyed diffing
79
- - [Virtual Lists](./docs/core/virtual.md) — Fixed-height windowed rendering for large datasets
79
+ - [Virtual Lists](./docs/core/virtual.md) — Fixed and dynamic-height windowed rendering with infinite-scroll hooks
80
80
 
81
81
  ### Data
82
82
 
@@ -108,7 +108,7 @@ Firefox extension workflows:
108
108
 
109
109
  ```
110
110
  dalila → signal, computed, effect, batch, ...
111
- dalila/runtime → bind(), mount(), configure(), defineComponent()
111
+ dalila/runtime → bind(), mount(), configure(), createPortalTarget(), defineComponent()
112
112
  dalila/context → createContext, provide, inject
113
113
  dalila/http → createHttpClient with XSRF protection
114
114
  ```
@@ -143,6 +143,23 @@ const dispose = mount('.app', { count: signal(0) });
143
143
  dispose();
144
144
  ```
145
145
 
146
+ ### Transitions and Portal
147
+
148
+ ```html
149
+ <div d-when="open" d-transition="fade">Panel</div>
150
+ <div d-portal="showModal ? '#modal-root' : null">Modal content</div>
151
+ ```
152
+
153
+ ```ts
154
+ import { configure, createPortalTarget } from 'dalila/runtime';
155
+
156
+ const modalTarget = createPortalTarget('modal-root');
157
+
158
+ configure({
159
+ transitions: [{ name: 'fade', duration: 250 }],
160
+ });
161
+ ```
162
+
146
163
  ### Scopes
147
164
 
148
165
  ```ts
package/dist/cli/check.js CHANGED
@@ -520,7 +520,7 @@ function extractTemplateIdentifiers(html) {
520
520
  i++;
521
521
  }
522
522
  // --- 2. Directive scanning (supports single and double quotes) ---
523
- const DIRECTIVE_RE = /\b(d-each|d-virtual-each|d-virtual-height|d-virtual-item-height|d-virtual-overscan|d-if|d-when|d-match|d-html|d-attr-[a-zA-Z][\w-]*|d-bind-[a-zA-Z][\w-]*|d-on-[a-zA-Z][\w-]*|d-form-error|d-form|d-array)\s*=\s*(['"])([\s\S]*?)\2/g;
523
+ const DIRECTIVE_RE = /\b(d-each|d-virtual-each|d-virtual-height|d-virtual-item-height|d-virtual-estimated-height|d-virtual-measure|d-virtual-infinite|d-virtual-overscan|d-if|d-when|d-match|d-portal|d-html|d-attr-[a-zA-Z][\w-]*|d-bind-[a-zA-Z][\w-]*|d-on-[a-zA-Z][\w-]*|d-form-error|d-form|d-array)\s*=\s*(['"])([\s\S]*?)\2/g;
524
524
  DIRECTIVE_RE.lastIndex = 0;
525
525
  let match;
526
526
  while ((match = DIRECTIVE_RE.exec(html))) {
@@ -528,6 +528,8 @@ function extractTemplateIdentifiers(html) {
528
528
  const value = match[3].trim();
529
529
  if (!value)
530
530
  continue;
531
+ if (directive === 'd-virtual-measure' && value.toLowerCase() === 'auto')
532
+ continue;
531
533
  const roots = extractRootIdentifiers(value);
532
534
  const loc = offsetToLineCol(match.index);
533
535
  for (const name of roots) {
@@ -691,6 +693,9 @@ const LOOP_FORCED_CHECK_SOURCES = new Set([
691
693
  'd-virtual-each',
692
694
  'd-virtual-height',
693
695
  'd-virtual-item-height',
696
+ 'd-virtual-estimated-height',
697
+ 'd-virtual-measure',
698
+ 'd-virtual-infinite',
694
699
  'd-virtual-overscan',
695
700
  ]);
696
701
  function checkHtmlContent(html, filePath, validIdentifiers, diagnostics) {
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * @module dalila/runtime
8
8
  */
9
+ import { Signal } from '../core/index.js';
9
10
  import type { Component } from './component.js';
10
11
  export interface BindOptions {
11
12
  /**
@@ -30,6 +31,10 @@ export interface BindOptions {
30
31
  components?: Record<string, Component> | Component[];
31
32
  /** Error policy for component `ctx.onMount()` callbacks. Default: 'log'. */
32
33
  onMountError?: 'log' | 'throw';
34
+ /**
35
+ * Optional runtime transition registry used by `d-transition`.
36
+ */
37
+ transitions?: TransitionConfig[];
33
38
  /**
34
39
  * Internal flag — set by fromHtml for router/template rendering.
35
40
  * Skips HMR context registration but KEEPS d-ready/d-loading lifecycle.
@@ -59,6 +64,24 @@ export interface BindHandle {
59
64
  getRef(name: string): Element | null;
60
65
  getRefs(): Readonly<Record<string, Element>>;
61
66
  }
67
+ export interface TransitionConfig {
68
+ name: string;
69
+ enter?: (el: HTMLElement) => void;
70
+ leave?: (el: HTMLElement) => void;
71
+ duration?: number;
72
+ }
73
+ export type VirtualListAlign = 'start' | 'center' | 'end';
74
+ export interface VirtualScrollToIndexOptions {
75
+ align?: VirtualListAlign;
76
+ behavior?: ScrollBehavior;
77
+ }
78
+ export interface VirtualListController {
79
+ scrollToIndex: (index: number, options?: VirtualScrollToIndexOptions) => void;
80
+ refresh: () => void;
81
+ }
82
+ export declare function getVirtualListController(target: Element | null): VirtualListController | null;
83
+ export declare function scrollToVirtualIndex(target: Element | null, index: number, options?: VirtualScrollToIndexOptions): boolean;
84
+ export declare function createPortalTarget(id: string): Signal<Element | null>;
62
85
  /**
63
86
  * Set global defaults for all `bind()` / `mount()` calls.
64
87
  *
@@ -97,6 +97,7 @@ function warn(message) {
97
97
  console.warn(`[Dalila] ${message}`);
98
98
  }
99
99
  }
100
+ const portalSyncByElement = new WeakMap();
100
101
  function describeBindRoot(root) {
101
102
  const explicit = root.getAttribute('data-component') ||
102
103
  root.getAttribute('data-devtools-label') ||
@@ -1135,13 +1136,231 @@ function bindEmit(root, ctx, cleanups) {
1135
1136
  }
1136
1137
  }
1137
1138
  }
1139
+ function createTransitionRegistry(transitions) {
1140
+ const registry = new Map();
1141
+ if (!transitions)
1142
+ return registry;
1143
+ for (const cfg of transitions) {
1144
+ if (!cfg || typeof cfg !== 'object')
1145
+ continue;
1146
+ const name = typeof cfg.name === 'string' ? cfg.name.trim() : '';
1147
+ if (!name) {
1148
+ warn('configure({ transitions }): each transition must have a non-empty "name"');
1149
+ continue;
1150
+ }
1151
+ registry.set(name, cfg);
1152
+ }
1153
+ return registry;
1154
+ }
1155
+ function readTransitionNames(el) {
1156
+ const raw = el.getAttribute('d-transition');
1157
+ if (!raw)
1158
+ return [];
1159
+ return raw
1160
+ .split(/\s+/)
1161
+ .map(v => v.trim())
1162
+ .filter(Boolean);
1163
+ }
1164
+ function parseCssTimeToMs(value) {
1165
+ const token = value.trim();
1166
+ if (!token)
1167
+ return 0;
1168
+ if (token.endsWith('ms')) {
1169
+ const ms = Number(token.slice(0, -2));
1170
+ return Number.isFinite(ms) ? Math.max(0, ms) : 0;
1171
+ }
1172
+ if (token.endsWith('s')) {
1173
+ const seconds = Number(token.slice(0, -1));
1174
+ return Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : 0;
1175
+ }
1176
+ const fallback = Number(token);
1177
+ return Number.isFinite(fallback) ? Math.max(0, fallback) : 0;
1178
+ }
1179
+ function getTransitionDurationMs(el, names, registry) {
1180
+ let durationFromRegistry = 0;
1181
+ for (const name of names) {
1182
+ const cfg = registry.get(name);
1183
+ if (!cfg)
1184
+ continue;
1185
+ if (typeof cfg.duration === 'number' && Number.isFinite(cfg.duration)) {
1186
+ durationFromRegistry = Math.max(durationFromRegistry, Math.max(0, cfg.duration));
1187
+ }
1188
+ }
1189
+ let durationFromCss = 0;
1190
+ if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {
1191
+ const style = window.getComputedStyle(el);
1192
+ const durations = style.transitionDuration.split(',');
1193
+ const delays = style.transitionDelay.split(',');
1194
+ const total = Math.max(durations.length, delays.length);
1195
+ for (let i = 0; i < total; i++) {
1196
+ const duration = parseCssTimeToMs(durations[Math.min(i, durations.length - 1)] ?? '0ms');
1197
+ const delay = parseCssTimeToMs(delays[Math.min(i, delays.length - 1)] ?? '0ms');
1198
+ durationFromCss = Math.max(durationFromCss, duration + delay);
1199
+ }
1200
+ }
1201
+ return Math.max(durationFromRegistry, durationFromCss);
1202
+ }
1203
+ function runTransitionHook(phase, el, names, registry) {
1204
+ for (const name of names) {
1205
+ const cfg = registry.get(name);
1206
+ const hook = phase === 'enter' ? cfg?.enter : cfg?.leave;
1207
+ if (typeof hook !== 'function')
1208
+ continue;
1209
+ try {
1210
+ hook(el);
1211
+ }
1212
+ catch (err) {
1213
+ warn(`d-transition (${name}): ${phase} hook failed (${err.message || String(err)})`);
1214
+ }
1215
+ }
1216
+ }
1217
+ function syncPortalElement(el) {
1218
+ const sync = portalSyncByElement.get(el);
1219
+ sync?.();
1220
+ }
1221
+ function createTransitionController(el, registry, cleanups) {
1222
+ const names = readTransitionNames(el);
1223
+ const hasTransition = names.length > 0;
1224
+ let token = 0;
1225
+ let timeoutId = null;
1226
+ const cancelPending = () => {
1227
+ token++;
1228
+ if (timeoutId != null) {
1229
+ clearTimeout(timeoutId);
1230
+ timeoutId = null;
1231
+ }
1232
+ };
1233
+ cleanups.push(cancelPending);
1234
+ const enter = () => {
1235
+ cancelPending();
1236
+ if (!hasTransition)
1237
+ return;
1238
+ el.removeAttribute('data-leave');
1239
+ el.setAttribute('data-enter', '');
1240
+ runTransitionHook('enter', el, names, registry);
1241
+ };
1242
+ const leave = (onDone) => {
1243
+ cancelPending();
1244
+ if (!hasTransition) {
1245
+ onDone();
1246
+ return;
1247
+ }
1248
+ const current = ++token;
1249
+ el.removeAttribute('data-enter');
1250
+ el.setAttribute('data-leave', '');
1251
+ runTransitionHook('leave', el, names, registry);
1252
+ const durationMs = getTransitionDurationMs(el, names, registry);
1253
+ if (durationMs <= 0) {
1254
+ if (current === token)
1255
+ onDone();
1256
+ return;
1257
+ }
1258
+ timeoutId = setTimeout(() => {
1259
+ timeoutId = null;
1260
+ if (current !== token)
1261
+ return;
1262
+ onDone();
1263
+ }, durationMs);
1264
+ };
1265
+ return { hasTransition, enter, leave };
1266
+ }
1267
+ function bindPortal(root, ctx, cleanups) {
1268
+ const elements = qsaIncludingRoot(root, '[d-portal]');
1269
+ for (const el of elements) {
1270
+ const rawExpression = el.getAttribute('d-portal')?.trim();
1271
+ if (!rawExpression)
1272
+ continue;
1273
+ let expressionAst = null;
1274
+ let fallbackSelector = null;
1275
+ try {
1276
+ expressionAst = parseExpression(rawExpression);
1277
+ }
1278
+ catch {
1279
+ // Allow selector shorthand: d-portal="#modal-root"
1280
+ fallbackSelector = rawExpression;
1281
+ }
1282
+ const htmlEl = el;
1283
+ const anchor = document.createComment('d-portal');
1284
+ htmlEl.parentNode?.insertBefore(anchor, htmlEl);
1285
+ const coerceTarget = (value) => {
1286
+ const resolved = resolve(value);
1287
+ if (resolved == null || resolved === false)
1288
+ return null;
1289
+ if (typeof resolved === 'string') {
1290
+ const selector = resolved.trim();
1291
+ if (!selector)
1292
+ return null;
1293
+ if (typeof document === 'undefined')
1294
+ return null;
1295
+ const target = document.querySelector(selector);
1296
+ if (!target) {
1297
+ warn(`d-portal: target "${selector}" not found`);
1298
+ return null;
1299
+ }
1300
+ return target;
1301
+ }
1302
+ if (typeof Element !== 'undefined' && resolved instanceof Element) {
1303
+ return resolved;
1304
+ }
1305
+ warn('d-portal: expression must resolve to selector string, Element, or null');
1306
+ return null;
1307
+ };
1308
+ const restoreToAnchor = () => {
1309
+ const hostParent = anchor.parentNode;
1310
+ if (!hostParent)
1311
+ return;
1312
+ if (htmlEl.parentNode === hostParent)
1313
+ return;
1314
+ const next = anchor.nextSibling;
1315
+ if (next)
1316
+ hostParent.insertBefore(htmlEl, next);
1317
+ else
1318
+ hostParent.appendChild(htmlEl);
1319
+ };
1320
+ const syncPortal = () => {
1321
+ let target = null;
1322
+ if (expressionAst) {
1323
+ const result = evalExpressionAst(expressionAst, ctx);
1324
+ if (!result.ok) {
1325
+ if (result.reason === 'missing_identifier') {
1326
+ warn(`d-portal: ${result.message}`);
1327
+ }
1328
+ else {
1329
+ warn(`d-portal: invalid expression "${rawExpression}"`);
1330
+ }
1331
+ target = null;
1332
+ }
1333
+ else {
1334
+ target = coerceTarget(result.value);
1335
+ }
1336
+ }
1337
+ else {
1338
+ target = coerceTarget(fallbackSelector);
1339
+ }
1340
+ if (!target) {
1341
+ restoreToAnchor();
1342
+ return;
1343
+ }
1344
+ if (htmlEl.parentNode !== target) {
1345
+ target.appendChild(htmlEl);
1346
+ }
1347
+ };
1348
+ portalSyncByElement.set(htmlEl, syncPortal);
1349
+ bindEffect(htmlEl, syncPortal);
1350
+ cleanups.push(() => {
1351
+ portalSyncByElement.delete(htmlEl);
1352
+ restoreToAnchor();
1353
+ anchor.remove();
1354
+ });
1355
+ }
1356
+ }
1138
1357
  // ============================================================================
1139
1358
  // d-when Directive
1140
1359
  // ============================================================================
1141
1360
  /**
1142
1361
  * Bind all [d-when] directives within root
1143
1362
  */
1144
- function bindWhen(root, ctx, cleanups) {
1363
+ function bindWhen(root, ctx, cleanups, transitionRegistry) {
1145
1364
  const elements = qsaIncludingRoot(root, '[when], [d-when]');
1146
1365
  for (const el of elements) {
1147
1366
  const attrName = el.hasAttribute('when') ? 'when' : 'd-when';
@@ -1154,13 +1373,32 @@ function bindWhen(root, ctx, cleanups) {
1154
1373
  continue;
1155
1374
  }
1156
1375
  const htmlEl = el;
1376
+ const transitions = createTransitionController(htmlEl, transitionRegistry, cleanups);
1157
1377
  // Apply initial state synchronously to avoid FOUC (flash of unstyled content)
1158
1378
  const initialValue = !!resolve(binding);
1159
- htmlEl.style.display = initialValue ? '' : 'none';
1379
+ if (initialValue) {
1380
+ htmlEl.style.display = '';
1381
+ if (transitions.hasTransition) {
1382
+ htmlEl.removeAttribute('data-leave');
1383
+ htmlEl.setAttribute('data-enter', '');
1384
+ }
1385
+ }
1386
+ else {
1387
+ htmlEl.style.display = 'none';
1388
+ htmlEl.removeAttribute('data-enter');
1389
+ htmlEl.removeAttribute('data-leave');
1390
+ }
1160
1391
  // Then create reactive effect to keep it updated
1161
1392
  bindEffect(htmlEl, () => {
1162
1393
  const value = !!resolve(binding);
1163
- htmlEl.style.display = value ? '' : 'none';
1394
+ if (value) {
1395
+ htmlEl.style.display = '';
1396
+ transitions.enter();
1397
+ return;
1398
+ }
1399
+ transitions.leave(() => {
1400
+ htmlEl.style.display = 'none';
1401
+ });
1164
1402
  });
1165
1403
  }
1166
1404
  }
@@ -1260,6 +1498,47 @@ function readVirtualHeightOption(raw, ctx) {
1260
1498
  }
1261
1499
  return trimmed;
1262
1500
  }
1501
+ function readVirtualMeasureOption(raw, ctx) {
1502
+ if (!raw)
1503
+ return false;
1504
+ const trimmed = raw.trim();
1505
+ if (!trimmed)
1506
+ return false;
1507
+ if (trimmed.toLowerCase() === 'auto')
1508
+ return true;
1509
+ const fromCtx = ctx[trimmed];
1510
+ if (fromCtx === undefined)
1511
+ return false;
1512
+ const resolved = resolve(fromCtx);
1513
+ if (resolved === true)
1514
+ return true;
1515
+ if (typeof resolved === 'string' && resolved.trim().toLowerCase() === 'auto')
1516
+ return true;
1517
+ return false;
1518
+ }
1519
+ function readVirtualCallbackOption(raw, ctx, label) {
1520
+ if (!raw)
1521
+ return null;
1522
+ const trimmed = raw.trim();
1523
+ if (!trimmed)
1524
+ return null;
1525
+ const fromCtx = ctx[trimmed];
1526
+ if (fromCtx === undefined) {
1527
+ warn(`${label}: "${trimmed}" not found in context`);
1528
+ return null;
1529
+ }
1530
+ if (typeof fromCtx === 'function' && !isSignal(fromCtx)) {
1531
+ return fromCtx;
1532
+ }
1533
+ if (isSignal(fromCtx)) {
1534
+ const resolved = fromCtx();
1535
+ if (typeof resolved === 'function') {
1536
+ return resolved;
1537
+ }
1538
+ }
1539
+ warn(`${label}: "${trimmed}" must resolve to a function`);
1540
+ return null;
1541
+ }
1263
1542
  function createVirtualSpacer(template, kind) {
1264
1543
  const spacer = template.cloneNode(false);
1265
1544
  spacer.removeAttribute('id');
@@ -1281,13 +1560,199 @@ function createVirtualSpacer(template, kind) {
1281
1560
  spacer.style.listStyle = 'none';
1282
1561
  return spacer;
1283
1562
  }
1563
+ const virtualScrollRestoreCache = new Map();
1564
+ const VIRTUAL_SCROLL_RESTORE_CACHE_MAX_ENTRIES = 256;
1565
+ function getVirtualScrollRestoreValue(key) {
1566
+ const value = virtualScrollRestoreCache.get(key);
1567
+ if (value === undefined)
1568
+ return undefined;
1569
+ // Touch entry to keep LRU ordering.
1570
+ virtualScrollRestoreCache.delete(key);
1571
+ virtualScrollRestoreCache.set(key, value);
1572
+ return value;
1573
+ }
1574
+ function setVirtualScrollRestoreValue(key, value) {
1575
+ virtualScrollRestoreCache.delete(key);
1576
+ virtualScrollRestoreCache.set(key, value);
1577
+ while (virtualScrollRestoreCache.size > VIRTUAL_SCROLL_RESTORE_CACHE_MAX_ENTRIES) {
1578
+ const oldestKey = virtualScrollRestoreCache.keys().next().value;
1579
+ if (!oldestKey)
1580
+ break;
1581
+ virtualScrollRestoreCache.delete(oldestKey);
1582
+ }
1583
+ }
1584
+ function clampVirtual(value, min, max) {
1585
+ return Math.max(min, Math.min(max, value));
1586
+ }
1587
+ function getElementPositionPath(el) {
1588
+ const parts = [];
1589
+ let current = el;
1590
+ while (current) {
1591
+ const tag = current.tagName.toLowerCase();
1592
+ const parentEl = current.parentElement;
1593
+ if (!parentEl) {
1594
+ parts.push(tag);
1595
+ break;
1596
+ }
1597
+ let index = 1;
1598
+ let sib = current.previousElementSibling;
1599
+ while (sib) {
1600
+ index++;
1601
+ sib = sib.previousElementSibling;
1602
+ }
1603
+ parts.push(`${tag}:${index}`);
1604
+ current = parentEl;
1605
+ }
1606
+ return parts.reverse().join('>');
1607
+ }
1608
+ const virtualRestoreDocumentIds = new WeakMap();
1609
+ let nextVirtualRestoreDocumentId = 0;
1610
+ function getVirtualRestoreDocumentId(doc) {
1611
+ const existing = virtualRestoreDocumentIds.get(doc);
1612
+ if (existing !== undefined)
1613
+ return existing;
1614
+ const next = ++nextVirtualRestoreDocumentId;
1615
+ virtualRestoreDocumentIds.set(doc, next);
1616
+ return next;
1617
+ }
1618
+ function getVirtualRestoreKey(doc, templatePath, scrollContainer, bindingName, keyBinding) {
1619
+ const locationPath = typeof window !== 'undefined'
1620
+ ? `${window.location.pathname}${window.location.search}`
1621
+ : '';
1622
+ const containerIdentity = scrollContainer?.id
1623
+ ? `#${scrollContainer.id}`
1624
+ : (scrollContainer ? getElementPositionPath(scrollContainer) : '');
1625
+ const docId = getVirtualRestoreDocumentId(doc);
1626
+ return `${docId}|${locationPath}|${bindingName}|${keyBinding ?? ''}|${containerIdentity}|${templatePath}`;
1627
+ }
1628
+ class VirtualHeightsIndex {
1629
+ constructor(itemCount, estimatedHeight) {
1630
+ this.itemCount = 0;
1631
+ this.estimatedHeight = 1;
1632
+ this.tree = [0];
1633
+ this.overrides = new Map();
1634
+ this.reset(itemCount, estimatedHeight);
1635
+ }
1636
+ get count() {
1637
+ return this.itemCount;
1638
+ }
1639
+ snapshotOverrides() {
1640
+ return new Map(this.overrides);
1641
+ }
1642
+ reset(itemCount, estimatedHeight, seed) {
1643
+ this.itemCount = Number.isFinite(itemCount) ? Math.max(0, Math.floor(itemCount)) : 0;
1644
+ this.estimatedHeight = Number.isFinite(estimatedHeight) ? Math.max(1, estimatedHeight) : 1;
1645
+ this.tree = new Array(this.itemCount + 1).fill(0);
1646
+ this.overrides.clear();
1647
+ for (let i = 0; i < this.itemCount; i++) {
1648
+ this.addAt(i + 1, this.estimatedHeight);
1649
+ }
1650
+ if (!seed)
1651
+ return;
1652
+ for (const [index, height] of seed.entries()) {
1653
+ if (index < 0 || index >= this.itemCount)
1654
+ continue;
1655
+ this.set(index, height);
1656
+ }
1657
+ }
1658
+ set(index, height) {
1659
+ if (!Number.isFinite(height) || height <= 0)
1660
+ return false;
1661
+ if (index < 0 || index >= this.itemCount)
1662
+ return false;
1663
+ const next = Math.max(1, height);
1664
+ const current = this.get(index);
1665
+ if (Math.abs(next - current) < 0.5)
1666
+ return false;
1667
+ this.addAt(index + 1, next - current);
1668
+ if (Math.abs(next - this.estimatedHeight) < 0.5) {
1669
+ this.overrides.delete(index);
1670
+ }
1671
+ else {
1672
+ this.overrides.set(index, next);
1673
+ }
1674
+ return true;
1675
+ }
1676
+ get(index) {
1677
+ if (index < 0 || index >= this.itemCount)
1678
+ return this.estimatedHeight;
1679
+ return this.overrides.get(index) ?? this.estimatedHeight;
1680
+ }
1681
+ prefix(endExclusive) {
1682
+ if (endExclusive <= 0)
1683
+ return 0;
1684
+ const clampedEnd = Math.min(this.itemCount, Math.max(0, Math.floor(endExclusive)));
1685
+ let i = clampedEnd;
1686
+ let sum = 0;
1687
+ while (i > 0) {
1688
+ sum += this.tree[i];
1689
+ i -= i & -i;
1690
+ }
1691
+ return sum;
1692
+ }
1693
+ total() {
1694
+ return this.prefix(this.itemCount);
1695
+ }
1696
+ lowerBound(target) {
1697
+ if (this.itemCount === 0 || target <= 0)
1698
+ return 0;
1699
+ let idx = 0;
1700
+ let bit = 1;
1701
+ while ((bit << 1) <= this.itemCount)
1702
+ bit <<= 1;
1703
+ let sum = 0;
1704
+ while (bit > 0) {
1705
+ const next = idx + bit;
1706
+ if (next <= this.itemCount && sum + this.tree[next] < target) {
1707
+ idx = next;
1708
+ sum += this.tree[next];
1709
+ }
1710
+ bit >>= 1;
1711
+ }
1712
+ return Math.min(this.itemCount, idx);
1713
+ }
1714
+ indexAtOffset(offset) {
1715
+ if (this.itemCount === 0)
1716
+ return 0;
1717
+ if (!Number.isFinite(offset) || offset <= 0)
1718
+ return 0;
1719
+ const totalHeight = this.total();
1720
+ if (offset >= totalHeight)
1721
+ return this.itemCount - 1;
1722
+ const idx = this.lowerBound(offset + 0.0001);
1723
+ return clampVirtual(idx, 0, this.itemCount - 1);
1724
+ }
1725
+ addAt(treeIndex, delta) {
1726
+ let i = treeIndex;
1727
+ while (i <= this.itemCount) {
1728
+ this.tree[i] += delta;
1729
+ i += i & -i;
1730
+ }
1731
+ }
1732
+ }
1733
+ function readVirtualListApi(target) {
1734
+ if (!target)
1735
+ return null;
1736
+ return target.__dalilaVirtualList ?? null;
1737
+ }
1738
+ export function getVirtualListController(target) {
1739
+ return readVirtualListApi(target);
1740
+ }
1741
+ export function scrollToVirtualIndex(target, index, options) {
1742
+ const controller = readVirtualListApi(target);
1743
+ if (!controller)
1744
+ return false;
1745
+ controller.scrollToIndex(index, options);
1746
+ return true;
1747
+ }
1284
1748
  /**
1285
1749
  * Bind all [d-virtual-each] directives within root.
1286
1750
  *
1287
- * V1 constraints:
1288
- * - Fixed item height (required via d-virtual-item-height)
1289
- * - Vertical virtualization only
1290
- * - Parent element is the scroll container
1751
+ * Supports:
1752
+ * - Fixed item height (`d-virtual-item-height`)
1753
+ * - Dynamic item height (`d-virtual-measure="auto"`)
1754
+ * - Infinite scroll callback (`d-virtual-infinite`)
1755
+ * - Parent element as vertical scroll container
1291
1756
  */
1292
1757
  function bindVirtualEach(root, ctx, cleanups) {
1293
1758
  const elements = qsaIncludingRoot(root, '[d-virtual-each]')
@@ -1299,16 +1764,28 @@ function bindVirtualEach(root, ctx, cleanups) {
1299
1764
  const itemHeightBinding = normalizeBinding(el.getAttribute('d-virtual-item-height'));
1300
1765
  const itemHeightRaw = itemHeightBinding ?? el.getAttribute('d-virtual-item-height');
1301
1766
  const itemHeightValue = readVirtualNumberOption(itemHeightRaw, ctx, 'd-virtual-item-height');
1302
- const itemHeight = itemHeightValue == null ? NaN : itemHeightValue;
1303
- if (!Number.isFinite(itemHeight) || itemHeight <= 0) {
1767
+ const fixedItemHeight = Number.isFinite(itemHeightValue) && itemHeightValue > 0
1768
+ ? itemHeightValue
1769
+ : NaN;
1770
+ const dynamicHeight = readVirtualMeasureOption(normalizeBinding(el.getAttribute('d-virtual-measure')) ?? el.getAttribute('d-virtual-measure'), ctx);
1771
+ if (!dynamicHeight && (!Number.isFinite(fixedItemHeight) || fixedItemHeight <= 0)) {
1304
1772
  warn(`d-virtual-each: invalid item height on "${bindingName}". Falling back to d-each.`);
1305
1773
  el.setAttribute('d-each', bindingName);
1306
1774
  el.removeAttribute('d-virtual-each');
1307
1775
  el.removeAttribute('d-virtual-item-height');
1776
+ el.removeAttribute('d-virtual-estimated-height');
1777
+ el.removeAttribute('d-virtual-measure');
1778
+ el.removeAttribute('d-virtual-infinite');
1308
1779
  el.removeAttribute('d-virtual-overscan');
1309
1780
  el.removeAttribute('d-virtual-height');
1310
1781
  continue;
1311
1782
  }
1783
+ const estimatedHeightBinding = normalizeBinding(el.getAttribute('d-virtual-estimated-height'));
1784
+ const estimatedHeightRaw = estimatedHeightBinding ?? el.getAttribute('d-virtual-estimated-height');
1785
+ const estimatedHeightValue = readVirtualNumberOption(estimatedHeightRaw, ctx, 'd-virtual-estimated-height');
1786
+ const estimatedItemHeight = Number.isFinite(estimatedHeightValue) && estimatedHeightValue > 0
1787
+ ? estimatedHeightValue
1788
+ : (Number.isFinite(fixedItemHeight) ? fixedItemHeight : 48);
1312
1789
  const overscanBinding = normalizeBinding(el.getAttribute('d-virtual-overscan'));
1313
1790
  const overscanRaw = overscanBinding ?? el.getAttribute('d-virtual-overscan');
1314
1791
  const overscanValue = readVirtualNumberOption(overscanRaw, ctx, 'd-virtual-overscan');
@@ -1316,15 +1793,20 @@ function bindVirtualEach(root, ctx, cleanups) {
1316
1793
  ? Math.max(0, Math.floor(overscanValue))
1317
1794
  : 6;
1318
1795
  const viewportHeight = readVirtualHeightOption(normalizeBinding(el.getAttribute('d-virtual-height')) ?? el.getAttribute('d-virtual-height'), ctx);
1796
+ const onEndReached = readVirtualCallbackOption(normalizeBinding(el.getAttribute('d-virtual-infinite')) ?? el.getAttribute('d-virtual-infinite'), ctx, 'd-virtual-infinite');
1319
1797
  let binding = ctx[bindingName];
1320
1798
  if (binding === undefined) {
1321
1799
  warn(`d-virtual-each: "${bindingName}" not found in context`);
1322
1800
  binding = [];
1323
1801
  }
1802
+ const templatePathBeforeDetach = getElementPositionPath(el);
1324
1803
  const comment = document.createComment('d-virtual-each');
1325
1804
  el.parentNode?.replaceChild(comment, el);
1326
1805
  el.removeAttribute('d-virtual-each');
1327
1806
  el.removeAttribute('d-virtual-item-height');
1807
+ el.removeAttribute('d-virtual-estimated-height');
1808
+ el.removeAttribute('d-virtual-measure');
1809
+ el.removeAttribute('d-virtual-infinite');
1328
1810
  el.removeAttribute('d-virtual-overscan');
1329
1811
  el.removeAttribute('d-virtual-height');
1330
1812
  const keyBinding = normalizeBinding(el.getAttribute('d-key'));
@@ -1341,8 +1823,14 @@ function bindVirtualEach(root, ctx, cleanups) {
1341
1823
  if (!scrollContainer.style.overflowY)
1342
1824
  scrollContainer.style.overflowY = 'auto';
1343
1825
  }
1826
+ const restoreKey = getVirtualRestoreKey(el.ownerDocument, templatePathBeforeDetach, scrollContainer, bindingName, keyBinding);
1827
+ const savedScrollTop = getVirtualScrollRestoreValue(restoreKey);
1828
+ if (scrollContainer && Number.isFinite(savedScrollTop)) {
1829
+ scrollContainer.scrollTop = Math.max(0, savedScrollTop);
1830
+ }
1344
1831
  const clonesByKey = new Map();
1345
1832
  const disposesByKey = new Map();
1833
+ const observedElements = new Set();
1346
1834
  const metadataByKey = new Map();
1347
1835
  const itemsByKey = new Map();
1348
1836
  const objectKeyIds = new WeakMap();
@@ -1352,6 +1840,7 @@ function bindVirtualEach(root, ctx, cleanups) {
1352
1840
  const missingKeyWarned = new Set();
1353
1841
  let warnedNonArray = false;
1354
1842
  let warnedViewportFallback = false;
1843
+ let heightsIndex = dynamicHeight ? new VirtualHeightsIndex(0, estimatedItemHeight) : null;
1355
1844
  const getObjectKeyId = (value) => {
1356
1845
  const existing = objectKeyIds.get(value);
1357
1846
  if (existing !== undefined)
@@ -1406,6 +1895,7 @@ function bindVirtualEach(root, ctx, cleanups) {
1406
1895
  }
1407
1896
  return index;
1408
1897
  };
1898
+ let rowResizeObserver = null;
1409
1899
  function createClone(key, item, index, count) {
1410
1900
  const clone = template.cloneNode(true);
1411
1901
  const itemCtx = Object.create(ctx);
@@ -1431,6 +1921,7 @@ function bindVirtualEach(root, ctx, cleanups) {
1431
1921
  itemCtx.$odd = metadata.$odd;
1432
1922
  itemCtx.$even = metadata.$even;
1433
1923
  clone.setAttribute('data-dalila-internal-bound', '');
1924
+ clone.setAttribute('data-dalila-virtual-index', String(index));
1434
1925
  const dispose = bind(clone, itemCtx, { _skipLifecycle: true });
1435
1926
  disposesByKey.set(key, dispose);
1436
1927
  clonesByKey.set(key, clone);
@@ -1446,9 +1937,17 @@ function bindVirtualEach(root, ctx, cleanups) {
1446
1937
  metadata.$odd.set(index % 2 !== 0);
1447
1938
  metadata.$even.set(index % 2 === 0);
1448
1939
  }
1940
+ const clone = clonesByKey.get(key);
1941
+ if (clone) {
1942
+ clone.setAttribute('data-dalila-virtual-index', String(index));
1943
+ }
1449
1944
  }
1450
1945
  function removeKey(key) {
1451
1946
  const clone = clonesByKey.get(key);
1947
+ if (clone && rowResizeObserver && observedElements.has(clone)) {
1948
+ rowResizeObserver.unobserve(clone);
1949
+ observedElements.delete(clone);
1950
+ }
1452
1951
  clone?.remove();
1453
1952
  clonesByKey.delete(key);
1454
1953
  metadataByKey.delete(key);
@@ -1460,31 +1959,106 @@ function bindVirtualEach(root, ctx, cleanups) {
1460
1959
  }
1461
1960
  }
1462
1961
  let currentItems = [];
1962
+ let lastEndReachedCount = -1;
1963
+ let endReachedPending = false;
1964
+ const remapDynamicHeights = (prevItems, nextItems) => {
1965
+ if (!dynamicHeight || !heightsIndex)
1966
+ return;
1967
+ const heightsByKey = new Map();
1968
+ for (let i = 0; i < prevItems.length; i++) {
1969
+ const key = keyValueToString(readKeyValue(prevItems[i], i), i);
1970
+ if (!heightsByKey.has(key)) {
1971
+ heightsByKey.set(key, heightsIndex.get(i));
1972
+ }
1973
+ }
1974
+ heightsIndex.reset(nextItems.length, estimatedItemHeight);
1975
+ for (let i = 0; i < nextItems.length; i++) {
1976
+ const key = keyValueToString(readKeyValue(nextItems[i], i), i);
1977
+ const height = heightsByKey.get(key);
1978
+ if (height !== undefined) {
1979
+ heightsIndex.set(i, height);
1980
+ }
1981
+ }
1982
+ };
1983
+ const replaceItems = (nextItems) => {
1984
+ remapDynamicHeights(currentItems, nextItems);
1985
+ currentItems = nextItems;
1986
+ };
1987
+ const maybeTriggerEndReached = (visibleEnd, totalCount) => {
1988
+ if (!onEndReached || totalCount === 0)
1989
+ return;
1990
+ if (visibleEnd < totalCount)
1991
+ return;
1992
+ if (lastEndReachedCount === totalCount || endReachedPending)
1993
+ return;
1994
+ lastEndReachedCount = totalCount;
1995
+ const result = onEndReached();
1996
+ if (result && typeof result.then === 'function') {
1997
+ endReachedPending = true;
1998
+ Promise.resolve(result)
1999
+ .catch(() => { })
2000
+ .finally(() => {
2001
+ endReachedPending = false;
2002
+ });
2003
+ }
2004
+ };
1463
2005
  function renderVirtualList(items) {
1464
2006
  const parent = comment.parentNode;
1465
2007
  if (!parent)
1466
2008
  return;
2009
+ if (dynamicHeight && heightsIndex && heightsIndex.count !== items.length) {
2010
+ heightsIndex.reset(items.length, estimatedItemHeight);
2011
+ }
1467
2012
  const viewportHeightValue = scrollContainer?.clientHeight ?? 0;
1468
- const effectiveViewportHeight = viewportHeightValue > 0 ? viewportHeightValue : itemHeight * 10;
2013
+ const effectiveViewportHeight = viewportHeightValue > 0
2014
+ ? viewportHeightValue
2015
+ : (dynamicHeight ? estimatedItemHeight * 10 : fixedItemHeight * 10);
1469
2016
  const scrollTop = scrollContainer?.scrollTop ?? 0;
1470
2017
  if (viewportHeightValue <= 0 && !warnedViewportFallback) {
1471
2018
  warnedViewportFallback = true;
1472
2019
  warn('d-virtual-each: scroll container has no measurable height. Using fallback viewport size.');
1473
2020
  }
1474
- const range = computeVirtualRange({
1475
- itemCount: items.length,
1476
- itemHeight,
1477
- scrollTop,
1478
- viewportHeight: effectiveViewportHeight,
1479
- overscan,
1480
- });
1481
- topSpacer.style.height = `${range.topOffset}px`;
1482
- bottomSpacer.style.height = `${range.bottomOffset}px`;
2021
+ let start = 0;
2022
+ let end = 0;
2023
+ let topOffset = 0;
2024
+ let bottomOffset = 0;
2025
+ let totalHeight = 0;
2026
+ let visibleEndForEndReached = 0;
2027
+ if (dynamicHeight && heightsIndex) {
2028
+ totalHeight = heightsIndex.total();
2029
+ if (items.length > 0) {
2030
+ const visibleStart = heightsIndex.indexAtOffset(scrollTop);
2031
+ const visibleEnd = clampVirtual(heightsIndex.lowerBound(scrollTop + effectiveViewportHeight) + 1, visibleStart + 1, items.length);
2032
+ visibleEndForEndReached = visibleEnd;
2033
+ start = clampVirtual(visibleStart - overscan, 0, items.length);
2034
+ end = clampVirtual(visibleEnd + overscan, start, items.length);
2035
+ topOffset = heightsIndex.prefix(start);
2036
+ bottomOffset = Math.max(0, totalHeight - heightsIndex.prefix(end));
2037
+ }
2038
+ }
2039
+ else {
2040
+ const range = computeVirtualRange({
2041
+ itemCount: items.length,
2042
+ itemHeight: fixedItemHeight,
2043
+ scrollTop,
2044
+ viewportHeight: effectiveViewportHeight,
2045
+ overscan,
2046
+ });
2047
+ start = range.start;
2048
+ end = range.end;
2049
+ topOffset = range.topOffset;
2050
+ bottomOffset = range.bottomOffset;
2051
+ totalHeight = range.totalHeight;
2052
+ visibleEndForEndReached = clampVirtual(Math.ceil((scrollTop + effectiveViewportHeight) / fixedItemHeight), 0, items.length);
2053
+ }
2054
+ topSpacer.style.height = `${topOffset}px`;
2055
+ bottomSpacer.style.height = `${bottomOffset}px`;
2056
+ topSpacer.setAttribute('data-dalila-virtual-total', String(totalHeight));
1483
2057
  const orderedClones = [];
1484
2058
  const orderedKeys = [];
1485
2059
  const nextKeys = new Set();
1486
2060
  const changedKeys = new Set();
1487
- for (let i = range.start; i < range.end; i++) {
2061
+ for (let i = start; i < end; i++) {
1488
2062
  const item = items[i];
1489
2063
  let key = keyValueToString(readKeyValue(item, i), i);
1490
2064
  if (nextKeys.has(key)) {
@@ -1510,7 +2084,7 @@ function bindVirtualEach(root, ctx, cleanups) {
1510
2084
  if (!changedKeys.has(key))
1511
2085
  continue;
1512
2086
  removeKey(key);
1513
- orderedClones[i] = createClone(key, items[range.start + i], range.start + i, items.length);
2087
+ orderedClones[i] = createClone(key, items[start + i], start + i, items.length);
1514
2088
  }
1515
2089
  for (const key of Array.from(clonesByKey.keys())) {
1516
2090
  if (nextKeys.has(key))
@@ -1525,6 +2099,22 @@ function bindVirtualEach(root, ctx, cleanups) {
1525
2099
  }
1526
2100
  referenceNode = clone;
1527
2101
  }
2102
+ if (dynamicHeight && rowResizeObserver) {
2103
+ const nextObserved = new Set(orderedClones);
2104
+ for (const clone of Array.from(observedElements)) {
2105
+ if (nextObserved.has(clone))
2106
+ continue;
2107
+ rowResizeObserver.unobserve(clone);
2108
+ observedElements.delete(clone);
2109
+ }
2110
+ for (const clone of orderedClones) {
2111
+ if (observedElements.has(clone))
2112
+ continue;
2113
+ rowResizeObserver.observe(clone);
2114
+ observedElements.add(clone);
2115
+ }
2116
+ }
2117
+ maybeTriggerEndReached(visibleEndForEndReached, items.length);
1528
2118
  }
1529
2119
  let framePending = false;
1530
2120
  let pendingRaf = null;
@@ -1552,28 +2142,89 @@ function bindVirtualEach(root, ctx, cleanups) {
1552
2142
  const onScroll = () => scheduleRender();
1553
2143
  const onResize = () => scheduleRender();
1554
2144
  scrollContainer?.addEventListener('scroll', onScroll, { passive: true });
1555
- if (typeof window !== 'undefined') {
2145
+ let containerResizeObserver = null;
2146
+ if (typeof ResizeObserver !== 'undefined' && scrollContainer) {
2147
+ containerResizeObserver = new ResizeObserver(() => scheduleRender());
2148
+ containerResizeObserver.observe(scrollContainer);
2149
+ }
2150
+ else if (typeof window !== 'undefined') {
1556
2151
  window.addEventListener('resize', onResize);
1557
2152
  }
2153
+ if (dynamicHeight && typeof ResizeObserver !== 'undefined' && heightsIndex) {
2154
+ rowResizeObserver = new ResizeObserver((entries) => {
2155
+ let changed = false;
2156
+ for (const entry of entries) {
2157
+ const target = entry.target;
2158
+ const indexRaw = target.getAttribute('data-dalila-virtual-index');
2159
+ if (!indexRaw)
2160
+ continue;
2161
+ const index = Number(indexRaw);
2162
+ if (!Number.isFinite(index))
2163
+ continue;
2164
+ const measured = entry.contentRect?.height;
2165
+ if (!Number.isFinite(measured) || measured <= 0)
2166
+ continue;
2167
+ changed = heightsIndex.set(index, measured) || changed;
2168
+ }
2169
+ if (changed)
2170
+ scheduleRender();
2171
+ });
2172
+ }
2173
+ const scrollToIndex = (index, options) => {
2174
+ if (!scrollContainer || currentItems.length === 0)
2175
+ return;
2176
+ const safeIndex = clampVirtual(Math.floor(index), 0, currentItems.length - 1);
2177
+ const viewportSize = scrollContainer.clientHeight > 0
2178
+ ? scrollContainer.clientHeight
2179
+ : (dynamicHeight ? estimatedItemHeight * 10 : fixedItemHeight * 10);
2180
+ const align = options?.align ?? 'start';
2181
+ let top = dynamicHeight && heightsIndex
2182
+ ? heightsIndex.prefix(safeIndex)
2183
+ : safeIndex * fixedItemHeight;
2184
+ const itemSize = dynamicHeight && heightsIndex
2185
+ ? heightsIndex.get(safeIndex)
2186
+ : fixedItemHeight;
2187
+ if (align === 'center') {
2188
+ top = top - (viewportSize / 2) + (itemSize / 2);
2189
+ }
2190
+ else if (align === 'end') {
2191
+ top = top - viewportSize + itemSize;
2192
+ }
2193
+ top = Math.max(0, top);
2194
+ if (options?.behavior && typeof scrollContainer.scrollTo === 'function') {
2195
+ scrollContainer.scrollTo({ top, behavior: options.behavior });
2196
+ }
2197
+ else {
2198
+ scrollContainer.scrollTop = top;
2199
+ }
2200
+ scheduleRender();
2201
+ };
2202
+ const virtualApi = {
2203
+ scrollToIndex,
2204
+ refresh: scheduleRender,
2205
+ };
2206
+ if (scrollContainer) {
2207
+ scrollContainer.__dalilaVirtualList = virtualApi;
2208
+ }
1558
2209
  if (isSignal(binding)) {
1559
2210
  bindEffect(scrollContainer ?? el, () => {
1560
2211
  const value = binding();
1561
2212
  if (Array.isArray(value)) {
1562
2213
  warnedNonArray = false;
1563
- currentItems = value;
2214
+ replaceItems(value);
1564
2215
  }
1565
2216
  else {
1566
2217
  if (!warnedNonArray) {
1567
2218
  warnedNonArray = true;
1568
2219
  warn(`d-virtual-each: "${bindingName}" is not an array or signal-of-array`);
1569
2220
  }
1570
- currentItems = [];
2221
+ replaceItems([]);
1571
2222
  }
1572
2223
  renderVirtualList(currentItems);
1573
2224
  });
1574
2225
  }
1575
2226
  else if (Array.isArray(binding)) {
1576
- currentItems = binding;
2227
+ replaceItems(binding);
1577
2228
  renderVirtualList(currentItems);
1578
2229
  }
1579
2230
  else {
@@ -1581,7 +2232,10 @@ function bindVirtualEach(root, ctx, cleanups) {
1581
2232
  }
1582
2233
  cleanups.push(() => {
1583
2234
  scrollContainer?.removeEventListener('scroll', onScroll);
1584
- if (typeof window !== 'undefined') {
2235
+ if (containerResizeObserver) {
2236
+ containerResizeObserver.disconnect();
2237
+ }
2238
+ else if (typeof window !== 'undefined') {
1585
2239
  window.removeEventListener('resize', onResize);
1586
2240
  }
1587
2241
  if (pendingRaf != null && typeof cancelAnimationFrame === 'function') {
@@ -1591,6 +2245,17 @@ function bindVirtualEach(root, ctx, cleanups) {
1591
2245
  if (pendingTimeout != null)
1592
2246
  clearTimeout(pendingTimeout);
1593
2247
  pendingTimeout = null;
2248
+ if (rowResizeObserver) {
2249
+ rowResizeObserver.disconnect();
2250
+ }
2251
+ observedElements.clear();
2252
+ if (scrollContainer) {
2253
+ setVirtualScrollRestoreValue(restoreKey, scrollContainer.scrollTop);
2254
+ const host = scrollContainer;
2255
+ if (host.__dalilaVirtualList === virtualApi) {
2256
+ delete host.__dalilaVirtualList;
2257
+ }
2258
+ }
1594
2259
  for (const key of Array.from(clonesByKey.keys()))
1595
2260
  removeKey(key);
1596
2261
  topSpacer.remove();
@@ -1851,7 +2516,7 @@ function bindEach(root, ctx, cleanups) {
1851
2516
  * Unlike [d-when] which toggles display, d-if adds/removes the element from
1852
2517
  * the DOM entirely. A comment node is left as placeholder for insertion position.
1853
2518
  */
1854
- function bindIf(root, ctx, cleanups) {
2519
+ function bindIf(root, ctx, cleanups, transitionRegistry) {
1855
2520
  const elements = qsaIncludingRoot(root, '[d-if]');
1856
2521
  const processedElse = new Set();
1857
2522
  for (const el of elements) {
@@ -1869,23 +2534,36 @@ function bindIf(root, ctx, cleanups) {
1869
2534
  el.parentNode?.replaceChild(comment, el);
1870
2535
  el.removeAttribute('d-if');
1871
2536
  const htmlEl = el;
2537
+ const transitions = createTransitionController(htmlEl, transitionRegistry, cleanups);
1872
2538
  // Handle d-else branch
1873
2539
  let elseHtmlEl = null;
1874
2540
  let elseComment = null;
2541
+ let elseTransitions = null;
1875
2542
  if (elseEl) {
1876
2543
  processedElse.add(elseEl);
1877
2544
  elseComment = document.createComment('d-else');
1878
2545
  elseEl.parentNode?.replaceChild(elseComment, elseEl);
1879
2546
  elseEl.removeAttribute('d-else');
1880
2547
  elseHtmlEl = elseEl;
2548
+ elseTransitions = createTransitionController(elseHtmlEl, transitionRegistry, cleanups);
1881
2549
  }
1882
2550
  // Apply initial state synchronously to avoid FOUC
1883
2551
  const initialValue = !!resolve(binding);
1884
2552
  if (initialValue) {
1885
2553
  comment.parentNode?.insertBefore(htmlEl, comment);
2554
+ syncPortalElement(htmlEl);
2555
+ if (transitions.hasTransition) {
2556
+ htmlEl.removeAttribute('data-leave');
2557
+ htmlEl.setAttribute('data-enter', '');
2558
+ }
1886
2559
  }
1887
2560
  else if (elseHtmlEl && elseComment) {
1888
2561
  elseComment.parentNode?.insertBefore(elseHtmlEl, elseComment);
2562
+ syncPortalElement(elseHtmlEl);
2563
+ if (elseTransitions?.hasTransition) {
2564
+ elseHtmlEl.removeAttribute('data-leave');
2565
+ elseHtmlEl.setAttribute('data-enter', '');
2566
+ }
1889
2567
  }
1890
2568
  // Then create reactive effect to keep it updated
1891
2569
  if (elseHtmlEl && elseComment) {
@@ -1896,18 +2574,26 @@ function bindIf(root, ctx, cleanups) {
1896
2574
  if (value) {
1897
2575
  if (!htmlEl.parentNode) {
1898
2576
  comment.parentNode?.insertBefore(htmlEl, comment);
2577
+ syncPortalElement(htmlEl);
1899
2578
  }
1900
- if (capturedElseEl.parentNode) {
1901
- capturedElseEl.parentNode.removeChild(capturedElseEl);
1902
- }
2579
+ transitions.enter();
2580
+ elseTransitions?.leave(() => {
2581
+ if (capturedElseEl.parentNode) {
2582
+ capturedElseEl.parentNode.removeChild(capturedElseEl);
2583
+ }
2584
+ });
1903
2585
  }
1904
2586
  else {
1905
- if (htmlEl.parentNode) {
1906
- htmlEl.parentNode.removeChild(htmlEl);
1907
- }
2587
+ transitions.leave(() => {
2588
+ if (htmlEl.parentNode) {
2589
+ htmlEl.parentNode.removeChild(htmlEl);
2590
+ }
2591
+ });
1908
2592
  if (!capturedElseEl.parentNode) {
1909
2593
  capturedElseComment.parentNode?.insertBefore(capturedElseEl, capturedElseComment);
2594
+ syncPortalElement(capturedElseEl);
1910
2595
  }
2596
+ elseTransitions?.enter();
1911
2597
  }
1912
2598
  });
1913
2599
  }
@@ -1917,12 +2603,16 @@ function bindIf(root, ctx, cleanups) {
1917
2603
  if (value) {
1918
2604
  if (!htmlEl.parentNode) {
1919
2605
  comment.parentNode?.insertBefore(htmlEl, comment);
2606
+ syncPortalElement(htmlEl);
1920
2607
  }
2608
+ transitions.enter();
1921
2609
  }
1922
2610
  else {
1923
- if (htmlEl.parentNode) {
1924
- htmlEl.parentNode.removeChild(htmlEl);
1925
- }
2611
+ transitions.leave(() => {
2612
+ if (htmlEl.parentNode) {
2613
+ htmlEl.parentNode.removeChild(htmlEl);
2614
+ }
2615
+ });
1926
2616
  }
1927
2617
  });
1928
2618
  }
@@ -3064,6 +3754,20 @@ function bindComponents(root, ctx, events, cleanups, onMountError) {
3064
3754
  // Global Configuration
3065
3755
  // ============================================================================
3066
3756
  let globalConfig = {};
3757
+ export function createPortalTarget(id) {
3758
+ const targetSignal = signal(null);
3759
+ if (typeof document === 'undefined') {
3760
+ return targetSignal;
3761
+ }
3762
+ let target = document.getElementById(id);
3763
+ if (!target) {
3764
+ target = document.createElement('div');
3765
+ target.id = id;
3766
+ document.body.appendChild(target);
3767
+ }
3768
+ targetSignal.set(target);
3769
+ return targetSignal;
3770
+ }
3067
3771
  /**
3068
3772
  * Set global defaults for all `bind()` / `mount()` calls.
3069
3773
  *
@@ -3116,8 +3820,8 @@ export function configure(config) {
3116
3820
  export function bind(root, ctx, options = {}) {
3117
3821
  // ── Merge global config with per-call options ──
3118
3822
  if (Object.keys(globalConfig).length > 0) {
3119
- const { components: globalComponents, ...globalRest } = globalConfig;
3120
- const { components: localComponents, ...localRest } = options;
3823
+ const { components: globalComponents, transitions: globalTransitions, ...globalRest } = globalConfig;
3824
+ const { components: localComponents, transitions: localTransitions, ...localRest } = options;
3121
3825
  const mergedOpts = { ...globalRest, ...localRest };
3122
3826
  // Combine component registries: local takes precedence over global
3123
3827
  if (globalComponents || localComponents) {
@@ -3142,6 +3846,20 @@ export function bind(root, ctx, options = {}) {
3142
3846
  mergeComponents(localComponents); // local wins
3143
3847
  mergedOpts.components = combined;
3144
3848
  }
3849
+ if (globalTransitions || localTransitions) {
3850
+ const byName = new Map();
3851
+ for (const item of globalTransitions ?? []) {
3852
+ if (!item || typeof item.name !== 'string')
3853
+ continue;
3854
+ byName.set(item.name, item);
3855
+ }
3856
+ for (const item of localTransitions ?? []) {
3857
+ if (!item || typeof item.name !== 'string')
3858
+ continue;
3859
+ byName.set(item.name, item);
3860
+ }
3861
+ mergedOpts.transitions = Array.from(byName.values());
3862
+ }
3145
3863
  options = mergedOpts;
3146
3864
  }
3147
3865
  // ── Resolve string selector ──
@@ -3186,6 +3904,7 @@ export function bind(root, ctx, options = {}) {
3186
3904
  const onMountError = options.onMountError ?? 'log';
3187
3905
  const rawTextSelectors = options.rawTextSelectors ?? DEFAULT_RAW_TEXT_SELECTORS;
3188
3906
  const templatePlanCacheConfig = resolveTemplatePlanCacheConfig(options);
3907
+ const transitionRegistry = createTransitionRegistry(options.transitions);
3189
3908
  const benchSession = createBindBenchSession();
3190
3909
  const htmlRoot = root;
3191
3910
  // HMR support: Register binding context globally in dev mode.
@@ -3229,14 +3948,16 @@ export function bind(root, ctx, options = {}) {
3229
3948
  // 13. d-emit-* bindings (component template → parent)
3230
3949
  bindEmit(root, ctx, cleanups);
3231
3950
  // 14. d-when directive
3232
- bindWhen(root, ctx, cleanups);
3951
+ bindWhen(root, ctx, cleanups, transitionRegistry);
3233
3952
  // 15. d-match directive
3234
3953
  bindMatch(root, ctx, cleanups);
3235
3954
  // 16. Form error displays — BEFORE d-if to bind errors in conditionally rendered sections
3236
3955
  bindError(root, ctx, cleanups);
3237
3956
  bindFormError(root, ctx, cleanups);
3238
- // 17. d-ifmust run last: elements are fully bound before conditional removal
3239
- bindIf(root, ctx, cleanups);
3957
+ // 17. d-portalmove already-bound elements to external targets
3958
+ bindPortal(root, ctx, cleanups);
3959
+ // 18. d-if — must run last: elements are fully bound before conditional removal
3960
+ bindIf(root, ctx, cleanups, transitionRegistry);
3240
3961
  });
3241
3962
  // Bindings complete: remove loading state and mark as ready.
3242
3963
  // Only the top-level bind owns this lifecycle — d-each clones skip it.
@@ -6,8 +6,8 @@
6
6
  *
7
7
  * @module dalila/runtime
8
8
  */
9
- export { bind, autoBind, mount, configure } from './bind.js';
10
- export type { BindOptions, BindContext, BindData, DisposeFunction, BindHandle } from './bind.js';
9
+ export { bind, autoBind, mount, configure, createPortalTarget, getVirtualListController, scrollToVirtualIndex } from './bind.js';
10
+ export type { BindOptions, BindContext, BindData, DisposeFunction, BindHandle, TransitionConfig, VirtualListAlign, VirtualScrollToIndexOptions, VirtualListController } from './bind.js';
11
11
  export { fromHtml } from './fromHtml.js';
12
12
  export type { FromHtmlOptions } from './fromHtml.js';
13
13
  export { defineComponent } from './component.js';
@@ -6,6 +6,6 @@
6
6
  *
7
7
  * @module dalila/runtime
8
8
  */
9
- export { bind, autoBind, mount, configure } from './bind.js';
9
+ export { bind, autoBind, mount, configure, createPortalTarget, getVirtualListController, scrollToVirtualIndex } from './bind.js';
10
10
  export { fromHtml } from './fromHtml.js';
11
11
  export { defineComponent } from './component.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dalila",
3
- "version": "1.9.5",
3
+ "version": "1.9.7",
4
4
  "description": "DOM-first reactive framework based on signals",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",