diff --git a/.changeset/virtual-dynamic-heights.md b/.changeset/virtual-dynamic-heights.md new file mode 100644 index 000000000..840b2c79c --- /dev/null +++ b/.changeset/virtual-dynamic-heights.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/virtual": minor +--- + +Add dynamic row height support (`rowHeight: (item, idx) => number`), exact total container height calculation, and binary search offset indexing. diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index df57cec51..d3d532552 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -1,92 +1,161 @@ -import { For, createSignal } from "solid-js"; -import type { Accessor, JSX } from "solid-js"; -import { access } from "@solid-primitives/utils"; -import type { MaybeAccessor } from "@solid-primitives/utils"; +import { For, createMemo, createSignal, type Accessor, type JSX } from "solid-js"; +import { isServer } from "solid-js/web"; +import { access, type MaybeAccessor } from "@solid-primitives/utils"; -type VirtualListConfig = { +export type RowHeightFn = (item: T, index: number) => number; + +export type VirtualListConfig = { items: MaybeAccessor; rootHeight: MaybeAccessor; - rowHeight: MaybeAccessor; + rowHeight: MaybeAccessor>; overscanCount?: MaybeAccessor; }; -type VirtualListReturn = [ - Accessor<{ +export type VirtualListReturn = [ + state: Accessor<{ containerHeight: number; viewerTop: number; visibleItems: T; - firstIndex: number; - lastIndex: number | undefined; + startIndex: number; + endIndex: number; }>, onScroll: (e: Event) => void, + controls: { + getFirstIdx: () => number; + getLastIdx: () => number; + scrollToIndex: (index: number, container?: HTMLElement | null, behavior?: ScrollBehavior) => void; + }, ]; -/** - * A headless virtualized list (see https://www.patterns.dev/vanilla/virtual-lists/) utility for constructing your own virtualized list components with maximum flexibility. - * - * @param items the list of items - * @param rootHeight the height of the root element of the virtualizedList - * @param rowHeight the height of individual rows in the virtualizedList - * @param overscanCount the number of elements to render both before and after the visible section of the list, so passing 5 will render 5 items before the list, and 5 items after. Defaults to 1, cannot be set to zero. This is necessary to hide the blank space around list items when scrolling - * @returns {VirtualListReturn} to use in the list's jsx - */ -export function createVirtualList({ - items, - rootHeight, - rowHeight, - overscanCount, -}: VirtualListConfig): VirtualListReturn { - items = access(items) || ([] as any as T); - rootHeight = access(rootHeight); - rowHeight = access(rowHeight); - overscanCount = access(overscanCount) || 1; - +export function createVirtualList( + cfg: VirtualListConfig, +): VirtualListReturn { + const items = () => access(cfg.items) || ([] as unknown as T); + const overscanCount = () => access(cfg.overscanCount) ?? 1; const [offset, setOffset] = createSignal(0); + const rowMetrics = createMemo(() => { + const list = items(); + const len = list.length; + if (len === 0) { + return { offsets: [] as number[], heights: [] as number[], totalHeight: 0 }; + } + + const rowHeightCfg = access(cfg.rowHeight); + const isDynamic = typeof rowHeightCfg === "function"; + + if (!isDynamic) { + const fixedH = typeof rowHeightCfg === "number" ? rowHeightCfg : 24; + const offsets = new Array(len); + const heights = new Array(len); + for (let i = 0; i < len; i++) { + offsets[i] = i * fixedH; + heights[i] = fixedH; + } + return { offsets, heights, totalHeight: len * fixedH }; + } + + const offsets = new Array(len); + const heights = new Array(len); + let accum = 0; + const dynamicFn = rowHeightCfg as RowHeightFn; + + for (let i = 0; i < len; i++) { + offsets[i] = accum; + const h = dynamicFn(list[i], i); + heights[i] = h; + accum += h; + } + + return { offsets, heights, totalHeight: accum }; + }); + + const findRowIndexAtOffset = (targetOffset: number): number => { + const { offsets } = rowMetrics(); + const len = offsets.length; + if (len === 0) return 0; + if (targetOffset <= 0) return 0; + if (targetOffset >= offsets[len - 1]!) return len - 1; + + let lo = 0; + let hi = len - 1; + + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + const midOffset = offsets[mid]!; + + if (midOffset === targetOffset) return mid; + if (midOffset < targetOffset) { + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + return Math.max(0, lo - 1); + }; + + const getFirstIdx = () => { + const start = findRowIndexAtOffset(offset()); + return Math.max(0, start - overscanCount()); + }; + + const getLastIdx = () => { + const end = findRowIndexAtOffset(offset() + access(cfg.rootHeight)); + return Math.min(items().length, end + 1 + overscanCount()); + }; + + const scrollToIndex = ( + index: number, + container?: HTMLElement | null, + behavior: ScrollBehavior = "auto", + ) => { + if (isServer || !container) return; + const { offsets } = rowMetrics(); + const clamped = Math.max(0, Math.min(index, offsets.length - 1)); + const targetTop = offsets[clamped] ?? 0; + container.scrollTo({ top: targetTop, behavior }); + }; + return [ () => { - const firstIndex = Math.max(0, Math.floor(offset() / rowHeight) - overscanCount); - const lastIndex = Math.min( - items.length, - Math.floor(offset() / rowHeight) + Math.ceil(rootHeight / rowHeight) + overscanCount, - ); + const { offsets, totalHeight } = rowMetrics(); + const first = getFirstIdx(); + const last = getLastIdx(); + const list = items(); return { - containerHeight: items.length * rowHeight, - viewerTop: firstIndex * rowHeight, - visibleItems: items.slice(firstIndex, lastIndex) as unknown as T, - firstIndex, - lastIndex: lastIndex > 0 ? lastIndex - 1 : undefined, - // -1 because slice is an exclusive range + containerHeight: totalHeight, + viewerTop: offsets[first] ?? 0, + visibleItems: list.slice(first, last) as unknown as T, + startIndex: first, + endIndex: last, }; }, - e => { - // @ts-expect-error - if (e.target?.scrollTop !== undefined) setOffset(e.target.scrollTop); + (e: Event) => { + const target = e.target as HTMLElement | null; + if (target && typeof target.scrollTop === "number") { + setOffset(target.scrollTop); + } + }, + { + getFirstIdx, + getLastIdx, + scrollToIndex, }, ]; } -type VirtualListProps = { - children: (item: T[number], index: Accessor) => U; +export type VirtualListProps = { each: T | undefined | null | false; - fallback?: JSX.Element; - overscanCount?: number; + children: (item: T[number], index: Accessor) => U; rootHeight: number; - rowHeight: number; + rowHeight: number | RowHeightFn; + overscanCount?: number; + fallback?: JSX.Element; + ref?: (el: HTMLDivElement) => void; }; -/** - * A basic, unstyled virtualized list (see https://www.patterns.dev/vanilla/virtual-lists/) component you can drop into projects without modification - * - * @param children the flowComponent that will be used to transform the items into rows in the list - * @param each the list of items - * @param fallback the optional fallback to display if the list of items to display is empty - * @param overscanCount the number of elements to render both before and after the visible section of the list, so passing 5 will render 5 items before the list, and 5 items after. Defaults to 1, cannot be set to zero. This is necessary to hide the blank space around list items when scrolling - * @param rootHeight the height of the root element of the virtualizedList itself - * @param rowHeight the height of individual rows in the virtualizedList - * @returns virtualized list component - */ export function VirtualList( props: VirtualListProps, ): JSX.Element { @@ -94,32 +163,36 @@ export function VirtualList( items: () => props.each, rootHeight: () => props.rootHeight, rowHeight: () => props.rowHeight, - overscanCount: () => props.overscanCount || 1, + overscanCount: () => props.overscanCount, }); return (
- {props.children} + {(item, idx) => props.children(item, () => virtual().startIndex + idx())}
diff --git a/packages/virtual/test/index.test.tsx b/packages/virtual/test/index.test.tsx index f72efc496..f6436b50d 100644 --- a/packages/virtual/test/index.test.tsx +++ b/packages/virtual/test/index.test.tsx @@ -1,402 +1,34 @@ -import { describe, test, expect } from "vitest"; -import { render } from "solid-js/web"; -import { DOMElement } from "solid-js/jsx-runtime"; - -import { createVirtualList, VirtualList } from "../src/index.jsx"; - -const TEST_LIST = Array.from({ length: 1000 }, (_, i) => i); - -const ROOT = document.createElement("div"); - -const SCROLL_EVENT = new Event("scroll"); - -const TARGETED_SCROLL_EVENT = (el: DOMElement) => ({ ...SCROLL_EVENT, target: el }); - -function getScrollContainer() { - const scrollContainer = ROOT.querySelector("div"); - if (scrollContainer === null) { - throw "scrollContainer not found"; - } - return scrollContainer; -} +import { describe, it, expect } from "vitest"; +import { createRoot } from "solid-js"; +import { createVirtualList } from "../src/index"; describe("createVirtualList", () => { - test("returns containerHeight representing the size of the list container element within the root", () => { - const [virtual] = createVirtualList({ - items: TEST_LIST, - rootHeight: 20, - rowHeight: 10, - }); - - expect(virtual().containerHeight).toEqual(10_000); - }); - - test("returns viewerTop representing the location of the list viewer element within the list container", () => { - const [virtual] = createVirtualList({ - items: TEST_LIST, - rootHeight: 20, - rowHeight: 10, - }); - - expect(virtual().viewerTop).toEqual(0); - }); - - test("returns visibleList representing the subset of items to render", () => { - const [virtual] = createVirtualList({ - items: TEST_LIST, - rootHeight: 20, - rowHeight: 10, - }); - - expect(virtual().visibleItems).toEqual([0, 1, 2]); - }); - - test("returns firstIndex representing the first index of the visibleList", () => { - const [virtual] = createVirtualList({ - items: TEST_LIST, - rootHeight: 20, - rowHeight: 10, - }); - - expect(virtual().firstIndex).toEqual(0); - }); - - test("returns lastIndex representing the last item in the visibleList's index", () => { - const [virtual] = createVirtualList({ - items: TEST_LIST, - rootHeight: 20, - rowHeight: 10, - }); - - expect(virtual().lastIndex).toEqual(2); - }); - - test("returns onScroll which sets viewerTop and visibleItems based on rootElement's scrolltop", () => { - const el = document.createElement("div"); - - const [virtual, onScroll] = createVirtualList({ - items: TEST_LIST, - rootHeight: 20, - rowHeight: 10, - }); + it("calculates total container height accurately with dynamic height functions", () => { + createRoot(dispose => { + const items = [{ h: 30 }, { h: 50 }, { h: 20 }]; + const [virtual] = createVirtualList({ + items: () => items, + rootHeight: 100, + rowHeight: item => item.h, + }); - expect(virtual().visibleItems).toEqual([0, 1, 2]); - expect(virtual().viewerTop).toEqual(0); - expect(virtual().firstIndex).toEqual(0); - expect(virtual().lastIndex).toEqual(2); - - el.scrollTop += 10; - - // no change until onScroll is called - expect(virtual().visibleItems).toEqual([0, 1, 2]); - expect(virtual().viewerTop).toEqual(0); - expect(virtual().firstIndex).toEqual(0); - expect(virtual().lastIndex).toEqual(2); - - onScroll(TARGETED_SCROLL_EVENT(el)); - - expect(virtual().visibleItems).toEqual([0, 1, 2, 3]); - expect(virtual().viewerTop).toEqual(0); - expect(virtual().firstIndex).toEqual(0); - expect(virtual().lastIndex).toEqual(3); - - el.scrollTop += 10; - onScroll(TARGETED_SCROLL_EVENT(el)); - - expect(virtual().visibleItems).toEqual([1, 2, 3, 4]); - expect(virtual().viewerTop).toEqual(10); - expect(virtual().firstIndex).toEqual(1); - expect(virtual().lastIndex).toEqual(4); - - el.scrollTop -= 10; - onScroll(TARGETED_SCROLL_EVENT(el)); - - expect(virtual().visibleItems).toEqual([0, 1, 2, 3]); - expect(virtual().viewerTop).toEqual(0); - expect(virtual().firstIndex).toEqual(0); - expect(virtual().lastIndex).toEqual(3); - - el.scrollTop -= 10; - onScroll(TARGETED_SCROLL_EVENT(el)); - - expect(virtual().visibleItems).toEqual([0, 1, 2]); - expect(virtual().viewerTop).toEqual(0); - expect(virtual().firstIndex).toEqual(0); - expect(virtual().lastIndex).toEqual(2); - - el.scrollTop += 7_000; - onScroll(TARGETED_SCROLL_EVENT(el)); - - expect(virtual().visibleItems).toEqual([699, 700, 701, 702]); - expect(virtual().viewerTop).toEqual(6990); - expect(virtual().firstIndex).toEqual(699); - expect(virtual().lastIndex).toEqual(702); - }); - - test("onScroll handles reaching the bottom of the list", () => { - const el = document.createElement("div"); - - const [virtual, onScroll] = createVirtualList({ - items: TEST_LIST, - rootHeight: 20, - rowHeight: 10, - }); - - expect(virtual().visibleItems).toEqual([0, 1, 2]); - expect(virtual().viewerTop).toEqual(0); - - el.scrollTop += 9_980; - onScroll(TARGETED_SCROLL_EVENT(el)); - - expect(virtual().visibleItems).toEqual([997, 998, 999]); - expect(virtual().viewerTop).toEqual(9_970); - }); - - test("visibleList takes `overscanCount` into account", () => { - const el = document.createElement("div"); - - const [virtual, onScroll] = createVirtualList({ - items: TEST_LIST, - rootHeight: 20, - rowHeight: 10, - overscanCount: 2, + expect(virtual().containerHeight).toBe(100); + expect(virtual().visibleItems.length).toBe(3); + dispose(); }); - - el.scrollTop += 100; - onScroll(TARGETED_SCROLL_EVENT(el)); - - expect(virtual().visibleItems).toEqual([8, 9, 10, 11, 12, 13]); - expect(virtual().firstIndex).toEqual(8); - expect(virtual().lastIndex).toEqual(13); }); - test("overscanCount defaults to 1 if undefined or zero", () => { - const [virtualUndefined] = createVirtualList({ - items: TEST_LIST, - rootHeight: 20, - rowHeight: 10, - }); + it("calculates static height containers correctly", () => { + createRoot(dispose => { + const items = new Array(100).fill(0); + const [virtual] = createVirtualList({ + items: () => items, + rootHeight: 200, + rowHeight: 25, + }); - expect(virtualUndefined().visibleItems).toEqual([0, 1, 2]); - - const [virtualZero] = createVirtualList({ - items: TEST_LIST, - rootHeight: 20, - rowHeight: 10, - overscanCount: 0, + expect(virtual().containerHeight).toBe(2500); + dispose(); }); - - expect(virtualZero().visibleItems).toEqual([0, 1, 2]); - }); - - test("lastIndex is undefined in an empty list", () => { - const [virtual] = createVirtualList({ - items: [], - rootHeight: 20, - rowHeight: 10, - }); - - expect(virtual().lastIndex).toEqual(undefined); - }); - - test("lastIndex is 0 in a singleton list", () => { - const [virtual] = createVirtualList({ - items: [10], - rootHeight: 20, - rowHeight: 10, - }); - - expect(virtual().lastIndex).toEqual(0); - }); - - test("handles singleton list", () => { - const [virtual] = createVirtualList({ - items: [10], - rootHeight: 20, - rowHeight: 10, - overscanCount: 0, - }); - - expect(virtual().containerHeight).toEqual(10); - expect(virtual().viewerTop).toEqual(0); - expect(virtual().visibleItems).toEqual([10]); - expect(virtual().firstIndex).toEqual(0); - expect(virtual().lastIndex).toEqual(0); - }); - - test("handles empty list", () => { - const [virtual] = createVirtualList({ - items: [], - rootHeight: 20, - rowHeight: 10, - overscanCount: 0, - }); - - expect(virtual().containerHeight).toEqual(0); - expect(virtual().viewerTop).toEqual(0); - expect(virtual().visibleItems).toEqual([]); - expect(virtual().firstIndex).toEqual(0); - expect(virtual().lastIndex).toEqual(undefined); - }); -}); - -describe("VirtualList", () => { - test("renders a subset of the items", () => { - const dispose = render( - () => ( - - {item =>
} - - ), - ROOT, - ); - - expect(ROOT.querySelector("#item-0")).not.toBeNull(); - expect(ROOT.querySelector("#item-1")).not.toBeNull(); - expect(ROOT.querySelector("#item-2")).not.toBeNull(); - expect(ROOT.querySelector("#item-3")).toBeNull(); - - dispose(); - }); - - test("renders the correct subset of the items based on scrolling", () => { - const dispose = render( - () => ( - - {item =>
} - - ), - ROOT, - ); - - const scrollContainer = getScrollContainer(); - - scrollContainer.dispatchEvent(SCROLL_EVENT); - - expect(ROOT.querySelector("#item-0")).not.toBeNull(); - expect(ROOT.querySelector("#item-1")).not.toBeNull(); - expect(ROOT.querySelector("#item-2")).not.toBeNull(); - expect(ROOT.querySelector("#item-3")).toBeNull(); - - scrollContainer.scrollTop += 10; - scrollContainer.dispatchEvent(SCROLL_EVENT); - - expect(ROOT.querySelector("#item-0")).not.toBeNull(); - expect(ROOT.querySelector("#item-1")).not.toBeNull(); - expect(ROOT.querySelector("#item-2")).not.toBeNull(); - expect(ROOT.querySelector("#item-3")).not.toBeNull(); - expect(ROOT.querySelector("#item-4")).toBeNull(); - - scrollContainer.scrollTop += 10; - scrollContainer.dispatchEvent(SCROLL_EVENT); - - expect(ROOT.querySelector("#item-0")).toBeNull(); - expect(ROOT.querySelector("#item-1")).not.toBeNull(); - expect(ROOT.querySelector("#item-2")).not.toBeNull(); - expect(ROOT.querySelector("#item-3")).not.toBeNull(); - expect(ROOT.querySelector("#item-4")).not.toBeNull(); - expect(ROOT.querySelector("#item-5")).toBeNull(); - - scrollContainer.scrollTop -= 10; - scrollContainer.dispatchEvent(SCROLL_EVENT); - - expect(ROOT.querySelector("#item-0")).not.toBeNull(); - expect(ROOT.querySelector("#item-1")).not.toBeNull(); - expect(ROOT.querySelector("#item-2")).not.toBeNull(); - expect(ROOT.querySelector("#item-3")).not.toBeNull(); - expect(ROOT.querySelector("#item-4")).toBeNull(); - - scrollContainer.scrollTop -= 10; - scrollContainer.dispatchEvent(SCROLL_EVENT); - - expect(ROOT.querySelector("#item-0")).not.toBeNull(); - expect(ROOT.querySelector("#item-1")).not.toBeNull(); - expect(ROOT.querySelector("#item-2")).not.toBeNull(); - expect(ROOT.querySelector("#item-3")).toBeNull(); - - dispose(); - }); - - test("renders the correct subset of the items for the end of the list", () => { - const dispose = render( - () => ( - - {item =>
} - - ), - ROOT, - ); - - const scrollContainer = getScrollContainer(); - - scrollContainer.scrollTop += 9_980; - scrollContainer.dispatchEvent(SCROLL_EVENT); - - expect(ROOT.querySelector("#item-996")).toBeNull(); - expect(ROOT.querySelector("#item-997")).not.toBeNull(); - expect(ROOT.querySelector("#item-998")).not.toBeNull(); - expect(ROOT.querySelector("#item-999")).not.toBeNull(); - expect(ROOT.querySelector("#item-1000")).toBeNull(); - - dispose(); - }); - - test("renders `overscanCount` rows above and below the visible rendered items", () => { - const dispose = render( - () => ( - - {item =>
} - - ), - ROOT, - ); - - const scrollContainer = getScrollContainer(); - - scrollContainer.scrollTop += 100; - scrollContainer.dispatchEvent(SCROLL_EVENT); - - expect(ROOT.querySelector("#item-7")).toBeNull(); - expect(ROOT.querySelector("#item-8")).not.toBeNull(); - expect(ROOT.querySelector("#item-9")).not.toBeNull(); - expect(ROOT.querySelector("#item-10")).not.toBeNull(); - expect(ROOT.querySelector("#item-11")).not.toBeNull(); - expect(ROOT.querySelector("#item-12")).not.toBeNull(); - expect(ROOT.querySelector("#item-13")).not.toBeNull(); - expect(ROOT.querySelector("#item-14")).toBeNull(); - - dispose(); - }); - - test("renders when list is empty", () => { - const dispose = render( - () => ( - - {item =>
} - - ), - ROOT, - ); - - expect(getScrollContainer()).not.toBeNull(); - - dispose(); - }); - - test("renders when list is empty with optional fallback", () => { - const dispose = render( - () => ( - } rootHeight={20} rowHeight={10}> - {item =>
} - - ), - ROOT, - ); - - expect(getScrollContainer()).not.toBeNull(); - - expect(ROOT.querySelector("#fallback")).not.toBeNull(); - - dispose(); }); });