diff --git a/examples/next-ts/package.json b/examples/next-ts/package.json index a02a7ed845..7605fd8f67 100644 --- a/examples/next-ts/package.json +++ b/examples/next-ts/package.json @@ -92,6 +92,7 @@ "@zag-js/tree-view": "workspace:*", "@zag-js/types": "workspace:*", "@zag-js/utils": "workspace:*", + "@zag-js/virtualizer": "workspace:*", "form-serialize": "0.7.2", "image-conversion": "2.1.1", "lucide-react": "0.548.0", diff --git a/examples/next-ts/pages/grid-virtualizer.tsx b/examples/next-ts/pages/grid-virtualizer.tsx new file mode 100644 index 0000000000..df87193f5f --- /dev/null +++ b/examples/next-ts/pages/grid-virtualizer.tsx @@ -0,0 +1,205 @@ +import { GridVirtualizer } from "@zag-js/virtualizer" +import { useCallback, useReducer, useRef, useState } from "react" +import { flushSync } from "react-dom" + +// Grid configuration +const TOTAL_ROWS = 1000 +const TOTAL_COLUMNS = 50 +const CELL_WIDTH = 120 +const CELL_HEIGHT = 40 + +// Generate cell data +const generateCellData = (row: number, col: number) => ({ + id: `${row}-${col}`, + value: `R${row + 1}C${col + 1}`, + color: `hsl(${((row + col) * 37) % 360}, 60%, 90%)`, +}) + +export default function Page() { + const isInitializedRef = useRef(false) + const [isSmooth, setIsSmooth] = useState(true) + const [, rerender] = useReducer(() => ({}), {}) + const [virtualizer] = useState(() => { + return new GridVirtualizer({ + rowCount: TOTAL_ROWS, + columnCount: TOTAL_COLUMNS, + estimatedRowSize: () => CELL_HEIGHT, + estimatedColumnSize: () => CELL_WIDTH, + overscan: { count: 3 }, + gap: 0, + paddingStart: 0, + paddingEnd: 0, + observeScrollElementSize: true, + onRangeChange: () => { + if (!isInitializedRef.current) return + flushSync(rerender) + }, + }) + }) + + const setScrollElementRef = useCallback((element: HTMLDivElement | null) => { + if (!element) return + virtualizer.init(element) + isInitializedRef.current = true + rerender() + return () => virtualizer.destroy() + }, []) + + const virtualCells = virtualizer.getVirtualCells() + const totalWidth = virtualizer.getTotalWidth() + const totalHeight = virtualizer.getTotalHeight() + const range = virtualizer.getRange() + + const visibleRows = range.endRow - range.startRow + 1 + const visibleCols = range.endColumn - range.startColumn + 1 + + return ( +
+
+

Grid Virtualizer Example

+

+ Virtualizing a {TOTAL_ROWS.toLocaleString()} × {TOTAL_COLUMNS} grid ( + {(TOTAL_ROWS * TOTAL_COLUMNS).toLocaleString()} cells) +

+

+ Scroll both horizontally and vertically - only visible cells are rendered! +

+ + + +
+ + + +
+ +
{ + flushSync(() => { + virtualizer.handleScroll(e) + }) + }} + {...virtualizer.getContainerAriaAttrs()} + style={{ + height: "500px", + width: "100%", + maxWidth: "900px", + ...virtualizer.getContainerStyle(), + border: "1px solid #ccc", + borderRadius: "8px", + marginTop: "16px", + }} + > + {/* Total scrollable area */} +
+ {/* Render only visible cells */} + {virtualCells.map((cell) => { + const data = generateCellData(cell.row, cell.column) + const style = virtualizer.getCellStyle(cell) + + return ( +
+ {data.value} +
+ ) + })} +
+
+ + {/* Stats */} +
+
+ Grid Size: {TOTAL_ROWS.toLocaleString()} rows × {TOTAL_COLUMNS} cols +
+
+ Total Cells: {(TOTAL_ROWS * TOTAL_COLUMNS).toLocaleString()} +
+
+ Rendered Cells: {virtualCells.length} +
+
+ Visible Rows: {range.startRow} - {range.endRow} ({visibleRows} rows) +
+
+ Visible Cols: {range.startColumn} - {range.endColumn} ({visibleCols} cols) +
+
+ Total Size: {totalWidth}px × {totalHeight}px +
+
+ {virtualCells.length < 500 ? ( + + ✅ Virtualization working! Only {virtualCells.length} of {(TOTAL_ROWS * TOTAL_COLUMNS).toLocaleString()}{" "} + cells rendered + + ) : ( + ⚠️ Many cells rendered + )} +
+
+
+
+ ) +} diff --git a/examples/next-ts/pages/list-virtualizer.tsx b/examples/next-ts/pages/list-virtualizer.tsx new file mode 100644 index 0000000000..cebe229fe0 --- /dev/null +++ b/examples/next-ts/pages/list-virtualizer.tsx @@ -0,0 +1,168 @@ +import { ListVirtualizer } from "@zag-js/virtualizer" +import { useCallback, useReducer, useRef, useState } from "react" +import { flushSync } from "react-dom" + +const generateItems = (count: number) => + Array.from({ length: count }, (_, i) => ({ + id: i, + name: `Item ${i + 1}`, + description: `This is the description for item ${i + 1}`, + })) + +const items = generateItems(10000) + +export default function Page() { + const isInitializedRef = useRef(false) + const [isSmooth, setIsSmooth] = useState(true) + const [virtualizer] = useState(() => { + return new ListVirtualizer({ + count: items.length, + estimatedSize: () => 142, + overscan: { count: 5 }, + gap: 0, + paddingStart: 0, + paddingEnd: 0, + onRangeChange: () => { + if (!isInitializedRef.current) return + flushSync(rerender) + }, + }) + }) + const [, rerender] = useReducer(() => ({}), {}) + + // Callback ref to measure when element mounts + const setScrollElementRef = useCallback((element: HTMLDivElement | null) => { + if (!element) return + if (virtualizer) { + virtualizer.init(element) + isInitializedRef.current = true + rerender() + return () => virtualizer.destroy() + } + }, []) + + const virtualItems = virtualizer.getVirtualItems() + const totalSize = virtualizer.getTotalSize() + + return ( +
+
+

List Virtualizer Example

+

Scrolling through {items.length.toLocaleString()} items efficiently

+ + + +
+ + + +
+ +
{ + flushSync(() => { + virtualizer.handleScroll(e) + }) + }} + {...virtualizer.getContainerAriaAttrs()} + style={{ + ...virtualizer.getContainerStyle(), + height: "400px", + border: "1px solid #ccc", + marginTop: "16px", + }} + > +
+ {virtualItems.map((virtualItem) => { + const item = items[virtualItem.index] + const style = virtualizer.getItemStyle(virtualItem) + + return ( +
+
+ {item.name} +
+

{item.description}

+
+ Virtual Index: {virtualItem.index} | Start: {virtualItem.start}px | Size: {virtualItem.size}px +
+
+ ) + })} +
+
+ +
+
Total items: {items.length.toLocaleString()}
+
Rendered items: {virtualItems.length}
+
Total height: {totalSize}px
+
+ Visible range: {virtualItems[0]?.index ?? 0} - {virtualItems[virtualItems.length - 1]?.index ?? 0} +
+ {virtualItems.length > 100 && ( +
⚠️ Many items rendered (fast scroll mode)
+ )} + {virtualItems.length <= 100 &&
✅ Virtualization working!
} +
+
+
+ ) +} diff --git a/examples/next-ts/pages/masonry-virtualizer.tsx b/examples/next-ts/pages/masonry-virtualizer.tsx new file mode 100644 index 0000000000..67d7283557 --- /dev/null +++ b/examples/next-ts/pages/masonry-virtualizer.tsx @@ -0,0 +1,153 @@ +import { MasonryVirtualizer } from "@zag-js/virtualizer" +import { useCallback, useReducer, useRef, useState } from "react" +import { flushSync } from "react-dom" + +interface Item { + id: number + title: string + height: number +} + +const makeItems = (count: number): Item[] => + Array.from({ length: count }, (_, i) => ({ + id: i, + title: `Card ${i + 1}`, + height: 80 + ((i * 37) % 220), + })) + +const items = makeItems(8000) + +export default function Page() { + const isInitializedRef = useRef(false) + const [isSmooth, setIsSmooth] = useState(true) + const [, rerender] = useReducer(() => ({}), {}) + const [virtualizer] = useState(() => { + return new MasonryVirtualizer({ + count: items.length, + lanes: 4, + gap: 12, + paddingStart: 12, + paddingEnd: 12, + estimatedSize: () => 180, + observeScrollElementSize: true, + overscan: { count: 16 }, + indexToKey: (index) => items[index]?.id ?? index, + onRangeChange: () => { + if (!isInitializedRef.current) return + flushSync(rerender) + }, + }) + }) + + const setScrollElementRef = useCallback((element: HTMLDivElement | null) => { + if (!element) return + virtualizer.init(element) + isInitializedRef.current = true + rerender() + return () => virtualizer.destroy() + }, []) + + const virtualItems = virtualizer.getVirtualItems() + const totalSize = virtualizer.getTotalSize() + const range = virtualizer.getRange() + + return ( +
+

Masonry Virtualizer Example

+

{items.length.toLocaleString()} items, variable heights, 4 lanes

+ + + +
+ + + +
+ +
{ + flushSync(() => { + virtualizer.handleScroll(e) + }) + }} + {...virtualizer.getContainerAriaAttrs()} + style={{ + ...virtualizer.getContainerStyle(), + height: 650, + width: "100%", + maxWidth: 1100, + border: "1px solid #ddd", + borderRadius: 10, + marginTop: 16, + background: "#fff", + }} + > +
+ {virtualItems.map((virtualItem) => { + const item = items[virtualItem.index] + const style = virtualizer.getItemStyle(virtualItem) + return ( +
+
{item?.title ?? "Missing"}
+
+ Index: {virtualItem.index} • Start: {Math.round(virtualItem.start)}px • Lane: {virtualItem.lane} +
+ {/* Force variable height content */} +
+
+ ) + })} +
+
+ +
+
Total items: {items.length.toLocaleString()}
+
Rendered items: {virtualItems.length}
+
Total height: {Math.round(totalSize)}px
+
+ Visible range (index-based): {range.startIndex} - {range.endIndex} +
+
+
+ ) +} diff --git a/examples/nuxt-ts/package.json b/examples/nuxt-ts/package.json index b7068a0b87..62e7c39188 100644 --- a/examples/nuxt-ts/package.json +++ b/examples/nuxt-ts/package.json @@ -87,6 +87,7 @@ "@zag-js/tree-view": "workspace:*", "@zag-js/types": "workspace:*", "@zag-js/utils": "workspace:*", + "@zag-js/virtualizer": "workspace:*", "@zag-js/vue": "workspace:*", "epic-spinners": "2.0.0", "form-serialize": "0.7.2", diff --git a/examples/preact-ts/package.json b/examples/preact-ts/package.json index 4f1cea13d4..301be37561 100644 --- a/examples/preact-ts/package.json +++ b/examples/preact-ts/package.json @@ -87,6 +87,7 @@ "@zag-js/tree-view": "workspace:*", "@zag-js/types": "workspace:*", "@zag-js/utils": "workspace:*", + "@zag-js/virtualizer": "workspace:*", "lucide-preact": "0.548.0", "match-sorter": "8.1.0", "preact": "10.27.2", diff --git a/examples/solid-ts/package.json b/examples/solid-ts/package.json index 5b4b065eff..ded549b09c 100644 --- a/examples/solid-ts/package.json +++ b/examples/solid-ts/package.json @@ -91,6 +91,7 @@ "@zag-js/tree-view": "workspace:*", "@zag-js/types": "workspace:*", "@zag-js/utils": "workspace:*", + "@zag-js/virtualizer": "workspace:*", "form-serialize": "0.7.2", "lucide-solid": "0.548.0", "match-sorter": "8.1.0", diff --git a/examples/svelte-ts/package.json b/examples/svelte-ts/package.json index 386587d6ed..ac608e98fd 100644 --- a/examples/svelte-ts/package.json +++ b/examples/svelte-ts/package.json @@ -92,6 +92,7 @@ "@zag-js/tree-view": "workspace:*", "@zag-js/types": "workspace:*", "@zag-js/utils": "workspace:*", + "@zag-js/virtualizer": "workspace:*", "form-serialize": "0.7.2", "lucide-svelte": "0.548.0", "match-sorter": "8.1.0" diff --git a/examples/vanilla-ts/package.json b/examples/vanilla-ts/package.json index e682c2b454..02085ed090 100644 --- a/examples/vanilla-ts/package.json +++ b/examples/vanilla-ts/package.json @@ -91,6 +91,7 @@ "@zag-js/tree-view": "workspace:*", "@zag-js/types": "workspace:*", "@zag-js/utils": "workspace:*", + "@zag-js/virtualizer": "workspace:*", "form-serialize": "0.7.2", "match-sorter": "8.1.0", "nanoid": "^5.1.6" diff --git a/packages/utilities/virtualizer/README.md b/packages/utilities/virtualizer/README.md new file mode 100644 index 0000000000..7a5e6c436b --- /dev/null +++ b/packages/utilities/virtualizer/README.md @@ -0,0 +1,22 @@ +# @zag-js/virtualizer + +Framework agnostic, high-perf virtualization library + +## Installation + +```sh +yarn add @zag-js/virtualizer +# or +npm i @zag-js/virtualizer +``` + +## Contribution + +Yes please! See the +[contributing guidelines](https://github.com/chakra-ui/zag/blob/main/CONTRIBUTING.md) +for details. + +## Licence + +This project is licensed under the terms of the +[MIT license](https://github.com/chakra-ui/zag/blob/main/LICENSE). diff --git a/packages/utilities/virtualizer/package.json b/packages/utilities/virtualizer/package.json new file mode 100644 index 0000000000..b275db77aa --- /dev/null +++ b/packages/utilities/virtualizer/package.json @@ -0,0 +1,40 @@ +{ + "name": "@zag-js/virtualizer", + "version": "0.0.0", + "description": "Framework agnostic, high-perf virtualization library", + "keywords": [ + "js", + "utils", + "virtualizer" + ], + "author": "Segun Adebayo ", + "homepage": "https://github.com/chakra-ui/zag#readme", + "license": "MIT", + "main": "src/index.ts", + "repository": "https://github.com/chakra-ui/zag/tree/main/packages/utilities/virtualizer", + "sideEffects": false, + "files": [ + "dist/**/*" + ], + "scripts": { + "build": "tsup", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "publishConfig": { + "access": "public" + }, + "bugs": { + "url": "https://github.com/chakra-ui/zag/issues" + }, + "clean-package": "../../../clean-package.config.json", + "dependencies": { + "@zag-js/types": "workspace:*", + "@zag-js/dom-query": "workspace:*" + }, + "devDependencies": { + "clean-package": "2.2.0" + } +} diff --git a/packages/utilities/virtualizer/src/grid-virtualizer.ts b/packages/utilities/virtualizer/src/grid-virtualizer.ts new file mode 100644 index 0000000000..2b70a4a64f --- /dev/null +++ b/packages/utilities/virtualizer/src/grid-virtualizer.ts @@ -0,0 +1,809 @@ +import type { CSSProperties, GridVirtualizerOptions, OverscanConfig, Range, VirtualCell } from "./types" +import { SizeObserver } from "./utils/size-observer" +import { ScrollRestorationManager } from "./utils/scroll-restoration-manager" +import { resolveOverscanConfig, SCROLL_END_DELAY_MS } from "./utils/overscan" +import { getScrollPositionFromEvent } from "./utils/scroll-helpers" +import { debounce, rafThrottle } from "./utils/debounce" +import { shallowCompare } from "./utils/shallow-compare" +import { CacheManager } from "./utils/cache-manager" +import { SizeTracker } from "./utils/size-tracker" + +interface GridRange { + startRow: number + endRow: number + startColumn: number + endColumn: number +} + +type ResolvedOptions = Required< + Omit +> & + Pick & { + overscan: Required + scrollRestoration?: GridVirtualizerOptions["scrollRestoration"] + } + +/** + * Virtualizer for 2D grid layouts with both row and column virtualization. + * Ideal for spreadsheets, data tables, calendars, and other true grid structures. + */ +export class GridVirtualizer { + private options: ResolvedOptions + + // Scroll state + private scrollTop = 0 + private scrollLeft = 0 + private isScrolling = false + private scrollEndTimer: ReturnType | null = null + private debouncedScrollEnd: ReturnType | null = null + private rafUpdateRange: ReturnType | null = null + + // Viewport dimensions + private viewportWidth = 0 + private viewportHeight = 0 + + // Visible range + private range: GridRange = { startRow: 0, endRow: -1, startColumn: 0, endColumn: -1 } + private lastScrollTop = -1 + private lastScrollLeft = -1 + private rangeCache!: CacheManager + private virtualCellCache!: CacheManager + + // Size tracking with Fenwick tree optimization + private rowSizeTracker!: SizeTracker + private columnSizeTracker!: SizeTracker + + // Scroll element + private scrollElement: Element | Window | null = null + + // Auto-sizing + private sizeObserver?: SizeObserver + + // Scroll restoration + private scrollTopRestoration?: ScrollRestorationManager + private scrollLeftRestoration?: ScrollRestorationManager + + constructor(options: GridVirtualizerOptions) { + const overscan = resolveOverscanConfig(options.overscan) + + this.options = { + gap: 0, + paddingStart: 0, + paddingEnd: 0, + initialOffset: 0, + horizontal: false, + rtl: false, + rootMargin: "50px", + preserveScrollAnchor: true, + observeScrollElementSize: false, + ...options, + overscan, + } as ResolvedOptions + + if (options.initialSize) { + this.viewportWidth = options.initialSize.width + this.viewportHeight = options.initialSize.height + } + + this.initializeScrollHandlers() + this.initializeMeasurements() + this.initializeAutoSizing() + this.initializeScrollRestoration() + } + + /** + * Initialize the grid virtualizer with a concrete scroll element. + * This avoids the common "ref is null during construction" issue. + */ + init(scrollElement: HTMLElement): void { + this.scrollElement = scrollElement + + // Observe size if enabled + this.initializeScrollingElement() + + // Prime measurements + this.measure() + } + + private initializeScrollHandlers(): void { + // Create debounced scroll end handler + this.debouncedScrollEnd = debounce(() => { + this.isScrolling = false + this.scrollTopRestoration?.recordScrollPosition(this.scrollTop, "user") + this.scrollLeftRestoration?.recordScrollPosition(this.scrollLeft, "user") + + this.options.onScroll?.({ + offset: { x: this.scrollLeft, y: this.scrollTop }, + direction: { + x: + this.scrollLeft > this.lastScrollLeft + ? "forward" + : this.scrollLeft < this.lastScrollLeft + ? "backward" + : "idle", + y: + this.scrollTop > this.lastScrollTop ? "forward" : this.scrollTop < this.lastScrollTop ? "backward" : "idle", + }, + isScrolling: false, + }) + }, SCROLL_END_DELAY_MS) + + // Create RAF-throttled range update for smooth scrolling + this.rafUpdateRange = rafThrottle((fn: () => void) => fn()) + } + + private initializeScrollRestoration(): void { + if (this.options.scrollRestoration) { + const { scrollRestoration } = this.options + const key = scrollRestoration.key ?? "default" + const maxEntries = scrollRestoration.maxEntries ?? 10 + const tolerance = scrollRestoration.tolerance ?? 5 + + this.scrollTopRestoration = new ScrollRestorationManager({ + enableScrollRestoration: true, + maxHistoryEntries: maxEntries, + restorationKey: `${key}-top`, + restorationTolerance: tolerance, + }) + this.scrollLeftRestoration = new ScrollRestorationManager({ + enableScrollRestoration: true, + maxHistoryEntries: maxEntries, + restorationKey: `${key}-left`, + restorationTolerance: tolerance, + }) + } + } + + private initializeMeasurements(): void { + this.resetMeasurements() + } + + private resetMeasurements(): void { + const { rowCount, columnCount, gap } = this.options + + // Lazy initialize caches if not already created + if (!this.rangeCache) { + this.rangeCache = new CacheManager(100) + } + if (!this.virtualCellCache) { + this.virtualCellCache = new CacheManager(200) + } + + // Initialize or reset size trackers with Fenwick tree optimization + if (!this.rowSizeTracker) { + this.rowSizeTracker = new SizeTracker(rowCount, gap, (i) => this.options.estimatedRowSize(i)) + } else { + this.rowSizeTracker.reset(rowCount) + } + + if (!this.columnSizeTracker) { + this.columnSizeTracker = new SizeTracker(columnCount, gap, (i) => this.options.estimatedColumnSize(i)) + } else { + this.columnSizeTracker.reset(columnCount) + } + } + + private onItemsChanged(): void { + if (this.rowSizeTracker) { + this.rowSizeTracker.clearMeasurements() + } + if (this.columnSizeTracker) { + this.columnSizeTracker.clearMeasurements() + } + this.resetMeasurements() + } + + private onRowMeasured(index: number, size: number): boolean { + // Initialize size tracker if needed + if (!this.rowSizeTracker) { + this.rowSizeTracker = new SizeTracker(this.options.rowCount, this.options.gap, (i) => + this.options.estimatedRowSize(i), + ) + } + + const changed = this.rowSizeTracker.setMeasuredSize(index, size) + if (!changed) return false + + // Clear range cache as measurements changed + if (this.rangeCache) { + this.rangeCache.clear() + } + + return true + } + + private onColumnMeasured(index: number, size: number): boolean { + // Initialize size tracker if needed + if (!this.columnSizeTracker) { + this.columnSizeTracker = new SizeTracker(this.options.columnCount, this.options.gap, (i) => + this.options.estimatedColumnSize(i), + ) + } + + const changed = this.columnSizeTracker.setMeasuredSize(index, size) + if (!changed) return false + + // Clear range cache as measurements changed + if (this.rangeCache) { + this.rangeCache.clear() + } + + return true + } + + /** + * Initialize auto-sizing if enabled + */ + private initializeAutoSizing(): void { + if (!this.options.observeScrollElementSize) return + + this.sizeObserver = new SizeObserver({ + onResize: (size) => { + this.setViewportSize(size.width, size.height) + this.scrollTopRestoration?.handleResize(this.scrollTop) + this.scrollLeftRestoration?.handleResize(this.scrollLeft) + }, + }) + + // Scroll element size observation is wired during `init(element)` + this.initializeScrollingElement() + } + + /** + * Initialize scrolling element observation + */ + private initializeScrollingElement(): void { + if (!this.sizeObserver) return + + if (this.scrollElement) { + this.sizeObserver.observe(this.scrollElement as Element) + } + } + + /** + * Get estimated row size for a specific row + */ + private getRowSize(rowIndex: number): number { + // Initialize size tracker if needed + if (!this.rowSizeTracker) { + this.rowSizeTracker = new SizeTracker(this.options.rowCount, this.options.gap, (i) => + this.options.estimatedRowSize(i), + ) + } + return this.rowSizeTracker.getSize(rowIndex) + } + + /** + * Get estimated column size for a specific column + */ + private getColumnSize(columnIndex: number): number { + // Initialize size tracker if needed + if (!this.columnSizeTracker) { + this.columnSizeTracker = new SizeTracker(this.options.columnCount, this.options.gap, (i) => + this.options.estimatedColumnSize(i), + ) + } + return this.columnSizeTracker.getSize(columnIndex) + } + + private getPrefixRowSize(index: number): number { + // Initialize size tracker if needed + if (!this.rowSizeTracker) { + this.rowSizeTracker = new SizeTracker(this.options.rowCount, this.options.gap, (i) => + this.options.estimatedRowSize(i), + ) + } + return this.rowSizeTracker.getPrefixSum(index) + } + + private getPrefixColumnSize(index: number): number { + // Initialize size tracker if needed + if (!this.columnSizeTracker) { + this.columnSizeTracker = new SizeTracker(this.options.columnCount, this.options.gap, (i) => + this.options.estimatedColumnSize(i), + ) + } + return this.columnSizeTracker.getPrefixSum(index) + } + + /** + * Optimized binary search for finding row index at offset + */ + private findRowIndexAtOffsetBinary(targetOffset: number): number { + // Initialize size tracker if needed + if (!this.rowSizeTracker) { + this.rowSizeTracker = new SizeTracker(this.options.rowCount, this.options.gap, (i) => + this.options.estimatedRowSize(i), + ) + } + return this.rowSizeTracker.findIndexAtOffset(targetOffset, this.options.paddingStart) + } + + /** + * Optimized binary search for finding column index at offset + */ + private findColumnIndexAtOffsetBinary(targetOffset: number): number { + // Initialize size tracker if needed + if (!this.columnSizeTracker) { + this.columnSizeTracker = new SizeTracker(this.options.columnCount, this.options.gap, (i) => + this.options.estimatedColumnSize(i), + ) + } + return this.columnSizeTracker.findIndexAtOffset(targetOffset, this.options.paddingStart) + } + + /** + * Calculate visible range based on scroll position + */ + private calculateRange(): void { + // Skip if already calculated for this scroll position + if (this.lastScrollTop === this.scrollTop && this.lastScrollLeft === this.scrollLeft) { + return + } + + const { rowCount, columnCount, overscan } = this.options + + if (rowCount === 0 || columnCount === 0 || this.viewportHeight === 0 || this.viewportWidth === 0) { + this.range = { startRow: 0, endRow: -1, startColumn: 0, endColumn: -1 } + this.lastScrollTop = this.scrollTop + this.lastScrollLeft = this.scrollLeft + return + } + + // Check cache first + const cacheKey = `${this.scrollTop}:${this.scrollLeft}:${this.viewportHeight}:${this.viewportWidth}` + const cached = this.rangeCache.get(cacheKey) + if (cached) { + this.range = cached + this.lastScrollTop = this.scrollTop + this.lastScrollLeft = this.scrollLeft + return + } + + // Find visible rows using binary search + const startRow = this.findRowIndexAtOffsetBinary(this.scrollTop) + const endRow = this.findRowIndexAtOffsetBinary(this.scrollTop + this.viewportHeight) + + // Find visible columns using binary search + const startColumn = this.findColumnIndexAtOffsetBinary(this.scrollLeft) + const endColumn = this.findColumnIndexAtOffsetBinary(this.scrollLeft + this.viewportWidth) + + // Apply overscan + const newRange: GridRange = { + startRow: Math.max(0, startRow - overscan.count), + endRow: Math.min(rowCount - 1, endRow + overscan.count), + startColumn: Math.max(0, startColumn - overscan.count), + endColumn: Math.min(columnCount - 1, endColumn + overscan.count), + } + + const rangeChanged = !shallowCompare(this.range, newRange) + + if (rangeChanged) { + this.range = newRange + this.options.onRangeChange?.({ startIndex: newRange.startRow, endIndex: newRange.endRow }) + } + + this.lastScrollTop = this.scrollTop + this.lastScrollLeft = this.scrollLeft + + // Cache the result (CacheManager handles LRU eviction automatically) + this.rangeCache.set(cacheKey, newRange) + } + + /** + * Get all virtual cells that should be rendered + */ + getVirtualCells(): VirtualCell[] { + this.calculateRange() + + const { startRow, endRow, startColumn, endColumn } = this.range + const cells: VirtualCell[] = [] + + // Clean up virtual cell cache for cells outside new range + for (const [key] of this.virtualCellCache) { + const [row, col] = key.split(",").map(Number) + if (row < startRow - 10 || row > endRow + 10 || col < startColumn - 10 || col > endColumn + 10) { + this.virtualCellCache.delete(key) + } + } + + for (let row = startRow; row <= endRow; row++) { + for (let col = startColumn; col <= endColumn; col++) { + const cellKey = `${row},${col}` + + // Try to reuse cached virtual cell + let cachedCell = this.virtualCellCache.get(cellKey) + + const x = this.getPrefixColumnSize(col - 1) + this.options.paddingStart + const y = this.getPrefixRowSize(row - 1) + this.options.paddingStart + const width = this.getColumnSize(col) + const height = this.getRowSize(row) + + if ( + cachedCell && + cachedCell.x === x && + cachedCell.y === y && + cachedCell.width === width && + cachedCell.height === height + ) { + // Reuse cached cell object - no allocation + cells.push(cachedCell) + } else { + // Create or update virtual cell + const virtualCell: VirtualCell = cachedCell || { + row, + column: col, + x, + y, + width, + height, + measureElement: (el: HTMLElement | null) => { + if (el) this.measureCell(el, row, col) + }, + } + + // Update properties if reusing object + if (cachedCell) { + virtualCell.x = x + virtualCell.y = y + virtualCell.width = width + virtualCell.height = height + } + + this.virtualCellCache.set(cellKey, virtualCell) + cells.push(virtualCell) + } + } + } + + return cells + } + + private measureCell(el: HTMLElement, row: number, col: number) { + const rect = el.getBoundingClientRect() + + const rowNeedsUpdate = this.onRowMeasured(row, rect.height) + const colNeedsUpdate = this.onColumnMeasured(col, rect.width) + + if (rowNeedsUpdate || colNeedsUpdate) { + this.forceUpdate() + } + } + + /** + * Get style for a virtual cell + */ + getCellStyle(cell: VirtualCell): CSSProperties { + return { + position: "absolute", + top: 0, + left: 0, + width: cell.width, + height: cell.height, + transform: `translate3d(${cell.x}px, ${cell.y}px, 0)`, + } + } + + /** + * Get container style + */ + getContainerStyle(): CSSProperties { + return { + position: "relative", + overflow: "auto", + willChange: "scroll-position", + WebkitOverflowScrolling: "touch", + } + } + + /** + * Get content/spacer style + */ + getContentStyle(): CSSProperties { + return { + position: "relative", + width: this.getTotalWidth(), + height: this.getTotalHeight(), + pointerEvents: this.isScrolling ? "none" : "auto", + } + } + + /** + * Handle scroll events + */ + handleScroll = (event: Event | { currentTarget: { scrollTop: number; scrollLeft: number } }): void => { + const { scrollTop, scrollLeft } = getScrollPositionFromEvent(event) + + // Quick exit if nothing changed + if (scrollTop === this.scrollTop && scrollLeft === this.scrollLeft) return + + const wasScrolling = this.isScrolling + + this.scrollTop = scrollTop + this.scrollLeft = scrollLeft + this.isScrolling = true + + // Use RAF throttling for smoother updates during fast scrolling + if (this.rafUpdateRange) { + this.rafUpdateRange(() => { + this.calculateRange() + }) + } else { + // Fallback to immediate calculation + this.calculateRange() + } + + // Debounced scroll end detection + if (this.debouncedScrollEnd) { + this.debouncedScrollEnd() + } else if (!wasScrolling) { + // First scroll event - notify immediately + this.options.onScroll?.({ + offset: { x: scrollLeft, y: scrollTop }, + direction: { + x: scrollLeft > this.lastScrollLeft ? "forward" : scrollLeft < this.lastScrollLeft ? "backward" : "idle", + y: scrollTop > this.lastScrollTop ? "forward" : scrollTop < this.lastScrollTop ? "backward" : "idle", + }, + isScrolling: true, + }) + } + } + + /** + * Get scroll handler for container + */ + getScrollHandler() { + return this.handleScroll + } + + /** + * Scroll wiring + * + * React-first: Consumers should call `handleScroll` from their framework scroll handler. + * We intentionally do NOT attach native `scroll` listeners inside the virtualizer to avoid + * double-handling (native listener + framework onScroll). + */ + + /** + * Set viewport dimensions + */ + setViewportSize(width: number, height: number): void { + this.viewportWidth = width + this.viewportHeight = height + this.lastScrollTop = -1 + this.lastScrollLeft = -1 + this.calculateRange() + } + + /** + * Measure the scroll container and set viewport size. + */ + measure(): void { + if (!this.scrollElement) return + const rect = (this.scrollElement as Element).getBoundingClientRect() + this.setViewportSize(rect.width, rect.height) + } + + /** + * Get total width + */ + getTotalWidth(): number { + const { columnCount, paddingStart, paddingEnd } = this.options + if (columnCount === 0) return paddingStart + paddingEnd + + // Initialize size tracker if needed + if (!this.columnSizeTracker) { + this.columnSizeTracker = new SizeTracker(this.options.columnCount, this.options.gap, (i) => + this.options.estimatedColumnSize(i), + ) + } + + return this.columnSizeTracker.getTotalSize(paddingStart, paddingEnd) + } + + /** + * Get total height + */ + getTotalHeight(): number { + const { rowCount, paddingStart, paddingEnd } = this.options + if (rowCount === 0) return paddingStart + paddingEnd + + // Initialize size tracker if needed + if (!this.rowSizeTracker) { + this.rowSizeTracker = new SizeTracker(this.options.rowCount, this.options.gap, (i) => + this.options.estimatedRowSize(i), + ) + } + + return this.rowSizeTracker.getTotalSize(paddingStart, paddingEnd) + } + + /** + * Get current range + */ + getRange(): GridRange { + return { ...this.range } + } + + /** + * Get visible row range + */ + getVisibleRowRange(): Range { + return { startIndex: this.range.startRow, endIndex: this.range.endRow } + } + + /** + * Get visible column range + */ + getVisibleColumnRange(): Range { + return { startIndex: this.range.startColumn, endIndex: this.range.endColumn } + } + + /** + * Scroll to specific row and column + */ + scrollToCell( + row: number, + column: number, + options: { behavior?: ScrollBehavior } = {}, + ): { scrollTop: number; scrollLeft: number } { + const { rowCount, columnCount } = this.options + const scrollTop = this.getPrefixRowSize(Math.min(row, rowCount - 1) - 1) + this.options.paddingStart + const scrollLeft = this.getPrefixColumnSize(Math.min(column, columnCount - 1) - 1) + this.options.paddingStart + + const behavior = options.behavior ?? "auto" + + // If we have a scroll element, perform the DOM scroll and let scroll events + // drive internal state during smooth scrolling. + if (this.scrollElement && typeof (this.scrollElement as any).scrollTo === "function") { + ;(this.scrollElement as any).scrollTo({ top: scrollTop, left: scrollLeft, behavior }) + + // For instant scroll, sync internal state immediately to avoid a blank frame. + if (behavior !== "smooth") { + this.handleScroll({ currentTarget: { scrollTop, scrollLeft } }) + this.scrollTopRestoration?.recordScrollPosition(scrollTop, "programmatic") + this.scrollLeftRestoration?.recordScrollPosition(scrollLeft, "programmatic") + } + } else { + // Fallback: no scroll element yet (or no scrollTo). Update internal state. + this.scrollTop = scrollTop + this.scrollLeft = scrollLeft + this.forceUpdate() + this.scrollTopRestoration?.recordScrollPosition(scrollTop, "programmatic") + this.scrollLeftRestoration?.recordScrollPosition(scrollLeft, "programmatic") + } + + return { scrollTop, scrollLeft } + } + + /** + * Scroll to specific row + */ + scrollToRow(row: number, options: { behavior?: ScrollBehavior } = {}): { scrollTop: number } { + const { rowCount } = this.options + const scrollTop = this.getPrefixRowSize(Math.min(row, rowCount - 1) - 1) + this.options.paddingStart + const behavior = options.behavior ?? "auto" + + if (this.scrollElement && typeof (this.scrollElement as any).scrollTo === "function") { + ;(this.scrollElement as any).scrollTo({ top: scrollTop, behavior }) + if (behavior !== "smooth") { + this.handleScroll({ currentTarget: { scrollTop, scrollLeft: this.scrollLeft } }) + this.scrollTopRestoration?.recordScrollPosition(scrollTop, "programmatic") + } + } else { + this.scrollTop = scrollTop + this.forceUpdate() + this.scrollTopRestoration?.recordScrollPosition(scrollTop, "programmatic") + } + return { scrollTop } + } + + /** + * Scroll to specific column + */ + scrollToColumn(column: number, options: { behavior?: ScrollBehavior } = {}): { scrollLeft: number } { + const { columnCount } = this.options + const scrollLeft = this.getPrefixColumnSize(Math.min(column, columnCount - 1) - 1) + this.options.paddingStart + const behavior = options.behavior ?? "auto" + + if (this.scrollElement && typeof (this.scrollElement as any).scrollTo === "function") { + ;(this.scrollElement as any).scrollTo({ left: scrollLeft, behavior }) + if (behavior !== "smooth") { + this.handleScroll({ currentTarget: { scrollTop: this.scrollTop, scrollLeft } }) + this.scrollLeftRestoration?.recordScrollPosition(scrollLeft, "programmatic") + } + } else { + this.scrollLeft = scrollLeft + this.forceUpdate() + this.scrollLeftRestoration?.recordScrollPosition(scrollLeft, "programmatic") + } + return { scrollLeft } + } + + /** + * Force recalculation + */ + forceUpdate(): void { + this.lastScrollTop = -1 + this.lastScrollLeft = -1 + this.calculateRange() + } + + /** + * Update options + */ + updateOptions(options: Partial): void { + const prevOptions = this.options + + this.options = { ...this.options, ...options } as ResolvedOptions + + if (this.options.rowCount !== prevOptions.rowCount || this.options.columnCount !== prevOptions.columnCount) { + this.onItemsChanged() + } + + const prevKey = prevOptions.scrollRestoration?.key + const newKey = this.options.scrollRestoration?.key + if (newKey && newKey !== prevKey) { + this.scrollTopRestoration?.updateRestorationKey(`${newKey}-top`) + this.scrollLeftRestoration?.updateRestorationKey(`${newKey}-left`) + } + + this.forceUpdate() + } + + /** + * Cleanup + */ + destroy(): void { + if (this.scrollEndTimer) { + clearTimeout(this.scrollEndTimer) + } + this.sizeObserver?.destroy() + this.scrollTopRestoration?.destroy() + this.scrollLeftRestoration?.destroy() + } + + /** + * Restore scroll position from history + */ + restoreScrollPosition(): { scrollTop: number; scrollLeft: number } | null { + const top = this.scrollTopRestoration?.getRestorationPosition() + const left = this.scrollLeftRestoration?.getRestorationPosition() + + if (!top && !left) return null + + const scrollTop = top?.offset ?? this.scrollTop + const scrollLeft = left?.offset ?? this.scrollLeft + + this.scrollTop = scrollTop + this.scrollLeft = scrollLeft + + this.scrollTopRestoration?.recordScrollPosition(scrollTop, "programmatic") + this.scrollLeftRestoration?.recordScrollPosition(scrollLeft, "programmatic") + + return { scrollTop, scrollLeft } + } + + /** + * Get ARIA attributes for the grid container + */ + getContainerAriaAttrs() { + const { rowCount, columnCount } = this.options + return { + role: "grid" as const, + "aria-rowcount": rowCount, + "aria-colcount": columnCount, + } + } + + /** + * Get ARIA attributes for a grid cell + */ + getCellAriaAttrs(rowIndex: number, columnIndex: number) { + return { + role: "gridcell" as const, + "aria-rowindex": rowIndex + 1, + "aria-colindex": columnIndex + 1, + } + } +} diff --git a/packages/utilities/virtualizer/src/index.ts b/packages/utilities/virtualizer/src/index.ts new file mode 100644 index 0000000000..5658b9761f --- /dev/null +++ b/packages/utilities/virtualizer/src/index.ts @@ -0,0 +1,34 @@ +// Core virtualizer types that users need +export type { + CSSProperties, + GridVirtualizerOptions, + ItemState, + ListVirtualizerOptions, + MasonryVirtualizerOptions, + OverscanConfig, + Range, + ScrollHistoryEntry, + ScrollAnchor, + ScrollRestorationConfig, + ScrollState, + ScrollToIndexOptions, + ScrollToIndexResult, + VirtualCell, + VirtualItem, + VirtualizerOptions, +} from "./types" + +export * from "./grid-virtualizer" +export * from "./list-virtualizer" +export * from "./masonry-virtualizer" +export * from "./window-virtualizer" + +// Advanced types for velocity tracking +export type { OverscanCalculationResult, VelocityState } from "./utils/velocity-tracker" + +// Utility classes for advanced use cases +export { SizeObserver } from "./utils/size-observer" + +// Shared utilities +export { resolveOverscanConfig, DEFAULT_OVERSCAN_CONFIG, SCROLL_END_DELAY_MS } from "./utils/overscan" +export { getScrollPositionFromEvent, type ScrollPosition } from "./utils/scroll-helpers" diff --git a/packages/utilities/virtualizer/src/list-virtualizer.ts b/packages/utilities/virtualizer/src/list-virtualizer.ts new file mode 100644 index 0000000000..283c9ea647 --- /dev/null +++ b/packages/utilities/virtualizer/src/list-virtualizer.ts @@ -0,0 +1,426 @@ +import type { CSSProperties, ItemState, ListVirtualizerOptions, Range, VirtualItem } from "./types" +import { Virtualizer } from "./virtualizer" +import { CacheManager } from "./utils/cache-manager" +import { SizeTracker } from "./utils/size-tracker" + +/** + * Virtualizer for one-dimensional lists (vertical or horizontal). + * Supports optional lanes for grid-like layouts. + * Uses incremental measurement with caching for dynamic item sizes. + */ +export class ListVirtualizer extends Virtualizer { + private sizeTracker!: SizeTracker + private groups: ListVirtualizerOptions["groups"] | null = null + private rangeCache!: CacheManager + + constructor(options: ListVirtualizerOptions) { + super(options) + // These will be initialized lazily if needed + if (options.initialSize) { + this.setViewportSize(options.initialSize) + } + } + + private get lanes(): number { + return this.options.lanes ?? 1 + } + + private get isGrid(): boolean { + return this.lanes > 1 + } + + protected initializeMeasurements(): void { + this.resetMeasurements() + } + + protected resetMeasurements(): void { + this.measureCache.clear() + this.groups = this.options.groups?.slice().sort((a, b) => a.startIndex - b.startIndex) ?? null + const count = this.options.count + + // Initialize or reset size tracker + if (!this.sizeTracker) { + this.sizeTracker = new SizeTracker(count, this.options.gap, (i) => this.getEstimatedSize(i)) + } else { + this.sizeTracker.reset(count) + } + + if (!this.rangeCache) { + this.rangeCache = new CacheManager(50) + } + } + + protected onItemsChanged(): void { + if (this.sizeTracker) { + this.sizeTracker.clearMeasurements() + } + this.resetMeasurements() + } + + protected getKnownItemSize(index: number): number | undefined { + return this.itemSizeCache.get(index) + } + + protected onItemMeasured(index: number, size: number): boolean { + // Initialize size tracker if needed + if (!this.sizeTracker) { + this.sizeTracker = new SizeTracker(this.options.count, this.options.gap, (i) => this.getEstimatedSize(i)) + } + + const changed = this.sizeTracker.setMeasuredSize(index, size) + if (!changed) return false + + // Also update parent's cache + this.itemSizeCache.set(index, size) + + // Clear range cache as measurements changed + if (this.rangeCache) { + this.rangeCache.clear() + } + + return true + } + + protected getMeasurement(index: number): { start: number; size: number; end: number } { + const cached = this.measureCache.get(index) + if (cached) return cached + + const { paddingStart, gap } = this.options + const size = this.getItemSize(index) + + let start: number + if (this.isGrid) { + // For grid mode, calculate row-based positioning + const row = Math.floor(index / this.lanes) + const rowHeight = this.getEstimatedSize(0) + gap + start = paddingStart + row * rowHeight + } else { + // For list mode, use prefix sum + const prefix = this.getPrefixSize(index - 1) + start = paddingStart + prefix + } + + const measurement: { start: number; size: number; end: number } = { + start, + size, + end: start + size, + } + + this.measureCache.set(index, measurement) + return measurement + } + + protected getItemLane(index: number): number { + return this.isGrid ? index % this.lanes : 0 + } + + protected findVisibleRange(viewportStart: number, viewportEnd: number): Range { + const { count, paddingStart, gap } = this.options + if (count === 0) return { startIndex: 0, endIndex: -1 } + + // Initialize cache if needed + if (!this.rangeCache) { + this.rangeCache = new CacheManager(50) + } + + // Check range cache first + const cacheKey = `${viewportStart}:${viewportEnd}:${count}` + const cached = this.rangeCache.get(cacheKey) + if (cached) return cached + + let range: Range + + if (this.isGrid) { + // Grid mode: calculate based on rows + const rowHeight = this.getEstimatedSize(0) + gap + const startRow = Math.max(0, Math.floor((viewportStart - paddingStart) / rowHeight)) + const endRow = Math.ceil((viewportEnd - paddingStart) / rowHeight) + + const startIndex = startRow * this.lanes + const endIndex = Math.min(endRow * this.lanes + this.lanes - 1, count - 1) + + range = { startIndex, endIndex } + } else { + // List mode: use size tracker's optimized binary search + // Initialize size tracker if needed + if (!this.sizeTracker) { + this.sizeTracker = new SizeTracker(this.options.count, this.options.gap, (i) => this.getEstimatedSize(i)) + } + + const startIndex = this.sizeTracker.findIndexAtOffset(viewportStart, paddingStart) + const endIndex = this.sizeTracker.findIndexAtOffset(viewportEnd, paddingStart) + + range = { startIndex, endIndex } + } + + // Cache the result (CacheManager handles LRU eviction automatically) + this.rangeCache.set(cacheKey, range) + + return range + } + + getItemState(virtualItem: VirtualItem): ItemState { + const { horizontal, gap } = this.options + const { index, start, size, lane } = virtualItem + + if (this.isGrid) { + const laneSize = this.getLaneSize() + const laneOffset = lane * (laneSize + gap) + + return { + index, + key: virtualItem.key, + position: horizontal ? { x: start, y: laneOffset } : { x: laneOffset, y: start }, + size: { width: laneSize, height: size }, + isScrolling: this.isScrolling, + } + } + + return { + index, + key: virtualItem.key, + position: horizontal ? { x: start, y: 0 } : { x: 0, y: start }, + size: { + width: horizontal ? size : "100%", + height: horizontal ? "100%" : size, + }, + isScrolling: this.isScrolling, + } + } + + getItemStyle(virtualItem: VirtualItem): CSSProperties { + const { horizontal, rtl, gap } = this.options + const { start, lane } = virtualItem + + if (this.isGrid) { + const laneSize = this.getLaneSize() + let x = lane * (laneSize + gap) + const y = start + + // For RTL mode, reverse the lane positioning + if (rtl) { + x = (this.lanes - 1 - lane) * (laneSize + gap) + } + + let transform: string + if (horizontal) { + transform = rtl ? `translate3d(-${y}px, ${x}px, 0)` : `translate3d(${y}px, ${x}px, 0)` + } else { + transform = `translate3d(${x}px, ${y}px, 0)` + } + + return { + position: "absolute", + top: 0, + left: 0, + width: laneSize, + height: undefined, + transform, + } + } + + // List mode + let transform: string + if (horizontal) { + transform = rtl ? `translate3d(-${start}px, 0, 0)` : `translate3d(${start}px, 0, 0)` + } else { + transform = `translate3d(0, ${start}px, 0)` + } + + return { + position: "absolute", + top: 0, + left: 0, + width: horizontal ? undefined : "100%", + height: horizontal ? "100%" : undefined, + transform, + } + } + + getTotalSize(): number { + const { count, paddingStart, paddingEnd, gap } = this.options + + if (count === 0) return paddingStart + paddingEnd + + if (this.isGrid) { + // Grid mode: calculate based on rows + const rows = Math.ceil(count / this.lanes) + const rowHeight = this.getEstimatedSize(0) + return paddingStart + rows * rowHeight + (rows - 1) * gap + paddingEnd + } + + // Initialize size tracker if needed + if (!this.sizeTracker) { + this.sizeTracker = new SizeTracker(this.options.count, this.options.gap, (i) => this.getEstimatedSize(i)) + } + + // List mode: use size tracker's optimized total size calculation + return this.sizeTracker.getTotalSize(paddingStart, paddingEnd) + } + + private getLaneSize(): number { + const { gap } = this.options + if (this.crossAxisSize <= 0) return 200 + return (this.crossAxisSize - (this.lanes - 1) * gap) / this.lanes + } + + protected onCrossAxisSizeChange(): void { + // Grid measurement depends on scroll element cross-axis size for lane sizing + if (this.isGrid) { + this.measureCache.clear() + } + } + + private getItemSize(index: number): number { + // Initialize size tracker if needed + if (!this.sizeTracker) { + this.sizeTracker = new SizeTracker(this.options.count, this.options.gap, (i) => this.getEstimatedSize(i)) + } + + return this.sizeTracker.getSize(index) + } + + private getPrefixSize(index: number): number { + // Initialize size tracker if needed + if (!this.sizeTracker) { + this.sizeTracker = new SizeTracker(this.options.count, this.options.gap, (i) => this.getEstimatedSize(i)) + } + + return this.sizeTracker.getPrefixSum(index) + } + + protected findIndexAtOffset(offset: number): number { + const { paddingStart, gap } = this.options + + if (this.isGrid) { + // Grid mode: calculate based on rows + const adjustedOffset = Math.max(0, offset - paddingStart) + const rowHeight = this.getEstimatedSize(0) + gap + const row = Math.floor(adjustedOffset / rowHeight) + return Math.min(row * this.lanes, this.options.count - 1) + } + + // Initialize size tracker if needed + if (!this.sizeTracker) { + this.sizeTracker = new SizeTracker(this.options.count, this.options.gap, (i) => this.getEstimatedSize(i)) + } + + // List mode: use size tracker's optimized binary search + return this.sizeTracker.findIndexAtOffset(offset, paddingStart) + } + + /** + * Group helpers (for sticky headers) + */ + getGroupForIndex(index: number) { + if (!this.groups || this.groups.length === 0) return null + // Groups are sorted; find the last group whose startIndex <= index + let lo = 0 + let hi = this.groups.length - 1 + let result: (typeof this.groups)[number] | null = null + while (lo <= hi) { + const mid = (lo + hi) >> 1 + const g = this.groups[mid] + if (g.startIndex <= index) { + result = g + lo = mid + 1 + } else { + hi = mid - 1 + } + } + return result + } + + /** + * Returns info needed to render a sticky header. + * translateY lets you push the current header up when the next group approaches. + */ + getGroupHeaderState(viewportOffset: number, headerSizeOverride?: number) { + if (!this.groups || this.groups.length === 0) return null + const currentIndex = this.findIndexAtOffset(viewportOffset) + const currentGroup = this.getGroupForIndex(currentIndex) + if (!currentGroup) return null + + const currentStart = this.getMeasurement(currentGroup.startIndex).start + const currentHeaderSize = headerSizeOverride ?? currentGroup.headerSize ?? 0 + + // Look ahead to next group to compute push-off distance + const currentIdx = this.groups.indexOf(currentGroup) + const nextGroup = this.groups[currentIdx + 1] + const nextStart = nextGroup ? this.getMeasurement(nextGroup.startIndex).start : Infinity + + const distanceToNext = nextStart - viewportOffset - currentHeaderSize + const translateY = Math.min(0, distanceToNext < 0 ? distanceToNext : 0) + + return { + group: currentGroup, + headerSize: currentHeaderSize, + translateY, + offset: viewportOffset - currentStart, + } + } + + /** + * Get ARIA attributes for the list container + */ + getContainerAriaAttrs() { + const { count, horizontal } = this.options + return { + role: "list" as const, + "aria-orientation": horizontal ? ("horizontal" as const) : ("vertical" as const), + "aria-rowcount": horizontal ? undefined : count, + "aria-colcount": horizontal ? count : undefined, + } + } + + /** + * Get ARIA attributes for a list item + */ + getItemAriaAttrs(index: number) { + const { count } = this.options + return { + role: "listitem" as const, + "aria-posinset": index + 1, + "aria-setsize": count, + } + } + + /** + * Prepend items while preserving the current viewport (chat "load older" UX). + * + * This method: + * - captures a keyed anchor (first visible item + intra-item offset) + * - increases `count` by `addedCount` + * - shifts internal measured sizes forward by `addedCount` + * - clears measurement caches (start/end offsets change) + * - restores scroll based on the anchor key + * + * For best results, provide `indexToKey` and preferably `keyToIndex`. + */ + prependItems(addedCount: number): void { + if (addedCount <= 0) return + + const anchor = this.getScrollAnchor() + + // Update count (most callers prepend in data then call this once) + this.options.count = this.options.count + addedCount + + // Shift group metadata if present (sticky headers) + if (this.groups) { + this.groups = this.groups.map((g) => ({ ...g, startIndex: g.startIndex + addedCount })) + } + + // Shift measured sizes forward (index re-mapping) and rebuild size tracker + this.sizeTracker.reindex(addedCount, this.options.count) + + // Item starts/ends have changed; drop all cached measurements/ranges + this.rangeCache?.clear() + this.invalidateMeasurements(0) + this.calculateRange() + + // Restore anchor if possible (keyed) + if (anchor) { + this.restoreScrollAnchor(anchor) + } + } +} diff --git a/packages/utilities/virtualizer/src/masonry-virtualizer.ts b/packages/utilities/virtualizer/src/masonry-virtualizer.ts new file mode 100644 index 0000000000..e8b91375aa --- /dev/null +++ b/packages/utilities/virtualizer/src/masonry-virtualizer.ts @@ -0,0 +1,338 @@ +import type { CSSProperties, ItemState, MasonryVirtualizerOptions, Range, VirtualItem } from "./types" +import { CacheManager } from "./utils/cache-manager" +import { findInsertionIndex, findInsertionIndexRight } from "./utils/binary-search" +import { Virtualizer } from "./virtualizer" + +/** + * Virtualizer for masonry layouts (Pinterest-style multi-lane columns). + */ +export class MasonryVirtualizer extends Virtualizer { + private laneItems!: Array> + private laneOffsets!: number[] + private masonryPositions!: Map + private measuredSizes!: Map + private rangeCache!: CacheManager + + protected initializeMeasurements(): void { + this.recalculatePositions() + } + + protected resetMeasurements(): void { + if (!this.masonryPositions) this.masonryPositions = new Map() + if (!this.measuredSizes) this.measuredSizes = new Map() + if (!this.laneOffsets) this.laneOffsets = [] + if (!this.laneItems) this.laneItems = [] + + this.measureCache.clear() + this.masonryPositions.clear() + this.laneItems = [] + + if (!this.rangeCache) this.rangeCache = new CacheManager(50) + this.rangeCache.clear() + this.recalculatePositions() + } + + protected onItemsChanged(): void { + this.measuredSizes?.clear() + this.resetMeasurements() + } + + protected getKnownItemSize(index: number): number | undefined { + return this.measuredSizes?.get(index) + } + + protected onItemMeasured(index: number, size: number): boolean { + if (!this.measuredSizes) this.measuredSizes = new Map() + const currentSize = this.getItemSize(index) + if (currentSize === size) return false + + this.measuredSizes.set(index, size) + + // Use incremental updates instead of full recalculation + this.updatePositionsFrom(index) + return true + } + + protected onCrossAxisSizeChange(): void { + this.resetMeasurements() + } + + protected getMeasurement(index: number) { + const cached = this.measureCache.get(index) + if (cached) return cached + + // Ensure positions are available before reading + this.recalculatePositions() + return this.measureCache.get(index) ?? { start: 0, size: 0, end: 0 } + } + + protected getItemLane(index: number): number { + return this.masonryPositions?.get(index)?.lane ?? 0 + } + + protected findVisibleRange(viewportStart: number, viewportEnd: number): Range { + const { count, lanes, gap, paddingStart = 0 } = this.options + if (count === 0) return { startIndex: 0, endIndex: -1 } + + // Ensure lane index is available + if (!this.laneItems || this.laneItems.length === 0) this.recalculatePositions() + + if (!this.rangeCache) this.rangeCache = new CacheManager(50) + const cacheKey = `${viewportStart}:${viewportEnd}:${count}:${lanes}:${gap}:${paddingStart}:${this.crossAxisSize}` + const cached = this.rangeCache.get(cacheKey) + if (cached) return cached + + let startIndex = count + let endIndex = -1 + + for (let lane = 0; lane < lanes; lane++) { + const items = this.laneItems[lane] + if (!items || items.length === 0) continue + + // First item whose end >= viewportStart + const firstPos = findInsertionIndex(items, viewportStart, (item) => item.end) + if (firstPos >= items.length) continue + + // Last item whose start <= viewportEnd + const lastPos = findInsertionIndexRight(items, viewportEnd, (item) => item.start) - 1 + if (lastPos < firstPos) continue + + startIndex = Math.min(startIndex, items[firstPos].index) + endIndex = Math.max(endIndex, items[lastPos].index) + } + + if (startIndex > endIndex) { + const near = this.findIndexAtOffset(viewportStart) + const range = { startIndex: near, endIndex: near } + this.rangeCache.set(cacheKey, range) + return range + } + + const range = { startIndex, endIndex } + this.rangeCache.set(cacheKey, range) + return range + } + + protected findIndexAtOffset(offset: number): number { + const { count, lanes } = this.options + if (count === 0) return 0 + + // Ensure lane index is available + if (!this.laneItems || this.laneItems.length === 0) this.recalculatePositions() + + let bestIndex = 0 + let bestStart = -Infinity + + for (let lane = 0; lane < lanes; lane++) { + const items = this.laneItems[lane] + if (!items || items.length === 0) continue + + const pos = findInsertionIndexRight(items, offset, (item) => item.start) - 1 + if (pos < 0) continue + + const candidate = items[pos] + if (candidate.start > bestStart) { + bestStart = candidate.start + bestIndex = candidate.index + } + } + + return bestIndex + } + + getItemState(virtualItem: VirtualItem): ItemState { + const { gap } = this.options + const { index, start, size, lane } = virtualItem + const laneSize = this.getLaneSize() + const x = lane * (laneSize + gap) + + return { + index, + key: virtualItem.key, + position: { x, y: start }, + size: { width: laneSize, height: size }, + isScrolling: this.isScrolling, + } + } + + getItemStyle(virtualItem: VirtualItem): CSSProperties { + const { gap, rtl } = this.options + const { start, lane } = virtualItem + const laneSize = this.getLaneSize() + + let x = lane * (laneSize + gap) + + // For RTL mode, reverse the lane positioning + if (rtl) { + const totalLanes = this.getTotalLanes() + x = (totalLanes - 1 - lane) * (laneSize + gap) + } + + // Don't constrain height - let items size naturally for measurement + return { + position: "absolute", + top: 0, + left: 0, + width: laneSize, + height: undefined, + transform: `translate3d(${x}px, ${start}px, 0)`, + } + } + + getTotalSize(): number { + if (!this.laneOffsets || this.laneOffsets.length === 0) { + this.recalculatePositions() + } + + const { paddingEnd, paddingStart } = this.options + const maxOffset = this.laneOffsets.length ? Math.max(...this.laneOffsets) : paddingStart + return maxOffset + paddingEnd + } + + /** + * Get ARIA attributes for the masonry container + */ + getContainerAriaAttrs() { + const { count, horizontal } = this.options + return { + role: "list" as const, + "aria-orientation": horizontal ? ("horizontal" as const) : ("vertical" as const), + "aria-rowcount": horizontal ? undefined : count, + "aria-colcount": horizontal ? count : undefined, + } + } + + /** + * Get ARIA attributes for a masonry item + */ + getItemAriaAttrs(index: number) { + const { count } = this.options + return { + role: "listitem" as const, + "aria-posinset": index + 1, + "aria-setsize": count, + } + } + + private recalculatePositions(): void { + const { count, lanes, gap, paddingStart = 0 } = this.options + + if (!this.masonryPositions) this.masonryPositions = new Map() + if (!this.measuredSizes) this.measuredSizes = new Map() + if (!this.laneOffsets) this.laneOffsets = [] + if (!this.laneItems) this.laneItems = [] + if (!this.rangeCache) this.rangeCache = new CacheManager(50) + + this.laneOffsets = new Array(lanes).fill(paddingStart) + this.laneItems = new Array(lanes).fill(null).map(() => [] as Array<{ index: number; start: number; end: number }>) + this.masonryPositions.clear() + this.measureCache.clear() + this.rangeCache.clear() + + for (let i = 0; i < count; i++) { + const lane = this.getShortestLane() + const start = this.laneOffsets[lane] + const size = this.getItemSize(i) + const end = start + size + + this.masonryPositions.set(i, { start, lane }) + this.measureCache.set(i, { start, size, end }) + this.laneItems[lane].push({ index: i, start, end }) + + this.laneOffsets[lane] += size + gap + } + } + + private getShortestLane(): number { + if (!this.laneOffsets || this.laneOffsets.length === 0) return 0 + let shortestLane = 0 + let shortestOffset = this.laneOffsets[0] ?? 0 + + for (let lane = 1; lane < this.laneOffsets.length; lane++) { + if (this.laneOffsets[lane] < shortestOffset) { + shortestLane = lane + shortestOffset = this.laneOffsets[lane] + } + } + + return shortestLane + } + + private getItemSize(index: number): number { + const laneSize = this.getLaneSize() + return this.measuredSizes?.get(index) ?? this.getEstimatedSize(index, laneSize) + } + + private getLaneSize(): number { + const { lanes, gap } = this.options + if (this.crossAxisSize <= 0) return 200 + return (this.crossAxisSize - (lanes - 1) * gap) / lanes + } + + private getTotalLanes(): number { + return this.options.lanes + } + + /** + * Incremental update: recalculate positions from a specific index onwards + */ + private updatePositionsFrom(fromIndex: number): void { + const { count, lanes, gap, paddingStart = 0 } = this.options + + if (fromIndex === 0) { + this.recalculatePositions() + return + } + + if (!this.masonryPositions) this.masonryPositions = new Map() + if (!this.measuredSizes) this.measuredSizes = new Map() + if (!this.laneOffsets) this.laneOffsets = new Array(lanes).fill(paddingStart) + if (!this.laneItems) this.laneItems = new Array(lanes).fill(null).map(() => []) + + // Find the lane heights at the point where we're starting the update + const laneHeights = new Array(lanes).fill(paddingStart) + const nextLaneItems = new Array(lanes) + .fill(null) + .map(() => [] as Array<{ index: number; start: number; end: number }>) + + // Calculate lane heights up to fromIndex + for (let i = 0; i < fromIndex; i++) { + const position = this.masonryPositions.get(i) + const cached = this.measureCache.get(i) + if (position && cached) { + nextLaneItems[position.lane].push({ index: i, start: cached.start, end: cached.end }) + laneHeights[position.lane] = Math.max(laneHeights[position.lane], cached.end + gap) + } + } + + // Update positions from fromIndex onwards + for (let i = fromIndex; i < count; i++) { + // Find shortest lane + let shortestLane = 0 + let shortestHeight = laneHeights[0] ?? paddingStart + for (let lane = 1; lane < lanes; lane++) { + if (laneHeights[lane] < shortestHeight) { + shortestLane = lane + shortestHeight = laneHeights[lane] + } + } + + const start = laneHeights[shortestLane] + const size = this.getItemSize(i) + const end = start + size + + // Update position and measurement cache + this.masonryPositions.set(i, { start, lane: shortestLane }) + this.measureCache.set(i, { start, size, end }) + nextLaneItems[shortestLane].push({ index: i, start, end }) + + // Update lane height + laneHeights[shortestLane] = end + gap + } + + // Update lane offsets for total size calculation + this.laneOffsets = laneHeights.map((h) => Math.max(paddingStart, h - gap)) // Remove trailing gap + this.laneItems = nextLaneItems + this.rangeCache?.clear() + } +} diff --git a/packages/utilities/virtualizer/src/types.ts b/packages/utilities/virtualizer/src/types.ts new file mode 100644 index 0000000000..25918ca7a6 --- /dev/null +++ b/packages/utilities/virtualizer/src/types.ts @@ -0,0 +1,255 @@ +export interface VirtualItem { + index: number + key: string | number + start: number + end: number + size: number + lane: number + measureElement: (element: HTMLElement | null) => void +} + +export interface ItemState { + index: number + key: string | number + position: { x: number; y: number } + size: { width: number | string; height: number | string } + isScrolling?: boolean + isVisible?: boolean +} + +export type CSSProperties = Record + +export type ScrollAxisDirection = "forward" | "backward" | "idle" + +export interface ScrollState { + offset: { x: number; y: number } + direction: { x: ScrollAxisDirection; y: ScrollAxisDirection } + isScrolling: boolean +} + +export interface ScrollAnchor { + /** Key of the anchor (usually the first visible item) */ + key: string | number + /** Offset inside the anchor item (px) */ + offset: number +} + +type ScrollAlignment = "start" | "center" | "end" +type ScrollEasingFunction = (t: number) => number +export type ScrollEasing = + | ScrollEasingFunction + | "linear" + | "easeInQuad" + | "easeOutQuad" + | "easeInOutQuad" + | "easeInCubic" + | "easeOutCubic" + | "easeInOutCubic" + | "easeInQuart" + | "easeOutQuart" + | "easeInOutQuart" + | "easeOutExpo" + | "easeOutBack" + +export interface ScrollToIndexOptions { + align?: ScrollAlignment + /** Enable smooth scrolling with custom options */ + smooth?: + | boolean + | { + /** Duration of the scroll animation in milliseconds */ + duration?: number + /** Easing function name or custom function */ + easing?: ScrollEasing + /** Custom scroll function */ + scrollFunction?: (position: { scrollTop?: number; scrollLeft?: number }) => void + } +} + +export interface ScrollToIndexResult { + scrollTop?: number + scrollLeft?: number +} + +export interface ScrollHistoryEntry { + offset: number + timestamp: number + key?: string | number | undefined + reason: "user" | "programmatic" | "resize" | "data-change" +} + +export interface ScrollRestorationConfig { + /** Maximum number of history entries to keep (default: 10) */ + maxEntries?: number + /** Key to identify scroll position for restoration */ + key?: string + /** Tolerance for considering positions equal in pixels (default: 5) */ + tolerance?: number +} + +/** Internal options for ScrollRestorationManager */ +export interface ScrollRestorationOptions { + enableScrollRestoration?: boolean + maxHistoryEntries?: number + restorationKey?: string + restorationTolerance?: number +} + +export interface OverscanConfig { + /** Base number of items to render outside viewport (default: 3) */ + count?: number + /** Enable dynamic overscan based on scroll velocity */ + dynamic?: boolean +} + +export interface Range { + startIndex: number + endIndex: number +} + +export interface MeasureCache { + size: number + start: number + end: number +} + +export interface VirtualizerBaseOptions { + /** + * Get a stable key for an item at an index. This is critical for: + * - preserving scroll position across insertions/removals + * - chat-style "prepend older items" without scroll jump + * + * If omitted, `index` is used as the key. + */ + indexToKey?: (index: number) => string | number + /** + * Optional inverse mapping for `indexToKey`. + * If provided, enables O(1) anchor restoration by key. + */ + keyToIndex?: ((key: string | number) => number) | undefined + + /** Total number of items */ + count: number + + /** + * Estimated item size (height for vertical, width for horizontal). + * + * For masonry layouts, the estimate may depend on the current `laneWidth`. + */ + estimatedSize: (index: number, laneWidth?: number) => number + + /** Gap between items */ + gap?: number + + /** + * Overscan configuration - extra items to render outside viewport. + */ + overscan?: OverscanConfig | number + + /** + * Scroll restoration configuration - enables saving and restoring scroll position. + */ + scrollRestoration?: ScrollRestorationConfig + + /** Horizontal scrolling */ + horizontal?: boolean + + /** RTL (Right-to-Left) mode - affects positioning for horizontal virtualization */ + rtl?: boolean + + /** Scroll padding (start) */ + paddingStart?: number + + /** Scroll padding (end) */ + paddingEnd?: number + + /** Initial scroll offset */ + initialOffset?: number + + /** Root margin for intersection observer */ + rootMargin?: string + + /** Enable scroll anchor preservation during updates */ + preserveScrollAnchor?: boolean + + /** Observe the scroll element size and automatically update virtualizer measurements */ + observeScrollElementSize?: boolean + + /** Callback when scroll state changes */ + onScroll?: (state: ScrollState) => void + + /** Callback when visible range changes */ + onRangeChange?: (range: Range) => void + + /** Callback when item visibility changes */ + onVisibilityChange?: (index: number, isVisible: boolean) => void +} + +/** Alias for VirtualizerBaseOptions */ +export type VirtualizerOptions = VirtualizerBaseOptions + +export interface ListVirtualizerOptions extends VirtualizerBaseOptions { + /** Number of lanes (columns for vertical, rows for horizontal). Defaults to 1. */ + lanes?: number + + /** Optional grouping info for sticky headers */ + groups?: GroupMeta[] + + /** + * The initial size of the viewport for server-side rendering. + * This should be the height for vertical lists or the width for horizontal lists. + */ + initialSize?: number +} + +export interface GroupMeta { + /** Unique group id */ + id: string + /** Starting item index for the group */ + startIndex: number + /** Optional header height */ + headerSize?: number +} + +export interface GridVirtualizerOptions extends Omit { + /** Number of rows in the grid */ + rowCount: number + + /** Number of columns in the grid */ + columnCount: number + + /** Estimated row height for each row */ + estimatedRowSize: (rowIndex: number) => number + + /** Estimated column width for each column */ + estimatedColumnSize: (columnIndex: number) => number + + /** + * The initial size of the viewport for server-side rendering. + * @see https://tanstack.com/virtual/v3/docs/framework/react/ssr + */ + initialSize?: { width: number; height: number } +} + +/** Virtual cell for grid virtualizer */ +export interface VirtualCell { + /** Row index */ + row: number + /** Column index */ + column: number + /** X position (left offset) */ + x: number + /** Y position (top offset) */ + y: number + /** Cell width */ + width: number + /** Cell height */ + height: number + /** Ref callback for measuring cell */ + measureElement: (element: HTMLElement | null) => void +} + +export interface MasonryVirtualizerOptions extends VirtualizerBaseOptions { + /** Number of lanes (columns) */ + lanes: number +} diff --git a/packages/utilities/virtualizer/src/utils/binary-search.ts b/packages/utilities/virtualizer/src/utils/binary-search.ts new file mode 100644 index 0000000000..a82a593406 --- /dev/null +++ b/packages/utilities/virtualizer/src/utils/binary-search.ts @@ -0,0 +1,132 @@ +/** + * Binary search utilities for virtualization + */ + +export interface BinarySearchOptions { + count: number + paddingStart: number + getSizeFn: (index: number) => number + getPrefixSizeFn: (index: number) => number +} + +/** + * Find index at target offset using binary search with hint + */ +export function findIndexAtOffsetBinary( + targetOffset: number, + options: BinarySearchOptions, + startHint: number = 0, +): number { + const { count, paddingStart, getSizeFn, getPrefixSizeFn } = options + + if (count === 0) return 0 + + const adjustedOffset = targetOffset - paddingStart + if (adjustedOffset <= 0) return 0 + + // Use hint as starting point for search + let low = Math.max(0, startHint - 10) + let high = count - 1 + + while (low <= high) { + const mid = Math.floor((low + high) / 2) + const midStart = getPrefixSizeFn(mid - 1) + const midEnd = midStart + getSizeFn(mid) + + if (adjustedOffset >= midStart && adjustedOffset < midEnd) { + return mid + } else if (adjustedOffset < midStart) { + high = mid - 1 + } else { + low = mid + 1 + } + } + + return Math.min(Math.max(low, 0), count - 1) +} + +/** + * Find range of indices visible in viewport using binary search + */ +export function findVisibleRangeBinary( + viewportStart: number, + viewportEnd: number, + options: BinarySearchOptions, +): { startIndex: number; endIndex: number } { + const startIndex = findIndexAtOffsetBinary(viewportStart, options) + const endIndex = findIndexAtOffsetBinary(viewportEnd, options, startIndex) + + return { startIndex, endIndex } +} + +/** + * Binary search for finding exact or nearest match + */ +export function findNearestIndex(items: T[], target: number, getValue: (item: T) => number): number { + if (items.length === 0) return -1 + + let left = 0 + let right = items.length - 1 + let nearest = 0 + let minDiff = Math.abs(getValue(items[0]) - target) + + while (left <= right) { + const mid = Math.floor((left + right) / 2) + const value = getValue(items[mid]) + const diff = Math.abs(value - target) + + if (diff < minDiff) { + minDiff = diff + nearest = mid + } + + if (value === target) { + return mid + } else if (value < target) { + left = mid + 1 + } else { + right = mid - 1 + } + } + + return nearest +} + +/** + * Find insertion index in sorted array + */ +export function findInsertionIndex(items: T[], value: number, getValue: (item: T) => number): number { + let left = 0 + let right = items.length + + while (left < right) { + const mid = Math.floor((left + right) / 2) + if (getValue(items[mid]) < value) { + left = mid + 1 + } else { + right = mid + } + } + + return left +} + +/** + * Find insertion index to the right of existing matches in a sorted array (upper bound). + * Returns the first index where `getValue(item) > value`. + */ +export function findInsertionIndexRight(items: T[], value: number, getValue: (item: T) => number): number { + let left = 0 + let right = items.length + + while (left < right) { + const mid = Math.floor((left + right) / 2) + if (getValue(items[mid]) <= value) { + left = mid + 1 + } else { + right = mid + } + } + + return left +} diff --git a/packages/utilities/virtualizer/src/utils/cache-manager.ts b/packages/utilities/virtualizer/src/utils/cache-manager.ts new file mode 100644 index 0000000000..8bb69049f9 --- /dev/null +++ b/packages/utilities/virtualizer/src/utils/cache-manager.ts @@ -0,0 +1,93 @@ +/** + * Generic cache manager with LRU eviction + * Shared between list and grid virtualizers + */ +export class CacheManager { + private cache: Map + private maxSize: number + + constructor(maxSize: number = 100) { + this.cache = new Map() + this.maxSize = maxSize + } + + /** + * Get cached value + */ + get(key: K): V | undefined { + return this.cache.get(key) + } + + /** + * Set cached value with LRU eviction + */ + set(key: K, value: V): void { + // Delete and re-add to move to end (most recent) + if (this.cache.has(key)) { + this.cache.delete(key) + } + + // Evict oldest if at capacity + if (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value + if (firstKey !== undefined) { + this.cache.delete(firstKey) + } + } + + this.cache.set(key, value) + } + + /** + * Check if key exists + */ + has(key: K): boolean { + return this.cache.has(key) + } + + /** + * Delete specific key + */ + delete(key: K): boolean { + return this.cache.delete(key) + } + + /** + * Clear all cached values + */ + clear(): void { + this.cache.clear() + } + + /** + * Get cache size + */ + get size(): number { + return this.cache.size + } + + /** + * Clean up entries outside a range + */ + cleanupOutsideRange(isInRange: (key: K, value: V) => boolean): void { + for (const [key, value] of this.cache) { + if (!isInRange(key, value)) { + this.cache.delete(key) + } + } + } + + /** + * Make the cache iterable + */ + [Symbol.iterator]() { + return this.cache[Symbol.iterator]() + } + + /** + * Get all entries + */ + entries() { + return this.cache.entries() + } +} diff --git a/packages/utilities/virtualizer/src/utils/debounce.ts b/packages/utilities/virtualizer/src/utils/debounce.ts new file mode 100644 index 0000000000..113d3982e0 --- /dev/null +++ b/packages/utilities/virtualizer/src/utils/debounce.ts @@ -0,0 +1,98 @@ +/** + * Debounce function with immediate option + */ +export function debounce any>( + fn: T, + delay: number, + options: { immediate?: boolean } = {}, +): T & { cancel: () => void; flush: () => void } { + let timeoutId: ReturnType | null = null + let lastArgs: any[] | null = null + let lastThis: any = null + let result: any + + const debounced = function (this: any, ...args: any[]) { + lastArgs = args + lastThis = this + + const callNow = options.immediate && !timeoutId + + const later = () => { + timeoutId = null + if (!options.immediate && lastArgs) { + result = fn.apply(lastThis, lastArgs) + lastArgs = null + lastThis = null + } + } + + if (timeoutId) { + clearTimeout(timeoutId) + } + + timeoutId = setTimeout(later, delay) + + if (callNow) { + result = fn.apply(this, args) + } + + return result + } as T & { cancel: () => void; flush: () => void } + + debounced.cancel = () => { + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + lastArgs = null + lastThis = null + } + + debounced.flush = () => { + if (timeoutId && lastArgs) { + clearTimeout(timeoutId) + result = fn.apply(lastThis, lastArgs) + timeoutId = null + lastArgs = null + lastThis = null + } + } + + return debounced +} + +/** + * RequestAnimationFrame-based throttle for smooth scroll handling + */ +export function rafThrottle any>(fn: T): T & { cancel: () => void } { + let rafId: number | null = null + let lastArgs: any[] | null = null + let lastThis: any = null + + const throttled = function (this: any, ...args: any[]) { + lastArgs = args + lastThis = this + + if (rafId === null) { + rafId = requestAnimationFrame(() => { + if (lastArgs) { + fn.apply(lastThis, lastArgs) + } + rafId = null + lastArgs = null + lastThis = null + }) + } + } as T & { cancel: () => void } + + throttled.cancel = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } + lastArgs = null + lastThis = null + } + + return throttled +} diff --git a/packages/utilities/virtualizer/src/utils/fenwick-tree.ts b/packages/utilities/virtualizer/src/utils/fenwick-tree.ts new file mode 100644 index 0000000000..7a27f9a66b --- /dev/null +++ b/packages/utilities/virtualizer/src/utils/fenwick-tree.ts @@ -0,0 +1,97 @@ +/** + * Fenwick tree (Binary Indexed Tree) backed by a typed array for fast prefix sums. + * Supports point updates and prefix queries in O(log n), suitable for variable-size lists. + */ +export class FenwickTree { + private tree: Float64Array + private size: number + + constructor(length: number) { + this.size = length + this.tree = new Float64Array(length + 1) // 1-based indexing + } + + /** + * Build the tree from an array of values. + */ + build(values: ArrayLike) { + const n = Math.min(values.length, this.size) + for (let i = 0; i < n; i++) { + this.add(i, values[i]) + } + } + + /** + * Add delta to position i (0-based). + */ + add(index: number, delta: number) { + for (let i = index + 1; i <= this.size; i += i & -i) { + this.tree[i] += delta + } + } + + /** + * Set value at i to newValue by computing delta. + */ + set(index: number, newValue: number, currentValue: number) { + const delta = newValue - currentValue + if (delta !== 0) { + this.add(index, delta) + } + } + + /** + * Prefix sum [0, index] inclusive. + */ + prefixSum(index: number): number { + let sum = 0 + for (let i = index + 1; i > 0; i -= i & -i) { + sum += this.tree[i] + } + return sum + } + + /** + * Range sum [l, r] inclusive. + */ + rangeSum(left: number, right: number): number { + if (right < left) return 0 + return this.prefixSum(right) - (left > 0 ? this.prefixSum(left - 1) : 0) + } + + /** + * Find the smallest index such that prefixSum(index) >= target. + * Returns size if target exceeds total sum. + */ + lowerBound(target: number): number { + let idx = 0 + let bitMask = 1 + while (bitMask << 1 <= this.size) { + bitMask <<= 1 + } + + let curr = 0 + for (let bit = bitMask; bit !== 0; bit >>= 1) { + const next = idx + bit + if (next <= this.size && curr + this.tree[next] < target) { + idx = next + curr += this.tree[next] + } + } + + return Math.min(idx, this.size - 1) + } + + /** + * Find index at offset with padding adjustment. + * Subtracts padding before searching. + */ + lowerBoundWithPadding(offset: number, paddingStart: number): number { + const target = Math.max(0, offset - paddingStart) + return this.lowerBound(target) + } + + clear() { + this.tree.fill(0) + } +} diff --git a/packages/utilities/virtualizer/src/utils/intersection-observer-manager.ts b/packages/utilities/virtualizer/src/utils/intersection-observer-manager.ts new file mode 100644 index 0000000000..8eebd1553f --- /dev/null +++ b/packages/utilities/virtualizer/src/utils/intersection-observer-manager.ts @@ -0,0 +1,103 @@ +type VisibilityCallback = (isVisible: boolean, entry: IntersectionObserverEntry) => void + +/** + * Manages IntersectionObserver for tracking item visibility + */ +export class IntersectionObserverManager { + private observer: IntersectionObserver | null = null + private callbacks = new WeakMap() + private observedElements = new Set() + private readonly options: IntersectionObserverInit + + constructor(options: IntersectionObserverInit = {}) { + this.options = { + rootMargin: "50px", + threshold: 0, + ...options, + } + + if (typeof IntersectionObserver !== "undefined") { + this.observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + const callback = this.callbacks.get(entry.target) + if (callback) { + callback(entry.isIntersecting, entry) + } + } + }, this.options) + } + } + + observe(element: Element, callback: VisibilityCallback): void { + if (!this.observer) return + + this.callbacks.set(element, callback) + + if (!this.observedElements.has(element)) { + this.observer.observe(element) + this.observedElements.add(element) + } + } + + unobserve(element: Element): void { + if (!this.observer) return + + this.callbacks.delete(element) + + if (this.observedElements.has(element)) { + this.observer.unobserve(element) + this.observedElements.delete(element) + } + } + + updateRoot(root: Element | null): void { + if (!this.observer) return + + // Recreate observer with new root + const oldCallbacks = new Map() + const oldElements = Array.from(this.observedElements) + + // Save current state + for (const element of oldElements) { + const callback = this.callbacks.get(element) + if (callback) { + oldCallbacks.set(element, callback) + } + } + + // Disconnect old observer + this.disconnect() + + // Create new observer with updated root + this.options.root = root + this.observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + const callback = this.callbacks.get(entry.target) + if (callback) { + callback(entry.isIntersecting, entry) + } + } + }, this.options) + + // Re-observe with new observer + for (const [element, callback] of oldCallbacks) { + this.observe(element, callback) + } + } + + disconnect(): void { + if (this.observer) { + this.observer.disconnect() + this.callbacks = new WeakMap() + this.observedElements.clear() + } + } + + get isSupported(): boolean { + return typeof IntersectionObserver !== "undefined" + } + + get observedCount(): number { + return this.observedElements.size + } +} diff --git a/packages/utilities/virtualizer/src/utils/overscan.ts b/packages/utilities/virtualizer/src/utils/overscan.ts new file mode 100644 index 0000000000..4760833f56 --- /dev/null +++ b/packages/utilities/virtualizer/src/utils/overscan.ts @@ -0,0 +1,15 @@ +import type { OverscanConfig } from "../types" + +export const DEFAULT_OVERSCAN_CONFIG: Required = { + count: 3, + dynamic: false, +} + +export function resolveOverscanConfig(overscan: OverscanConfig | number | undefined): Required { + if (overscan === undefined) return DEFAULT_OVERSCAN_CONFIG + if (typeof overscan === "number") return { ...DEFAULT_OVERSCAN_CONFIG, count: overscan } + return { ...DEFAULT_OVERSCAN_CONFIG, ...overscan } +} + +/** Scroll end detection delay in milliseconds */ +export const SCROLL_END_DELAY_MS = 150 diff --git a/packages/utilities/virtualizer/src/utils/resize-observer-manager.ts b/packages/utilities/virtualizer/src/utils/resize-observer-manager.ts new file mode 100644 index 0000000000..c814604449 --- /dev/null +++ b/packages/utilities/virtualizer/src/utils/resize-observer-manager.ts @@ -0,0 +1,53 @@ +import { resizeObserverBorderBox } from "@zag-js/dom-query" + +type ResizeCallback = (size: { width: number; height: number }) => void + +/** + * Manages ResizeObserver instances for efficient item size tracking using Zag's dom-query + */ +export class ResizeObserverManager { + private unobserveCallbacks = new Map void>() + + observe(element: Element, callback: ResizeCallback): void { + // Unobserve first if already observing + this.unobserve(element) + + const unobserve = resizeObserverBorderBox.observe(element, (entry) => { + const { borderBoxSize } = entry + const size = borderBoxSize?.[0] || { + inlineSize: entry.contentRect.width, + blockSize: entry.contentRect.height, + } + + callback({ + width: size.inlineSize, + height: size.blockSize, + }) + }) + + this.unobserveCallbacks.set(element, unobserve) + } + + unobserve(element: Element): void { + const unobserve = this.unobserveCallbacks.get(element) + if (unobserve) { + unobserve() + this.unobserveCallbacks.delete(element) + } + } + + disconnect(): void { + for (const unobserve of this.unobserveCallbacks.values()) { + unobserve() + } + this.unobserveCallbacks.clear() + } + + get isSupported(): boolean { + return typeof ResizeObserver !== "undefined" + } + + get observedCount(): number { + return this.unobserveCallbacks.size + } +} diff --git a/packages/utilities/virtualizer/src/utils/scroll-helpers.ts b/packages/utilities/virtualizer/src/utils/scroll-helpers.ts new file mode 100644 index 0000000000..cf2b95cb6e --- /dev/null +++ b/packages/utilities/virtualizer/src/utils/scroll-helpers.ts @@ -0,0 +1,70 @@ +import { isWindow } from "@zag-js/dom-query" + +export interface ScrollPosition { + scrollTop: number + scrollLeft: number +} + +export type ScrollTarget = HTMLElement | Window + +/** + * Input type for setters. With `exactOptionalPropertyTypes: true`, callers can end up with + * `number | undefined` values for optional properties; we treat `undefined` as "no-op". + */ +export interface ScrollPositionInput { + scrollTop?: number | undefined + scrollLeft?: number | undefined +} + +/** + * Read scroll position from a scroll container (HTMLElement or Window). + */ +export function getScrollPosition(v: ScrollTarget): ScrollPosition { + if (isWindow(v)) return { scrollTop: v.scrollY, scrollLeft: v.scrollX } + return { scrollTop: v.scrollTop, scrollLeft: v.scrollLeft } +} + +/** + * Set scroll position on a scroll container (HTMLElement or Window). + * + * - Uses `scrollTo` when available to avoid accidentally resetting the other axis. + * - Accepts partial updates (e.g. only scrollTop). + */ +export function setScrollPosition(target: ScrollTarget, next: ScrollPositionInput): void { + const current = getScrollPosition(target) + const scrollTop = next.scrollTop ?? current.scrollTop + const scrollLeft = next.scrollLeft ?? current.scrollLeft + + if (isWindow(target)) { + target.scrollTo(scrollLeft, scrollTop) + return + } + + const element = target + if (typeof element.scrollTo === "function") { + element.scrollTo({ top: scrollTop, left: scrollLeft }) + } else { + element.scrollTop = scrollTop + element.scrollLeft = scrollLeft + } +} + +/** + * Extract scroll position from various event types. + * Handles both native DOM events and React-style synthetic events. + */ +export function getScrollPositionFromEvent( + event: Event | { currentTarget: { scrollTop: number; scrollLeft: number } }, +): ScrollPosition { + // React-style event with currentTarget + if ("currentTarget" in event && event.currentTarget && "scrollTop" in event.currentTarget) { + return { + scrollTop: event.currentTarget.scrollTop, + scrollLeft: event.currentTarget.scrollLeft, + } + } + + // Native DOM event + const target = (event as Event).target as ScrollTarget + return getScrollPosition(target) +} diff --git a/packages/utilities/virtualizer/src/utils/scroll-restoration-manager.ts b/packages/utilities/virtualizer/src/utils/scroll-restoration-manager.ts new file mode 100644 index 0000000000..c766da20a5 --- /dev/null +++ b/packages/utilities/virtualizer/src/utils/scroll-restoration-manager.ts @@ -0,0 +1,224 @@ +import type { ScrollHistoryEntry, ScrollRestorationOptions } from "../types" + +/** + * Manages scroll position history and restoration + * Supports restoration across navigation, data changes, and resizes + */ +export class ScrollRestorationManager { + private history: ScrollHistoryEntry[] = [] + private options: Required + private sessionStorageKey: string + private pendingRestore: ScrollHistoryEntry | null = null + private lastSavedPosition: ScrollHistoryEntry | null = null + + constructor(options: ScrollRestorationOptions = {}) { + this.options = { + enableScrollRestoration: true, + maxHistoryEntries: 10, + restorationKey: "default", + restorationTolerance: 5, + ...options, + } + + this.sessionStorageKey = `zag-virtualizer-scroll-${this.options.restorationKey}` + this.loadFromSessionStorage() + } + + /** + * Record a scroll position in history + */ + recordScrollPosition(offset: number, reason: ScrollHistoryEntry["reason"] = "user", key?: string | number): void { + if (!this.options.enableScrollRestoration) return + + const entry: ScrollHistoryEntry = { + offset: Math.round(offset), + timestamp: Date.now(), + key, + reason, + } + + // Don't record duplicate positions + if ( + this.lastSavedPosition && + Math.abs(this.lastSavedPosition.offset - entry.offset) < this.options.restorationTolerance + ) { + return + } + + this.history.push(entry) + this.lastSavedPosition = entry + + // Trim history to max size + if (this.history.length > this.options.maxHistoryEntries) { + this.history.shift() + } + + this.saveToSessionStorage() + } + + /** + * Get the most recent scroll position for restoration + */ + getRestorationPosition(): ScrollHistoryEntry | null { + if (!this.options.enableScrollRestoration || this.history.length === 0) { + return null + } + + // Return pending restore if available + if (this.pendingRestore) { + const restore = this.pendingRestore + this.pendingRestore = null + return restore + } + + // Return most recent user scroll position + for (let i = this.history.length - 1; i >= 0; i--) { + const entry = this.history[i] + if (entry.reason === "user" || entry.reason === "programmatic") { + return entry + } + } + + return this.history[this.history.length - 1] + } + + /** + * Set a pending restoration position (e.g., from navigation) + */ + setPendingRestore(offset: number, key?: string | number): void { + if (!this.options.enableScrollRestoration) return + + this.pendingRestore = { + offset: Math.round(offset), + timestamp: Date.now(), + key, + reason: "programmatic", + } + } + + /** + * Get scroll history for debugging or analysis + */ + getHistory(): readonly ScrollHistoryEntry[] { + return [...this.history] + } + + /** + * Clear all history + */ + clearHistory(): void { + this.history = [] + this.lastSavedPosition = null + this.pendingRestore = null + this.clearSessionStorage() + } + + /** + * Update restoration key (useful for dynamic content) + */ + updateRestorationKey(key: string): void { + if (key !== this.options.restorationKey) { + this.saveToSessionStorage() // Save current state + this.options.restorationKey = key + this.sessionStorageKey = `zag-virtualizer-scroll-${key}` + this.loadFromSessionStorage() // Load new state + } + } + + /** + * Check if current position should be restored + */ + shouldRestore(currentOffset: number): boolean { + if (!this.options.enableScrollRestoration) return false + + const restoration = this.getRestorationPosition() + if (!restoration) return false + + return Math.abs(currentOffset - restoration.offset) > this.options.restorationTolerance + } + + /** + * Save history to session storage for persistence across page loads + */ + private saveToSessionStorage(): void { + if (typeof sessionStorage === "undefined") return + + try { + const data = { + history: this.history, + timestamp: Date.now(), + } + sessionStorage.setItem(this.sessionStorageKey, JSON.stringify(data)) + } catch (error) { + console.warn("Failed to save scroll restoration data:", error) + } + } + + /** + * Load history from session storage + */ + private loadFromSessionStorage(): void { + if (typeof sessionStorage === "undefined") return + + try { + const stored = sessionStorage.getItem(this.sessionStorageKey) + if (!stored) return + + const data = JSON.parse(stored) + + // Only restore recent data (within last hour) + const oneHour = 60 * 60 * 1000 + if (Date.now() - data.timestamp < oneHour && Array.isArray(data.history)) { + this.history = data.history + this.lastSavedPosition = this.history[this.history.length - 1] || null + } + } catch (error) { + console.warn("Failed to load scroll restoration data:", error) + } + } + + /** + * Clear session storage + */ + private clearSessionStorage(): void { + if (typeof sessionStorage === "undefined") return + + try { + sessionStorage.removeItem(this.sessionStorageKey) + } catch (error) { + console.warn("Failed to clear scroll restoration data:", error) + } + } + + /** + * Handle data changes that might affect scroll position + */ + handleDataChange(): void { + if (!this.options.enableScrollRestoration) return + + // Record current position before data change + const lastEntry = this.history[this.history.length - 1] + if (lastEntry) { + this.recordScrollPosition(lastEntry.offset, "data-change") + } + } + + /** + * Handle resize that might affect scroll position + */ + handleResize(currentOffset: number): void { + if (!this.options.enableScrollRestoration) return + + this.recordScrollPosition(currentOffset, "resize") + } + + /** + * Clean up resources + */ + destroy(): void { + this.saveToSessionStorage() + this.history = [] + this.lastSavedPosition = null + this.pendingRestore = null + } +} diff --git a/packages/utilities/virtualizer/src/utils/shallow-compare.ts b/packages/utilities/virtualizer/src/utils/shallow-compare.ts new file mode 100644 index 0000000000..b06e857855 --- /dev/null +++ b/packages/utilities/virtualizer/src/utils/shallow-compare.ts @@ -0,0 +1,50 @@ +/** + * Shallow comparison of two objects + * Returns true if objects are equal at the first level + */ +export function shallowCompare>(a: T, b: T): boolean { + if (a === b) return true + if (!a || !b) return false + + const keysA = Object.keys(a) + const keysB = Object.keys(b) + + if (keysA.length !== keysB.length) return false + + for (const key of keysA) { + if (a[key] !== b[key]) return false + } + + return true +} + +/** + * Memoize a function with shallow comparison of arguments + */ +export function memoShallow any>(fn: T): T { + let lastArgs: any[] | undefined + let lastResult: any + + return ((...args: any[]) => { + if (!lastArgs || !shallowArrayCompare(args, lastArgs)) { + lastArgs = args + lastResult = fn(...args) + } + return lastResult + }) as T +} + +/** + * Shallow comparison of two arrays + */ +function shallowArrayCompare(a: T[], b: T[]): boolean { + if (a === b) return true + if (!a || !b) return false + if (a.length !== b.length) return false + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + + return true +} diff --git a/packages/utilities/virtualizer/src/utils/size-observer.ts b/packages/utilities/virtualizer/src/utils/size-observer.ts new file mode 100644 index 0000000000..1cb0c54966 --- /dev/null +++ b/packages/utilities/virtualizer/src/utils/size-observer.ts @@ -0,0 +1,81 @@ +import { resizeObserverBorderBox } from "@zag-js/dom-query" + +export interface SizeObserverCallbacks { + onResize?: (size: { width: number; height: number }) => void +} + +/** + * Observes and tracks container size changes + */ +export class SizeObserver { + private element: Element | null = null + private unobserveCallback: (() => void) | undefined = undefined + private callbacks: SizeObserverCallbacks = {} + private lastSize = { width: 0, height: 0 } + + constructor(callbacks: SizeObserverCallbacks = {}) { + this.callbacks = callbacks + } + + /** + * Start observing an element's size + */ + observe(element: Element): void { + if (this.element === element) return + + this.unobserve() + this.element = element + + this.unobserveCallback = resizeObserverBorderBox.observe(element, (entry) => { + const { borderBoxSize } = entry + const size = borderBoxSize?.[0] || { + inlineSize: entry.contentRect.width, + blockSize: entry.contentRect.height, + } + + const newSize = { + width: size.inlineSize, + height: size.blockSize, + } + + // Only notify if size actually changed + if (newSize.width !== this.lastSize.width || newSize.height !== this.lastSize.height) { + this.lastSize = newSize + this.callbacks.onResize?.(newSize) + } + }) + } + + /** + * Stop observing + */ + unobserve(): void { + if (this.unobserveCallback) { + this.unobserveCallback() + this.unobserveCallback = undefined + } + this.element = null + } + + /** + * Get current size + */ + getCurrentSize(): { width: number; height: number } { + return { ...this.lastSize } + } + + /** + * Set resize callback + */ + setResizeCallback(callback: (size: { width: number; height: number }) => void): void { + this.callbacks.onResize = callback + } + + /** + * Cleanup + */ + destroy(): void { + this.unobserve() + this.callbacks = {} + } +} diff --git a/packages/utilities/virtualizer/src/utils/size-tracker.ts b/packages/utilities/virtualizer/src/utils/size-tracker.ts new file mode 100644 index 0000000000..d1aeded928 --- /dev/null +++ b/packages/utilities/virtualizer/src/utils/size-tracker.ts @@ -0,0 +1,156 @@ +/** + * Optimized size tracking with Fenwick tree for fast prefix sums + * Combines the benefits of Fenwick tree with caching + */ +import { FenwickTree } from "./fenwick-tree" + +export class SizeTracker { + private fenwick: FenwickTree + private sizes: Float64Array + private measuredSizes: Map = new Map() + private count: number + private gap: number + private estimateFn: (index: number) => number + private initialized = false + + constructor(count: number, gap: number, estimateFn: (index: number) => number) { + this.count = count + this.gap = gap + this.estimateFn = estimateFn + this.fenwick = new FenwickTree(count) + this.sizes = new Float64Array(count) + } + + /** + * Initialize all sizes lazily + */ + private ensureInitialized(): void { + if (this.initialized) return + + for (let i = 0; i < this.count; i++) { + const size = this.getSize(i) + this.sizes[i] = size + this.fenwick.add(i, size + (i < this.count - 1 ? this.gap : 0)) + } + this.initialized = true + } + + /** + * Get size for an index (measured > estimated) + */ + getSize(index: number): number { + // Priority 1: Measured size + const measured = this.measuredSizes.get(index) + if (measured !== undefined) return measured + + // Priority 2: Cached size + if (this.initialized && this.sizes[index] > 0) { + return this.sizes[index] + } + + // Priority 3: Estimate + const size = this.estimateFn(index) + if (this.initialized) { + this.sizes[index] = size + } + return size + } + + /** + * Update measured size + */ + setMeasuredSize(index: number, size: number): boolean { + const currentSize = this.getSize(index) + if (currentSize === size) return false + + this.measuredSizes.set(index, size) + + if (this.initialized) { + const delta = size - this.sizes[index] + this.sizes[index] = size + + if (delta !== 0) { + // O(log n) update + this.fenwick.add(index, delta) + } + } + + return true + } + + /** + * Get prefix sum up to index (sum of all sizes from 0 to index) + */ + getPrefixSum(index: number): number { + this.ensureInitialized() + if (index < 0) return 0 + return this.fenwick.prefixSum(index) + } + + /** + * Find index at offset using binary search on fenwick tree + */ + findIndexAtOffset(offset: number, paddingStart: number): number { + this.ensureInitialized() + return this.fenwick.lowerBoundWithPadding(offset, paddingStart) + } + + /** + * Reset all measurements + */ + reset(count: number): void { + this.count = count + this.measuredSizes.clear() + this.fenwick = new FenwickTree(count) + this.sizes = new Float64Array(count) + this.initialized = false + } + + /** + * Reindex internal measurements when items are inserted/removed before existing indices. + * Used for chat-style prepend without losing measured sizes. + * + * Example: prepend N items => old index i becomes i + N. + * + * Note: This is an O(m) operation over the number of measured items and is + * intended for relatively infrequent structural changes (not per-scroll). + */ + reindex(delta: number, newCount: number): void { + if (delta === 0 && newCount === this.count) return + + const nextMeasured = new Map() + for (const [index, size] of this.measuredSizes) { + const nextIndex = index + delta + if (nextIndex >= 0 && nextIndex < newCount) { + nextMeasured.set(nextIndex, size) + } + } + + this.count = newCount + this.measuredSizes = nextMeasured + this.fenwick = new FenwickTree(newCount) + this.sizes = new Float64Array(newCount) + this.initialized = false + } + + /** + * Clear only measured sizes, keep estimates + */ + clearMeasurements(): void { + this.measuredSizes.clear() + // Don't reset fenwick tree - keep structure + } + + /** + * Get total size + */ + getTotalSize(paddingStart: number, paddingEnd: number): number { + if (this.count === 0) return paddingStart + paddingEnd + this.ensureInitialized() + + // Prefix sum at the last index already represents the total content size + // (sizes + inter-item gaps, no trailing gap). + const total = this.getPrefixSum(this.count - 1) + return paddingStart + total + paddingEnd + } +} diff --git a/packages/utilities/virtualizer/src/utils/smooth-scroll.ts b/packages/utilities/virtualizer/src/utils/smooth-scroll.ts new file mode 100644 index 0000000000..90d13b54b4 --- /dev/null +++ b/packages/utilities/virtualizer/src/utils/smooth-scroll.ts @@ -0,0 +1,166 @@ +import { AnimationFrame } from "@zag-js/dom-query" +import { getScrollPosition, setScrollPosition, type ScrollTarget } from "./scroll-helpers" + +export type EasingFunction = (t: number) => number + +export interface SmoothScrollOptions { + /** Duration of the scroll animation in milliseconds */ + duration?: number + /** Easing function for the animation */ + easing?: EasingFunction + /** Custom scroll function to use instead of element.scrollTo */ + scrollFunction?: (position: { scrollTop?: number; scrollLeft?: number }) => void + /** Callback when scroll completes */ + onComplete?: () => void + /** Callback when scroll is cancelled */ + onCancel?: () => void +} + +export interface SmoothScrollResult { + /** Promise that resolves when scroll completes */ + promise: Promise + /** Function to cancel the ongoing scroll */ + cancel: () => void +} + +// Built-in easing functions +export const easingFunctions = { + linear: (t: number): number => t, + + easeInQuad: (t: number): number => t * t, + easeOutQuad: (t: number): number => t * (2 - t), + easeInOutQuad: (t: number): number => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t), + + easeInCubic: (t: number): number => t * t * t, + easeOutCubic: (t: number): number => --t * t * t + 1, + easeInOutCubic: (t: number): number => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1), + + easeInQuart: (t: number): number => t * t * t * t, + easeOutQuart: (t: number): number => 1 - --t * t * t * t, + easeInOutQuart: (t: number): number => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t), + + // Smooth deceleration - good for scroll animations + easeOutExpo: (t: number): number => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)), + + // Spring-like animation + easeOutBack: (t: number): number => { + const c1 = 1.70158 + const c3 = c1 + 1 + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2) + }, +} as const + +/** + * Perform smooth scrolling with customizable options + */ +export function smoothScrollTo( + element: Element | Window, + target: { x?: number; y?: number }, + options: SmoothScrollOptions = {}, +): SmoothScrollResult { + const { duration = 600, easing = easingFunctions.easeOutCubic, scrollFunction, onComplete, onCancel } = options + + let cancelled = false + const hasRAF = + typeof globalThis.requestAnimationFrame === "function" && typeof globalThis.cancelAnimationFrame === "function" + const frame = hasRAF ? AnimationFrame.create() : null + + const targetEl = element as ScrollTarget + + // Get current scroll position + const current = getScrollPosition(targetEl) + const currentX = current.scrollLeft + const currentY = current.scrollTop + + const startX = currentX + const startY = currentY + const targetX = target.x ?? startX + const targetY = target.y ?? startY + + const deltaX = targetX - startX + const deltaY = targetY - startY + + // If RAF isn't available, fall back to an instant scroll (no polyfill). + if (!hasRAF) { + if (scrollFunction) { + scrollFunction({ scrollLeft: targetX, scrollTop: targetY }) + } else { + setScrollPosition(targetEl, { scrollLeft: targetX, scrollTop: targetY }) + } + + onComplete?.() + return { + promise: Promise.resolve(), + cancel: () => {}, + } + } + + // If no movement needed, complete immediately + if (Math.abs(deltaX) < 1 && Math.abs(deltaY) < 1) { + onComplete?.() + return { + promise: Promise.resolve(), + cancel: () => {}, + } + } + + const startTime = performance.now() + + let resolvePromise: (() => void) | null = null + + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + + const animate = (currentTime: number) => { + if (cancelled) { + onCancel?.() + // Cancellation is an expected control-flow event (e.g. starting a new scroll). + // Treat it as a normal completion to avoid unhandled promise rejections. + resolvePromise?.() + return + } + + const elapsed = currentTime - startTime + const progress = Math.min(elapsed / duration, 1) + const easedProgress = easing(progress) + + const newX = startX + deltaX * easedProgress + const newY = startY + deltaY * easedProgress + + // Use custom scroll function if provided + if (scrollFunction) { + scrollFunction({ + scrollLeft: newX, + scrollTop: newY, + }) + } else { + setScrollPosition(targetEl, { scrollLeft: newX, scrollTop: newY }) + } + + if (progress < 1) { + frame!.request(() => { + // `AnimationFrame` doesn't pass a timestamp. Use `performance.now()`. + animate(performance.now()) + }) + } else { + onComplete?.() + resolvePromise?.() + } + } + + // Start animation + frame!.request(() => { + animate(performance.now()) + }) + + const cancel = () => { + cancelled = true + frame!.cancel() + onCancel?.() + // See note above: cancellation is not exceptional. + resolvePromise?.() + } + + return { promise, cancel } +} diff --git a/packages/utilities/virtualizer/src/utils/velocity-tracker.ts b/packages/utilities/virtualizer/src/utils/velocity-tracker.ts new file mode 100644 index 0000000000..002db0298b --- /dev/null +++ b/packages/utilities/virtualizer/src/utils/velocity-tracker.ts @@ -0,0 +1,238 @@ +const now = typeof performance !== "undefined" ? () => performance.now() : () => Date.now() + +export interface VelocityState { + velocity: number + direction: "forward" | "backward" | "idle" + acceleration: number + isStable: boolean +} + +export interface OverscanCalculationResult { + leading: number + trailing: number + total: number +} + +/** + * Velocity tracker for dynamic overscan. + * Tracks recent scroll deltas to estimate velocity, direction, and acceleration, + * then converts that into leading/trailing overscan counts. + */ +export class VelocityTracker { + private lastOffset = 0 + private lastTime = 0 + + // Enhanced velocity tracking with direction + private velocityHistory: Array<{ velocity: number; direction: "forward" | "backward"; timestamp: number }> = [] + private readonly historySize = 12 // Increased for better averaging + private readonly stabilityThreshold = 0.05 // Lower threshold - more sensitive to changes + + // Velocity thresholds (pixels per millisecond) - LOWERED for earlier activation + private readonly lowVelocityThreshold = 0.1 // Was 0.3 - now triggers at slower speeds + private readonly mediumVelocityThreshold = 0.5 // Was 1.0 - activates medium overscan sooner + private readonly highVelocityThreshold = 1.5 // Was 2.5 - max overscan triggers earlier + + constructor() { + this.lastTime = now() + } + + /** + * Update velocity tracking with new offset + */ + update(offset: number, rtl?: boolean): VelocityState { + const currentTime = now() + const deltaTime = currentTime - this.lastTime + const deltaOffset = offset - this.lastOffset + + if (deltaTime <= 0) { + return this.getCurrentVelocityState() + } + + const rawVelocity = deltaOffset / deltaTime + + // In RTL mode, positive velocity means moving backward (right to left) + let direction: "forward" | "backward" + if (rtl) { + direction = rawVelocity > 0 ? "backward" : "forward" + } else { + direction = rawVelocity > 0 ? "forward" : "backward" + } + + const velocity = Math.abs(rawVelocity) + + // Add to history + this.velocityHistory.push({ + velocity, + direction, + timestamp: currentTime, + }) + + // Trim old history + if (this.velocityHistory.length > this.historySize) { + this.velocityHistory.shift() + } + + // Clean up old entries (older than 200ms) + const cutoff = currentTime - 200 + this.velocityHistory = this.velocityHistory.filter((entry) => entry.timestamp >= cutoff) + + this.lastOffset = offset + this.lastTime = currentTime + + return this.getCurrentVelocityState() + } + + /** + * Get current velocity state with acceleration and stability info + */ + getCurrentVelocityState(): VelocityState { + if (this.velocityHistory.length === 0) { + return { + velocity: 0, + direction: "idle", + acceleration: 0, + isStable: true, + } + } + + const recentEntries = this.velocityHistory.slice(-3) + const avgVelocity = recentEntries.reduce((sum, entry) => sum + entry.velocity, 0) / recentEntries.length + + // Calculate acceleration (change in velocity) + let acceleration = 0 + if (this.velocityHistory.length >= 2) { + const recent = this.velocityHistory[this.velocityHistory.length - 1] + const previous = this.velocityHistory[this.velocityHistory.length - 2] + acceleration = recent.velocity - previous.velocity + } + + // Determine if velocity is stable + const velocityVariance = this.calculateVelocityVariance() + const isStable = velocityVariance < this.stabilityThreshold + + // Determine direction (most common recent direction) + const directionCounts = recentEntries.reduce( + (counts, entry) => { + counts[entry.direction] = (counts[entry.direction] || 0) + 1 + return counts + }, + {} as Record, + ) + + let direction: "forward" | "backward" | "idle" = "idle" + if (avgVelocity > 0.05) { + direction = directionCounts.forward > directionCounts.backward ? "forward" : "backward" + } + + return { + velocity: avgVelocity, + direction, + acceleration, + isStable, + } + } + + /** + * Calculate velocity variance for stability detection + */ + private calculateVelocityVariance(): number { + if (this.velocityHistory.length < 2) return 0 + + const velocities = this.velocityHistory.map((entry) => entry.velocity) + const mean = velocities.reduce((sum, v) => sum + v, 0) / velocities.length + const variance = velocities.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / velocities.length + + return Math.sqrt(variance) + } + + /** + * Dynamic overscan calculation based on current velocity/acceleration. + */ + calculateDynamicOverscan(baseOverscan: number, viewportSize: number, itemSize: number): OverscanCalculationResult { + // Internal max multiplier to prevent runaway overscan. + const maxMultiplier = 4 + + const state = this.getCurrentVelocityState() + const { velocity, direction, acceleration, isStable } = state + + // Calculate items visible in viewport for scaling + const itemsInViewport = Math.ceil(viewportSize / itemSize) + + // Minimum overscan - never go below base even when "stable" + const minOverscan = Math.max(baseOverscan, Math.ceil(itemsInViewport * 0.5)) + + // Strategy 1: Static overscan for truly idle/very slow velocity + // Only use static when BOTH velocity is very low AND stable + if (velocity < this.lowVelocityThreshold && isStable) { + return { + leading: minOverscan, + trailing: minOverscan, + total: minOverscan * 2, + } + } + + // Strategy 2: Velocity-based overscan with more aggressive scaling + let velocityMultiplier = 1 + if (velocity < this.mediumVelocityThreshold) { + // Linear ramp from 1x to 2x + velocityMultiplier = 1 + velocity / this.mediumVelocityThreshold + } else if (velocity < this.highVelocityThreshold) { + // Quadratic ramp from 2x to maxMultiplier + const normalizedVelocity = + (velocity - this.mediumVelocityThreshold) / (this.highVelocityThreshold - this.mediumVelocityThreshold) + velocityMultiplier = 2 + normalizedVelocity * normalizedVelocity * (maxMultiplier - 2) + } else { + // Beyond high threshold - use max multiplier + velocityMultiplier = maxMultiplier + } + + // Strategy 3: Acceleration-based adjustments (more aggressive) + let accelerationMultiplier = 1 + if (Math.abs(acceleration) > 0.05) { + // Accelerating: boost overscan significantly + // Decelerating: still maintain good overscan (don't reduce as much) + accelerationMultiplier = acceleration > 0 ? 1.5 : 0.9 + } + + // Strategy 4: Predictive overscan based on direction + // Keep substantial overscan in BOTH directions to handle direction changes + let leading = baseOverscan + let trailing = baseOverscan + + const directionalMultiplier = velocityMultiplier * accelerationMultiplier + // Keep minimum 80% overscan in non-scroll direction (was 60%) + const minDirectionRatio = 0.8 + + if (direction === "forward") { + leading = Math.ceil(baseOverscan * directionalMultiplier) + trailing = Math.max(minOverscan, Math.ceil(baseOverscan * minDirectionRatio * velocityMultiplier)) + } else if (direction === "backward") { + trailing = Math.ceil(baseOverscan * directionalMultiplier) + leading = Math.max(minOverscan, Math.ceil(baseOverscan * minDirectionRatio * velocityMultiplier)) + } else { + // Idle/unknown direction - boost both equally + leading = Math.ceil(baseOverscan * velocityMultiplier) + trailing = Math.ceil(baseOverscan * velocityMultiplier) + } + + // Cap at max multiplier + leading = Math.min(leading, baseOverscan * maxMultiplier) + trailing = Math.min(trailing, baseOverscan * maxMultiplier) + + // Ensure we never return less than minimum overscan + return { + leading: Math.max(minOverscan, leading), + trailing: Math.max(minOverscan, trailing), + total: Math.max(minOverscan, leading) + Math.max(minOverscan, trailing), + } + } + + /** + * Reset all tracking state + */ + reset(): void { + this.velocityHistory.length = 0 + this.lastTime = now() + this.lastOffset = 0 + } +} diff --git a/packages/utilities/virtualizer/src/virtualizer.ts b/packages/utilities/virtualizer/src/virtualizer.ts new file mode 100644 index 0000000000..621d50bf73 --- /dev/null +++ b/packages/utilities/virtualizer/src/virtualizer.ts @@ -0,0 +1,1129 @@ +import type { + CSSProperties, + ItemState, + OverscanConfig, + Range, + ScrollAnchor, + ScrollHistoryEntry, + ScrollRestorationConfig, + ScrollState, + ScrollToIndexOptions, + ScrollToIndexResult, + VirtualItem, + VirtualizerOptions, +} from "./types" +import { SizeObserver } from "./utils/size-observer" +import { IntersectionObserverManager } from "./utils/intersection-observer-manager" +import { resolveOverscanConfig, SCROLL_END_DELAY_MS } from "./utils/overscan" +import { ResizeObserverManager } from "./utils/resize-observer-manager" +import { getScrollPosition, getScrollPositionFromEvent, setScrollPosition } from "./utils/scroll-helpers" +import { ScrollRestorationManager } from "./utils/scroll-restoration-manager" +import { easingFunctions, smoothScrollTo, type SmoothScrollResult } from "./utils/smooth-scroll" +import { VelocityTracker, type OverscanCalculationResult, type VelocityState } from "./utils/velocity-tracker" +import { debounce, rafThrottle } from "./utils/debounce" +import { shallowCompare } from "./utils/shallow-compare" + +type ResolvedBaseOptions = Required< + Omit +> & + Pick & { + overscan: Required + scrollRestoration?: ScrollRestorationConfig + } + +/** + * Shared logic for all virtualizer variants (list, grid, table, masonry). + * Layout-specific classes implement measurement and range calculation details. + */ +export abstract class Virtualizer { + protected options: ResolvedBaseOptions & O + + // Measurements + protected measureCache: Map = new Map() + protected itemSizeCache: Map = new Map() + + // Scroll state + protected scrollOffset = 0 + protected prevScrollOffset = 0 + protected scrollDirection: "forward" | "backward" = "forward" + protected isScrolling = false + protected scrollEndTimer: ReturnType | null = null + private debouncedScrollEnd: ReturnType | null = null + private rafUpdateRange: ReturnType | null = null + + // Viewport + protected viewportSize = 0 + // Cross-axis size of the scroll element (width for vertical, height for horizontal). + protected crossAxisSize = 0 + + // Visible range + protected range: Range = { startIndex: 0, endIndex: -1 } + private lastCalculatedOffset: number = -1 + private cachedVirtualItems: VirtualItem[] = [] + private virtualItemsCacheKey: string = "" + private virtualItemObjectCache: Map = new Map() + private keyIndexCache: Map = new Map() + + // Advanced features + private velocityTracker?: VelocityTracker + private resizeObserver?: ResizeObserverManager + private intersectionObserver?: IntersectionObserverManager + private sizeObserver?: SizeObserver + private scrollRestoration?: ScrollRestorationManager + + // Dynamic sizing + private pendingSizeUpdates = new Map() + private sizeUpdateScheduled = false + + // Element tracking for proper cleanup + private elementsByIndex = new Map() + + // Smooth scroll state + private currentSmoothScroll: SmoothScrollResult | null = null + + // Scroll listener management + protected scrollElement: HTMLElement | null = null + + constructor(options: O) { + const overscan = resolveOverscanConfig(options.overscan) + + this.options = { + horizontal: false, + gap: 0, + paddingStart: 0, + paddingEnd: 0, + initialOffset: 0, + rootMargin: "50px", + preserveScrollAnchor: true, + observeScrollElementSize: false, + ...options, + overscan, + } as ResolvedBaseOptions & O + + this.scrollOffset = this.options.initialOffset + this.initializeScrollHandlers() + this.initializeAdvancedFeatures() + this.initializeMeasurements() + // Don't attach scroll listener in constructor since DOM may not be ready + } + + /** + * Initialize the virtualizer with a concrete scroll element. + * + * This avoids the common "ref is null during construction" issue by allowing + * consumers to wire the element explicitly once it mounts. + */ + init(scrollElement: HTMLElement): void { + this.scrollElement = scrollElement + + // Observe size if enabled + this.initializeScrollingElement() + + // Prime measurements + this.measure() + } + + private initializeScrollHandlers(): void { + // Create debounced scroll end handler + this.debouncedScrollEnd = debounce(() => { + this.isScrolling = false + this.notifyScroll() + + // Record scroll position when scrolling stops (user interaction) + this.scrollRestoration?.recordScrollPosition(this.scrollOffset, "user") + }, SCROLL_END_DELAY_MS) + + // Create RAF-throttled range update for smooth scrolling + this.rafUpdateRange = rafThrottle((fn: () => void) => fn()) + } + + private initializeAdvancedFeatures(): void { + const { horizontal, overscan } = this.options + + // Initialize velocity tracker for dynamic overscan + if (overscan.dynamic) { + this.velocityTracker = new VelocityTracker() + } + + // Initialize resize observer + this.resizeObserver = new ResizeObserverManager() + + // Initialize intersection observer for visibility tracking + if (this.options.onVisibilityChange) { + this.intersectionObserver = new IntersectionObserverManager({ + rootMargin: this.options.rootMargin, + }) + } + + // Observe scroll element size + if (this.options.observeScrollElementSize) { + this.sizeObserver = new SizeObserver({ + onResize: (size) => { + const viewportSize = horizontal ? size.width : size.height + const crossAxisSize = horizontal ? size.height : size.width + + this.setViewportSize(viewportSize) + this.setCrossAxisSize(crossAxisSize) + + // Record resize in scroll restoration + this.scrollRestoration?.handleResize(this.scrollOffset) + }, + }) + + // Scroll element size observation is wired during `init(element)` + this.initializeScrollingElement() + } + + // Initialize scroll restoration if enabled + if (this.options.scrollRestoration) { + const { scrollRestoration } = this.options + this.scrollRestoration = new ScrollRestorationManager({ + enableScrollRestoration: true, + maxHistoryEntries: scrollRestoration.maxEntries ?? 10, + restorationKey: scrollRestoration.key ?? "default", + restorationTolerance: scrollRestoration.tolerance ?? 5, + }) + } + } + + // ============================================ + // Style Getters + // ============================================ + + /** + * Get container element styles + */ + getContainerStyle(): CSSProperties { + return { + position: "relative", + overflow: "auto", + willChange: "scroll-position", + WebkitOverflowScrolling: "touch", + contain: "strict", + } + } + + /** + * Get content/spacer element styles + */ + getContentStyle(): CSSProperties { + const totalSize = this.getTotalSize() + const { horizontal } = this.options + + return { + position: "relative", + width: horizontal ? totalSize : "100%", + height: horizontal ? "100%" : totalSize, + pointerEvents: this.isScrolling ? "none" : "auto", + } + } + + /** + * Get scroll handler for container + */ + getScrollHandler() { + return this.handleScroll + } + + /** + * Get item state data (position, size, etc.) + */ + abstract getItemState(virtualItem: VirtualItem): ItemState + + /** + * Get item element styles + */ + abstract getItemStyle(virtualItem: VirtualItem): CSSProperties + + // ============================================ + // Virtual Items + // ============================================ + + /** + * Compute all virtual items that should be rendered + * Uses caching to avoid redundant calculations (like TanStack Virtual) + */ + getVirtualItems(): VirtualItem[] { + this.calculateRange() + + const { startIndex, endIndex } = this.range + + // Cache key based on range - if range hasn't changed, return cached items + const newCacheKey = `${startIndex}:${endIndex}` + if (newCacheKey === this.virtualItemsCacheKey) { + return this.cachedVirtualItems + } + + const newVirtualItems: VirtualItem[] = [] + + // Clean up virtual item cache for items outside new range + for (const [index] of this.virtualItemObjectCache) { + if (index < startIndex - 100 || index > endIndex + 100) { + this.virtualItemObjectCache.delete(index) + } + } + + for (let i = startIndex; i <= endIndex; i++) { + const measurement = this.getMeasurement(i) + const lane = this.getItemLane(i) + const key = this.getItemKey(i) + + // Track key -> index mapping for fast anchor restoration in common cases + this.keyIndexCache.set(key, i) + + // Try to reuse cached virtual item object + let cachedItem = this.virtualItemObjectCache.get(i) + + if ( + cachedItem && + cachedItem.key === key && + cachedItem.start === measurement.start && + cachedItem.size === measurement.size && + cachedItem.lane === lane + ) { + // Reuse cached item object - no allocation + newVirtualItems.push(cachedItem) + } else { + // Create or update virtual item + const virtualItem: VirtualItem = cachedItem || { + index: i, + key, + start: measurement.start, + end: measurement.end, + size: measurement.size, + lane, + measureElement: this.createMeasureElement(i), + } + + // Update properties if reusing object + if (cachedItem) { + virtualItem.key = key + virtualItem.start = measurement.start + virtualItem.end = measurement.end + virtualItem.size = measurement.size + virtualItem.lane = lane + } + + this.virtualItemObjectCache.set(i, virtualItem) + newVirtualItems.push(virtualItem) + } + } + + // Cache the result + this.cachedVirtualItems = newVirtualItems + this.virtualItemsCacheKey = newCacheKey + + return newVirtualItems + } + + // ============================================ + // Measurement hooks (implemented by subclasses) + // ============================================ + + protected abstract initializeMeasurements(): void + protected abstract resetMeasurements(): void + protected abstract getMeasurement(index: number): { start: number; size: number; end: number } + protected abstract getItemLane(index: number): number + protected abstract findVisibleRange(viewportStart: number, viewportEnd: number): Range + protected abstract findIndexAtOffset(offset: number): number + abstract getTotalSize(): number + protected onItemsChanged(): void { + // Optionally overridden by subclasses that need to rebuild caches when data changes + } + protected onItemMeasured(index: number, size: number): boolean { + // Cache the measured size for future use + const prevSize = this.itemSizeCache.get(index) + if (prevSize === size) return false + + this.itemSizeCache.set(index, size) + // Invalidate virtual item cache for this index + this.virtualItemObjectCache.delete(index) + return true + } + protected onCrossAxisSizeChange(_size: number): void { + // Optionally overridden by subclasses that depend on cross-axis size (e.g., masonry) + } + protected getKnownItemSize(_index: number): number | undefined { + // Optionally overridden by subclasses to return the currently known size for an item + // Used to skip redundant size updates + return undefined + } + + /** + * Get the estimated size for an item at the given index + */ + protected getEstimatedSize(index: number, laneWidth?: number): number { + return this.options.estimatedSize(index, laneWidth) + } + + protected invalidateMeasurements(fromIndex: number = 0) { + this.lastCalculatedOffset = -1 // Invalidate range cache + this.virtualItemsCacheKey = "" // Invalidate items cache + for (const key of this.measureCache.keys()) { + if (key >= fromIndex) { + this.measureCache.delete(key) + this.itemSizeCache.delete(key) + this.virtualItemObjectCache.delete(key) + } + } + // Key cache is no longer trustworthy after invalidation + this.keyIndexCache.clear() + } + + /** + * Resolve the stable key for an index. + */ + protected getItemKey(index: number): string | number { + return this.options.indexToKey?.(index) ?? index + } + + /** + * Attempt to resolve an index for a key. + * - Prefers user-provided inverse map + * - Falls back to recent key cache + * - Finally falls back to a linear scan (correct but O(n)) + */ + protected getIndexForKey(key: string | number): number | undefined { + const byUser = this.options.keyToIndex?.(key) + if (byUser !== undefined) return byUser + + const cached = this.keyIndexCache.get(key) + if (cached !== undefined) return cached + + for (let i = 0; i < this.options.count; i++) { + if (this.getItemKey(i) === key) return i + } + return undefined + } + + // ============================================ + // Range Calculation + // ============================================ + + /** + * Calculate visible range and apply overscan + * Optimized with caching and shallow comparison + */ + protected calculateRange(): void { + // Skip if already calculated for this scroll offset (avoid double calculation) + if (this.lastCalculatedOffset === this.scrollOffset && this.range.endIndex >= 0) { + return + } + + const { count, overscan, horizontal, rtl } = this.options + + if (count === 0 || this.viewportSize === 0) { + this.range = { startIndex: 0, endIndex: -1 } + this.lastCalculatedOffset = this.scrollOffset + return + } + + if (overscan.dynamic) { + this.velocityTracker?.update(this.scrollOffset, horizontal && rtl) + } + + // Calculate the visible range + const viewportStart = this.scrollOffset + const viewportEnd = this.scrollOffset + this.viewportSize + let { startIndex, endIndex } = this.findVisibleRange(viewportStart, viewportEnd) + + // Apply overscan + let leadingOverscan = overscan.count + let trailingOverscan = overscan.count + + if (overscan.dynamic && this.velocityTracker) { + const overscanResult = this.getCurrentOverscan() + if (overscanResult) { + leadingOverscan = overscanResult.leading + trailingOverscan = overscanResult.trailing + } + } + + const overscanStart = leadingOverscan + const overscanEnd = trailingOverscan + + startIndex = Math.max(0, startIndex - overscanStart) + endIndex = Math.min(count - 1, endIndex + overscanEnd) + + const newRange = { startIndex, endIndex } + const rangeChanged = !shallowCompare(this.range, newRange) + + if (rangeChanged) { + this.range = newRange + this.options.onRangeChange?.(this.range) + } + + this.lastCalculatedOffset = this.scrollOffset + } + + /** + * Get average item size for overscan calculations + * Uses estimatedSize for performance - accurate enough for overscan + */ + private getAverageItemSize(): number { + return this.getEstimatedSize(0) + } + + // ============================================ + // Scroll Handling + // ============================================ + + handleScroll = (event: Event | { currentTarget: { scrollTop: number; scrollLeft: number } }): void => { + const { horizontal } = this.options + const { scrollTop, scrollLeft } = getScrollPositionFromEvent(event) + const offset = horizontal ? scrollLeft : scrollTop + + // Quick exit if offset hasn't changed + if (offset === this.scrollOffset) return + + this.prevScrollOffset = this.scrollOffset + this.scrollOffset = offset + + // Calculate scroll direction (considering RTL for horizontal scroll) + const rawDirection = offset > this.prevScrollOffset ? "forward" : "backward" + if (this.options.horizontal && this.options.rtl) { + // In RTL horizontal mode, increasing offset means moving backward + this.scrollDirection = rawDirection === "forward" ? "backward" : "forward" + } else { + this.scrollDirection = rawDirection + } + + const wasScrolling = this.isScrolling + this.isScrolling = true + + // Use RAF throttling for smoother updates during fast scrolling + if (this.rafUpdateRange) { + this.rafUpdateRange(() => { + this.calculateRange() + this.notifyScroll() + }) + } else { + // Fallback to immediate calculation + this.calculateRange() + this.notifyScroll() + } + + // Debounced scroll end detection + if (this.debouncedScrollEnd) { + this.debouncedScrollEnd() + } else if (!wasScrolling) { + // First scroll event - notify immediately + this.notifyScroll() + } + } + + private notifyScroll(): void { + const { horizontal } = this.options + const offset: ScrollState["offset"] = horizontal ? { x: this.scrollOffset, y: 0 } : { x: 0, y: this.scrollOffset } + const direction: ScrollState["direction"] = horizontal + ? { x: this.scrollDirection, y: "idle" } + : { x: "idle", y: this.scrollDirection } + + this.options.onScroll?.({ + offset, + direction, + isScrolling: this.isScrolling, + }) + } + + // ============================================ + // Public API + // ============================================ + + setViewportSize(size: number): void { + this.viewportSize = size + this.lastCalculatedOffset = -1 // Invalidate range cache + this.virtualItemsCacheKey = "" // Invalidate items cache + this.calculateRange() + } + + setCrossAxisSize(size: number): void { + const sizeChanged = this.crossAxisSize !== size + this.crossAxisSize = size + + if (sizeChanged) { + this.lastCalculatedOffset = -1 // Invalidate range cache + this.virtualItemsCacheKey = "" // Invalidate items cache + this.onCrossAxisSizeChange(size) + this.calculateRange() + } + } + + /** + * Measure the scroll container and set viewport/container sizes. + */ + measure(): void { + if (!this.scrollElement) return + + const rect = this.scrollElement.getBoundingClientRect() + const { horizontal } = this.options + + this.setViewportSize(horizontal ? rect.width : rect.height) + this.setCrossAxisSize(horizontal ? rect.height : rect.width) + } + + scrollTo(offset: number): { scrollTop?: number; scrollLeft?: number } { + const { horizontal } = this.options + const existing = this.scrollElement ? getScrollPosition(this.scrollElement) : { scrollTop: 0, scrollLeft: 0 } + + // Apply to scroll element if available (container-based virtualizers) + if (this.scrollElement) { + setScrollPosition(this.scrollElement, horizontal ? { scrollLeft: offset } : { scrollTop: offset }) + } + + this.prevScrollOffset = this.scrollOffset + this.scrollOffset = offset + + // Calculate scroll direction (considering RTL for horizontal scroll) + const rawDirection = offset > this.prevScrollOffset ? "forward" : "backward" + if (this.options.horizontal && this.options.rtl) { + // In RTL horizontal mode, increasing offset means moving backward + this.scrollDirection = rawDirection === "forward" ? "backward" : "forward" + } else { + this.scrollDirection = rawDirection + } + + // Programmatic scroll should update range/scroll callbacks immediately, + // even if the consumer doesn't wire `onScroll` to `handleScroll`. + this.calculateRange() + this.notifyScroll() + + // Record programmatic scroll + this.scrollRestoration?.recordScrollPosition(offset, "programmatic") + + // Return the updated axis + preserve other axis (useful for consumers) + if (horizontal) return { scrollLeft: offset, scrollTop: existing.scrollTop } + return { scrollTop: offset, scrollLeft: existing.scrollLeft } + } + + scrollToIndex(index: number, options: ScrollToIndexOptions = {}): ScrollToIndexResult { + const { align = "start", smooth } = options + const { count } = this.options + + if (index < 0 || index >= count) { + return this.scrollTo(this.scrollOffset) + } + + const measurement = this.getMeasurement(index) + let offset = measurement.start + + switch (align) { + case "center": + offset -= (this.viewportSize - measurement.size) / 2 + break + case "end": + offset -= this.viewportSize - measurement.size + break + } + + const targetOffset = Math.max(0, offset) + + // Use smooth scrolling if requested + if (smooth) { + return this.performSmoothScroll(targetOffset, smooth) + } + + return this.scrollTo(targetOffset) + } + + /** + * Perform smooth scrolling to target offset + */ + private performSmoothScroll( + targetOffset: number, + smoothOptions: boolean | NonNullable, + ): ScrollToIndexResult { + const { horizontal } = this.options + + // Cancel any existing smooth scroll + this.currentSmoothScroll?.cancel() + + // Default smooth scroll options + const defaultOptions = { + duration: 600, + easing: "easeOutCubic" as const, + } + + const options = smoothOptions === true ? defaultOptions : { ...defaultOptions, ...smoothOptions } + + // Get the easing function + let easingFn: (t: number) => number + if (typeof options.easing === "string") { + easingFn = easingFunctions[options.easing] + } else { + easingFn = options.easing + } + + // Create custom scroll function that updates our internal state + const customScrollFunction = + ("scrollFunction" in options ? options.scrollFunction : undefined) || + ((position: { scrollTop?: number; scrollLeft?: number }) => { + const newOffset = horizontal + ? (position.scrollLeft ?? this.scrollOffset) + : (position.scrollTop ?? this.scrollOffset) + + // Apply to the real scroll container. + // NOTE: When we provide a scrollFunction to `smoothScrollTo`, it will NOT + // apply scrolling itself, so we must do it here. + if (this.scrollElement) { + setScrollPosition(this.scrollElement, { scrollTop: position.scrollTop, scrollLeft: position.scrollLeft }) + } + + // Update our internal scroll state + this.prevScrollOffset = this.scrollOffset + this.scrollOffset = newOffset + + // Calculate scroll direction (considering RTL for horizontal scroll) + const rawDirection = newOffset > this.prevScrollOffset ? "forward" : "backward" + if (this.options.horizontal && this.options.rtl) { + // In RTL horizontal mode, increasing offset means moving backward + this.scrollDirection = rawDirection === "forward" ? "backward" : "forward" + } else { + this.scrollDirection = rawDirection + } + + this.isScrolling = true + + // Update velocity tracking + if (this.velocityTracker) { + this.velocityTracker.update(newOffset, this.options.horizontal && this.options.rtl) + } + + // Recalculate range & notify scroll (even if consumer doesn't wire onScroll) + this.calculateRange() + this.notifyScroll() + }) + + // Create target position object + const target = horizontal ? { x: targetOffset } : { y: targetOffset } + + if (!this.scrollElement) { + throw new Error( + "[@zag-js/virtualizer] Missing scroll element. Call `virtualizer.init(element)` before using smooth scrolling.", + ) + } + + this.currentSmoothScroll = smoothScrollTo(this.scrollElement, target, { + duration: options.duration, + easing: easingFn, + scrollFunction: customScrollFunction, + onComplete: () => { + this.currentSmoothScroll = null + this.isScrolling = false + + // Record the final position + this.scrollRestoration?.recordScrollPosition(this.scrollOffset, "programmatic") + + this.notifyScroll() + }, + onCancel: () => { + this.currentSmoothScroll = null + this.isScrolling = false + }, + }) + + return horizontal ? { scrollLeft: targetOffset } : { scrollTop: targetOffset } + } + + measureItem(index: number, size: number): void { + if (this.options.preserveScrollAnchor) { + this.preserveScrollPosition(() => { + this.onItemMeasured(index, size) + this.invalidateMeasurements(index) + }) + } else { + this.onItemMeasured(index, size) + this.invalidateMeasurements(index) + } + this.calculateRange() + } + + /** + * Schedule a size update with batching for smoother updates + */ + scheduleSizeUpdate(index: number, size: number): void { + this.pendingSizeUpdates.set(index, size) + + if (!this.sizeUpdateScheduled) { + this.sizeUpdateScheduled = true + queueMicrotask(() => { + this.flushSizeUpdates() + }) + } + } + + /** + * Flush all pending size updates + */ + private flushSizeUpdates(): void { + if (this.pendingSizeUpdates.size === 0) { + this.sizeUpdateScheduled = false + return + } + + // Capture range before updates + const prevRange = { ...this.range } + let anySizeChanged = false + + if (this.options.preserveScrollAnchor) { + this.preserveScrollPosition(() => { + for (const [index, size] of this.pendingSizeUpdates) { + if (this.onItemMeasured(index, size)) { + anySizeChanged = true + } + } + if (anySizeChanged) { + this.invalidateMeasurements(Math.min(...this.pendingSizeUpdates.keys())) + } + }) + } else { + for (const [index, size] of this.pendingSizeUpdates) { + if (this.onItemMeasured(index, size)) { + anySizeChanged = true + } + } + if (anySizeChanged) { + this.invalidateMeasurements(Math.min(...this.pendingSizeUpdates.keys())) + } + } + + this.pendingSizeUpdates.clear() + this.sizeUpdateScheduled = false + + // Only recalculate and notify if sizes actually changed + if (anySizeChanged) { + this.calculateRange() + + // Notify after measurements change so consumers can re-render + // calculateRange only notifies when range indices change, but we need to + // notify even when just sizes change (same indices, different measurements) + const rangeNotified = prevRange.startIndex !== this.range.startIndex || prevRange.endIndex !== this.range.endIndex + if (!rangeNotified) { + this.options.onRangeChange?.(this.range) + } + } + } + + /** + * Preserve scroll position during updates that change measurements + */ + private preserveScrollPosition(callback: () => void): void { + if (!this.options.preserveScrollAnchor || this.range.startIndex < 0) { + callback() + return + } + + // Find anchor item (first visible item) + const anchorIndex = this.range.startIndex + const anchorMeasurement = this.getMeasurement(anchorIndex) + const anchorOffset = anchorMeasurement.start + + // Execute the update + callback() + + // Restore scroll position relative to anchor + const newAnchorMeasurement = this.getMeasurement(anchorIndex) + const newAnchorOffset = newAnchorMeasurement.start + const deltaOffset = newAnchorOffset - anchorOffset + + if (deltaOffset !== 0) { + this.scrollOffset += deltaOffset + } + } + + getScrollState(): ScrollState { + const { horizontal } = this.options + const offset: ScrollState["offset"] = horizontal ? { x: this.scrollOffset, y: 0 } : { x: 0, y: this.scrollOffset } + const direction: ScrollState["direction"] = horizontal + ? { x: this.scrollDirection, y: "idle" } + : { x: "idle", y: this.scrollDirection } + return { + offset, + direction, + isScrolling: this.isScrolling, + } + } + + /** + * Capture an anchor representing "what the user is looking at". + * This is key-based, so it can survive insertions/removals if keys are stable. + */ + getScrollAnchor(): ScrollAnchor | null { + this.calculateRange() + if (this.range.startIndex < 0 || this.range.endIndex < 0) return null + + const anchorIndex = this.range.startIndex + const measurement = this.getMeasurement(anchorIndex) + const key = this.getItemKey(anchorIndex) + + return { + key, + offset: this.scrollOffset - measurement.start, + } + } + + /** + * Restore scroll position so that the anchor item is at the same intra-item offset. + */ + restoreScrollAnchor(anchor: ScrollAnchor): ScrollToIndexResult | null { + const index = this.getIndexForKey(anchor.key) + if (index === undefined) return null + + const measurement = this.getMeasurement(index) + const targetOffset = measurement.start + anchor.offset + return this.scrollTo(Math.max(0, targetOffset)) + } + + getRange(): Range { + return { ...this.range } + } + + forceUpdate(): void { + this.lastCalculatedOffset = -1 // Invalidate range cache + this.virtualItemsCacheKey = "" // Invalidate items cache + this.resetMeasurements() + this.calculateRange() + } + + /** + * Create a measureElement callback for a virtual item + * Handles both observing new elements and cleaning up old ones + */ + private createMeasureElement(index: number): (element: HTMLElement | null) => void { + return (element: HTMLElement | null) => { + // Get the previously tracked element for this index + const prevElement = this.elementsByIndex.get(index) + + // Clean up previous element if it exists and is different + if (prevElement && prevElement !== element) { + this.unobserveElement(prevElement) + this.elementsByIndex.delete(index) + } + + if (element) { + // Track and observe the new element + this.elementsByIndex.set(index, element) + + // Immediately measure and update size (only if changed) + const { horizontal } = this.options + const rect = element.getBoundingClientRect() + const size = horizontal ? rect.width : rect.height + if (size > 0) { + const knownSize = this.getKnownItemSize(index) + if (knownSize === undefined || knownSize !== size) { + this.scheduleSizeUpdate(index, size) + } + } + + // Set up observer for future size changes + this.observeElementSize(element, index) + } else { + // Element is null (unmounting), cleanup tracking + this.elementsByIndex.delete(index) + } + } + } + + /** + * Observe element for size changes + */ + observeElementSize(element: Element, index: number): void { + if (!this.resizeObserver) return + + this.resizeObserver.observe(element, (size) => { + const measuredSize = this.options.horizontal ? size.width : size.height + // Only schedule update if size actually changed + const knownSize = this.getKnownItemSize(index) + if (knownSize === undefined || knownSize !== measuredSize) { + this.scheduleSizeUpdate(index, measuredSize) + } + }) + } + + /** + * Observe element for visibility changes + */ + observeElementVisibility(element: Element, index: number): void { + if (!this.intersectionObserver) return + + this.intersectionObserver.observe(element, (isVisible) => { + this.options.onVisibilityChange?.(index, isVisible) + }) + } + + /** + * Stop observing element + */ + unobserveElement(element: Element): void { + this.resizeObserver?.unobserve(element) + this.intersectionObserver?.unobserve(element) + } + + /** + * Initialize scrolling element observation + */ + private initializeScrollingElement(): void { + if (!this.sizeObserver) return + + if (this.scrollElement) { + this.sizeObserver.observe(this.scrollElement) + } + } + + /** + * Get current scroll element size (observeScrollElementSize only) + */ + getScrollElementSize(): { width: number; height: number } | null { + if (!this.sizeObserver) return null + return this.sizeObserver.getCurrentSize() + } + + /** + // Scroll Restoration API + // ============================================ + + /** + * Restore scroll position from history + */ + restoreScrollPosition(): ScrollToIndexResult | null { + if (!this.scrollRestoration) return null + + const restoration = this.scrollRestoration.getRestorationPosition() + if (!restoration) return null + + return this.scrollTo(restoration.offset) + } + + /** + * Set a pending scroll restoration position (e.g., from navigation) + */ + setPendingScrollRestore(offset: number, key?: string | number): void { + this.scrollRestoration?.setPendingRestore(offset, key) + } + + /** + * Get scroll restoration history + */ + getScrollHistory(): readonly ScrollHistoryEntry[] { + return this.scrollRestoration?.getHistory() ?? [] + } + + /** + * Clear scroll restoration history + */ + clearScrollHistory(): void { + this.scrollRestoration?.clearHistory() + } + + /** + * Update scroll restoration key for dynamic content + */ + updateScrollRestorationKey(key: string): void { + this.scrollRestoration?.updateRestorationKey(key) + } + + /** + * Check if scroll position should be restored + */ + shouldRestoreScroll(): boolean { + return this.scrollRestoration?.shouldRestore(this.scrollOffset) ?? false + } + + // ============================================ + // Smooth Scrolling API + // ============================================ + + /** + * Perform smooth scroll to specific offset + */ + smoothScrollTo( + offset: number, + options: { + duration?: number + easing?: ScrollToIndexOptions["smooth"] extends object ? ScrollToIndexOptions["smooth"]["easing"] : never + scrollFunction?: (position: { scrollTop?: number; scrollLeft?: number }) => void + } = {}, + ): ScrollToIndexResult { + return this.performSmoothScroll(offset, options) + } + + /** + * Cancel any ongoing smooth scroll + */ + cancelSmoothScroll(): void { + this.currentSmoothScroll?.cancel() + } + + /** + * Check if smooth scrolling is currently active + */ + isSmoothScrolling(): boolean { + return this.currentSmoothScroll !== null + } + + /** + * Set scroll element for smooth scrolling (for subclasses to override) + */ + setScrollElement(element: Element): void { + // Backwards-compatible alias for init() + if (element instanceof HTMLElement) { + this.init(element) + } + } + + // ============================================ + // Velocity Tracking & Overscan API + // ============================================ + + /** + * Get current velocity state + */ + getVelocityState(): VelocityState | null { + return this.velocityTracker?.getCurrentVelocityState() ?? null + } + + /** + * Get current overscan calculation result + */ + getCurrentOverscan(): OverscanCalculationResult | null { + const { overscan } = this.options + + if (!this.velocityTracker || !overscan.dynamic) return null + + const averageItemSize = this.getAverageItemSize() + return this.velocityTracker.calculateDynamicOverscan(overscan.count, this.viewportSize, averageItemSize) + } + + /** + * Update overscan configuration dynamically + */ + setOverscan(overscan: OverscanConfig | number): void { + this.options.overscan = resolveOverscanConfig(overscan) + // Initialize velocity tracker if dynamic overscan is now enabled + if (this.options.overscan.dynamic && !this.velocityTracker) { + this.velocityTracker = new VelocityTracker() + } + // Trigger range recalculation + this.calculateRange() + } + + destroy(): void { + if (this.scrollEndTimer) { + clearTimeout(this.scrollEndTimer) + } + + // Cleanup advanced features + this.resizeObserver?.disconnect() + this.intersectionObserver?.disconnect() + this.velocityTracker?.reset() + this.sizeObserver?.destroy() + this.scrollRestoration?.destroy() + + // Cancel any ongoing smooth scroll + this.cancelSmoothScroll() + + this.measureCache.clear() + this.pendingSizeUpdates.clear() + this.elementsByIndex.clear() + } +} diff --git a/packages/utilities/virtualizer/src/window-virtualizer.ts b/packages/utilities/virtualizer/src/window-virtualizer.ts new file mode 100644 index 0000000000..f1b4c4172c --- /dev/null +++ b/packages/utilities/virtualizer/src/window-virtualizer.ts @@ -0,0 +1,157 @@ +import { getDocumentElement } from "@zag-js/dom-query" +import { ListVirtualizer } from "./list-virtualizer" +import type { ListVirtualizerOptions } from "./types" + +interface WindowVirtualizerOptions extends ListVirtualizerOptions { + /** Element to measure scroll offset against (defaults to window) */ + scrollingElement?: Element + /** Offset from window top/left where content starts */ + windowOffset?: number + /** Function to get the window */ + getWindow?: () => typeof window +} + +/** + * Virtualizer that uses window scrolling instead of container scrolling + */ +export class WindowVirtualizer extends ListVirtualizer { + private windowOptions: WindowVirtualizerOptions + private windowScrollHandler?: () => void + private getWindow: () => typeof window + + constructor(options: WindowVirtualizerOptions) { + super(options) + this.windowOptions = options + this.getWindow = options.getWindow ?? (() => window) + this.setupWindowScrolling() + } + + private setupWindowScrolling(): void { + this.windowScrollHandler = () => { + const offset = this.getWindowScrollOffset() + + // Create a mock event that matches the expected interface + const mockEvent = { + currentTarget: { + scrollTop: this.options.horizontal ? 0 : offset, + scrollLeft: this.options.horizontal ? offset : 0, + }, + } + + this.handleScroll(mockEvent) + } + + // Listen to window scroll events + const win = this.getWindow() + if (typeof win !== "undefined") { + win.addEventListener("scroll", this.windowScrollHandler, { passive: true }) + win.addEventListener("resize", this.windowScrollHandler, { passive: true }) + } + } + + private getWindowScrollOffset(): number { + const win = this.getWindow() + const rootElement = getDocumentElement(win) + + const { horizontal, scrollingElement = rootElement } = this.windowOptions + + const windowOffset = this.windowOptions.windowOffset ?? 0 + + if (horizontal) { + return (scrollingElement.scrollLeft || win.pageXOffset || 0) - windowOffset + } else { + return (scrollingElement.scrollTop || win.pageYOffset || 0) - windowOffset + } + } + + getContainerStyle() { + const baseStyle = super.getContainerStyle() + // Override container style for window scrolling + return { + ...baseStyle, + overflow: "visible", + position: "static", + } + } + + getContentStyle() { + const baseStyle = super.getContentStyle() + const windowOffset = this.windowOptions.windowOffset ?? 0 + + return { + ...baseStyle, + position: "relative", + marginTop: this.options.horizontal ? 0 : windowOffset, + marginLeft: this.options.horizontal ? windowOffset : 0, + } + } + + getScrollHandler(): any { + // Window scrolling doesn't use a scroll handler on the container + return undefined + } + + setViewportSize = (): void => { + const win = this.getWindow() + if (typeof win === "undefined") return + const windowSize = this.options.horizontal ? win.innerWidth : win.innerHeight + super.setViewportSize(windowSize) + } + + scrollTo = (offset: number): { scrollTop?: number; scrollLeft?: number } => { + const win = this.getWindow() + + const { horizontal } = this.options + const windowOffset = this.windowOptions.windowOffset ?? 0 + const targetOffset = offset + windowOffset + + if (typeof win !== "undefined") { + if (horizontal) { + win.scrollTo(targetOffset, win.pageYOffset) + } else { + win.scrollTo(win.pageXOffset, targetOffset) + } + } + + return horizontal ? { scrollLeft: targetOffset } : { scrollTop: targetOffset } + } + + scrollToIndex( + index: number, + options: { align?: "start" | "center" | "end"; behavior?: ScrollBehavior } = {}, + ): { scrollTop?: number; scrollLeft?: number } { + const win = this.getWindow() + const { behavior = "auto" } = options + const targetScroll = super.scrollToIndex(index, options) + + if (typeof win !== "undefined") { + const { horizontal } = this.options + const windowOffset = this.windowOptions.windowOffset ?? 0 + + if (horizontal && targetScroll.scrollLeft !== undefined) { + win.scrollTo({ + left: targetScroll.scrollLeft + windowOffset, + behavior, + }) + } else if (!horizontal && targetScroll.scrollTop !== undefined) { + win.scrollTo({ + top: targetScroll.scrollTop + windowOffset, + behavior, + }) + } + } + + return targetScroll + } + + destroy(): void { + const win = this.getWindow() + // Remove window scroll listeners + if (this.windowScrollHandler && typeof win !== "undefined") { + win.removeEventListener("scroll", this.windowScrollHandler) + win.removeEventListener("resize", this.windowScrollHandler) + } + + super.destroy() + } +} diff --git a/packages/utilities/virtualizer/tests/binary-search.test.ts b/packages/utilities/virtualizer/tests/binary-search.test.ts new file mode 100644 index 0000000000..01c298cdb5 --- /dev/null +++ b/packages/utilities/virtualizer/tests/binary-search.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "vitest" +import { findInsertionIndex, findInsertionIndexRight, findNearestIndex } from "../src/utils/binary-search" + +describe("@zag-js/virtualizer - binary search utils", () => { + test("findInsertionIndex (lower bound) returns first index where value can be inserted", () => { + const items = [1, 2, 2, 5] + expect(findInsertionIndex(items, 0, (x) => x)).toBe(0) + expect(findInsertionIndex(items, 1, (x) => x)).toBe(0) + expect(findInsertionIndex(items, 2, (x) => x)).toBe(1) + expect(findInsertionIndex(items, 3, (x) => x)).toBe(3) + expect(findInsertionIndex(items, 6, (x) => x)).toBe(4) + }) + + test("findInsertionIndexRight (upper bound) returns first index strictly greater than value", () => { + const items = [1, 2, 2, 5] + expect(findInsertionIndexRight(items, 0, (x) => x)).toBe(0) + expect(findInsertionIndexRight(items, 1, (x) => x)).toBe(1) + expect(findInsertionIndexRight(items, 2, (x) => x)).toBe(3) + expect(findInsertionIndexRight(items, 3, (x) => x)).toBe(3) + expect(findInsertionIndexRight(items, 5, (x) => x)).toBe(4) + expect(findInsertionIndexRight(items, 6, (x) => x)).toBe(4) + }) + + test("handles empty arrays", () => { + expect(findInsertionIndex([], 123, (x) => x)).toBe(0) + expect(findInsertionIndexRight([], 123, (x) => x)).toBe(0) + }) + + test("findNearestIndex returns index of nearest value (or exact match)", () => { + const items = [0, 10, 20, 30] + expect(findNearestIndex(items, 0, (x) => x)).toBe(0) + expect(findNearestIndex(items, 30, (x) => x)).toBe(3) + expect(findNearestIndex(items, 21, (x) => x)).toBe(2) + expect(findNearestIndex(items, 29, (x) => x)).toBe(3) + }) + + test("findNearestIndex returns -1 for empty array", () => { + expect(findNearestIndex([], 123, (x) => x)).toBe(-1) + }) +}) diff --git a/packages/utilities/virtualizer/tests/size-tracker.test.ts b/packages/utilities/virtualizer/tests/size-tracker.test.ts new file mode 100644 index 0000000000..777cf2b125 --- /dev/null +++ b/packages/utilities/virtualizer/tests/size-tracker.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "vitest" +import { SizeTracker } from "../src/utils/size-tracker" + +describe("@zag-js/virtualizer - SizeTracker", () => { + test("getTotalSize returns sum of sizes + gaps (no trailing gap)", () => { + const tracker = new SizeTracker(3, 10, () => 40) // [40,40,40] with 10px gaps => 40+10+40+10+40 = 140 + expect(tracker.getTotalSize(0, 0)).toBe(140) + }) + + test("getTotalSize includes padding", () => { + const tracker = new SizeTracker(2, 0, () => 50) // total 100 + expect(tracker.getTotalSize(12, 8)).toBe(120) + }) +}) diff --git a/packages/utilities/virtualizer/tsconfig.json b/packages/utilities/virtualizer/tsconfig.json new file mode 100644 index 0000000000..8e781cd154 --- /dev/null +++ b/packages/utilities/virtualizer/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src"], + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/.tsbuildinfo" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3826dce02c..9d2a9661b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -377,6 +377,9 @@ importers: '@zag-js/utils': specifier: workspace:* version: link:../../packages/utilities/core + '@zag-js/virtualizer': + specifier: workspace:* + version: link:../../packages/utilities/virtualizer form-serialize: specifier: 0.7.2 version: 0.7.2 @@ -675,6 +678,9 @@ importers: '@zag-js/utils': specifier: workspace:* version: link:../../packages/utilities/core + '@zag-js/virtualizer': + specifier: workspace:* + version: link:../../packages/utilities/virtualizer '@zag-js/vue': specifier: workspace:* version: link:../../packages/frameworks/vue @@ -949,6 +955,9 @@ importers: '@zag-js/utils': specifier: workspace:* version: link:../../packages/utilities/core + '@zag-js/virtualizer': + specifier: workspace:* + version: link:../../packages/utilities/virtualizer lucide-preact: specifier: 0.548.0 version: 0.548.0(preact@10.27.2) @@ -1229,6 +1238,9 @@ importers: '@zag-js/utils': specifier: workspace:* version: link:../../packages/utilities/core + '@zag-js/virtualizer': + specifier: workspace:* + version: link:../../packages/utilities/virtualizer form-serialize: specifier: 0.7.2 version: 0.7.2 @@ -1484,6 +1496,9 @@ importers: '@zag-js/utils': specifier: workspace:* version: link:../../packages/utilities/core + '@zag-js/virtualizer': + specifier: workspace:* + version: link:../../packages/utilities/virtualizer form-serialize: specifier: 0.7.2 version: 0.7.2 @@ -1764,6 +1779,9 @@ importers: '@zag-js/utils': specifier: workspace:* version: link:../../packages/utilities/core + '@zag-js/virtualizer': + specifier: workspace:* + version: link:../../packages/utilities/virtualizer form-serialize: specifier: 0.7.2 version: 0.7.2 @@ -3449,6 +3467,19 @@ importers: specifier: 2.2.0 version: 2.2.0 + packages/utilities/virtualizer: + dependencies: + '@zag-js/dom-query': + specifier: workspace:* + version: link:../dom-query + '@zag-js/types': + specifier: workspace:* + version: link:../../types + devDependencies: + clean-package: + specifier: 2.2.0 + version: 2.2.0 + shared: dependencies: '@zag-js/image-cropper':