Skip to content
Draft
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
72 changes: 72 additions & 0 deletions pages/table/virtual-scroll.page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box padding="l">
<SpaceBetween size="m">
<Header variant="h1" description="Renders only the rows near the viewport for very large datasets.">
Table virtual scrolling (v0, experimental)
</Header>

<Checkbox checked={enabled} onChange={({ detail }) => setEnabled(detail.checked)}>
Enable virtualScroll
</Checkbox>

<ScreenshotArea>
<div style={{ blockSize: 400, overflow: 'auto' }} data-testid="virtual-scroll-container">
<Table
items={items}
columnDefinitions={columnDefinitions}
trackBy="id"
variant="embedded"
virtualScroll={enabled ? { rowHeight: 40, overscan: 5 } : undefined}
ariaLabels={{ tableLabel: 'Virtual scroll table' }}
/>
</div>
</ScreenshotArea>

<Box variant="small">Total items: {items.length}</Box>
</SpaceBetween>
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
90 changes: 90 additions & 0 deletions src/table/__tests__/use-virtual-scroll.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>;
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<HTMLElement>;
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<HTMLElement>;
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<HTMLElement>;
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<HTMLElement>;
const { result } = renderHook(() =>
useVirtualScroll({ enabled: true, itemCount: 100, containerRef: ref, rowHeight: 0 })
);

expect(result.current.totalSize).toBe(100 * DEFAULT_VIRTUAL_ROW_HEIGHT);
});
});
55 changes: 55 additions & 0 deletions src/table/__tests__/virtual-scroll.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Item>[] = [
{ 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<TableProps<Item>>) {
const { container } = render(
<Table items={generateItems(500)} columnDefinitions={columnDefinitions} trackBy="id" {...props} />
);
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);
});
});
28 changes: 28 additions & 0 deletions src/table/interfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ export interface TableProps<T = any> 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)
Expand Down Expand Up @@ -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<T> = TableDataRow<T> | TableLoaderRow<T>;
Expand Down
Loading
Loading