dalila 1.9.6 → 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
@@ -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
 
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-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;
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) {
@@ -70,6 +70,17 @@ export interface TransitionConfig {
70
70
  leave?: (el: HTMLElement) => void;
71
71
  duration?: number;
72
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;
73
84
  export declare function createPortalTarget(id: string): Signal<Element | null>;
74
85
  /**
75
86
  * Set global defaults for all `bind()` / `mount()` calls.
@@ -1498,6 +1498,47 @@ function readVirtualHeightOption(raw, ctx) {
1498
1498
  }
1499
1499
  return trimmed;
1500
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
+ }
1501
1542
  function createVirtualSpacer(template, kind) {
1502
1543
  const spacer = template.cloneNode(false);
1503
1544
  spacer.removeAttribute('id');
@@ -1519,13 +1560,199 @@ function createVirtualSpacer(template, kind) {
1519
1560
  spacer.style.listStyle = 'none';
1520
1561
  return spacer;
1521
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
+ }
1522
1748
  /**
1523
1749
  * Bind all [d-virtual-each] directives within root.
1524
1750
  *
1525
- * V1 constraints:
1526
- * - Fixed item height (required via d-virtual-item-height)
1527
- * - Vertical virtualization only
1528
- * - 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
1529
1756
  */
1530
1757
  function bindVirtualEach(root, ctx, cleanups) {
1531
1758
  const elements = qsaIncludingRoot(root, '[d-virtual-each]')
@@ -1537,16 +1764,28 @@ function bindVirtualEach(root, ctx, cleanups) {
1537
1764
  const itemHeightBinding = normalizeBinding(el.getAttribute('d-virtual-item-height'));
1538
1765
  const itemHeightRaw = itemHeightBinding ?? el.getAttribute('d-virtual-item-height');
1539
1766
  const itemHeightValue = readVirtualNumberOption(itemHeightRaw, ctx, 'd-virtual-item-height');
1540
- const itemHeight = itemHeightValue == null ? NaN : itemHeightValue;
1541
- 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)) {
1542
1772
  warn(`d-virtual-each: invalid item height on "${bindingName}". Falling back to d-each.`);
1543
1773
  el.setAttribute('d-each', bindingName);
1544
1774
  el.removeAttribute('d-virtual-each');
1545
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');
1546
1779
  el.removeAttribute('d-virtual-overscan');
1547
1780
  el.removeAttribute('d-virtual-height');
1548
1781
  continue;
1549
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);
1550
1789
  const overscanBinding = normalizeBinding(el.getAttribute('d-virtual-overscan'));
1551
1790
  const overscanRaw = overscanBinding ?? el.getAttribute('d-virtual-overscan');
1552
1791
  const overscanValue = readVirtualNumberOption(overscanRaw, ctx, 'd-virtual-overscan');
@@ -1554,15 +1793,20 @@ function bindVirtualEach(root, ctx, cleanups) {
1554
1793
  ? Math.max(0, Math.floor(overscanValue))
1555
1794
  : 6;
1556
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');
1557
1797
  let binding = ctx[bindingName];
1558
1798
  if (binding === undefined) {
1559
1799
  warn(`d-virtual-each: "${bindingName}" not found in context`);
1560
1800
  binding = [];
1561
1801
  }
1802
+ const templatePathBeforeDetach = getElementPositionPath(el);
1562
1803
  const comment = document.createComment('d-virtual-each');
1563
1804
  el.parentNode?.replaceChild(comment, el);
1564
1805
  el.removeAttribute('d-virtual-each');
1565
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');
1566
1810
  el.removeAttribute('d-virtual-overscan');
1567
1811
  el.removeAttribute('d-virtual-height');
1568
1812
  const keyBinding = normalizeBinding(el.getAttribute('d-key'));
@@ -1579,8 +1823,14 @@ function bindVirtualEach(root, ctx, cleanups) {
1579
1823
  if (!scrollContainer.style.overflowY)
1580
1824
  scrollContainer.style.overflowY = 'auto';
1581
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
+ }
1582
1831
  const clonesByKey = new Map();
1583
1832
  const disposesByKey = new Map();
1833
+ const observedElements = new Set();
1584
1834
  const metadataByKey = new Map();
1585
1835
  const itemsByKey = new Map();
1586
1836
  const objectKeyIds = new WeakMap();
@@ -1590,6 +1840,7 @@ function bindVirtualEach(root, ctx, cleanups) {
1590
1840
  const missingKeyWarned = new Set();
1591
1841
  let warnedNonArray = false;
1592
1842
  let warnedViewportFallback = false;
1843
+ let heightsIndex = dynamicHeight ? new VirtualHeightsIndex(0, estimatedItemHeight) : null;
1593
1844
  const getObjectKeyId = (value) => {
1594
1845
  const existing = objectKeyIds.get(value);
1595
1846
  if (existing !== undefined)
@@ -1644,6 +1895,7 @@ function bindVirtualEach(root, ctx, cleanups) {
1644
1895
  }
1645
1896
  return index;
1646
1897
  };
1898
+ let rowResizeObserver = null;
1647
1899
  function createClone(key, item, index, count) {
1648
1900
  const clone = template.cloneNode(true);
1649
1901
  const itemCtx = Object.create(ctx);
@@ -1669,6 +1921,7 @@ function bindVirtualEach(root, ctx, cleanups) {
1669
1921
  itemCtx.$odd = metadata.$odd;
1670
1922
  itemCtx.$even = metadata.$even;
1671
1923
  clone.setAttribute('data-dalila-internal-bound', '');
1924
+ clone.setAttribute('data-dalila-virtual-index', String(index));
1672
1925
  const dispose = bind(clone, itemCtx, { _skipLifecycle: true });
1673
1926
  disposesByKey.set(key, dispose);
1674
1927
  clonesByKey.set(key, clone);
@@ -1684,9 +1937,17 @@ function bindVirtualEach(root, ctx, cleanups) {
1684
1937
  metadata.$odd.set(index % 2 !== 0);
1685
1938
  metadata.$even.set(index % 2 === 0);
1686
1939
  }
1940
+ const clone = clonesByKey.get(key);
1941
+ if (clone) {
1942
+ clone.setAttribute('data-dalila-virtual-index', String(index));
1943
+ }
1687
1944
  }
1688
1945
  function removeKey(key) {
1689
1946
  const clone = clonesByKey.get(key);
1947
+ if (clone && rowResizeObserver && observedElements.has(clone)) {
1948
+ rowResizeObserver.unobserve(clone);
1949
+ observedElements.delete(clone);
1950
+ }
1690
1951
  clone?.remove();
1691
1952
  clonesByKey.delete(key);
1692
1953
  metadataByKey.delete(key);
@@ -1698,31 +1959,106 @@ function bindVirtualEach(root, ctx, cleanups) {
1698
1959
  }
1699
1960
  }
1700
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
+ };
1701
2005
  function renderVirtualList(items) {
1702
2006
  const parent = comment.parentNode;
1703
2007
  if (!parent)
1704
2008
  return;
2009
+ if (dynamicHeight && heightsIndex && heightsIndex.count !== items.length) {
2010
+ heightsIndex.reset(items.length, estimatedItemHeight);
2011
+ }
1705
2012
  const viewportHeightValue = scrollContainer?.clientHeight ?? 0;
1706
- const effectiveViewportHeight = viewportHeightValue > 0 ? viewportHeightValue : itemHeight * 10;
2013
+ const effectiveViewportHeight = viewportHeightValue > 0
2014
+ ? viewportHeightValue
2015
+ : (dynamicHeight ? estimatedItemHeight * 10 : fixedItemHeight * 10);
1707
2016
  const scrollTop = scrollContainer?.scrollTop ?? 0;
1708
2017
  if (viewportHeightValue <= 0 && !warnedViewportFallback) {
1709
2018
  warnedViewportFallback = true;
1710
2019
  warn('d-virtual-each: scroll container has no measurable height. Using fallback viewport size.');
1711
2020
  }
1712
- const range = computeVirtualRange({
1713
- itemCount: items.length,
1714
- itemHeight,
1715
- scrollTop,
1716
- viewportHeight: effectiveViewportHeight,
1717
- overscan,
1718
- });
1719
- topSpacer.style.height = `${range.topOffset}px`;
1720
- 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));
1721
2057
  const orderedClones = [];
1722
2058
  const orderedKeys = [];
1723
2059
  const nextKeys = new Set();
1724
2060
  const changedKeys = new Set();
1725
- for (let i = range.start; i < range.end; i++) {
2061
+ for (let i = start; i < end; i++) {
1726
2062
  const item = items[i];
1727
2063
  let key = keyValueToString(readKeyValue(item, i), i);
1728
2064
  if (nextKeys.has(key)) {
@@ -1748,7 +2084,7 @@ function bindVirtualEach(root, ctx, cleanups) {
1748
2084
  if (!changedKeys.has(key))
1749
2085
  continue;
1750
2086
  removeKey(key);
1751
- 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);
1752
2088
  }
1753
2089
  for (const key of Array.from(clonesByKey.keys())) {
1754
2090
  if (nextKeys.has(key))
@@ -1763,6 +2099,22 @@ function bindVirtualEach(root, ctx, cleanups) {
1763
2099
  }
1764
2100
  referenceNode = clone;
1765
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);
1766
2118
  }
1767
2119
  let framePending = false;
1768
2120
  let pendingRaf = null;
@@ -1790,28 +2142,89 @@ function bindVirtualEach(root, ctx, cleanups) {
1790
2142
  const onScroll = () => scheduleRender();
1791
2143
  const onResize = () => scheduleRender();
1792
2144
  scrollContainer?.addEventListener('scroll', onScroll, { passive: true });
1793
- 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') {
1794
2151
  window.addEventListener('resize', onResize);
1795
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
+ }
1796
2209
  if (isSignal(binding)) {
1797
2210
  bindEffect(scrollContainer ?? el, () => {
1798
2211
  const value = binding();
1799
2212
  if (Array.isArray(value)) {
1800
2213
  warnedNonArray = false;
1801
- currentItems = value;
2214
+ replaceItems(value);
1802
2215
  }
1803
2216
  else {
1804
2217
  if (!warnedNonArray) {
1805
2218
  warnedNonArray = true;
1806
2219
  warn(`d-virtual-each: "${bindingName}" is not an array or signal-of-array`);
1807
2220
  }
1808
- currentItems = [];
2221
+ replaceItems([]);
1809
2222
  }
1810
2223
  renderVirtualList(currentItems);
1811
2224
  });
1812
2225
  }
1813
2226
  else if (Array.isArray(binding)) {
1814
- currentItems = binding;
2227
+ replaceItems(binding);
1815
2228
  renderVirtualList(currentItems);
1816
2229
  }
1817
2230
  else {
@@ -1819,7 +2232,10 @@ function bindVirtualEach(root, ctx, cleanups) {
1819
2232
  }
1820
2233
  cleanups.push(() => {
1821
2234
  scrollContainer?.removeEventListener('scroll', onScroll);
1822
- if (typeof window !== 'undefined') {
2235
+ if (containerResizeObserver) {
2236
+ containerResizeObserver.disconnect();
2237
+ }
2238
+ else if (typeof window !== 'undefined') {
1823
2239
  window.removeEventListener('resize', onResize);
1824
2240
  }
1825
2241
  if (pendingRaf != null && typeof cancelAnimationFrame === 'function') {
@@ -1829,6 +2245,17 @@ function bindVirtualEach(root, ctx, cleanups) {
1829
2245
  if (pendingTimeout != null)
1830
2246
  clearTimeout(pendingTimeout);
1831
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
+ }
1832
2259
  for (const key of Array.from(clonesByKey.keys()))
1833
2260
  removeKey(key);
1834
2261
  topSpacer.remove();
@@ -6,8 +6,8 @@
6
6
  *
7
7
  * @module dalila/runtime
8
8
  */
9
- export { bind, autoBind, mount, configure, createPortalTarget } from './bind.js';
10
- export type { BindOptions, BindContext, BindData, DisposeFunction, BindHandle, TransitionConfig } 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, createPortalTarget } 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.6",
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",