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
5 changes: 5 additions & 0 deletions .changeset/red-webs-sink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@drivenets/design-system': minor
---

Add `infiniteScroll` prop to `DsTable`, letting it handle viewport/scroll and auto-fill pagination while consumers manage data and loading state.
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, expect, it, vi } from 'vitest';
import { page } from 'vitest/browser';
import DsTable from '../ds-table';
import { columns, type Person } from '../stories/common/story-data';

const statuses: Person['status'][] = ['single', 'relationship', 'complicated'];

function generateTestData(count: number): Person[] {
return Array.from({ length: count }, (_, i) => ({
id: String(i + 1),
firstName: `First${String(i + 1)}`,
lastName: `Last${String(i + 1)}`,
age: 20 + (i % 50),
visits: i * 10,
status: statuses[i % statuses.length] ?? 'single',
progress: i % 100,
}));
}

const getScrollContainer = (): HTMLElement => {
const el = document.querySelector<HTMLElement>('[class*="virtualizedContainer"]');
if (!el) {
throw new Error('Expected virtualized scroll container');
}
return el;
};

describe('DsTable infinite scroll', () => {
it('calls onLoadMore when scrolled within thresholdPx of bottom', async () => {
const onLoadMore = vi.fn();
const tallData = generateTestData(200);

await page.render(
<div style={{ height: '400px' }}>
<DsTable
columns={columns}
data={tallData}
virtualized
infiniteScroll={{
hasMore: true,
isLoadingMore: false,
onLoadMore,
thresholdPx: 200,
autoFill: false,
}}
/>
</div>,
);

const container = getScrollContainer();
container.scrollTop = container.scrollHeight - container.clientHeight - 100;

await expect.poll(() => onLoadMore.mock.calls.length, { timeout: 2000 }).toBeGreaterThan(0);
});

it('does not call onLoadMore when hasMore is false', async () => {
const onLoadMore = vi.fn();
const tallData = generateTestData(200);

await page.render(
<div style={{ height: '400px' }}>
<DsTable
columns={columns}
data={tallData}
virtualized
infiniteScroll={{
hasMore: false,
isLoadingMore: false,
onLoadMore,
thresholdPx: 200,
autoFill: false,
}}
/>
</div>,
);

const container = getScrollContainer();
container.scrollTop = container.scrollHeight;

await new Promise((resolve) => setTimeout(resolve, 200));
expect(onLoadMore).not.toHaveBeenCalled();
});

it('does not call onLoadMore while isLoadingMore is true', async () => {
const onLoadMore = vi.fn();
const tallData = generateTestData(200);

await page.render(
<div style={{ height: '400px' }}>
<DsTable
columns={columns}
data={tallData}
virtualized
infiniteScroll={{
hasMore: true,
isLoadingMore: true,
onLoadMore,
thresholdPx: 200,
autoFill: false,
}}
/>
</div>,
);

const container = getScrollContainer();
container.scrollTop = container.scrollHeight;

await new Promise((resolve) => setTimeout(resolve, 200));
expect(onLoadMore).not.toHaveBeenCalled();
});

it('autoFill triggers onLoadMore on mount when content does not fill the viewport', async () => {
const onLoadMore = vi.fn();
const shortData = generateTestData(3);

await page.render(
<div style={{ height: '800px' }}>
<DsTable
columns={columns}
data={shortData}
virtualized
infiniteScroll={{
hasMore: true,
isLoadingMore: false,
onLoadMore,
autoFill: true,
}}
/>
</div>,
);

await expect.poll(() => onLoadMore.mock.calls.length, { timeout: 2000 }).toBeGreaterThan(0);
});

it('autoFill=false does NOT trigger onLoadMore on mount when content is short', async () => {
const onLoadMore = vi.fn();
const shortData = generateTestData(3);

await page.render(
<div style={{ height: '800px' }}>
<DsTable
columns={columns}
data={shortData}
virtualized
infiniteScroll={{
hasMore: true,
isLoadingMore: false,
onLoadMore,
autoFill: false,
}}
/>
</div>,
);

await new Promise((resolve) => setTimeout(resolve, 200));
expect(onLoadMore).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DsTableRowVirtualized } from '../ds-table-row-virtualized';
import type { DsTableBodyVirtualizedProps } from './ds-table-body-virtualized.types';
import { TableBody, TableRow, TableCell } from '../core-table';
import { EMPTY_TABLE_STATE_TEXT } from '../../utils/constants';
import { useInfiniteScroll } from './use-infinite-scroll';

export const DsTableBodyVirtualized = <TData,>({
table,
Expand All @@ -15,6 +16,7 @@ export const DsTableBodyVirtualized = <TData,>({
overscan,
onScroll,
rowSelection,
infiniteScroll,
}: DsTableBodyVirtualizedProps<TData>) => {
const rowsMapRef = useRef(new Map<string, HTMLTableRowElement>());
const rowHeightsMapRef = useRef(new Map<string, number>());
Expand All @@ -30,6 +32,8 @@ export const DsTableBodyVirtualized = <TData,>({
return item ? `${item.row.id}${item.isExpandedRowContent ? '-expanded-content' : ''}` : String(index);
};

const { loadDataIfNeeded } = useInfiniteScroll(tableContainerRef, rows.length, infiniteScroll);

const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: rowsAndExpandedRowContent.length,
estimateSize: (index) => {
Expand All @@ -56,17 +60,21 @@ export const DsTableBodyVirtualized = <TData,>({
});
});

if (sync && onScroll) {
const scrollOffset = instance.scrollOffset || 0;
const totalContentHeight = instance.getTotalSize();
const viewportHeight = instance.scrollElement?.clientHeight;
const scrollDirection = instance.scrollDirection;
if (sync) {
if (onScroll) {
const scrollOffset = instance.scrollOffset || 0;
const totalContentHeight = instance.getTotalSize();
const viewportHeight = instance.scrollElement?.clientHeight;
const scrollDirection = instance.scrollDirection;

if (viewportHeight) {
const bottomOffset = totalContentHeight - (scrollOffset + viewportHeight);
if (viewportHeight) {
const bottomOffset = totalContentHeight - (scrollOffset + viewportHeight);

onScroll({ scrollOffset, totalContentHeight, viewportHeight, bottomOffset, scrollDirection });
onScroll({ scrollOffset, totalContentHeight, viewportHeight, bottomOffset, scrollDirection });
}
}

loadDataIfNeeded();
}
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export interface DsTableBodyVirtualizedProps<TData> {
* Current row selection state from TanStack Table. Used to style selected rows.
*/
rowSelection: RowSelectionState;
/**
* Optional infinite-scroll configuration. When provided, the body requests
* `onLoadMore` as the user nears the bottom of the scroll container and
* runs an auto-fill loop until the viewport becomes scrollable.
*/
infiniteScroll?: InfiniteScrollConfig;
Comment thread
iromanchuk-dn marked this conversation as resolved.
}

/**
Expand All @@ -53,3 +59,58 @@ export interface ScrollParams {
/** The direction of the scroll movement */
scrollDirection: 'forward' | 'backward' | null;
}

/**
* Configuration for infinite scroll on a virtualized table.
*
* The Table is responsible for detecting when more rows should be requested
* (proximity to the bottom of the scroll container, and the auto-fill loop
* when the rendered content does not fill the viewport). The consumer is
* responsible for fetching, pagination cursors, error handling, retry, and
* for reflecting loading state back via `isLoadingMore`.
*/
export interface InfiniteScrollConfig {
/**
* Whether more rows can be loaded. When `false`, no further `onLoadMore`
* calls are made. Flip this off when the end of the data set is reached.
*/
hasMore: boolean;

/**
* Called when the Table wants the next page of rows. May be synchronous or
* return a `Promise` (e.g. pass an async fetcher such as React Query's
* `fetchNextPage` directly); the Table does not await the return value.
* Reflect the loading state via `isLoadingMore` so the Table can guard
* against duplicate calls.
*/
onLoadMore: (() => void) | (() => Promise<unknown>);

/**
* Whether a fetch is currently in flight. While `true`, the Table will not
* call `onLoadMore` again. Set this synchronously inside your handler (or
* track it with a state hook flipped before the fetch starts) if
* `onLoadMore` is not idempotent and you need one-call-per-page semantics;
* consumers using idempotent fetchers (e.g. React Query's `fetchNextPage`)
* can safely leave this unset.
*
* @default false
*/
isLoadingMore?: boolean;

/**
* Distance in pixels from the bottom of the scroll container at which
* `onLoadMore` is requested.
*
* @default 500
*/
thresholdPx?: number;

/**
* When `true`, the Table will continue to request more rows as long as the
* rendered content does not fill the viewport (so the user can actually
* start scrolling). Disable only when you want strictly user-driven loading.
*
* @default true
*/
autoFill?: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useEffect, type RefObject } from 'react';
import type { InfiniteScrollConfig } from './ds-table-body-virtualized.types';

const DEFAULT_THRESHOLD_PX = 500;
const DEFAULT_AUTO_FILL = true;

interface UseInfiniteScrollResult {
/**
* Evaluates the current scroll position and, if appropriate, calls
* `onLoadMore`. Safe to call from scroll/`onChange` handlers and effects.
*/
loadDataIfNeeded: () => void;
}

/**
* Owns the infinite-scroll trigger logic for `DsTable`:
* threshold detection and the auto-fill loop for short initial pages.
*/
export const useInfiniteScroll = (
scrollElementRef: RefObject<HTMLElement | null>,
rowCount: number,
config: InfiniteScrollConfig | undefined,
): UseInfiniteScrollResult => {
const hasMore = config?.hasMore ?? false;
const isLoadingMore = config?.isLoadingMore ?? false;
const thresholdPx = config?.thresholdPx ?? DEFAULT_THRESHOLD_PX;
const autoFill = config?.autoFill ?? DEFAULT_AUTO_FILL;

const loadDataIfNeeded = () => {
const el = scrollElementRef.current;

if (!config || !hasMore || isLoadingMore || !el) {
return;
}

const isScrollable = el.scrollHeight > el.clientHeight;
const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
const isNearBottom = isScrollable && distanceToBottom <= thresholdPx;
const isNotScrollable = autoFill && !isScrollable;

if (isNearBottom || isNotScrollable) {
void config.onLoadMore();
}
};

useEffect(() => {
loadDataIfNeeded();
// loadDataIfNeeded reads the latest config values from closure; deps cover
// the meaningful inputs (data growth, hasMore, loading state).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rowCount, hasMore, isLoadingMore]);

return { loadDataIfNeeded };
};
2 changes: 2 additions & 0 deletions packages/design-system/src/components/ds-table/ds-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const DsTable = <TData extends { id: string }, TValue>({
columnVisibility: externalColumnVisibility,
onColumnVisibilityChange,
activeRowId,
infiniteScroll,
}: DsDataTableProps<TData, TValue>) => {
const [data, setData] = React.useState(tableData);
const [sorting, setSorting] = React.useState<SortingState>([]);
Expand Down Expand Up @@ -297,6 +298,7 @@ const DsTable = <TData extends { id: string }, TValue>({
overscan={virtualizedOptions?.overscan}
onScroll={onScroll}
rowSelection={rowSelection}
infiniteScroll={infiniteScroll}
/>
) : (
<TableBody>
Expand Down
Loading
Loading