Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/virtual-dynamic-heights.md
Original file line number Diff line number Diff line change
@@ -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.
199 changes: 136 additions & 63 deletions packages/virtual/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,125 +1,198 @@
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<T extends readonly any[]> = {
export type RowHeightFn<T> = (item: T, index: number) => number;

export type VirtualListConfig<T extends readonly any[]> = {
items: MaybeAccessor<T | undefined | null | false>;
rootHeight: MaybeAccessor<number>;
rowHeight: MaybeAccessor<number>;
rowHeight: MaybeAccessor<number | RowHeightFn<T[number]>>;
overscanCount?: MaybeAccessor<number>;
};

type VirtualListReturn<T extends readonly any[]> = [
Accessor<{
export type VirtualListReturn<T extends readonly any[]> = [
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<T extends readonly any[]>({
items,
rootHeight,
rowHeight,
overscanCount,
}: VirtualListConfig<T>): VirtualListReturn<T> {
items = access(items) || ([] as any as T);
rootHeight = access(rootHeight);
rowHeight = access(rowHeight);
overscanCount = access(overscanCount) || 1;

export function createVirtualList<T extends readonly any[]>(
cfg: VirtualListConfig<T>,
): VirtualListReturn<T> {
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<number>(len);
const heights = new Array<number>(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<number>(len);
const heights = new Array<number>(len);
let accum = 0;
const dynamicFn = rowHeightCfg as RowHeightFn<T[number]>;

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<T extends readonly any[], U extends JSX.Element> = {
children: (item: T[number], index: Accessor<number>) => U;
export type VirtualListProps<T extends readonly any[], U extends JSX.Element> = {
each: T | undefined | null | false;
fallback?: JSX.Element;
overscanCount?: number;
children: (item: T[number], index: Accessor<number>) => U;
rootHeight: number;
rowHeight: number;
rowHeight: number | RowHeightFn<T[number]>;
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<T extends readonly any[], U extends JSX.Element>(
props: VirtualListProps<T, U>,
): JSX.Element {
const [virtual, onScroll] = createVirtualList({
items: () => props.each,
rootHeight: () => props.rootHeight,
rowHeight: () => props.rowHeight,
overscanCount: () => props.overscanCount || 1,
overscanCount: () => props.overscanCount,

Check failure on line 166 in packages/virtual/src/index.tsx

View workflow job for this annotation

GitHub Actions / build-test

Type '() => number | undefined' is not assignable to type 'MaybeAccessor<number> | undefined'.
});

return (
<div
ref={props.ref}
style={{
overflow: "auto",
height: `${props.rootHeight}px`,
position: "relative",
}}
onScroll={onScroll}
>
<div
style={{
height: `${virtual().containerHeight}px`,
position: "relative",
width: "100%",
height: `${virtual().containerHeight}px`,
}}
>
<div
style={{
position: "absolute",
top: `${virtual().viewerTop}px`,
left: 0,
right: 0,
}}
>
<For fallback={props.fallback} each={virtual().visibleItems}>
{props.children}
{(item, idx) => props.children(item, () => virtual().startIndex + idx())}
</For>
</div>
</div>
Expand Down
Loading
Loading