From 270c2386c455c6195d9a433da43600b3d5b79b10 Mon Sep 17 00:00:00 2001 From: Jonathan Raphaelson Date: Sun, 30 Nov 2025 22:50:04 -0700 Subject: [PATCH 1/4] expose the current indices in virtual scroller --- packages/virtual/src/index.tsx | 30 +++++++++++++----------- packages/virtual/test/index.test.tsx | 35 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index dbebd2b4c..c6d19c66d 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -15,6 +15,8 @@ type VirtualListReturn = [ containerHeight: number; viewerTop: number; visibleItems: T; + firstIndex: number; + lastIndex: number; }>, onScroll: (e: Event) => void, ]; @@ -41,20 +43,22 @@ export function createVirtualList({ const [offset, setOffset] = createSignal(0); - const getFirstIdx = () => Math.max(0, Math.floor(offset() / rowHeight) - overscanCount); - - const getLastIdx = () => - Math.min( - items.length, - Math.floor(offset() / rowHeight) + Math.ceil(rootHeight / rowHeight) + overscanCount, - ); - return [ - () => ({ - containerHeight: items.length * rowHeight, - viewerTop: getFirstIdx() * rowHeight, - visibleItems: items.slice(getFirstIdx(), getLastIdx()) as unknown as T, - }), + () => { + 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, + ); + + return ({ + containerHeight: items.length * rowHeight, + viewerTop: firstIndex * rowHeight, + visibleItems: items.slice(firstIndex, lastIndex) as unknown as T, + firstIndex, + lastIndex + }) + }, e => { // @ts-expect-error if (e.target?.scrollTop !== undefined) setOffset(e.target.scrollTop); diff --git a/packages/virtual/test/index.test.tsx b/packages/virtual/test/index.test.tsx index f30abccb0..7df26b8f9 100644 --- a/packages/virtual/test/index.test.tsx +++ b/packages/virtual/test/index.test.tsx @@ -51,6 +51,27 @@ describe("createVirtualList", () => { expect(virtual().visibleItems).toEqual([0, 1, 2]); }); + test("returns firstIndex representing the index of the visibleList", () => { + const [virtual] = createVirtualList({ + items: TEST_LIST, + rootHeight: 20, + rowHeight: 10, + }); + + expect(virtual().firstIndex).toEqual(0); + }); + + test("returns lastIndex representing the index of the visibleList", () => { + 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"); @@ -62,35 +83,47 @@ describe("createVirtualList", () => { 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(1); + 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(1); + expect(virtual().lastIndex).toEqual(2); }); test("onScroll handles reaching the bottom of the list", () => { @@ -126,6 +159,8 @@ describe("createVirtualList", () => { 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", () => { From d69431501a83b02f26a09869a30ef87626df27f6 Mon Sep 17 00:00:00 2001 From: Jonathan Raphaelson Date: Mon, 1 Dec 2025 06:08:07 +0000 Subject: [PATCH 2/4] remove lastIndex from return, update tests lastIndex is actually _exclusive_, which would require math, and I don't feel like it's likely to be as useful as firstIndex --- packages/virtual/src/index.tsx | 6 ++---- packages/virtual/test/index.test.tsx | 29 +++++++++------------------- 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index c6d19c66d..443a0655a 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -16,7 +16,6 @@ type VirtualListReturn = [ viewerTop: number; visibleItems: T; firstIndex: number; - lastIndex: number; }>, onScroll: (e: Event) => void, ]; @@ -51,13 +50,12 @@ export function createVirtualList({ Math.floor(offset() / rowHeight) + Math.ceil(rootHeight / rowHeight) + overscanCount, ); - return ({ + return { containerHeight: items.length * rowHeight, viewerTop: firstIndex * rowHeight, visibleItems: items.slice(firstIndex, lastIndex) as unknown as T, firstIndex, - lastIndex - }) + }; }, e => { // @ts-expect-error diff --git a/packages/virtual/test/index.test.tsx b/packages/virtual/test/index.test.tsx index 7df26b8f9..265ebb32f 100644 --- a/packages/virtual/test/index.test.tsx +++ b/packages/virtual/test/index.test.tsx @@ -61,17 +61,6 @@ describe("createVirtualList", () => { expect(virtual().firstIndex).toEqual(0); }); - test("returns lastIndex representing the index of the visibleList", () => { - 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"); @@ -84,7 +73,6 @@ describe("createVirtualList", () => { 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; @@ -92,14 +80,12 @@ describe("createVirtualList", () => { 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)); @@ -107,23 +93,27 @@ describe("createVirtualList", () => { 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(1); - expect(virtual().lastIndex).toEqual(3); + expect(virtual().firstIndex).toEqual(0); el.scrollTop -= 10; onScroll(TARGETED_SCROLL_EVENT(el)); expect(virtual().visibleItems).toEqual([0, 1, 2]); expect(virtual().viewerTop).toEqual(0); - expect(virtual().firstIndex).toEqual(1); - expect(virtual().lastIndex).toEqual(2); + expect(virtual().firstIndex).toEqual(0); + + 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); }); test("onScroll handles reaching the bottom of the list", () => { @@ -160,7 +150,6 @@ describe("createVirtualList", () => { 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", () => { From bfaf8ff37c4f64a019bb985e3739e023b0dbac12 Mon Sep 17 00:00:00 2001 From: Jonathan Raphaelson Date: Mon, 1 Dec 2025 06:30:26 +0000 Subject: [PATCH 3/4] add lastIndex to the returned data, undefined for empty lists --- packages/virtual/src/index.tsx | 3 ++ packages/virtual/test/index.test.tsx | 57 +++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index 443a0655a..df57cec51 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -16,6 +16,7 @@ type VirtualListReturn = [ viewerTop: number; visibleItems: T; firstIndex: number; + lastIndex: number | undefined; }>, onScroll: (e: Event) => void, ]; @@ -55,6 +56,8 @@ export function createVirtualList({ 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 }; }, e => { diff --git a/packages/virtual/test/index.test.tsx b/packages/virtual/test/index.test.tsx index 265ebb32f..f72efc496 100644 --- a/packages/virtual/test/index.test.tsx +++ b/packages/virtual/test/index.test.tsx @@ -51,7 +51,7 @@ describe("createVirtualList", () => { expect(virtual().visibleItems).toEqual([0, 1, 2]); }); - test("returns firstIndex representing the index of the visibleList", () => { + test("returns firstIndex representing the first index of the visibleList", () => { const [virtual] = createVirtualList({ items: TEST_LIST, rootHeight: 20, @@ -61,6 +61,16 @@ describe("createVirtualList", () => { 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"); @@ -73,6 +83,7 @@ describe("createVirtualList", () => { 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; @@ -80,12 +91,14 @@ describe("createVirtualList", () => { 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)); @@ -93,6 +106,7 @@ describe("createVirtualList", () => { 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)); @@ -100,6 +114,7 @@ describe("createVirtualList", () => { 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)); @@ -107,6 +122,7 @@ describe("createVirtualList", () => { 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)); @@ -114,6 +130,7 @@ describe("createVirtualList", () => { 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", () => { @@ -150,6 +167,7 @@ describe("createVirtualList", () => { 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", () => { @@ -171,6 +189,41 @@ describe("createVirtualList", () => { 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: [], @@ -182,6 +235,8 @@ describe("createVirtualList", () => { expect(virtual().containerHeight).toEqual(0); expect(virtual().viewerTop).toEqual(0); expect(virtual().visibleItems).toEqual([]); + expect(virtual().firstIndex).toEqual(0); + expect(virtual().lastIndex).toEqual(undefined); }); }); From 16727c18d2272c35a94fca8c27a1c694fbfb210b Mon Sep 17 00:00:00 2001 From: Jonathan Raphaelson Date: Wed, 3 Dec 2025 15:12:29 -0700 Subject: [PATCH 4/4] add changeset --- .changeset/shaky-chicken-marry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/shaky-chicken-marry.md diff --git a/.changeset/shaky-chicken-marry.md b/.changeset/shaky-chicken-marry.md new file mode 100644 index 000000000..ca678f365 --- /dev/null +++ b/.changeset/shaky-chicken-marry.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/virtual": patch +--- + +expose `firstIndex` and `lastIndex` in virtual lists, to support counting