Skip to content
Merged
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
38 changes: 21 additions & 17 deletions apps/marketing/src/components/demos/InfiniteScrollDemo.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { SimpleTable } from "@simple-table/react";
import type { ReactHeaderObject, Theme } from "@simple-table/react";
import { useState, useCallback } from "react";
import { useState, useCallback, useRef } from "react";
import "@simple-table/react/styles.css";

// Define headers
Expand Down Expand Up @@ -101,33 +101,37 @@ const InfiniteScrollDemo = ({
const [rows, setRows] = useState(initialData);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
// Synchronous re-entry guard. The `loading` state alone can't block
// back-to-back invocations: between the first `setLoading(true)` and React's
// next commit, the callback the table is holding still has `loading=false`
// in its closure, so multiple scroll-RAF ticks would all sneak past the guard.
const loadingRef = useRef(false);

// Simulate loading more data
const handleLoadMore = useCallback(async () => {
if (loading || !hasMore) return;

if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setLoading(true);

// Simulate API delay
await new Promise((resolve) => setTimeout(resolve, 1000));

try {
// Generate next batch of data
const nextStartId = rows.length + 1;
const newData = generateSampleData(nextStartId, 15);
await new Promise((resolve) => setTimeout(resolve, 1000));

// Stop loading more after 200 records for demo purposes
if (nextStartId > 200) {
setHasMore(false);
} else {
setRows((prevRows) => [...prevRows, ...newData]);
}
// Compute the next id from the live `prev` so duplicate ids can't appear
// even if a stale closure ever runs this path.
setRows((prev) => {
const nextStartId = prev.length + 1;
if (nextStartId > 200) {
setHasMore(false);
return prev;
}
return [...prev, ...generateSampleData(nextStartId, 15)];
});
} catch (error) {
console.error("Failed to load more data:", error);
} finally {
setLoading(false);
loadingRef.current = false;
}
}, [loading, hasMore, rows.length]);
}, [hasMore]);

return (
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,51 @@ const INFINITE_SCROLL_PROPS: PropInfo[] = [
name: "height",
required: false,
description:
"Height of the table container. Required for infinite scroll to work properly as it enables the scroll detection.",
"Height of the table container. Use this OR scrollParent to enable scroll detection. With a fixed height the table's own body scrolls; without height the table grows to fit and scrollParent drives the scroll.",
type: "string",
example: `<SimpleTable
height="400px"
onLoadMore={handleLoadMore}
// ... other props
/>`,
},
{
key: "infiniteScrollThreshold",
name: "infiniteScrollThreshold",
required: false,
description:
"Pixel distance from the bottom at which onLoadMore fires. Defaults to 200. Increase for earlier pre-fetching.",
type: "number",
example: `<SimpleTable
onLoadMore={handleLoadMore}
infiniteScrollThreshold={400}
// ... other props
/>`,
},
];

const WINDOW_SCROLL_PROPS: PropInfo[] = [
{
key: "scrollParent",
name: "scrollParent",
required: false,
description:
"Opts the table into window / external scroll mode. When set and neither height nor maxHeight is provided, the table grows to its natural size inside the parent and that parent's scroll position drives both row virtualization and onLoadMore. Accepts an element, the string \"window\", or a getter (useful for refs that resolve after first render). The header automatically pins to the top of the parent's scroll viewport.",
type: 'HTMLElement | "window" | (() => HTMLElement | null)',
example: `// Page-level scroll (most common in real apps)
<SimpleTable
defaultHeaders={headers}
rows={rows}
scrollParent="window"
onLoadMore={handleLoadMore}
/>

// Custom container (e.g. a side panel with overflow: auto)
<SimpleTable
defaultHeaders={headers}
rows={rows}
scrollParent={() => containerRef.current}
onLoadMore={handleLoadMore}
/>`,
},
];
Expand Down Expand Up @@ -155,7 +194,11 @@ const InfiniteScrollContent = () => {
<code className="bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200">
onLoadMore
</code>{" "}
when user scrolls near the bottom (typically 100px before the end)
when user scrolls within{" "}
<code className="bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200">
infiniteScrollThreshold
</code>{" "}
pixels of the bottom (default 200px)
</li>
<li>
<strong>Debouncing</strong> - Prevents multiple simultaneous requests by debouncing the
Expand All @@ -168,6 +211,119 @@ const InfiniteScrollContent = () => {
</ul>
</motion.div>

{/* Window / External Scroll Section */}
<motion.h2
className="text-2xl font-bold text-gray-800 dark:text-white mb-4 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.7 }}
>
Window / External Scroll Mode
</motion.h2>

<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.8 }}
>
<p className="text-gray-700 dark:text-gray-300 mb-4">
Want the table to behave like a regular page section — growing to its natural height
while the page (or a parent container) scrolls? Drop{" "}
<code className="bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200">
height
</code>{" "}
/{" "}
<code className="bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200">
maxHeight
</code>{" "}
and pass{" "}
<code className="bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200">
scrollParent
</code>
. The table will:
</p>

<ul className="list-disc pl-5 space-y-3 text-gray-700 dark:text-gray-300 mb-6">
<li>
<strong>Virtualize against the parent</strong> - Only the rows visible inside the
parent's viewport are rendered, even with tens of thousands of rows.
</li>
<li>
<strong>Fire onLoadMore from the parent's scroll</strong> - The threshold check uses the
parent's position relative to the table, not the table's own (non-existent) inner
scroll.
</li>
<li>
<strong>Pin the header automatically</strong> - The header sticks to the top of the
parent's scroll viewport via CSS{" "}
<code className="bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200">
position: sticky
</code>{" "}
so it stays visible as users scroll through the rows.
</li>
<li>
<strong>Suppress overscroll bounce</strong> - Sets{" "}
<code className="bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200">
overscroll-behavior-y: none
</code>{" "}
on the scroll parent so the rubber-band effect doesn't visually shift the sticky header
off the viewport. Restored on unmount.
</li>
</ul>

<PropTable props={WINDOW_SCROLL_PROPS} title="Window / External Scroll Props" />

<h3 className="text-lg font-semibold text-gray-800 dark:text-white mt-6 mb-3">
Precedence rules
</h3>
<ul className="list-disc pl-5 space-y-2 text-gray-700 dark:text-gray-300 mb-6">
<li>
<code className="bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200">
height
</code>{" "}
or{" "}
<code className="bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200">
maxHeight
</code>{" "}
always win. If either is set,{" "}
<code className="bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200">
scrollParent
</code>{" "}
is ignored and the table uses its own inner scroll.
</li>
<li>
Without{" "}
<code className="bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200">
scrollParent
</code>{" "}
and without{" "}
<code className="bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200">
height
</code>
, all rows render (no virtualization, no infinite scroll).
</li>
<li>
<code className="bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200">
enableStickyParents
</code>{" "}
(for grouped row parents) works in external scroll mode too — pinned parent rows
stay just under the sticky header as you scroll past their children.
</li>
</ul>

<h3 className="text-lg font-semibold text-gray-800 dark:text-white mt-6 mb-3">
Padding on the scroll parent
</h3>
<p className="text-gray-700 dark:text-gray-300 mb-6">
If your scroll parent has{" "}
<code className="bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200">
padding-top
</code>
, the table reads it and offsets the sticky header so it pins flush to the parent's outer
top edge instead of sitting beneath the padding. No extra config required.
</p>
</motion.div>

<DocNavigationButtons />
</PageWrapper>
);
Expand Down
67 changes: 67 additions & 0 deletions apps/marketing/src/constants/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,71 @@ export interface ChangelogEntry {
link?: string;
}[];
}
export const v3_6_1: ChangelogEntry = {
version: "3.6.1",
date: "2026-05-16",
title: "Sticky row-group parents in external scroll",
description:
"enableStickyParents now works in external scroll mode. Grouped parent rows pin under the sticky header as you scroll past their children, instead of scrolling away with the table. Removes the warn-and-noop guard added in 3.6.0.",
changes: [
{
type: "feature",
description:
"enableStickyParents is now supported alongside scrollParent — pinned grouped parents stay flush under the sticky header in external scroll mode.",
link: "/docs/infinite-scroll",
},
{
type: "improvement",
description:
"Removed the one-shot console.warn that fired when enableStickyParents and scrollParent were combined; the conflict no longer exists.",
},
],
};

export const v3_6_0: ChangelogEntry = {
version: "3.6.0",
date: "2026-05-15",
title: "Window / external scroll mode",
description:
"New scrollParent prop lets the table grow to its natural height inside a page-level or custom scroll container, while that parent's scroll drives virtualization and onLoadMore. Header automatically pins to the top of the parent's scroll viewport.",
changes: [
{
type: "feature",
description:
"New scrollParent prop (HTMLElement | \"window\" | () => HTMLElement | null) opts the table into external scroll mode when no height/maxHeight is set; the parent's scroll drives row virtualization.",
link: "/docs/infinite-scroll",
},
{
type: "feature",
description:
"onLoadMore now fires based on the external scroll parent's position relative to the table bottom when scrollParent is active.",
link: "/docs/infinite-scroll",
},
{
type: "feature",
description:
"New infiniteScrollThreshold prop (default 200px) exposes the bottom-distance at which onLoadMore fires.",
link: "/docs/infinite-scroll",
},
{
type: "feature",
description:
"Header is automatically sticky-pinned to the top of the external scroll parent's viewport in scrollParent mode. Auto-compensates for parent padding-top.",
link: "/docs/infinite-scroll",
},
{
type: "improvement",
description:
"Suppresses the browser's elastic rubber-band on the scroll parent while external scroll mode is active so the sticky header stays put during overscroll. Restored on detach.",
},
{
type: "improvement",
description:
"enableStickyParents (sticky row-group rows) is now safely no-op + warn when combined with scrollParent (incompatible CSS containing-block).",
},
],
};

export const v3_5_3: ChangelogEntry = {
version: "3.5.3",
date: "2026-05-09",
Expand Down Expand Up @@ -1677,6 +1742,8 @@ export const v1_4_4: ChangelogEntry = {

// Array of all changelog entries (newest first)
export const CHANGELOG_ENTRIES: ChangelogEntry[] = [
v3_6_1,
v3_6_0,
v3_5_3,
v3_5_2,
v3_4_2,
Expand Down
37 changes: 37 additions & 0 deletions apps/marketing/src/constants/propDefinitions/simpleTableProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,43 @@ quickFilter={{
setRows(prevRows => [...prevRows, ...newRows]);
});
}}`,
},
{
key: "scrollParent",
name: "scrollParent",
required: false,
description:
"Opts the table into 'window' / external scroll mode. When set and neither height nor maxHeight is provided, the table grows to its natural height inside the given parent and that parent's scroll position drives both row virtualization and onLoadMore. Accepts an element, the string \"window\", or a getter (useful for React/Angular refs that resolve after first render). The header is automatically pinned to the top of the parent's scroll viewport.",
type: 'HTMLElement | "window" | (() => HTMLElement | null)',
example: `// Page-level scroll (most common in real apps)
<SimpleTable
defaultHeaders={headers}
rows={rows}
scrollParent="window"
onLoadMore={handleLoadMore}
/>

// Scroll inside a specific container (e.g. a side panel)
<SimpleTable
defaultHeaders={headers}
rows={rows}
scrollParent={() => containerRef.current}
onLoadMore={handleLoadMore}
/>`,
},
{
key: "infiniteScrollThreshold",
name: "infiniteScrollThreshold",
required: false,
description:
"Pixel distance from the bottom of the scrollable area at which onLoadMore fires. Defaults to 200. Increase for earlier pre-fetching; decrease to fire only very close to the bottom.",
type: "number",
example: `<SimpleTable
defaultHeaders={headers}
rows={rows}
onLoadMore={handleLoadMore}
infiniteScrollThreshold={400}
/>`,
},
{
key: "onSortChange",
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@simple-table/angular",
"version": "3.5.3",
"version": "3.6.1",
"main": "dist/cjs/index.js",
"module": "dist/index.es.js",
"types": "dist/types/angular/src/index.d.ts",
Expand Down
5 changes: 5 additions & 0 deletions packages/angular/src/lib/SimpleTableComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export class SimpleTableComponent implements OnInit, OnChanges, OnDestroy {
@Input() totalRowCount?: SimpleTableAngularProps["totalRowCount"];
@Input() height?: SimpleTableAngularProps["height"];
@Input() maxHeight?: SimpleTableAngularProps["maxHeight"];
@Input() scrollParent?: SimpleTableAngularProps["scrollParent"];
@Input() infiniteScrollThreshold?: SimpleTableAngularProps["infiniteScrollThreshold"];
@Input() columnResizing?: SimpleTableAngularProps["columnResizing"];
@Input() columnReordering?: SimpleTableAngularProps["columnReordering"];
@Input() editColumns?: SimpleTableAngularProps["editColumns"];
Expand Down Expand Up @@ -170,6 +172,9 @@ export class SimpleTableComponent implements OnInit, OnChanges, OnDestroy {
if (this.totalRowCount !== undefined) props.totalRowCount = this.totalRowCount;
if (this.height !== undefined) props.height = this.height;
if (this.maxHeight !== undefined) props.maxHeight = this.maxHeight;
if (this.scrollParent !== undefined) props.scrollParent = this.scrollParent;
if (this.infiniteScrollThreshold !== undefined)
props.infiniteScrollThreshold = this.infiniteScrollThreshold;
if (this.columnResizing !== undefined) props.columnResizing = this.columnResizing;
if (this.columnReordering !== undefined) props.columnReordering = this.columnReordering;
if (this.editColumns !== undefined) props.editColumns = this.editColumns;
Expand Down
Loading
Loading