` so the behavior is visible inside the Storybook iframe.
+ */
+import { SimpleTableVanilla } from "../../src/index";
+import type { HeaderObject, Row } from "../../src/index";
+import { defaultVanillaArgs, type UniversalVanillaArgs } from "../vanillaStoryConfig";
+
+const INITIAL_ROWS = 50;
+const BATCH_SIZE = 50;
+const MAX_ROWS = 5_000;
+const LOAD_DELAY_MS = 350;
+
+const FIRST_NAMES = [
+ "Elena",
+ "Kai",
+ "Amara",
+ "Santiago",
+ "Priya",
+ "Magnus",
+ "Zara",
+ "Luca",
+ "Sarah",
+ "Olumide",
+ "Isabella",
+ "Dmitri",
+ "Aiko",
+ "Mateo",
+ "Noor",
+ "Fionn",
+];
+const LAST_NAMES = [
+ "Vasquez",
+ "Tanaka",
+ "Okafor",
+ "Rodriguez",
+ "Chakraborty",
+ "Eriksson",
+ "Al-Rashid",
+ "Rossi",
+ "Kim",
+ "Adebayo",
+ "Chen",
+ "Volkov",
+];
+const DEPARTMENTS = [
+ "Engineering",
+ "AI Research",
+ "UX Design",
+ "DevOps",
+ "Marketing",
+ "Product",
+ "Sales",
+ "Finance",
+ "Operations",
+];
+const STATUSES = ["Active", "On Leave", "Remote", "Onsite"];
+
+function generateRows(startIndex: number, count: number): Row[] {
+ const rows: Row[] = [];
+ for (let i = 0; i < count; i++) {
+ const idx = startIndex + i;
+ const first = FIRST_NAMES[idx % FIRST_NAMES.length];
+ const last = LAST_NAMES[(idx * 3) % LAST_NAMES.length];
+ rows.push({
+ id: idx + 1,
+ name: `${first} ${last}`,
+ email: `${first.toLowerCase()}.${last.toLowerCase().replace(/'/g, "")}@example.com`,
+ department: DEPARTMENTS[idx % DEPARTMENTS.length],
+ status: STATUSES[idx % STATUSES.length],
+ tenureYears: 1 + ((idx * 13) % 18),
+ salary: 60_000 + Math.floor(((idx * 7919) % 120_000)),
+ });
+ }
+ return rows;
+}
+
+const HEADERS: HeaderObject[] = [
+ { accessor: "id", label: "ID", width: 80, type: "number", align: "right" },
+ { accessor: "name", label: "Name", width: "1fr", minWidth: 160 },
+ { accessor: "email", label: "Email", width: 260 },
+ { accessor: "department", label: "Department", width: 160 },
+ { accessor: "status", label: "Status", width: 120 },
+ {
+ accessor: "tenureYears",
+ label: "Tenure",
+ width: 110,
+ type: "number",
+ align: "right",
+ valueFormatter: ({ value }) => `${value} yrs`,
+ },
+ {
+ accessor: "salary",
+ label: "Salary",
+ width: 140,
+ type: "number",
+ align: "right",
+ valueFormatter: ({ value }) => `$${(value as number).toLocaleString()}`,
+ },
+];
+
+export const windowInfiniteScrollExampleDefaults: Partial
= {
+ // `height` / `maxHeight` are intentionally left off — the table grows to its
+ // natural size inside the outer scrollContainer instead.
+};
+
+export function renderWindowInfiniteScrollExample(
+ args?: Partial,
+): HTMLElement {
+ const options = {
+ ...defaultVanillaArgs,
+ ...windowInfiniteScrollExampleDefaults,
+ ...args,
+ };
+
+ const wrapper = document.createElement("div");
+
+ const scrollContainer = document.createElement("div");
+ Object.assign(scrollContainer.style, {
+ height: "640px",
+ overflow: "auto",
+ border: "1px solid #e5e7eb",
+ borderRadius: "8px",
+ padding: "32px",
+ background: "#fafafa",
+ });
+ wrapper.appendChild(scrollContainer);
+
+ const inner = document.createElement("div");
+ inner.style.maxWidth = "1000px";
+ inner.style.margin = "0 auto";
+ scrollContainer.appendChild(inner);
+
+ const heading = document.createElement("h1");
+ heading.textContent = "Window-Scroll Infinite Loading";
+ Object.assign(heading.style, {
+ fontSize: "28px",
+ margin: "0 0 12px 0",
+ color: "#0f172a",
+ });
+ inner.appendChild(heading);
+
+ const intro = document.createElement("p");
+ intro.innerHTML =
+ "This table has no height or maxHeight — it grows to fit its rows. " +
+ "We pass scrollParent pointing at the outer scrolling box (or " +
+ "\"window\" in a regular page) so the table virtualizes rows and fires " +
+ "onLoadMore based on the parent's scroll position. The header pins to the " +
+ "top of the outer scroll viewport as you scroll. Scroll down to load more.";
+ Object.assign(intro.style, {
+ fontSize: "15px",
+ lineHeight: "1.6",
+ color: "#475569",
+ margin: "0 0 16px 0",
+ });
+ inner.appendChild(intro);
+
+ const status = document.createElement("div");
+ Object.assign(status.style, {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "8px",
+ padding: "6px 12px",
+ marginBottom: "16px",
+ background: "#eef2ff",
+ color: "#3730a3",
+ borderRadius: "999px",
+ fontSize: "13px",
+ fontWeight: "500",
+ });
+ inner.appendChild(status);
+
+ const tableContainer = document.createElement("div");
+ inner.appendChild(tableContainer);
+
+ const footer = document.createElement("p");
+ footer.textContent =
+ "End of the demo content. As you scroll near the bottom, onLoadMore keeps firing until the dataset is exhausted.";
+ Object.assign(footer.style, {
+ fontSize: "13px",
+ color: "#94a3b8",
+ margin: "24px 0 48px 0",
+ textAlign: "center",
+ });
+ inner.appendChild(footer);
+
+ let rows: Row[] = generateRows(0, INITIAL_ROWS);
+ let loading = false;
+ let hasMore = true;
+
+ const updateStatus = () => {
+ const dot =
+ '';
+ const label = loading
+ ? "Loading more rows…"
+ : hasMore
+ ? `${rows.length.toLocaleString()} rows loaded · scroll for more`
+ : `${rows.length.toLocaleString()} rows loaded · end of dataset`;
+ status.innerHTML = `${dot}${label}`;
+ };
+ updateStatus();
+
+ const table = new SimpleTableVanilla(tableContainer, {
+ defaultHeaders: HEADERS,
+ rows,
+ theme: options.theme,
+ customTheme: options.customTheme,
+ getRowId: (p: { row?: { id?: unknown } }) => String(p.row?.id),
+ scrollParent: scrollContainer,
+ infiniteScrollThreshold: 400,
+ onLoadMore: () => {
+ if (loading || !hasMore) return;
+ loading = true;
+ updateStatus();
+
+ setTimeout(() => {
+ const next = generateRows(rows.length, BATCH_SIZE);
+ rows = [...rows, ...next];
+ if (rows.length >= MAX_ROWS) {
+ rows = rows.slice(0, MAX_ROWS);
+ hasMore = false;
+ }
+ loading = false;
+ table.update({ rows });
+ updateStatus();
+ }, LOAD_DELAY_MS);
+ },
+ });
+ table.mount();
+
+ // Hold a reference so the story container can dispose of the instance.
+ (wrapper as unknown as { _table?: SimpleTableVanilla })._table = table;
+
+ return wrapper;
+}
diff --git a/packages/core/stories/tests/43-CollapseExpandAnimationsTests.stories.ts b/packages/core/stories/tests/43-CollapseExpandAnimationsTests.stories.ts
index d10a14b5b..588778dfa 100644
--- a/packages/core/stories/tests/43-CollapseExpandAnimationsTests.stories.ts
+++ b/packages/core/stories/tests/43-CollapseExpandAnimationsTests.stories.ts
@@ -21,7 +21,7 @@ import { renderVanillaTable, addParagraph } from "../utils";
import { waitForTable } from "./testUtils";
const meta: Meta = {
- title: "Tests/43 - Collapse/Expand Accordion Animations",
+ title: "Tests/43 - Collapse Expand Accordion Animations",
tags: ["test", "animations", "collapse-expand"],
parameters: {
layout: "padded",
@@ -58,9 +58,7 @@ const findFirstBodyExpandIcon = (
): HTMLElement | null => {
const bodyContainer = canvasElement.querySelector(".st-body-container");
if (!bodyContainer) return null;
- const rowCells = bodyContainer.querySelectorAll(
- `.st-cell[data-row-index="${rowIndex}"]`,
- );
+ const rowCells = bodyContainer.querySelectorAll(`.st-cell[data-row-index="${rowIndex}"]`);
for (const cell of Array.from(rowCells)) {
const icon = cell.querySelector(".st-expand-icon-container");
if (icon && icon.getAttribute("aria-hidden") !== "true") {
@@ -274,14 +272,10 @@ export const RowExpand_IncomingCellsStartAtZeroHeight = {
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
await waitForTable();
- const bodyContainer = canvasElement.querySelector(
- ".st-body-container",
- ) as HTMLElement | null;
+ const bodyContainer = canvasElement.querySelector(".st-body-container") as HTMLElement | null;
expect(bodyContainer).toBeTruthy();
- const cellsBefore = bodyContainer!.querySelectorAll(
- ".st-cell[data-row-index]",
- );
+ const cellsBefore = bodyContainer!.querySelectorAll(".st-cell[data-row-index]");
const rowCountBefore = new Set(
Array.from(cellsBefore).map((c) => c.getAttribute("data-row-index")),
).size;
@@ -293,18 +287,13 @@ export const RowExpand_IncomingCellsStartAtZeroHeight = {
// Sample synchronously: new cells exist with 0 height and the
// accordion-grow marker, before any rAF has run to write the final size.
- const cellsMid = bodyContainer!.querySelectorAll(
- ".st-cell[data-row-index]",
- );
- const rowCountMid = new Set(
- Array.from(cellsMid).map((c) => c.getAttribute("data-row-index")),
- ).size;
+ const cellsMid = bodyContainer!.querySelectorAll(".st-cell[data-row-index]");
+ const rowCountMid = new Set(Array.from(cellsMid).map((c) => c.getAttribute("data-row-index")))
+ .size;
expect(rowCountMid).toBeGreaterThan(rowCountBefore);
const growingCells = Array.from(
- bodyContainer!.querySelectorAll(
- '.st-cell[data-st-accordion-grow="vertical"]',
- ),
+ bodyContainer!.querySelectorAll('.st-cell[data-st-accordion-grow="vertical"]'),
);
expect(growingCells.length).toBeGreaterThan(0);
for (const c of growingCells) {
@@ -352,9 +341,7 @@ export const Disabled_NoAccordionClass = {
expect(root!.classList.contains(ACCORDION_CLASS)).toBe(false);
// Final state reached immediately.
- const q1Header = canvasElement.querySelector(
- `.st-header-cell[data-accessor="q1"]`,
- );
+ const q1Header = canvasElement.querySelector(`.st-header-cell[data-accessor="q1"]`);
expect(q1Header).toBeNull();
},
};
diff --git a/packages/core/stories/tests/44-ExternalScrollTests.stories.ts b/packages/core/stories/tests/44-ExternalScrollTests.stories.ts
new file mode 100644
index 000000000..1aadc2a68
--- /dev/null
+++ b/packages/core/stories/tests/44-ExternalScrollTests.stories.ts
@@ -0,0 +1,440 @@
+/**
+ * EXTERNAL SCROLL TESTS
+ *
+ * Tests for the `scrollParent` prop which lets a consumer point the table at
+ * an external scroll container (HTMLElement or window). When active and
+ * neither height nor maxHeight is set, the external parent's scroll drives
+ * row virtualization and onLoadMore.
+ */
+
+import type { Meta } from "@storybook/html";
+import { expect } from "@storybook/test";
+import { SimpleTableVanilla, HeaderObject } from "../../src/index";
+import { waitForTable, getRowCount } from "./testUtils";
+
+const meta: Meta = {
+ title: "Tests/44 - External Scroll",
+ parameters: {
+ layout: "fullscreen",
+ chromatic: { disableSnapshot: true },
+ },
+};
+
+export default meta;
+
+const headers: HeaderObject[] = [
+ { accessor: "id", label: "ID", width: 80, type: "number" },
+ { accessor: "name", label: "Name", width: 200, type: "string" },
+ { accessor: "description", label: "Description", width: 300, type: "string" },
+];
+
+const createRows = (count: number) =>
+ Array.from({ length: count }, (_, i) => ({
+ id: i + 1,
+ name: `Item ${i + 1}`,
+ description: `Description for item ${i + 1}`,
+ }));
+
+// ============================================================================
+// TEST 1: External element scroll virtualizes a large dataset
+// ============================================================================
+
+export const ExternalElementVirtualizes = {
+ tags: ["external-scroll"],
+ render: () => {
+ const wrapper = document.createElement("div");
+ wrapper.style.padding = "1rem";
+
+ const scrollContainer = document.createElement("div");
+ scrollContainer.id = "external-scroll-host";
+ scrollContainer.style.height = "400px";
+ scrollContainer.style.overflow = "auto";
+ scrollContainer.style.border = "1px solid #ccc";
+ wrapper.appendChild(scrollContainer);
+
+ const tableContainer = document.createElement("div");
+ scrollContainer.appendChild(tableContainer);
+
+ const table = new SimpleTableVanilla(tableContainer, {
+ defaultHeaders: headers,
+ rows: createRows(2000),
+ getRowId: (p) => String((p.row as { id?: number })?.id),
+ scrollParent: scrollContainer,
+ });
+ table.mount();
+ (wrapper as unknown as { _table?: SimpleTableVanilla })._table = table;
+ return wrapper;
+ },
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ await waitForTable();
+
+ const scrollContainer = canvasElement.querySelector("#external-scroll-host") as HTMLElement;
+ expect(scrollContainer).toBeTruthy();
+
+ const tableRoot = canvasElement.querySelector(".simple-table-root") as HTMLElement;
+ expect(tableRoot).toBeTruthy();
+
+ // Internal body container must NOT be scrollable: the external host owns scroll.
+ const bodyContainer = canvasElement.querySelector(".st-body-container") as HTMLElement;
+ expect(bodyContainer.scrollHeight).toBe(bodyContainer.clientHeight);
+
+ // External container, on the other hand, must show a large scrollHeight (table grew).
+ expect(scrollContainer.scrollHeight).toBeGreaterThan(scrollContainer.clientHeight + 1000);
+
+ // Only a virtualized subset of rows is rendered (must be far less than 2000).
+ const rendered = getRowCount(canvasElement);
+ expect(rendered).toBeGreaterThan(0);
+ expect(rendered).toBeLessThan(200);
+ },
+};
+
+// ============================================================================
+// TEST 2: Scrolling the external container fires onLoadMore near table bottom
+// ============================================================================
+
+export const ExternalScrollFiresOnLoadMore = {
+ tags: ["external-scroll"],
+ render: () => {
+ const captured: { count: number } = { count: 0 };
+ (
+ window as unknown as { __externalLoadMoreCapture?: { count: number } }
+ ).__externalLoadMoreCapture = captured;
+
+ const wrapper = document.createElement("div");
+ wrapper.style.padding = "1rem";
+
+ const scrollContainer = document.createElement("div");
+ scrollContainer.id = "external-scroll-host-loadmore";
+ scrollContainer.style.height = "350px";
+ scrollContainer.style.overflow = "auto";
+ scrollContainer.style.border = "1px solid #ccc";
+ wrapper.appendChild(scrollContainer);
+
+ const tableContainer = document.createElement("div");
+ scrollContainer.appendChild(tableContainer);
+
+ const table = new SimpleTableVanilla(tableContainer, {
+ defaultHeaders: headers,
+ rows: createRows(500),
+ getRowId: (p) => String((p.row as { id?: number })?.id),
+ scrollParent: scrollContainer,
+ infiniteScrollThreshold: 200,
+ onLoadMore: () => {
+ captured.count += 1;
+ },
+ });
+ table.mount();
+ (wrapper as unknown as { _table?: SimpleTableVanilla })._table = table;
+ return wrapper;
+ },
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ await waitForTable();
+ const captured = (window as unknown as { __externalLoadMoreCapture?: { count: number } })
+ .__externalLoadMoreCapture;
+ expect(captured).toBeTruthy();
+ expect(captured!.count).toBe(0);
+
+ const scrollContainer = canvasElement.querySelector(
+ "#external-scroll-host-loadmore",
+ ) as HTMLElement;
+ expect(scrollContainer).toBeTruthy();
+
+ // Scroll to near the bottom (within the 200px threshold) — onLoadMore must fire.
+ scrollContainer.scrollTop = scrollContainer.scrollHeight - scrollContainer.clientHeight - 50;
+ scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
+ await new Promise((r) => setTimeout(r, 250));
+
+ expect(captured!.count).toBeGreaterThanOrEqual(1);
+ },
+};
+
+// ============================================================================
+// TEST 3: When neither scrollParent nor height/maxHeight is set, render all rows
+// ============================================================================
+
+export const NoScrollParentRendersAll = {
+ tags: ["external-scroll"],
+ render: () => {
+ const wrapper = document.createElement("div");
+ wrapper.style.padding = "1rem";
+
+ const tableContainer = document.createElement("div");
+ wrapper.appendChild(tableContainer);
+
+ const table = new SimpleTableVanilla(tableContainer, {
+ defaultHeaders: headers,
+ rows: createRows(50),
+ getRowId: (p) => String((p.row as { id?: number })?.id),
+ });
+ table.mount();
+ (wrapper as unknown as { _table?: SimpleTableVanilla })._table = table;
+ return wrapper;
+ },
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ await waitForTable();
+ const rendered = getRowCount(canvasElement);
+ // Without virtualization, all 50 rows render.
+ expect(rendered).toBe(50);
+ },
+};
+
+// ============================================================================
+// TEST 4: height takes precedence over scrollParent
+// ============================================================================
+
+export const HeightOverridesScrollParent = {
+ tags: ["external-scroll"],
+ render: () => {
+ const wrapper = document.createElement("div");
+ wrapper.style.padding = "1rem";
+
+ const scrollContainer = document.createElement("div");
+ scrollContainer.id = "ignored-scroll-host";
+ scrollContainer.style.height = "800px";
+ scrollContainer.style.overflow = "auto";
+ wrapper.appendChild(scrollContainer);
+
+ const tableContainer = document.createElement("div");
+ scrollContainer.appendChild(tableContainer);
+
+ const table = new SimpleTableVanilla(tableContainer, {
+ defaultHeaders: headers,
+ rows: createRows(500),
+ getRowId: (p) => String((p.row as { id?: number })?.id),
+ height: "300px",
+ scrollParent: scrollContainer,
+ });
+ table.mount();
+ (wrapper as unknown as { _table?: SimpleTableVanilla })._table = table;
+ return wrapper;
+ },
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ await waitForTable();
+
+ // Internal body container should be the one that's scrollable when height is set.
+ const bodyContainer = canvasElement.querySelector(".st-body-container") as HTMLElement;
+ expect(bodyContainer.scrollHeight).toBeGreaterThan(bodyContainer.clientHeight);
+
+ // Virtualization is active via the table's own height, so rendered rows are far fewer than 500.
+ const rendered = getRowCount(canvasElement);
+ expect(rendered).toBeLessThan(200);
+
+ // Sticky header mode is OFF when height takes precedence — no external-scroll class on the root.
+ const tableRoot = canvasElement.querySelector(".simple-table-root") as HTMLElement;
+ expect(tableRoot.classList.contains("st-external-scroll")).toBe(false);
+ },
+};
+
+// ============================================================================
+// TEST 5: Header is sticky to the top of the external scroll viewport
+// ============================================================================
+
+export const StickyHeaderInExternalScroll = {
+ tags: ["external-scroll"],
+ render: () => {
+ const wrapper = document.createElement("div");
+ wrapper.style.padding = "1rem";
+
+ const scrollContainer = document.createElement("div");
+ scrollContainer.id = "external-scroll-host-sticky";
+ scrollContainer.style.height = "400px";
+ scrollContainer.style.overflow = "auto";
+ scrollContainer.style.border = "1px solid #ccc";
+ scrollContainer.style.padding = "1rem";
+ wrapper.appendChild(scrollContainer);
+
+ const tableContainer = document.createElement("div");
+ scrollContainer.appendChild(tableContainer);
+
+ const table = new SimpleTableVanilla(tableContainer, {
+ defaultHeaders: headers,
+ rows: createRows(2000),
+ getRowId: (p) => String((p.row as { id?: number })?.id),
+ scrollParent: scrollContainer,
+ });
+ table.mount();
+ (wrapper as unknown as { _table?: SimpleTableVanilla })._table = table;
+ return wrapper;
+ },
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ await waitForTable();
+
+ const scrollContainer = canvasElement.querySelector(
+ "#external-scroll-host-sticky",
+ ) as HTMLElement;
+ expect(scrollContainer).toBeTruthy();
+
+ const tableRoot = canvasElement.querySelector(".simple-table-root") as HTMLElement;
+ expect(tableRoot).toBeTruthy();
+
+ // External scroll mode should opt the root into the sticky-header CSS class.
+ expect(tableRoot.classList.contains("st-external-scroll")).toBe(true);
+
+ const headerContainer = canvasElement.querySelector(".st-header-container") as HTMLElement;
+ expect(headerContainer).toBeTruthy();
+
+ // The CSS class must actually resolve to position: sticky at runtime
+ // (no ancestor with `overflow: hidden` between header and scrollContainer).
+ expect(getComputedStyle(headerContainer).position).toBe("sticky");
+
+ // Before scrolling, the header sits at its natural position inside the
+ // scroll container — that is, below any padding-top the consumer added.
+ // (CSS sticky preserves the element's natural in-flow position; only the
+ // pinned position is offset by our negative-`top` variable.)
+ const containerTop = scrollContainer.getBoundingClientRect().top;
+ const paddingTop = parseFloat(getComputedStyle(scrollContainer).paddingTop) || 0;
+ const headerTopBefore = headerContainer.getBoundingClientRect().top;
+ expect(Math.abs(headerTopBefore - (containerTop + paddingTop))).toBeLessThan(3);
+
+ // Scroll the external container; the header must stay pinned to the
+ // *outer* top edge of the scroll container — not the padding edge — so
+ // there is no visible gap above the header when the parent has padding.
+ scrollContainer.scrollTop = 500;
+ scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
+ await new Promise((r) => setTimeout(r, 100));
+
+ const headerTopAfter = headerContainer.getBoundingClientRect().top;
+ expect(Math.abs(headerTopAfter - containerTop)).toBeLessThan(3);
+ },
+};
+
+// ============================================================================
+// TEST 6: Row grouping with enableStickyParents works in external scroll mode.
+// The sticky-parents container's `top` is JS-driven by the external scrollTop
+// so grouped parent rows pin under the sticky header instead of scrolling away.
+// ============================================================================
+
+const groupedHeaders: HeaderObject[] = [
+ // `expandable: true` is required for the column to render the
+ // expand/collapse chevron next to grouped parent rows.
+ { accessor: "name", label: "Name", width: 240, expandable: true },
+ { accessor: "id", label: "ID", width: 160, type: "string" },
+ { accessor: "value", label: "Value", width: 120, type: "number" },
+];
+
+const createGroupedRows = (groupCount: number, childrenPerGroup: number) =>
+ Array.from({ length: groupCount }, (_, gi) => ({
+ id: `group-${gi + 1}`,
+ name: `Group ${gi + 1}`,
+ value: (gi + 1) * 100,
+ children: Array.from({ length: childrenPerGroup }, (_, ci) => ({
+ id: `group-${gi + 1}-child-${ci + 1}`,
+ name: `Child ${ci + 1} of group ${gi + 1}`,
+ value: (gi + 1) * 100 + ci,
+ })),
+ }));
+
+export const RowGroupingInExternalScroll = {
+ tags: ["external-scroll"],
+ render: () => {
+ const wrapper = document.createElement("div");
+ wrapper.style.padding = "1rem";
+
+ const scrollContainer = document.createElement("div");
+ scrollContainer.id = "external-scroll-host-grouping";
+ scrollContainer.style.height = "400px";
+ scrollContainer.style.overflow = "auto";
+ scrollContainer.style.border = "1px solid #ccc";
+ wrapper.appendChild(scrollContainer);
+
+ const tableContainer = document.createElement("div");
+ scrollContainer.appendChild(tableContainer);
+
+ // 50 groups × 20 children fully expanded = 1050 visible rows — plenty for
+ // virtualization to kick in inside a 400px viewport.
+ const table = new SimpleTableVanilla(tableContainer, {
+ defaultHeaders: groupedHeaders,
+ rows: createGroupedRows(50, 20),
+ getRowId: ({ row }) => String((row as { id?: string }).id),
+ rowGrouping: ["children"],
+ expandAll: true,
+ enableStickyParents: true,
+ scrollParent: scrollContainer,
+ });
+ table.mount();
+ (wrapper as unknown as { _table?: SimpleTableVanilla })._table = table;
+ return wrapper;
+ },
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ await waitForTable();
+
+ const scrollContainer = canvasElement.querySelector(
+ "#external-scroll-host-grouping",
+ ) as HTMLElement;
+ expect(scrollContainer).toBeTruthy();
+
+ const tableRoot = canvasElement.querySelector(".simple-table-root") as HTMLElement;
+ expect(tableRoot).toBeTruthy();
+
+ // External scroll mode is active even with rowGrouping configured.
+ expect(tableRoot.classList.contains("st-external-scroll")).toBe(true);
+
+ // Virtualization is active: only a subset of the 1050 expanded rows is rendered.
+ const renderedBeforeScroll = getRowCount(canvasElement);
+ expect(renderedBeforeScroll).toBeGreaterThan(0);
+ expect(renderedBeforeScroll).toBeLessThan(200);
+
+ // Group parent rows are present: the first rendered group's parent cell
+ // ("Group 1") should be in the DOM with the table fully scrolled to top.
+ const allCells = Array.from(canvasElement.querySelectorAll(".st-cell"));
+ const hasGroupParent = allCells.some((c) => c.textContent?.includes("Group 1"));
+ const hasGroupChild = allCells.some((c) =>
+ c.textContent?.includes("Child 1 of group 1"),
+ );
+ expect(hasGroupParent).toBe(true);
+ expect(hasGroupChild).toBe(true);
+
+ // Expand/collapse chevrons must render on the `expandable` column for the
+ // parent rows — this is the user-facing affordance for grouped rows.
+ const bodyContainer = canvasElement.querySelector(".st-body-container") as HTMLElement;
+ const expandIcons = bodyContainer.querySelectorAll(".st-expand-icon-container");
+ expect(expandIcons.length).toBeGreaterThan(0);
+
+ // Scroll the external container deep into the dataset — past the first
+ // group's children so the grouping renderer must pick a sticky ancestor.
+ const indicesBefore = new Set(
+ Array.from(canvasElement.querySelectorAll(".st-cell[data-row-index]")).map((c) =>
+ c.getAttribute("data-row-index"),
+ ),
+ );
+
+ scrollContainer.scrollTop = 8000;
+ scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
+ await new Promise((r) => setTimeout(r, 200));
+
+ const indicesAfter = new Set(
+ Array.from(canvasElement.querySelectorAll(".st-cell[data-row-index]")).map((c) =>
+ c.getAttribute("data-row-index"),
+ ),
+ );
+ // The rendered window must have shifted — at least one new row index that
+ // wasn't visible before scrolling.
+ const newlyVisible = Array.from(indicesAfter).filter((idx) => !indicesBefore.has(idx));
+ expect(newlyVisible.length).toBeGreaterThan(0);
+
+ // After scrolling, enableStickyParents must produce the .st-sticky-top
+ // overlay (this is the user-facing fix — grouped parents pin under the
+ // sticky header rather than scrolling out of view with the table).
+ const stickyTop = canvasElement.querySelector(".st-sticky-top") as HTMLElement | null;
+ expect(stickyTop).not.toBeNull();
+
+ // The overlay must pin directly under the sticky header. The header is
+ // already proven to pin flush to the parent's outer edge (Test 5), so
+ // the sticky-top's top edge should equal scrollContainerTop + headerHeight
+ // (within a small layout tolerance).
+ const headerContainer = canvasElement.querySelector(".st-header-container") as HTMLElement;
+ const headerRect = headerContainer.getBoundingClientRect();
+ const stickyTopRect = stickyTop!.getBoundingClientRect();
+ expect(Math.abs(stickyTopRect.top - headerRect.bottom)).toBeLessThan(3);
+
+ // The pinned parent row should belong to a group whose children are
+ // currently in view at scrollTop ~ 8000. We don't pin the exact group
+ // number (depends on row height + grouping math), but we *do* assert that
+ // the sticky-top renders at least one parent cell labelled "Group N".
+ const stickyParentCells = Array.from(stickyTop!.querySelectorAll(".st-cell"));
+ const hasPinnedGroupParent = stickyParentCells.some((c) =>
+ /^Group \d+$/.test((c.textContent ?? "").trim()),
+ );
+ expect(hasPinnedGroupParent).toBe(true);
+ },
+};
diff --git a/packages/examples/react/src/demo-list.ts b/packages/examples/react/src/demo-list.ts
index be2a4bf34..f72ee0c2e 100644
--- a/packages/examples/react/src/demo-list.ts
+++ b/packages/examples/react/src/demo-list.ts
@@ -22,6 +22,7 @@ export const DEMO_LIST = [
{ id: "external-filter", label: "External Filter" },
{ id: "loading-state", label: "Loading State" },
{ id: "infinite-scroll", label: "Infinite Scroll" },
+ { id: "window-infinite-scroll", label: "Window Infinite Scroll" },
{ id: "row-selection", label: "Row Selection" },
{ id: "csv-export", label: "CSV Export" },
{ id: "programmatic-control", label: "Programmatic Control" },
diff --git a/packages/examples/react/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.tsx b/packages/examples/react/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.tsx
new file mode 100644
index 000000000..2a5e26264
--- /dev/null
+++ b/packages/examples/react/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.tsx
@@ -0,0 +1,144 @@
+import { useCallback, useRef, useState } from "react";
+import { SimpleTable } from "@simple-table/react";
+import type { Row, Theme } from "@simple-table/react";
+import {
+ generateWindowScrollRows,
+ windowScrollHeaders,
+} from "./window-infinite-scroll.demo-data";
+import "@simple-table/react/styles.css";
+
+const INITIAL_ROWS = 50;
+const BATCH_SIZE = 50;
+const MAX_ROWS = 5_000;
+const LOAD_DELAY_MS = 350;
+
+/**
+ * Window-style infinite scroll demo.
+ *
+ * The table has no `height` / `maxHeight` — it grows to its natural size inside
+ * whatever scroll parent surrounds it. `scrollParent` is a getter that resolves
+ * to the nearest scrollable ancestor at render time (the demo shell wraps
+ * everything in a scrollable ``, so `window` itself doesn't scroll inside
+ * this preview). In a typical app you'd just pass `scrollParent="window"`.
+ *
+ * As the user scrolls toward the bottom, `onLoadMore` appends a batch of rows
+ * until the dataset cap is reached.
+ */
+const WindowInfiniteScrollDemo = ({
+ theme,
+}: {
+ // `height` is intentionally ignored — this demo is about *not* setting one.
+ height?: string | number;
+ theme?: Theme;
+}) => {
+ const wrapperRef = useRef(null);
+
+ const [rows, setRows] = useState(() => generateWindowScrollRows(0, INITIAL_ROWS));
+ const [loading, setLoading] = useState(false);
+ const [hasMore, setHasMore] = useState(true);
+
+ const handleLoadMore = useCallback(() => {
+ if (loading || !hasMore) return;
+ setLoading(true);
+
+ setTimeout(() => {
+ setRows((prev) => {
+ const next = generateWindowScrollRows(prev.length, BATCH_SIZE);
+ const updated = [...prev, ...next];
+ if (updated.length >= MAX_ROWS) {
+ setHasMore(false);
+ return updated.slice(0, MAX_ROWS);
+ }
+ return updated;
+ });
+ setLoading(false);
+ }, LOAD_DELAY_MS);
+ }, [loading, hasMore]);
+
+ const statusLabel = loading
+ ? "Loading more rows…"
+ : hasMore
+ ? `${rows.length.toLocaleString()} rows loaded · scroll for more`
+ : `${rows.length.toLocaleString()} rows loaded · end of dataset`;
+
+ return (
+
+
+ Window-Scroll Infinite Loading
+
+
+ This table has no height or maxHeight. It grows to its natural
+ size inside the page, and uses the outer scroll container (scrollParent) to
+ drive both row virtualization and onLoadMore. The header pins to the top of
+ the outer scroll viewport as you scroll. Scroll down — new rows stream in as you approach
+ the bottom.
+
+
+
+
+ {statusLabel}
+
+
+
String((p.row as { id?: number })?.id)}
+ // Getter form so we don't capture the wrapper before React attaches the ref.
+ // In a regular app outside this preview shell, pass scrollParent="window".
+ scrollParent={() => wrapperRef.current?.parentElement ?? null}
+ infiniteScrollThreshold={400}
+ onLoadMore={handleLoadMore}
+ isLoading={loading && rows.length === 0}
+ />
+
+
+ End of demo content. Keep scrolling near the bottom and onLoadMore will keep firing until
+ the dataset is exhausted.
+
+
+ );
+};
+
+export default WindowInfiniteScrollDemo;
diff --git a/packages/examples/react/src/demos/window-infinite-scroll/window-infinite-scroll.demo-data.ts b/packages/examples/react/src/demos/window-infinite-scroll/window-infinite-scroll.demo-data.ts
new file mode 100644
index 000000000..09a756400
--- /dev/null
+++ b/packages/examples/react/src/demos/window-infinite-scroll/window-infinite-scroll.demo-data.ts
@@ -0,0 +1,98 @@
+// Self-contained data + headers for the window-scroll infinite scroll example.
+import type { ReactHeaderObject, Row } from "@simple-table/react";
+
+const FIRST_NAMES = [
+ "Elena",
+ "Kai",
+ "Amara",
+ "Santiago",
+ "Priya",
+ "Magnus",
+ "Zara",
+ "Luca",
+ "Sarah",
+ "Olumide",
+ "Isabella",
+ "Dmitri",
+ "Aiko",
+ "Mateo",
+ "Noor",
+ "Fionn",
+];
+
+const LAST_NAMES = [
+ "Vasquez",
+ "Tanaka",
+ "Okafor",
+ "Rodriguez",
+ "Chakraborty",
+ "Eriksson",
+ "Al-Rashid",
+ "Rossi",
+ "Kim",
+ "Adebayo",
+ "Chen",
+ "Volkov",
+ "Nakamura",
+ "Silva",
+ "Hassan",
+ "O'Brien",
+];
+
+const DEPARTMENTS = [
+ "Engineering",
+ "AI Research",
+ "UX Design",
+ "DevOps",
+ "Marketing",
+ "Product",
+ "Sales",
+ "Finance",
+ "Operations",
+ "Customer Success",
+];
+
+const STATUSES = ["Active", "On Leave", "Remote", "Onsite"] as const;
+
+export function generateWindowScrollRows(startIndex: number, count: number): Row[] {
+ const rows: Row[] = [];
+ for (let i = 0; i < count; i++) {
+ const idx = startIndex + i;
+ const first = FIRST_NAMES[idx % FIRST_NAMES.length];
+ const last = LAST_NAMES[(idx * 3) % LAST_NAMES.length];
+ rows.push({
+ id: idx + 1,
+ name: `${first} ${last}`,
+ email: `${first.toLowerCase()}.${last.toLowerCase().replace(/'/g, "")}@example.com`,
+ department: DEPARTMENTS[idx % DEPARTMENTS.length],
+ status: STATUSES[idx % STATUSES.length],
+ tenureYears: 1 + ((idx * 13) % 18),
+ salary: 60_000 + Math.floor(((idx * 7919) % 120_000)),
+ });
+ }
+ return rows;
+}
+
+export const windowScrollHeaders: ReactHeaderObject[] = [
+ { accessor: "id", label: "ID", width: 80, type: "number", align: "right" },
+ { accessor: "name", label: "Name", width: "1fr", minWidth: 160 },
+ { accessor: "email", label: "Email", width: 260 },
+ { accessor: "department", label: "Department", width: 160 },
+ { accessor: "status", label: "Status", width: 120 },
+ {
+ accessor: "tenureYears",
+ label: "Tenure",
+ width: 110,
+ type: "number",
+ align: "right",
+ valueFormatter: ({ value }) => `${value} yrs`,
+ },
+ {
+ accessor: "salary",
+ label: "Salary",
+ width: 140,
+ type: "number",
+ align: "right",
+ valueFormatter: ({ value }) => `$${(value as number).toLocaleString()}`,
+ },
+];
diff --git a/packages/examples/react/src/registry.ts b/packages/examples/react/src/registry.ts
index 7d59b06ca..cfd140461 100644
--- a/packages/examples/react/src/registry.ts
+++ b/packages/examples/react/src/registry.ts
@@ -33,6 +33,7 @@ export const registry: DemoRegistry = {
"external-filter": () => import("./demos/external-filter/ExternalFilterDemo"),
"loading-state": () => import("./demos/loading-state/LoadingStateDemo"),
"infinite-scroll": () => import("./demos/infinite-scroll/InfiniteScrollDemo"),
+ "window-infinite-scroll": () => import("./demos/window-infinite-scroll/WindowInfiniteScrollDemo"),
"row-selection": () => import("./demos/row-selection/RowSelectionDemo"),
"csv-export": () => import("./demos/csv-export/CsvExportDemo"),
"programmatic-control": () => import("./demos/programmatic-control/ProgrammaticControlDemo"),
diff --git a/packages/examples/vanilla/src/demo-list.ts b/packages/examples/vanilla/src/demo-list.ts
index be2a4bf34..f72ee0c2e 100644
--- a/packages/examples/vanilla/src/demo-list.ts
+++ b/packages/examples/vanilla/src/demo-list.ts
@@ -22,6 +22,7 @@ export const DEMO_LIST = [
{ id: "external-filter", label: "External Filter" },
{ id: "loading-state", label: "Loading State" },
{ id: "infinite-scroll", label: "Infinite Scroll" },
+ { id: "window-infinite-scroll", label: "Window Infinite Scroll" },
{ id: "row-selection", label: "Row Selection" },
{ id: "csv-export", label: "CSV Export" },
{ id: "programmatic-control", label: "Programmatic Control" },
diff --git a/packages/examples/vanilla/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.ts b/packages/examples/vanilla/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.ts
new file mode 100644
index 000000000..aa4455d80
--- /dev/null
+++ b/packages/examples/vanilla/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.ts
@@ -0,0 +1,139 @@
+import { SimpleTableVanilla } from "simple-table-core";
+import type { Theme, Row } from "simple-table-core";
+import {
+ windowScrollHeaders,
+ generateWindowScrollRows,
+} from "./window-infinite-scroll.demo-data";
+import "simple-table-core/styles.css";
+
+const INITIAL_ROWS = 50;
+const BATCH_SIZE = 50;
+const MAX_ROWS = 5_000;
+const LOAD_DELAY_MS = 350;
+
+/**
+ * Window-style infinite scroll demo.
+ *
+ * The table is given no `height` / `maxHeight` — it grows to its natural size
+ * inside whatever scroll parent surrounds it. We pass `scrollParent: container`
+ * so the table virtualizes rows and triggers `onLoadMore` based on the demo's
+ * outer scroll area (which is `` in this shell;
+ * in a regular app you'd pass `"window"` instead). As the user scrolls toward
+ * the bottom of the page, more rows are appended.
+ */
+export function renderWindowInfiniteScrollDemo(
+ container: HTMLElement,
+ options?: { height?: string | number; theme?: Theme }
+): SimpleTableVanilla {
+ // The `height` URL param doesn't apply to this demo — the whole point is to
+ // let the table grow to fit all rows so the parent scrolls. Same for
+ // `maxHeight`. We accept the option to match the demo registry signature
+ // but intentionally ignore it.
+ void options?.height;
+
+ const wrapper = document.createElement("div");
+ wrapper.style.maxWidth = "1100px";
+ wrapper.style.margin = "0 auto";
+
+ const heading = document.createElement("h1");
+ heading.textContent = "Window-Scroll Infinite Loading";
+ Object.assign(heading.style, {
+ fontSize: "28px",
+ margin: "0 0 12px 0",
+ color: "#0f172a",
+ });
+ wrapper.appendChild(heading);
+
+ const intro = document.createElement("p");
+ intro.innerHTML =
+ "This table has no height or maxHeight. It grows to its natural size inside the page, " +
+ "and uses the outer scroll container (scrollParent) to drive both row virtualization and " +
+ "onLoadMore. The header pins to the top of the outer scroll viewport as you scroll. " +
+ "Scroll down — new rows stream in as you approach the bottom.";
+ Object.assign(intro.style, {
+ fontSize: "15px",
+ lineHeight: "1.6",
+ color: "#475569",
+ margin: "0 0 16px 0",
+ });
+ wrapper.appendChild(intro);
+
+ const status = document.createElement("div");
+ Object.assign(status.style, {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "8px",
+ padding: "6px 12px",
+ marginBottom: "16px",
+ background: "#eef2ff",
+ color: "#3730a3",
+ borderRadius: "999px",
+ fontSize: "13px",
+ fontWeight: "500",
+ });
+ wrapper.appendChild(status);
+
+ const tableContainer = document.createElement("div");
+ wrapper.appendChild(tableContainer);
+
+ const footer = document.createElement("p");
+ footer.textContent =
+ "End of demo content. Keep scrolling near the bottom and onLoadMore will keep firing until the dataset is exhausted.";
+ Object.assign(footer.style, {
+ fontSize: "13px",
+ color: "#94a3b8",
+ margin: "24px 0 48px 0",
+ textAlign: "center",
+ });
+ wrapper.appendChild(footer);
+
+ container.appendChild(wrapper);
+
+ let rows: Row[] = generateWindowScrollRows(0, INITIAL_ROWS);
+ let loading = false;
+ let hasMore = true;
+
+ const updateStatus = () => {
+ const dot = '';
+ const label = loading
+ ? "Loading more rows…"
+ : hasMore
+ ? `${rows.length.toLocaleString()} rows loaded · scroll for more`
+ : `${rows.length.toLocaleString()} rows loaded · end of dataset`;
+ status.innerHTML = `${dot}${label}`;
+ };
+ updateStatus();
+
+ const table = new SimpleTableVanilla(tableContainer, {
+ defaultHeaders: windowScrollHeaders,
+ rows,
+ theme: options?.theme,
+ getRowId: (p) => String((p.row as { id?: number })?.id),
+ // Use the demo container as the scroll parent. In a typical app you would
+ // pass `"window"` here — the demo shell wraps everything in a scrollable
+ // `` so `window` doesn't actually scroll inside this preview.
+ scrollParent: container,
+ // Fire onLoadMore a bit earlier than the default so new rows appear before
+ // the user hits the very bottom.
+ infiniteScrollThreshold: 400,
+ onLoadMore: () => {
+ if (loading || !hasMore) return;
+ loading = true;
+ updateStatus();
+
+ setTimeout(() => {
+ const next = generateWindowScrollRows(rows.length, BATCH_SIZE);
+ rows = [...rows, ...next];
+ if (rows.length >= MAX_ROWS) {
+ hasMore = false;
+ rows = rows.slice(0, MAX_ROWS);
+ }
+ loading = false;
+ table.update({ rows });
+ updateStatus();
+ }, LOAD_DELAY_MS);
+ },
+ });
+
+ return table;
+}
diff --git a/packages/examples/vanilla/src/demos/window-infinite-scroll/window-infinite-scroll.demo-data.ts b/packages/examples/vanilla/src/demos/window-infinite-scroll/window-infinite-scroll.demo-data.ts
new file mode 100644
index 000000000..7e3efb1e7
--- /dev/null
+++ b/packages/examples/vanilla/src/demos/window-infinite-scroll/window-infinite-scroll.demo-data.ts
@@ -0,0 +1,98 @@
+// Self-contained demo data for the window-scroll infinite scroll example.
+import type { HeaderObject, Row } from "simple-table-core";
+
+const FIRST_NAMES = [
+ "Elena",
+ "Kai",
+ "Amara",
+ "Santiago",
+ "Priya",
+ "Magnus",
+ "Zara",
+ "Luca",
+ "Sarah",
+ "Olumide",
+ "Isabella",
+ "Dmitri",
+ "Aiko",
+ "Mateo",
+ "Noor",
+ "Fionn",
+];
+
+const LAST_NAMES = [
+ "Vasquez",
+ "Tanaka",
+ "Okafor",
+ "Rodriguez",
+ "Chakraborty",
+ "Eriksson",
+ "Al-Rashid",
+ "Rossi",
+ "Kim",
+ "Adebayo",
+ "Chen",
+ "Volkov",
+ "Nakamura",
+ "Silva",
+ "Hassan",
+ "O'Brien",
+];
+
+const DEPARTMENTS = [
+ "Engineering",
+ "AI Research",
+ "UX Design",
+ "DevOps",
+ "Marketing",
+ "Product",
+ "Sales",
+ "Finance",
+ "Operations",
+ "Customer Success",
+];
+
+const STATUSES = ["Active", "On Leave", "Remote", "Onsite"] as const;
+
+export function generateWindowScrollRows(startIndex: number, count: number): Row[] {
+ const rows: Row[] = [];
+ for (let i = 0; i < count; i++) {
+ const idx = startIndex + i;
+ const first = FIRST_NAMES[idx % FIRST_NAMES.length];
+ const last = LAST_NAMES[(idx * 3) % LAST_NAMES.length];
+ rows.push({
+ id: idx + 1,
+ name: `${first} ${last}`,
+ email: `${first.toLowerCase()}.${last.toLowerCase().replace(/'/g, "")}@example.com`,
+ department: DEPARTMENTS[idx % DEPARTMENTS.length],
+ status: STATUSES[idx % STATUSES.length],
+ tenureYears: 1 + ((idx * 13) % 18),
+ salary: 60_000 + Math.floor(((idx * 7919) % 120_000)),
+ });
+ }
+ return rows;
+}
+
+export const windowScrollHeaders: HeaderObject[] = [
+ { accessor: "id", label: "ID", width: 80, type: "number", align: "right" },
+ { accessor: "name", label: "Name", width: "1fr", minWidth: 160 },
+ { accessor: "email", label: "Email", width: 260 },
+ { accessor: "department", label: "Department", width: 160 },
+ { accessor: "status", label: "Status", width: 120 },
+ {
+ accessor: "tenureYears",
+ label: "Tenure",
+ width: 110,
+ type: "number",
+ align: "right",
+ valueFormatter: ({ value }) => `${value} yrs`,
+ },
+ {
+ accessor: "salary",
+ label: "Salary",
+ width: 140,
+ type: "number",
+ align: "right",
+ valueFormatter: ({ value }) => `$${(value as number).toLocaleString()}`,
+ },
+];
diff --git a/packages/examples/vanilla/src/main.ts b/packages/examples/vanilla/src/main.ts
index f5ba7d6b6..f3af8450d 100644
--- a/packages/examples/vanilla/src/main.ts
+++ b/packages/examples/vanilla/src/main.ts
@@ -111,6 +111,10 @@ const registry: Record<
import("./demos/infinite-scroll/InfiniteScrollDemo").then((m) => ({
render: m.renderInfiniteScrollDemo,
})),
+ "window-infinite-scroll": () =>
+ import("./demos/window-infinite-scroll/WindowInfiniteScrollDemo").then((m) => ({
+ render: m.renderWindowInfiniteScrollDemo,
+ })),
"row-selection": () =>
import("./demos/row-selection/RowSelectionDemo").then((m) => ({
render: m.renderRowSelectionDemo,
diff --git a/packages/react/package.json b/packages/react/package.json
index 3d253eba8..d86974422 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -1,6 +1,6 @@
{
"name": "@simple-table/react",
- "version": "3.5.3",
+ "version": "3.6.1",
"main": "dist/cjs/index.js",
"module": "dist/index.es.js",
"types": "dist/types/react/src/index.d.ts",
diff --git a/packages/solid/package.json b/packages/solid/package.json
index 83427230f..3ee4c250f 100644
--- a/packages/solid/package.json
+++ b/packages/solid/package.json
@@ -1,6 +1,6 @@
{
"name": "@simple-table/solid",
- "version": "3.5.3",
+ "version": "3.6.1",
"main": "dist/cjs/index.js",
"module": "dist/index.es.js",
"types": "dist/types/solid/src/index.d.ts",
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index dea1ee095..c25e93e2a 100644
--- a/packages/svelte/package.json
+++ b/packages/svelte/package.json
@@ -1,6 +1,6 @@
{
"name": "@simple-table/svelte",
- "version": "3.5.3",
+ "version": "3.6.1",
"main": "dist/cjs/index.js",
"module": "dist/index.es.js",
"types": "dist/types/index.d.ts",
diff --git a/packages/vue/package.json b/packages/vue/package.json
index 78b6c9f96..81b298c62 100644
--- a/packages/vue/package.json
+++ b/packages/vue/package.json
@@ -1,6 +1,6 @@
{
"name": "@simple-table/vue",
- "version": "3.5.3",
+ "version": "3.6.1",
"main": "dist/cjs/index.js",
"module": "dist/index.es.js",
"types": "dist/types/vue/src/index.d.ts",