diff --git a/pages/table/virtual-scroll.page.tsx b/pages/table/virtual-scroll.page.tsx
new file mode 100644
index 0000000000..1afb1067bc
--- /dev/null
+++ b/pages/table/virtual-scroll.page.tsx
@@ -0,0 +1,72 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React, { useMemo, useState } from 'react';
+
+import Box from '~components/box';
+import Checkbox from '~components/checkbox';
+import Header from '~components/header';
+import SpaceBetween from '~components/space-between';
+import Table from '~components/table';
+
+import ScreenshotArea from '../utils/screenshot-area';
+
+interface Item {
+ id: string;
+ name: string;
+ region: string;
+ state: string;
+}
+
+function generateItems(count: number): Item[] {
+ const states = ['RUNNING', 'STOPPED', 'PENDING', 'TERMINATED'];
+ const regions = ['us-east-1', 'us-west-2', 'eu-central-1', 'ap-south-1'];
+ return Array.from({ length: count }, (_, i) => ({
+ id: `id-${i + 1}`,
+ name: `Instance ${i + 1}`,
+ region: regions[i % regions.length],
+ state: states[i % states.length],
+ }));
+}
+
+const columnDefinitions = [
+ { id: 'id', header: 'ID', cell: (item: Item) => item.id, sortingField: 'id' },
+ { id: 'name', header: 'Name', cell: (item: Item) => item.name },
+ { id: 'region', header: 'Region', cell: (item: Item) => item.region },
+ { id: 'state', header: 'State', cell: (item: Item) => item.state },
+];
+
+// Dev page for the experimental opt-in windowed (virtual) scrolling mode.
+// The table is placed inside a fixed-height scroll container so windowing takes effect.
+export default function TableVirtualScrollPage() {
+ const [enabled, setEnabled] = useState(true);
+ const items = useMemo(() => generateItems(10000), []);
+
+ return (
+
+
+
+ Table virtual scrolling (v0, experimental)
+
+
+ setEnabled(detail.checked)}>
+ Enable virtualScroll
+
+
+
+
+
+
+ Total items: {items.length}
+
+
+ );
+}
diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
index b54c1b56ab..c8d42ac1a0 100644
--- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
+++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
@@ -29162,6 +29162,33 @@ It is also used in the following situations:
"type": "string",
"visualRefreshTag": "\`embedded\`, \`stacked\`, and \`full-page\` variants",
},
+ {
+ "description": "Enables experimental windowed (virtual) scrolling for the table body. When set, only the
+rows within (and near) the visible viewport are rendered to the DOM, which keeps very large
+tables performant. Pass \`true\` to enable with defaults, or an object to configure it:
+* \`rowHeight\` (number) - Estimated height of a single row in pixels. Used to size the virtual
+ window. Defaults to \`40\`. **Note:** v0 assumes a uniform row height.
+* \`overscan\` (number) - Number of additional rows rendered above and below the viewport to
+ reduce blank areas while scrolling. Defaults to \`5\`.
+
+The table must be rendered inside a scroll container with a bounded height for virtualization
+to take effect. This API is currently experimental.",
+ "inlineType": {
+ "name": "boolean | TableProps.VirtualScrollConfig",
+ "type": "union",
+ "values": [
+ "false",
+ "true",
+ "TableProps.VirtualScrollConfig",
+ ],
+ },
+ "name": "virtualScroll",
+ "optional": true,
+ "systemTags": [
+ "core",
+ ],
+ "type": "boolean | TableProps.VirtualScrollConfig",
+ },
{
"deprecatedTag": "Replaced by \`columnDisplay\`.",
"description": "Specifies an array containing the \`id\`s of visible columns. If not set, all columns are displayed.
diff --git a/src/table/__tests__/use-virtual-scroll.test.tsx b/src/table/__tests__/use-virtual-scroll.test.tsx
new file mode 100644
index 0000000000..5455f19372
--- /dev/null
+++ b/src/table/__tests__/use-virtual-scroll.test.tsx
@@ -0,0 +1,90 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React from 'react';
+import { fireEvent } from '@testing-library/react';
+
+import { act, renderHook } from '../../__tests__/render-hook';
+import { DEFAULT_VIRTUAL_ROW_HEIGHT, useVirtualScroll } from '../use-virtual-scroll';
+
+function createContainer({ scrollTop = 0, clientHeight = 0 }: { scrollTop?: number; clientHeight?: number }) {
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+ Object.defineProperty(el, 'clientHeight', { configurable: true, value: clientHeight });
+ el.scrollTop = scrollTop;
+ return el;
+}
+
+describe('useVirtualScroll', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ test('is a no-op when disabled: renders the full range with no padding', () => {
+ const container = createContainer({ clientHeight: 400 });
+ const ref = { current: container } as React.RefObject;
+ const { result } = renderHook(() =>
+ useVirtualScroll({ enabled: false, itemCount: 1000, containerRef: ref, rowHeight: 40 })
+ );
+
+ expect(result.current.enabled).toBe(false);
+ expect(result.current.startIndex).toBe(0);
+ expect(result.current.endIndex).toBe(1000);
+ expect(result.current.topPadding).toBe(0);
+ expect(result.current.bottomPadding).toBe(0);
+ expect(result.current.totalSize).toBe(1000 * 40);
+ });
+
+ test('computes a windowed range based on scroll offset and viewport height', () => {
+ const container = createContainer({ scrollTop: 4000, clientHeight: 400 });
+ const ref = { current: container } as React.RefObject;
+ const { result } = renderHook(() =>
+ useVirtualScroll({ enabled: true, itemCount: 1000, containerRef: ref, rowHeight: 40, overscan: 5 })
+ );
+
+ // 4000 / 40 = row 100; minus overscan 5 => startIndex 95
+ expect(result.current.startIndex).toBe(95);
+ // visibleCount ceil(400/40)=10; endIndex = 95 + 10 + 2*5 = 115
+ expect(result.current.endIndex).toBe(115);
+ expect(result.current.topPadding).toBe(95 * 40);
+ expect(result.current.bottomPadding).toBe((1000 - 115) * 40);
+ });
+
+ test('recomputes the window after a scroll event', () => {
+ const container = createContainer({ scrollTop: 0, clientHeight: 400 });
+ const ref = { current: container } as React.RefObject;
+ const { result } = renderHook(() =>
+ useVirtualScroll({ enabled: true, itemCount: 1000, containerRef: ref, rowHeight: 40, overscan: 0 })
+ );
+
+ expect(result.current.startIndex).toBe(0);
+
+ act(() => {
+ container.scrollTop = 2000;
+ fireEvent.scroll(container);
+ });
+
+ expect(result.current.startIndex).toBe(50);
+ });
+
+ test('handles an empty dataset', () => {
+ const container = createContainer({ clientHeight: 400 });
+ const ref = { current: container } as React.RefObject;
+ const { result } = renderHook(() =>
+ useVirtualScroll({ enabled: true, itemCount: 0, containerRef: ref, rowHeight: 40 })
+ );
+
+ expect(result.current.startIndex).toBe(0);
+ expect(result.current.endIndex).toBe(0);
+ expect(result.current.totalSize).toBe(0);
+ });
+
+ test('falls back to the default row height when a non-positive value is provided', () => {
+ const container = createContainer({ clientHeight: 400 });
+ const ref = { current: container } as React.RefObject;
+ const { result } = renderHook(() =>
+ useVirtualScroll({ enabled: true, itemCount: 100, containerRef: ref, rowHeight: 0 })
+ );
+
+ expect(result.current.totalSize).toBe(100 * DEFAULT_VIRTUAL_ROW_HEIGHT);
+ });
+});
diff --git a/src/table/__tests__/virtual-scroll.test.tsx b/src/table/__tests__/virtual-scroll.test.tsx
new file mode 100644
index 0000000000..13c769846a
--- /dev/null
+++ b/src/table/__tests__/virtual-scroll.test.tsx
@@ -0,0 +1,55 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import * as React from 'react';
+import { render } from '@testing-library/react';
+
+import Table, { TableProps } from '../../../lib/components/table';
+import createWrapper from '../../../lib/components/test-utils/dom';
+
+import styles from '../../../lib/components/table/styles.css.js';
+
+interface Item {
+ id: number;
+ name: string;
+}
+
+const columnDefinitions: TableProps.ColumnDefinition- [] = [
+ { id: 'id', header: 'ID', cell: item => item.id },
+ { id: 'name', header: 'Name', cell: item => item.name },
+];
+
+function generateItems(count: number): Item[] {
+ return Array.from({ length: count }, (_, i) => ({ id: i + 1, name: `Item ${i + 1}` }));
+}
+
+function renderTable(props: Partial>) {
+ const { container } = render(
+
+ );
+ return createWrapper(container).findTable()!;
+}
+
+describe('Table virtualScroll (v0)', () => {
+ test('renders every row when virtualScroll is not set', () => {
+ const wrapper = renderTable({});
+ expect(wrapper.findRows()).toHaveLength(500);
+ expect(wrapper.findByClassName(styles['virtual-scroll-spacer'])).toBeNull();
+ });
+
+ test('renders only a windowed subset of rows when virtualScroll is enabled', () => {
+ const wrapper = renderTable({ virtualScroll: true });
+ // Only a small window (viewport + overscan) is rendered, far fewer than 500.
+ expect(wrapper.findRows().length).toBeLessThan(500);
+ expect(wrapper.findRows().length).toBeGreaterThan(0);
+ });
+
+ test('renders a spacer row to preserve total scroll height', () => {
+ const wrapper = renderTable({ virtualScroll: true });
+ expect(wrapper.findByClassName(styles['virtual-scroll-spacer'])).not.toBeNull();
+ });
+
+ test('accepts an object configuration', () => {
+ const wrapper = renderTable({ virtualScroll: { rowHeight: 30, overscan: 2 } });
+ expect(wrapper.findRows().length).toBeLessThan(500);
+ });
+});
diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx
index e00ef8fc17..94ffd04a92 100644
--- a/src/table/interfaces.tsx
+++ b/src/table/interfaces.tsx
@@ -70,6 +70,21 @@ export interface TableProps extends BaseComponentProps {
*/
skeleton?: TableProps.SkeletonConfig;
+ /**
+ * Enables experimental windowed (virtual) scrolling for the table body. When set, only the
+ * rows within (and near) the visible viewport are rendered to the DOM, which keeps very large
+ * tables performant. Pass `true` to enable with defaults, or an object to configure it:
+ * * `rowHeight` (number) - Estimated height of a single row in pixels. Used to size the virtual
+ * window. Defaults to `40`. **Note:** v0 assumes a uniform row height.
+ * * `overscan` (number) - Number of additional rows rendered above and below the viewport to
+ * reduce blank areas while scrolling. Defaults to `5`.
+ *
+ * The table must be rendered inside a scroll container with a bounded height for virtualization
+ * to take effect. This API is currently experimental.
+ * @awsuiSystem core
+ */
+ virtualScroll?: boolean | TableProps.VirtualScrollConfig;
+
/**
* Specifies a property that uniquely identifies an individual item.
* When it's set, it's used to provide [keys for React](https://reactjs.org/docs/lists-and-keys.html#keys)
@@ -709,6 +724,19 @@ export namespace TableProps {
export interface SkeletonConfig {
totalRows: number;
}
+
+ export interface VirtualScrollConfig {
+ /**
+ * Estimated height of a single row in pixels. Used to size the virtual window.
+ * Defaults to `40`. v0 assumes a uniform row height.
+ */
+ rowHeight?: number;
+ /**
+ * Number of additional rows rendered above and below the viewport to reduce
+ * blank areas while scrolling. Defaults to `5`.
+ */
+ overscan?: number;
+ }
}
export type TableRow = TableDataRow | TableLoaderRow;
diff --git a/src/table/internal.tsx b/src/table/internal.tsx
index d58a59f1f9..194f496d4c 100644
--- a/src/table/internal.tsx
+++ b/src/table/internal.tsx
@@ -68,6 +68,7 @@ import { ColumnWidthDefinition, ColumnWidthsProvider, DEFAULT_COLUMN_WIDTH } fro
import { usePreventStickyClickScroll } from './use-prevent-sticky-click-scroll';
import { useRowEvents } from './use-row-events';
import useTableFocusNavigation from './use-table-focus-navigation';
+import { useVirtualScroll } from './use-virtual-scroll';
import { checkSortingState, getColumnKey, getItemKey, getVisibleColumnDefinitions, toContainerVariant } from './utils';
import buttonStyles from '../button/styles.css.js';
@@ -152,6 +153,7 @@ const InternalTable = React.forwardRef(
renderLoaderEmpty,
renderLoaderCounter,
cellVerticalAlign,
+ virtualScroll,
__funnelSubStepProps,
...rest
}: InternalTableProps,
@@ -296,6 +298,21 @@ const InternalTable = React.forwardRef(
const wrapperRefObject = useRef(null);
const handleScroll = useScrollSync([wrapperRefObject, scrollbarRef, secondaryWrapperRef]);
+ // Experimental opt-in windowed (virtual) scrolling. Additive: when disabled the hook is a
+ // no-op and every row is rendered as before.
+ const virtualScrollConfig = virtualScroll === true ? {} : virtualScroll || undefined;
+ const virtualScrollState = useVirtualScroll({
+ enabled: !!virtualScroll,
+ itemCount: allRows.length,
+ containerRef: wrapperRefObject,
+ rowHeight: virtualScrollConfig?.rowHeight,
+ overscan: virtualScrollConfig?.overscan,
+ });
+ const renderStartIndex = virtualScrollState.enabled ? virtualScrollState.startIndex : 0;
+ const renderedRows = virtualScrollState.enabled
+ ? allRows.slice(virtualScrollState.startIndex, virtualScrollState.endIndex)
+ : allRows;
+
const { moveFocusDown, moveFocusUp, moveFocus } = useSelectionFocusMove(selectionType, allItems.length);
const { onRowClickHandler, onRowContextMenuHandler } = useRowEvents({ onRowClick, onRowContextMenu });
@@ -638,189 +655,210 @@ const InternalTable = React.forwardRef(
/>
) : (
- allRows.map((row, rowIndex) => {
- const isFirstRow = rowIndex === 0;
- const hasSkeletonBelow =
- loading && skeleton && allItems.length > 0 && skeleton.totalRows - allItems.length > 0;
- const isLastDataRow = rowIndex === allRows.length - 1;
- const isLastRow = isLastDataRow && !hasSkeletonBelow;
- const rowExpandableProps =
- row.type === 'data' ? expandableRows.getExpandableItemProps(row.item) : undefined;
- const rowRoleProps = getTableRowRoleProps({
- tableRole,
- firstIndex,
- rowIndex,
- headerRowCount,
- level: row.type === 'loader' ? row.level : undefined,
- ...rowExpandableProps,
- });
- const getTableItemKey = (item: T) => getItemKey(trackBy, item, rowIndex);
- const sharedCellProps = {
- isFirstRow,
- isLastRow,
- isSelected: hasSelection && isRowSelected(row),
- isPrevSelected: hasSelection && !isFirstRow && isRowSelected(allRows[rowIndex - 1]),
- isNextSelected: hasSelection && !isLastDataRow && isRowSelected(allRows[rowIndex + 1]),
- isEvenRow: rowIndex % 2 === 0,
- stripedRows,
- hasSelection,
- hasFooter,
- stickyState,
- tableRole,
- };
- if (row.type === 'data') {
- const rowId = `${getTableItemKey(row.item)}`;
- return (
-
{
- // When an element inside table row receives focus we want to adjust the scroll.
- // However, that behavior is unwanted when the focus is received as result of a click
- // as it causes the click to never reach the target element.
- if (!currentTarget.contains(getMouseDownTarget())) {
- stickyHeaderRef.current?.scrollToRow(currentTarget);
+ <>
+ {virtualScrollState.enabled && virtualScrollState.topPadding > 0 && (
+
+ |
+
+ )}
+ {renderedRows.map((row, localRowIndex) => {
+ const rowIndex = renderStartIndex + localRowIndex;
+ const isFirstRow = rowIndex === 0;
+ const hasSkeletonBelow =
+ loading && skeleton && allItems.length > 0 && skeleton.totalRows - allItems.length > 0;
+ const isLastDataRow = rowIndex === allRows.length - 1;
+ const isLastRow = isLastDataRow && !hasSkeletonBelow;
+ const rowExpandableProps =
+ row.type === 'data' ? expandableRows.getExpandableItemProps(row.item) : undefined;
+ const rowRoleProps = getTableRowRoleProps({
+ tableRole,
+ firstIndex,
+ rowIndex,
+ headerRowCount,
+ level: row.type === 'loader' ? row.level : undefined,
+ ...rowExpandableProps,
+ });
+ const getTableItemKey = (item: T) => getItemKey(trackBy, item, rowIndex);
+ const sharedCellProps = {
+ isFirstRow,
+ isLastRow,
+ isSelected: hasSelection && isRowSelected(row),
+ isPrevSelected: hasSelection && !isFirstRow && isRowSelected(allRows[rowIndex - 1]),
+ isNextSelected: hasSelection && !isLastDataRow && isRowSelected(allRows[rowIndex + 1]),
+ isEvenRow: rowIndex % 2 === 0,
+ stripedRows,
+ hasSelection,
+ hasFooter,
+ stickyState,
+ tableRole,
+ };
+ if (row.type === 'data') {
+ const rowId = `${getTableItemKey(row.item)}`;
+ return (
+ {
+ // When an element inside table row receives focus we want to adjust the scroll.
+ // However, that behavior is unwanted when the focus is received as result of a click
+ // as it causes the click to never reach the target element.
+ if (!currentTarget.contains(getMouseDownTarget())) {
+ stickyHeaderRef.current?.scrollToRow(currentTarget);
+ }
+ }}
+ {...focusMarkers.item}
+ onClick={onRowClickHandler && onRowClickHandler.bind(null, rowIndex, row.item)}
+ onContextMenu={
+ onRowContextMenuHandler && onRowContextMenuHandler.bind(null, rowIndex, row.item)
}
- }}
- {...focusMarkers.item}
- onClick={onRowClickHandler && onRowClickHandler.bind(null, rowIndex, row.item)}
- onContextMenu={
- onRowContextMenuHandler && onRowContextMenuHandler.bind(null, rowIndex, row.item)
- }
- {...rowRoleProps}
- >
- {selection.getItemSelectionProps && (
-
- )}
-
- {visibleColumnDefinitions.map((column, colIndex) => {
- const colId = `${getColumnKey(column, colIndex)}`;
- const cellId = { row: rowId, col: colId };
- const isEditing = cellEditing.checkEditing(cellId);
- const successfulEdit = cellEditing.checkLastSuccessfulEdit(cellId);
- const isEditable = !!column.editConfig && !cellEditing.isLoading;
- const cellExpandableProps =
- isExpandable && colIndex === 0 ? rowExpandableProps : undefined;
- const counter = column.counter?.({
- item: row.item,
- itemsCount: rowExpandableProps?.itemsCount,
- selectedItemsCount: rowExpandableProps?.selectedItemsCount,
- });
-
- const analyticsMetadata: GeneratedAnalyticsMetadataFragment = {
- component: {
- innerContext: {
- position: `${rowIndex + 1},${colIndex + 1}`,
- columnId: column.id ? `${column.id}` : '',
- columnLabel: {
- selector: `table thead tr th:nth-child(${colIndex + (selectionType ? 2 : 1)})`,
- root: 'component',
- },
- item: rowId,
- } as GeneratedAnalyticsMetadataTableComponent['innerContext'],
- },
- };
-
- return (
-
+ {selection.getItemSelectionProps && (
+ cellEditing.startEdit(cellId)}
- onEditEnd={editCancelled => cellEditing.completeEdit(cellId, editCancelled)}
- submitEdit={cellEditing.submitEdit}
- columnId={column.id ?? colIndex}
- colIndex={colIndex + colIndexOffset}
- verticalAlign={column.verticalAlign ?? cellVerticalAlign}
+ verticalAlign={cellVerticalAlign}
+ tableVariant={computedVariant}
+ />
+ )}
+
+ {visibleColumnDefinitions.map((column, colIndex) => {
+ const colId = `${getColumnKey(column, colIndex)}`;
+ const cellId = { row: rowId, col: colId };
+ const isEditing = cellEditing.checkEditing(cellId);
+ const successfulEdit = cellEditing.checkLastSuccessfulEdit(cellId);
+ const isEditable = !!column.editConfig && !cellEditing.isLoading;
+ const cellExpandableProps =
+ isExpandable && colIndex === 0 ? rowExpandableProps : undefined;
+ const counter = column.counter?.({
+ item: row.item,
+ itemsCount: rowExpandableProps?.itemsCount,
+ selectedItemsCount: rowExpandableProps?.selectedItemsCount,
+ });
+
+ const analyticsMetadata: GeneratedAnalyticsMetadataFragment = {
+ component: {
+ innerContext: {
+ position: `${rowIndex + 1},${colIndex + 1}`,
+ columnId: column.id ? `${column.id}` : '',
+ columnLabel: {
+ selector: `table thead tr th:nth-child(${colIndex + (selectionType ? 2 : 1)})`,
+ root: 'component',
+ },
+ item: rowId,
+ } as GeneratedAnalyticsMetadataTableComponent['innerContext'],
+ },
+ };
+
+ return (
+ cellEditing.startEdit(cellId)}
+ onEditEnd={editCancelled => cellEditing.completeEdit(cellId, editCancelled)}
+ submitEdit={cellEditing.submitEdit}
+ columnId={column.id ?? colIndex}
+ colIndex={colIndex + colIndexOffset}
+ verticalAlign={column.verticalAlign ?? cellVerticalAlign}
+ tableVariant={computedVariant}
+ counter={counter}
+ {...cellExpandableProps}
+ {...getAnalyticsMetadataAttribute(analyticsMetadata)}
+ />
+ );
+ })}
+
+ );
+ }
+ const loaderSelectionProps =
+ selection.getLoaderSelectionProps && selection.getLoaderSelectionProps(row.item);
+ const rowSelection = selectionType === 'group' ? loaderSelectionProps : undefined;
+ const loaderContent = getLoaderContent({
+ item: row.item,
+ loadingStatus: row.status,
+ renderLoaderPending,
+ renderLoaderLoading,
+ renderLoaderError,
+ renderLoaderEmpty,
+ });
+ const loaderCounter = renderLoaderCounter?.({
+ item: row.item,
+ loadingStatus: row.status,
+ selected: !!rowSelection?.checked,
+ });
+ return (
+ loaderContent && (
+
+ {selectionType ? (
+
- );
- })}
-
+ ) : null}
+ {visibleColumnDefinitions.map((column, colIndex) => (
+
+ {loaderContent}
+
+ ))}
+
+ )
);
- }
- const loaderSelectionProps =
- selection.getLoaderSelectionProps && selection.getLoaderSelectionProps(row.item);
- const rowSelection = selectionType === 'group' ? loaderSelectionProps : undefined;
- const loaderContent = getLoaderContent({
- item: row.item,
- loadingStatus: row.status,
- renderLoaderPending,
- renderLoaderLoading,
- renderLoaderError,
- renderLoaderEmpty,
- });
- const loaderCounter = renderLoaderCounter?.({
- item: row.item,
- loadingStatus: row.status,
- selected: !!rowSelection?.checked,
- });
- return (
- loaderContent && (
-
- {selectionType ? (
-
- ) : null}
- {visibleColumnDefinitions.map((column, colIndex) => (
-
- {loaderContent}
-
- ))}
-
- )
- );
- })
+ })}
+ {virtualScrollState.enabled && virtualScrollState.bottomPadding > 0 && (
+
+ |
+
+ )}
+ >
)}
{loading && skeleton && allItems.length > 0 && skeleton.totalRows - allItems.length > 0 && (
;
+ /** Estimated per-row height in px. v0 assumes a uniform row height. */
+ rowHeight?: number;
+ /** Extra rows rendered outside the viewport on each side. */
+ overscan?: number;
+}
+
+export interface VirtualScrollResult {
+ /** Mirrors the `enabled` input; convenient for guarding render logic. */
+ enabled: boolean;
+ /** Index of the first rendered row (inclusive). */
+ startIndex: number;
+ /** Index one past the last rendered row (exclusive). */
+ endIndex: number;
+ /** Height (px) of the spacer rendered before the first rendered row. */
+ topPadding: number;
+ /** Height (px) of the spacer rendered after the last rendered row. */
+ bottomPadding: number;
+ /** Total virtual height (px) of all rows. */
+ totalSize: number;
+}
+
+interface Viewport {
+ scrollTop: number;
+ clientHeight: number;
+}
+
+/**
+ * Computes the slice of rows that need to be rendered for a windowed (virtual)
+ * table body, based on the current scroll offset and viewport height of the
+ * scroll container.
+ *
+ * This is a first-cut (v0) implementation that assumes a uniform `rowHeight`.
+ * Variable-height rows, expandable-row measurement, and window-level scrolling
+ * are intentionally out of scope and tracked as follow-ups.
+ */
+export function useVirtualScroll({
+ enabled,
+ itemCount,
+ containerRef,
+ rowHeight = DEFAULT_VIRTUAL_ROW_HEIGHT,
+ overscan = DEFAULT_VIRTUAL_OVERSCAN,
+}: UseVirtualScrollProps): VirtualScrollResult {
+ const [viewport, setViewport] = useState({ scrollTop: 0, clientHeight: 0 });
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!enabled || !container) {
+ return;
+ }
+
+ const measure = () => {
+ setViewport({ scrollTop: container.scrollTop, clientHeight: container.clientHeight });
+ };
+
+ // Measure once on mount and whenever inputs that affect geometry change.
+ measure();
+ container.addEventListener('scroll', measure, { passive: true });
+ return () => {
+ container.removeEventListener('scroll', measure);
+ };
+ }, [enabled, containerRef, itemCount, rowHeight]);
+
+ return useMemo(() => {
+ const safeRowHeight = rowHeight > 0 ? rowHeight : DEFAULT_VIRTUAL_ROW_HEIGHT;
+ const totalSize = itemCount * safeRowHeight;
+
+ if (!enabled || itemCount === 0) {
+ return {
+ enabled,
+ startIndex: 0,
+ endIndex: itemCount,
+ topPadding: 0,
+ bottomPadding: 0,
+ totalSize,
+ };
+ }
+
+ const visibleCount = Math.max(1, Math.ceil(viewport.clientHeight / safeRowHeight));
+ const rawStart = Math.floor(viewport.scrollTop / safeRowHeight) - overscan;
+ const startIndex = Math.max(0, Math.min(rawStart, Math.max(0, itemCount - 1)));
+ const endIndex = Math.min(itemCount, startIndex + visibleCount + overscan * 2);
+
+ return {
+ enabled,
+ startIndex,
+ endIndex,
+ topPadding: startIndex * safeRowHeight,
+ bottomPadding: Math.max(0, (itemCount - endIndex) * safeRowHeight),
+ totalSize,
+ };
+ }, [enabled, itemCount, rowHeight, overscan, viewport.scrollTop, viewport.clientHeight]);
+}