diff --git a/src/app/globals.css b/src/app/globals.css index 030b798..0a15607 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @import "../styles/breakpoints.css"; @import "../styles/containers.css"; +@import "../styles/virtual-list.css"; :root, .light { diff --git a/src/components/ui/VirtualList.tsx b/src/components/ui/VirtualList.tsx new file mode 100644 index 0000000..27a3adf --- /dev/null +++ b/src/components/ui/VirtualList.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useCallback, type ReactNode } from "react"; +import { + useVirtualList, + type UseVirtualListOptions, + type VirtualItem, +} from "@/hooks/useVirtualList"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface VirtualListProps { + /** Full dataset to be rendered virtually. */ + items: T[]; + /** + * Render callback for each visible item. Receives the data item, its + * virtual-layout metadata, and a `measureRef` callback that *must* be + * attached to the outermost DOM element of the row so the list can + * measure its real height. + */ + renderItem: ( + item: T, + virtualItem: VirtualItem, + measureRef: (el: HTMLElement | null) => void + ) => ReactNode; + /** + * Derive a stable React key for each item. + * Falls back to the item's array index if omitted. + */ + getItemKey?: (item: T, index: number) => string | number; + /** Height of the scrollable container (px or CSS value). */ + height?: number | string; + /** Optional extra className on the outer container. */ + className?: string; + /** + * Estimated average row height (px). A closer estimate improves initial + * scroll-bar accuracy. + * @default 48 + */ + estimatedItemHeight?: number; + /** + * Extra rows rendered above/below the viewport. + * @default 5 + */ + overscan?: number; + /** + * Session-storage key for scroll-position restoration. + * Omit to disable. + */ + scrollRestorationKey?: string; + /** Async callback invoked near the bottom of the list. */ + onLoadMore?: () => Promise | void; + /** + * Distance (px) from the bottom at which `onLoadMore` fires. + * @default 200 + */ + loadMoreThreshold?: number; + /** Whether a load-more request is in flight. */ + isLoading?: boolean; + /** Rendered at the very bottom while `isLoading` is true. */ + loadingIndicator?: ReactNode; + /** Rendered when `items` is empty and not loading. */ + emptyState?: ReactNode; + /** Accessible label for the list container. */ + ariaLabel?: string; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function VirtualList({ + items, + renderItem, + getItemKey, + height = 400, + className, + estimatedItemHeight, + overscan, + scrollRestorationKey, + onLoadMore, + loadMoreThreshold, + isLoading = false, + loadingIndicator, + emptyState, + ariaLabel, +}: VirtualListProps) { + const hookOptions: UseVirtualListOptions = { + items, + estimatedItemHeight, + overscan, + scrollRestorationKey, + onLoadMore, + loadMoreThreshold, + isLoading, + getItemKey, + }; + + const { + containerRef, + innerRef, + virtualItems, + totalHeight, + measureElement, + } = useVirtualList(hookOptions); + + // Factory that produces a stable `measureRef` per row index. + const createMeasureRef = useCallback( + (index: number) => (el: HTMLElement | null) => { + measureElement(index, el); + }, + [measureElement] + ); + + const resolvedHeight = + typeof height === "number" ? `${height}px` : height; + + // Empty state + if (items.length === 0 && !isLoading) { + return ( +
+ {emptyState ?? ( +
No items to display
+ )} +
+ ); + } + + return ( +
+
+ {virtualItems.map((vi) => { + const key = getItemKey + ? getItemKey(items[vi.index], vi.index) + : vi.index; + + return ( +
+ {renderItem(items[vi.index], vi, createMeasureRef(vi.index))} +
+ ); + })} +
+ + {isLoading && ( +
+ {loadingIndicator ?? ( +
+
+ )} +
+ )} +
+ ); +} + +export default VirtualList; diff --git a/src/hooks/useVirtualList.ts b/src/hooks/useVirtualList.ts new file mode 100644 index 0000000..33299f9 --- /dev/null +++ b/src/hooks/useVirtualList.ts @@ -0,0 +1,359 @@ +"use client"; + +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface VirtualItem { + /** Index in the source data array. */ + index: number; + /** Offset from the top of the scrollable content (px). */ + offsetTop: number; + /** Measured or estimated height (px). */ + height: number; +} + +export interface UseVirtualListOptions { + /** Full dataset — the hook only renders a visible window. */ + items: T[]; + /** + * Estimated average row height (px). Used for initial layout before + * measurements are available. A closer estimate reduces layout jumps. + * @default 48 + */ + estimatedItemHeight?: number; + /** + * Extra items rendered above/below the visible window to reduce flicker + * during fast scrolling. + * @default 5 + */ + overscan?: number; + /** + * Session-storage key to persist scroll position across unmounts. + * Omit to disable scroll restoration. + */ + scrollRestorationKey?: string; + /** + * Called when the user scrolls within `loadMoreThreshold` of the bottom. + * Return a Promise that resolves when the next page is ready. + */ + onLoadMore?: () => Promise | void; + /** + * Distance from the bottom (px) at which `onLoadMore` triggers. + * @default 200 + */ + loadMoreThreshold?: number; + /** If true, indicates that more data is currently being fetched. */ + isLoading?: boolean; + /** + * If true the container element is expected to be an `overflow: auto` + * scrollable. Otherwise the hook attaches to `window` scroll events. + * @default true + */ + useContainerScroll?: boolean; + /** + * Optional function to derive a unique key for each item. + * Falls back to the array index if omitted. + */ + getItemKey?: (item: T, index: number) => string | number; +} + +export interface UseVirtualListReturn { + /** Ref to attach to the scrollable container element. */ + containerRef: React.RefObject; + /** Ref to attach to the inner "spacer" element that defines total height. */ + innerRef: React.RefObject; + /** The visible slice of virtual items to render. */ + virtualItems: VirtualItem[]; + /** Total estimated content height (px). */ + totalHeight: number; + /** Whether new data is currently loading. */ + isLoading: boolean; + /** Register a measured DOM element for a given data index. */ + measureElement: (index: number, element: HTMLElement | null) => void; + /** Programmatically scroll to a specific item index. */ + scrollToIndex: (index: number, behavior?: ScrollBehavior) => void; + /** The original items slice corresponding to `virtualItems`. */ + visibleItems: T[]; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_ESTIMATED_HEIGHT = 48; +const DEFAULT_OVERSCAN = 5; +const DEFAULT_LOAD_MORE_THRESHOLD = 200; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Binary-search for the first item whose cumulative bottom edge is at or + * below `scrollTop`. + */ +function findStartIndex( + offsets: number[], + heights: number[], + scrollTop: number +): number { + let lo = 0; + let hi = offsets.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + const bottom = offsets[mid] + heights[mid]; + if (bottom <= scrollTop) { + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return lo; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useVirtualList( + options: UseVirtualListOptions +): UseVirtualListReturn { + const { + items, + estimatedItemHeight = DEFAULT_ESTIMATED_HEIGHT, + overscan = DEFAULT_OVERSCAN, + scrollRestorationKey, + onLoadMore, + loadMoreThreshold = DEFAULT_LOAD_MORE_THRESHOLD, + isLoading = false, + useContainerScroll = true, + } = options; + + const containerRef = useRef(null); + const innerRef = useRef(null); + + // Per-index measured heights; `undefined` means "not yet measured". + const measuredHeights = useRef>(new Map()); + + // Track whether we're already loading to prevent duplicate triggers. + const loadingRef = useRef(false); + loadingRef.current = isLoading; + + // Debounce RAF handle. + const rafRef = useRef(null); + + // A simple counter that forces re-computation when measurements change. + const [measureVersion, setMeasureVersion] = useState(0); + + // ----------------------------------------------------------------------- + // Derived: offsets + heights arrays + // ----------------------------------------------------------------------- + + const { offsets, heights, totalHeight } = useMemo(() => { + const count = items.length; + const _offsets = new Array(count); + const _heights = new Array(count); + + let cumulative = 0; + for (let i = 0; i < count; i++) { + _offsets[i] = cumulative; + _heights[i] = measuredHeights.current.get(i) ?? estimatedItemHeight; + cumulative += _heights[i]; + } + + return { offsets: _offsets, heights: _heights, totalHeight: cumulative }; + // measureVersion dependency triggers recomputation after DOM measurement. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items.length, estimatedItemHeight, measureVersion]); + + // ----------------------------------------------------------------------- + // Scroll state + // ----------------------------------------------------------------------- + + const [scrollTop, setScrollTop] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); + + // Store totalHeight in a ref so the scroll handler always reads the latest. + const totalHeightRef = useRef(totalHeight); + totalHeightRef.current = totalHeight; + + const handleScroll = useCallback(() => { + if (rafRef.current !== null) return; // coalesce to one per frame + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + const el = containerRef.current; + if (!el) return; + + const top = useContainerScroll ? el.scrollTop : window.scrollY; + const height = useContainerScroll ? el.clientHeight : window.innerHeight; + + setScrollTop(top); + setContainerHeight(height); + + // Persist scroll position for restoration. + if (scrollRestorationKey) { + try { + sessionStorage.setItem( + `vlist-scroll-${scrollRestorationKey}`, + String(top) + ); + } catch { + /* quota exceeded — non-critical */ + } + } + + // Infinite-scroll trigger. + if ( + onLoadMore && + !loadingRef.current && + totalHeightRef.current - (top + height) < loadMoreThreshold + ) { + loadingRef.current = true; + onLoadMore(); + } + }); + }, [useContainerScroll, scrollRestorationKey, onLoadMore, loadMoreThreshold]); + + // Attach / detach scroll listener. + useEffect(() => { + const target = useContainerScroll ? containerRef.current : window; + if (!target) return; + + target.addEventListener("scroll", handleScroll as EventListener, { + passive: true, + }); + + // Capture initial measurements. + handleScroll(); + + return () => { + target.removeEventListener("scroll", handleScroll as EventListener); + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [handleScroll, useContainerScroll]); + + // ----------------------------------------------------------------------- + // Scroll position restoration + // ----------------------------------------------------------------------- + + useLayoutEffect(() => { + if (!scrollRestorationKey) return; + try { + const saved = sessionStorage.getItem( + `vlist-scroll-${scrollRestorationKey}` + ); + if (saved !== null) { + const pos = Number(saved); + if (!Number.isNaN(pos) && containerRef.current) { + containerRef.current.scrollTop = pos; + } + } + } catch { + /* ignore */ + } + }, [scrollRestorationKey]); + + // ----------------------------------------------------------------------- + // Visible window calculation + // ----------------------------------------------------------------------- + + const virtualItems = useMemo(() => { + if (items.length === 0 || offsets.length === 0) return []; + + const start = Math.max( + 0, + findStartIndex(offsets, heights, scrollTop) - overscan + ); + const visibleEnd = scrollTop + containerHeight; + let end = start; + while (end < items.length && offsets[end] < visibleEnd) { + end++; + } + end = Math.min(items.length - 1, end + overscan); + + const result: VirtualItem[] = []; + for (let i = start; i <= end; i++) { + result.push({ + index: i, + offsetTop: offsets[i], + height: heights[i], + }); + } + return result; + }, [items.length, offsets, heights, scrollTop, containerHeight, overscan]); + + const visibleItems = useMemo( + () => virtualItems.map((vi) => items[vi.index]), + [virtualItems, items] + ); + + // ----------------------------------------------------------------------- + // Dynamic measurement + // ----------------------------------------------------------------------- + + const measureElement = useCallback( + (index: number, element: HTMLElement | null) => { + if (!element) return; + const measured = element.getBoundingClientRect().height; + const prev = measuredHeights.current.get(index); + if (prev !== measured) { + measuredHeights.current.set(index, measured); + setMeasureVersion((v) => v + 1); + } + }, + [] + ); + + // ----------------------------------------------------------------------- + // Programmatic scroll + // ----------------------------------------------------------------------- + + const scrollToIndex = useCallback( + (index: number, behavior: ScrollBehavior = "auto") => { + const clamped = Math.max(0, Math.min(index, items.length - 1)); + const targetOffset = offsets[clamped] ?? 0; + if (useContainerScroll && containerRef.current) { + containerRef.current.scrollTo({ top: targetOffset, behavior }); + } else { + window.scrollTo({ top: targetOffset, behavior }); + } + }, + [items.length, offsets, useContainerScroll] + ); + + // ----------------------------------------------------------------------- + // Clean up on unmount + // ----------------------------------------------------------------------- + + useEffect(() => { + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + }; + }, []); + + return { + containerRef, + innerRef, + virtualItems, + totalHeight, + isLoading, + measureElement, + scrollToIndex, + visibleItems, + }; +} diff --git a/src/styles/virtual-list.css b/src/styles/virtual-list.css new file mode 100644 index 0000000..ee85b35 --- /dev/null +++ b/src/styles/virtual-list.css @@ -0,0 +1,95 @@ +/* + * VirtualList — Styles for the virtualised scrolling component. + * + * The component uses absolute positioning for each row and relies on + * `transform: translateY()` for GPU-accelerated layout. These styles + * handle the container chrome, loading states, and scrollbar treatment. + */ + +/* ------------------------------------------------------------------ */ +/* Container */ +/* ------------------------------------------------------------------ */ + +.virtual-list-container { + contain: strict; + will-change: scroll-position; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.virtual-list-container::-webkit-scrollbar { + width: 6px; +} + +.virtual-list-container::-webkit-scrollbar-track { + background: transparent; +} + +.virtual-list-container::-webkit-scrollbar-thumb { + background-color: var(--border); + border-radius: 3px; +} + +/* ------------------------------------------------------------------ */ +/* Inner spacer (defines total scrollable height) */ +/* ------------------------------------------------------------------ */ + +.virtual-list-inner { + contain: layout size; +} + +/* ------------------------------------------------------------------ */ +/* Individual rows */ +/* ------------------------------------------------------------------ */ + +.virtual-list-row { + contain: layout style; +} + +/* ------------------------------------------------------------------ */ +/* Empty state */ +/* ------------------------------------------------------------------ */ + +.virtual-list-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--muted-foreground); + font-size: 0.875rem; +} + +/* ------------------------------------------------------------------ */ +/* Loading indicator */ +/* ------------------------------------------------------------------ */ + +.virtual-list-loading { + display: flex; + justify-content: center; + padding: 1rem 0; +} + +.virtual-list-loading-default { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--muted-foreground); + font-size: 0.875rem; +} + +.virtual-list-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--border); + border-top-color: var(--foreground); + border-radius: 50%; + animation: vlist-spin 0.6s linear infinite; +} + +@keyframes vlist-spin { + to { + transform: rotate(360deg); + } +} diff --git a/tests/components/VirtualList.test.tsx b/tests/components/VirtualList.test.tsx new file mode 100644 index 0000000..4c57756 --- /dev/null +++ b/tests/components/VirtualList.test.tsx @@ -0,0 +1,247 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { VirtualList } from "@/components/ui/VirtualList"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeItems(count: number) { + return Array.from({ length: count }, (_, i) => ({ + id: i, + label: `Item ${i}`, + })); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("VirtualList component", () => { + beforeEach(() => { + vi.stubGlobal( + "requestAnimationFrame", + (cb: FrameRequestCallback) => { + cb(performance.now()); + return 1; + } + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ----------------------------------------------------------------------- + // Empty state + // ----------------------------------------------------------------------- + + it("renders default empty state when items is empty", () => { + render( +
row
} + ariaLabel="test-list" + /> + ); + + expect(screen.getByText("No items to display")).toBeDefined(); + }); + + it("renders custom empty state when provided", () => { + render( +
row
} + emptyState={

Nothing here

} + ariaLabel="test-list" + /> + ); + + expect(screen.getByText("Nothing here")).toBeDefined(); + }); + + // ----------------------------------------------------------------------- + // Rendering rows + // ----------------------------------------------------------------------- + + it("renders visible items with role='listitem'", () => { + const items = makeItems(5); + render( + ( +
{item.label}
+ )} + ariaLabel="meter-readings" + /> + ); + + // Should find at least some items rendered + const listItems = screen.getAllByRole("listitem"); + expect(listItems.length).toBeGreaterThan(0); + }); + + it("applies aria-label to the container", () => { + render( + ( +
{item.label}
+ )} + ariaLabel="reading-list" + /> + ); + + const list = screen.getByRole("list"); + expect(list.getAttribute("aria-label")).toBe("reading-list"); + }); + + it("applies aria-rowcount to the container", () => { + const items = makeItems(100); + render( + ( +
{item.label}
+ )} + ariaLabel="big-list" + /> + ); + + const list = screen.getByRole("list"); + expect(list.getAttribute("aria-rowcount")).toBe("100"); + }); + + // ----------------------------------------------------------------------- + // Loading indicator + // ----------------------------------------------------------------------- + + it("shows default loading indicator when isLoading", () => { + render( + ( +
{item.label}
+ )} + ariaLabel="loading-list" + /> + ); + + expect(screen.getByText("Loading more items…")).toBeDefined(); + }); + + it("shows custom loading indicator when provided", () => { + render( + Fetching…} + renderItem={(item, _vi, measureRef) => ( +
{item.label}
+ )} + ariaLabel="loading-list" + /> + ); + + expect(screen.getByText("Fetching…")).toBeDefined(); + }); + + it("hides loading indicator when not loading", () => { + render( + ( +
{item.label}
+ )} + ariaLabel="idle-list" + /> + ); + + expect(screen.queryByText("Loading more items…")).toBeNull(); + }); + + // ----------------------------------------------------------------------- + // Custom className + // ----------------------------------------------------------------------- + + it("forwards className to the container", () => { + render( + ( +
{item.label}
+ )} + ariaLabel="classed-list" + /> + ); + + const list = screen.getByRole("list"); + expect(list.className).toContain("my-custom-list"); + }); + + // ----------------------------------------------------------------------- + // getItemKey + // ----------------------------------------------------------------------- + + it("uses getItemKey for stable React keys", () => { + const items = makeItems(5); + const getKey = vi.fn((item: (typeof items)[0]) => `key-${item.id}`); + + render( + ( +
{item.label}
+ )} + ariaLabel="keyed-list" + /> + ); + + // getItemKey should have been called for each rendered item + expect(getKey).toHaveBeenCalled(); + }); + + // ----------------------------------------------------------------------- + // Height prop + // ----------------------------------------------------------------------- + + it("accepts a number height and converts to px", () => { + render( + ( +
{item.label}
+ )} + ariaLabel="height-list" + /> + ); + + const list = screen.getByRole("list"); + expect(list.style.height).toBe("600px"); + }); + + it("accepts a string height (e.g., '100vh')", () => { + render( + ( +
{item.label}
+ )} + ariaLabel="vh-list" + /> + ); + + const list = screen.getByRole("list"); + expect(list.style.height).toBe("80vh"); + }); +}); diff --git a/tests/hooks/useVirtualList.test.ts b/tests/hooks/useVirtualList.test.ts new file mode 100644 index 0000000..3145b27 --- /dev/null +++ b/tests/hooks/useVirtualList.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useVirtualList } from "@/hooks/useVirtualList"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Generate N simple items. */ +function makeItems(count: number): Array<{ id: number; label: string }> { + return Array.from({ length: count }, (_, i) => ({ + id: i, + label: `Item ${i}`, + })); +} + +/** Stub the container ref with controllable scrollTop / clientHeight. */ +function mockContainerRef( + hook: ReturnType, unknown>>, + scrollTop = 0, + clientHeight = 400 +) { + const div = document.createElement("div"); + + Object.defineProperties(div, { + scrollTop: { value: scrollTop, writable: true }, + clientHeight: { value: clientHeight, configurable: true }, + scrollTo: { + value: vi.fn(({ top }: ScrollToOptions) => { + Object.defineProperty(div, "scrollTop", { + value: top, + writable: true, + configurable: true, + }); + }), + configurable: true, + }, + }); + + // Assign the mock div to the containerRef + (hook.result.current.containerRef as { current: HTMLDivElement | null }).current = div; + return div; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("useVirtualList", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + // Mock requestAnimationFrame to execute callback immediately + vi.stubGlobal( + "requestAnimationFrame", + (cb: FrameRequestCallback) => { + cb(performance.now()); + return 1; + } + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + sessionStorage.clear(); + }); + + // ----------------------------------------------------------------------- + // Basic rendering + // ----------------------------------------------------------------------- + + it("returns empty virtualItems for an empty dataset", () => { + const { result } = renderHook(() => + useVirtualList({ items: [], estimatedItemHeight: 40 }) + ); + expect(result.current.virtualItems).toEqual([]); + expect(result.current.totalHeight).toBe(0); + expect(result.current.visibleItems).toEqual([]); + }); + + it("computes totalHeight from estimatedItemHeight × item count", () => { + const items = makeItems(100); + const { result } = renderHook(() => + useVirtualList({ items, estimatedItemHeight: 50 }) + ); + expect(result.current.totalHeight).toBe(100 * 50); + }); + + it("uses default estimatedItemHeight of 48 when unspecified", () => { + const items = makeItems(10); + const { result } = renderHook(() => useVirtualList({ items })); + expect(result.current.totalHeight).toBe(10 * 48); + }); + + // ----------------------------------------------------------------------- + // Large dataset support + // ----------------------------------------------------------------------- + + it("handles 10,000+ items without error", () => { + const items = makeItems(15_000); + const { result } = renderHook(() => + useVirtualList({ items, estimatedItemHeight: 40 }) + ); + expect(result.current.totalHeight).toBe(15_000 * 40); + // Only a small window should be in virtualItems (not all 15k) + expect(result.current.virtualItems.length).toBeLessThan(100); + }); + + it("handles 50,000 items efficiently", () => { + const items = makeItems(50_000); + const start = performance.now(); + const { result } = renderHook(() => + useVirtualList({ items, estimatedItemHeight: 40 }) + ); + const elapsed = performance.now() - start; + + expect(result.current.totalHeight).toBe(50_000 * 40); + // Should initialise in under 500ms even with 50k items + expect(elapsed).toBeLessThan(500); + }); + + // ----------------------------------------------------------------------- + // Dynamic row height measurement + // ----------------------------------------------------------------------- + + it("updates totalHeight after measuring a row", () => { + const items = makeItems(10); + const { result } = renderHook(() => + useVirtualList({ items, estimatedItemHeight: 40 }) + ); + + expect(result.current.totalHeight).toBe(400); // 10 × 40 + + // Simulate measuring index 0 as 80px tall (double the estimate) + const el = document.createElement("div"); + vi.spyOn(el, "getBoundingClientRect").mockReturnValue({ + height: 80, + width: 200, + x: 0, + y: 0, + top: 0, + right: 200, + bottom: 80, + left: 0, + toJSON: () => ({}), + }); + + act(() => { + result.current.measureElement(0, el); + }); + + // Total should now be 80 + 9×40 = 440 + expect(result.current.totalHeight).toBe(440); + }); + + it("does not re-render when measured height is unchanged", () => { + const items = makeItems(5); + const { result } = renderHook(() => + useVirtualList({ items, estimatedItemHeight: 40 }) + ); + + const el = document.createElement("div"); + vi.spyOn(el, "getBoundingClientRect").mockReturnValue({ + height: 40, + width: 200, + x: 0, + y: 0, + top: 0, + right: 200, + bottom: 40, + left: 0, + toJSON: () => ({}), + }); + + const totalBefore = result.current.totalHeight; + + act(() => { + result.current.measureElement(0, el); + }); + + // Height matches the estimate — totalHeight should be unchanged. + expect(result.current.totalHeight).toBe(totalBefore); + }); + + // ----------------------------------------------------------------------- + // measureElement null safety + // ----------------------------------------------------------------------- + + it("safely ignores null elements passed to measureElement", () => { + const items = makeItems(5); + const { result } = renderHook(() => useVirtualList({ items })); + + // Should not throw + act(() => { + result.current.measureElement(0, null); + }); + + expect(result.current.totalHeight).toBe(5 * 48); + }); + + // ----------------------------------------------------------------------- + // Scroll position restoration + // ----------------------------------------------------------------------- + + it("stores scroll position in sessionStorage", () => { + const items = makeItems(100); + const key = "test-scroll"; + + renderHook(() => + useVirtualList({ + items, + estimatedItemHeight: 40, + scrollRestorationKey: key, + }) + ); + + // The key should have been accessed + const stored = sessionStorage.getItem(`vlist-scroll-${key}`); + // It may be "0" on initial render + expect(stored).not.toBeNull(); + }); + + // ----------------------------------------------------------------------- + // Loading state + // ----------------------------------------------------------------------- + + it("exposes isLoading from options", () => { + const items = makeItems(10); + const { result, rerender } = renderHook( + ({ loading }: { loading: boolean }) => + useVirtualList({ items, isLoading: loading }), + { initialProps: { loading: false } } + ); + + expect(result.current.isLoading).toBe(false); + + rerender({ loading: true }); + expect(result.current.isLoading).toBe(true); + }); + + // ----------------------------------------------------------------------- + // scrollToIndex + // ----------------------------------------------------------------------- + + it("provides a scrollToIndex function", () => { + const items = makeItems(100); + const { result } = renderHook(() => + useVirtualList({ items, estimatedItemHeight: 40 }) + ); + + // scrollToIndex should be a callable function + expect(typeof result.current.scrollToIndex).toBe("function"); + }); + + // ----------------------------------------------------------------------- + // Refs + // ----------------------------------------------------------------------- + + it("provides containerRef and innerRef", () => { + const items = makeItems(10); + const { result } = renderHook(() => useVirtualList({ items })); + + expect(result.current.containerRef).toBeDefined(); + expect(result.current.innerRef).toBeDefined(); + }); + + // ----------------------------------------------------------------------- + // Virtual items structure + // ----------------------------------------------------------------------- + + it("virtual items contain correct index and offsetTop", () => { + const items = makeItems(20); + const { result } = renderHook(() => + useVirtualList({ items, estimatedItemHeight: 50 }) + ); + + for (const vi of result.current.virtualItems) { + expect(vi.index).toBeGreaterThanOrEqual(0); + expect(vi.index).toBeLessThan(20); + expect(vi.offsetTop).toBe(vi.index * 50); + expect(vi.height).toBe(50); + } + }); + + it("visibleItems maps correctly to virtualItems indices", () => { + const items = makeItems(20); + const { result } = renderHook(() => + useVirtualList({ items, estimatedItemHeight: 50 }) + ); + + const { virtualItems, visibleItems } = result.current; + expect(visibleItems.length).toBe(virtualItems.length); + + visibleItems.forEach((item, i) => { + expect(item).toBe(items[virtualItems[i].index]); + }); + }); + + // ----------------------------------------------------------------------- + // Reactivity to items change + // ----------------------------------------------------------------------- + + it("updates when items array grows", () => { + let data = makeItems(10); + const { result, rerender } = renderHook(() => + useVirtualList({ items: data, estimatedItemHeight: 40 }) + ); + + expect(result.current.totalHeight).toBe(400); + + data = makeItems(20); + rerender(); + + expect(result.current.totalHeight).toBe(800); + }); +});