From 670f11cf7d0ee0f8e37f82117736d74210da3227 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 21 Jul 2026 14:11:12 +0000 Subject: [PATCH 01/11] feat(table): add automatic skeleton rows --- pages/table/auto-skeleton-rows.page.tsx | 34 +++++ pages/table/skeleton-rows.page.tsx | 17 ++- src/table/__integ__/skeleton.test.ts | 31 ++++ src/table/__tests__/skeleton.test.tsx | 47 +++++++ .../__tests__/table-feature-metrics.test.tsx | 34 +++++ src/table/index.tsx | 4 +- src/table/interfaces.tsx | 6 +- src/table/internal.tsx | 25 +++- src/table/use-auto-skeleton-rows.ts | 132 ++++++++++++++++++ 9 files changed, 318 insertions(+), 12 deletions(-) create mode 100644 pages/table/auto-skeleton-rows.page.tsx create mode 100644 src/table/__integ__/skeleton.test.ts create mode 100644 src/table/use-auto-skeleton-rows.ts diff --git a/pages/table/auto-skeleton-rows.page.tsx b/pages/table/auto-skeleton-rows.page.tsx new file mode 100644 index 0000000000..f2ae0f3b32 --- /dev/null +++ b/pages/table/auto-skeleton-rows.page.tsx @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Header from '~components/header'; +import Table, { TableProps } from '~components/table'; + +interface Item { + id: string; + name: string; +} + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: item => item.id }, + { id: 'name', header: 'Name', cell: item => item.name }, +]; + +export default function AutoSkeletonRowsPage() { + return ( +
+
+
+ Items} + /> + + + ); +} diff --git a/pages/table/skeleton-rows.page.tsx b/pages/table/skeleton-rows.page.tsx index cc5cfb9b2b..e0ec5783a6 100644 --- a/pages/table/skeleton-rows.page.tsx +++ b/pages/table/skeleton-rows.page.tsx @@ -36,13 +36,14 @@ type SelectionMode = 'none' | 'single' | 'multi'; export default function TableSkeletonRowsPage() { const { urlParams, setUrlParams } = useAppContext< - 'loadingState' | 'skeletonRows' | 'dataRows' | 'stripedRows' | 'selectionMode' + 'loadingState' | 'skeletonRows' | 'dataRows' | 'stripedRows' | 'selectionMode' | 'autoSkeletonRows' >(); const loadingState = (urlParams.loadingState || 'skeleton') as LoadingState; const skeletonRowsCount = String(urlParams.skeletonRows || '5'); const dataRowsCount = String(urlParams.dataRows || '10'); const stripedRows = urlParams.stripedRows !== 'false' && urlParams.stripedRows !== false; + const autoSkeletonRows = urlParams.autoSkeletonRows === true || urlParams.autoSkeletonRows === 'true'; const selectionMode = (urlParams.selectionMode || 'multi') as SelectionMode; const [selectedItems, setSelectedItems] = useState([]); @@ -135,6 +136,12 @@ export default function TableSkeletonRowsPage() { > Striped rows + setUrlParams({ autoSkeletonRows: detail.checked })} + > + Fill the viewport automatically + @@ -144,7 +151,13 @@ export default function TableSkeletonRowsPage() { columnDefinitions={columnDefinitions} items={items} enableKeyboardNavigation={true} - skeleton={loadingState === 'skeleton' ? { totalRows: skeletonRows } : undefined} + skeleton={ + loadingState === 'skeleton' + ? autoSkeletonRows + ? { totalRows: 'auto', maxAutoRows: 10 } + : { totalRows: skeletonRows } + : undefined + } loading={loadingState !== 'data'} loadingText="Loading items..." empty="No items to display" diff --git a/src/table/__integ__/skeleton.test.ts b/src/table/__integ__/skeleton.test.ts new file mode 100644 index 0000000000..6f98bf2f3d --- /dev/null +++ b/src/table/__integ__/skeleton.test.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import type { Browser } from 'webdriverio'; + +import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +const scrollContainerSelector = '#auto-skeleton-scroll-container'; +const innerScrollContainerSelector = '#auto-skeleton-inner-scroll-container'; +const skeletonRowSelector = `${innerScrollContainerSelector} tr[aria-hidden="true"]`; + +async function expectNoVerticalOverflow(browser: Browser, selector: string) { + await expect( + browser.execute(scrollContainerSelector => { + const scrollContainer = document.querySelector(scrollContainerSelector)!; + return scrollContainer.scrollHeight - scrollContainer.clientHeight; + }, selector) + ).resolves.toBeLessThanOrEqual(0); +} + +test( + 'fits automatic skeleton rows inside nested scroll viewports', + useBrowser(async browser => { + const page = new BasePageObject(browser); + await browser.url('#/light/table/auto-skeleton-rows'); + await page.waitForVisible(skeletonRowSelector); + + await expectNoVerticalOverflow(browser, scrollContainerSelector); + await expectNoVerticalOverflow(browser, innerScrollContainerSelector); + }) +); diff --git a/src/table/__tests__/skeleton.test.tsx b/src/table/__tests__/skeleton.test.tsx index 089207d6bf..e9d3eb6322 100644 --- a/src/table/__tests__/skeleton.test.tsx +++ b/src/table/__tests__/skeleton.test.tsx @@ -95,4 +95,51 @@ describe('Table skeleton loading', () => { expect(wrapper.getElement().textContent).toContain('Loading more'); }); }); + + describe('automatic rows', () => { + const originalClientHeight = document.documentElement.clientHeight; + + beforeEach(() => { + Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 400 }); + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.matches('tr[aria-hidden="true"]')) { + return { top: 200, bottom: 240, height: 40 } as DOMRect; + } + if (this.querySelector('tbody')) { + return { top: 0, bottom: 300, height: 300 } as DOMRect; + } + return { top: 0, bottom: 0, height: 0 } as DOMRect; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + Object.defineProperty(document.documentElement, 'clientHeight', { + configurable: true, + value: originalClientHeight, + }); + }); + + test('fills the viewport automatically', () => { + const wrapper = renderTable({ items: [], loading: true, skeleton: { totalRows: 'auto' } }); + + expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(3); + }); + + test('respects maxAutoRows', () => { + const wrapper = renderTable({ items: [], loading: true, skeleton: { totalRows: 'auto', maxAutoRows: 2 } }); + + expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(2); + }); + + test('adds automatic skeleton rows after partial data', () => { + const wrapper = renderTable({ + items: defaultItems, + loading: true, + skeleton: { totalRows: 'auto', maxAutoRows: 2 }, + }); + + expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(2); + }); + }); }); diff --git a/src/table/__tests__/table-feature-metrics.test.tsx b/src/table/__tests__/table-feature-metrics.test.tsx index 07d09a07f5..88f4dabb8b 100644 --- a/src/table/__tests__/table-feature-metrics.test.tsx +++ b/src/table/__tests__/table-feature-metrics.test.tsx @@ -44,3 +44,37 @@ test('reports cellVerticalAlign and columnDefinitionsVerticalAlign correctly', ( expect.anything() ); }); +test('reports automatic skeleton configuration', () => { + render(
); + + expect(useBaseComponentSpy).toHaveBeenCalledWith( + 'Table', + { + props: expect.anything(), + metadata: expect.objectContaining({ + hasSkeleton: true, + skeletonAutoRows: true, + skeletonMaxAutoRows: 10, + skeletonTotalRows: null, + }), + }, + expect.anything() + ); +}); + +test('does not report an automatic row cap for fixed skeleton rows', () => { + render(
); + + expect(useBaseComponentSpy).toHaveBeenCalledWith( + 'Table', + { + props: expect.anything(), + metadata: expect.objectContaining({ + skeletonAutoRows: false, + skeletonMaxAutoRows: null, + skeletonTotalRows: 5, + }), + }, + expect.anything() + ); +}); diff --git a/src/table/index.tsx b/src/table/index.tsx index 28a1fd60ed..2aedb63f82 100644 --- a/src/table/index.tsx +++ b/src/table/index.tsx @@ -55,7 +55,9 @@ const Table = React.forwardRef( expandableRows: !!props.expandableRows, progressiveLoading: !!props.getLoadingStatus, hasSkeleton: !!props.skeleton, - skeletonTotalRows: props.skeleton?.totalRows ?? null, + skeletonTotalRows: typeof props.skeleton?.totalRows === 'number' ? props.skeleton.totalRows : null, + skeletonAutoRows: props.skeleton?.totalRows === 'auto', + skeletonMaxAutoRows: props.skeleton?.totalRows === 'auto' ? (props.skeleton.maxAutoRows ?? null) : null, groupSelection: !!props.expandableRows?.groupSelection, columnGroups: !!props.groupDefinitions?.length, columnGroupsDepth: getColumnGroupsDepth(props.columnDisplay), diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index e00ef8fc17..191e4bf932 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -67,6 +67,8 @@ export interface TableProps extends BaseComponentProps { * - `totalRows` (number) - The total number of rows that should be rendered. If `items` * are also provided, those items will be rendered first, and `totalRows - items.length` * additional skeleton rows rendered after. + * - `totalRows` ('auto') - The number of skeleton rows is calculated from the available viewport height. + * - `maxAutoRows` (number) - Limits the number of rows rendered when `totalRows` is set to `'auto'`. */ skeleton?: TableProps.SkeletonConfig; @@ -706,9 +708,7 @@ export namespace TableProps { item: T; } - export interface SkeletonConfig { - totalRows: number; - } + export type SkeletonConfig = { totalRows: number; maxAutoRows?: never } | { totalRows: 'auto'; maxAutoRows?: number }; } export type TableRow = TableDataRow | TableLoaderRow; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index d58a59f1f9..27d057795b 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -63,6 +63,7 @@ import { } from './table-role'; import Thead, { TheadProps } from './thead'; import ToolsHeader from './tools-header'; +import { useAutoSkeletonRows } from './use-auto-skeleton-rows'; import { useCellEditing } from './use-cell-editing'; import { ColumnWidthDefinition, ColumnWidthsProvider, DEFAULT_COLUMN_WIDTH } from './use-column-widths'; import { usePreventStickyClickScroll } from './use-prevent-sticky-click-scroll'; @@ -293,6 +294,8 @@ const InternalTable = React.forwardRef( [cancelEdit] ); + const tableRootRefObject = useRef(null); + const tableBodyRef = useRef(null); const wrapperRefObject = useRef(null); const handleScroll = useScrollSync([wrapperRefObject, scrollbarRef, secondaryWrapperRef]); @@ -446,8 +449,18 @@ const InternalTable = React.forwardRef( usePreventStickyClickScroll(wrapperRefObject); + const tableRootRef = useMergeRefs(__internalRootRef, tableRootRefObject); const wrapperRef = useMergeRefs(wrapperRefObject, stickyState.refs.wrapper); const tableRef = useMergeRefs(tableMeasureRef, tableRefObject, stickyState.refs.table); + const autoSkeletonRows = useAutoSkeletonRows({ + enabled: !!loading && skeleton?.totalRows === 'auto', + maxRows: skeleton?.maxAutoRows, + tableBodyRef, + tableRootRef: tableRootRefObject, + tableWrapperRef: wrapperRefObject, + }); + const skeletonRowsCount = + skeleton?.totalRows === 'auto' ? allItems.length + autoSkeletonRows : (skeleton?.totalRows ?? 0); const wrapperProps = getTableWrapperRoleProps({ tableRole, @@ -486,7 +499,7 @@ const InternalTable = React.forwardRef( stickyHeaderRef.current?.setFocus(focusId)} {...theadProps} /> - + {skeleton && allItems.length === 0 && loading ? ( { const isFirstRow = rowIndex === 0; const hasSkeletonBelow = - loading && skeleton && allItems.length > 0 && skeleton.totalRows - allItems.length > 0; + loading && skeleton && allItems.length > 0 && skeletonRowsCount - allItems.length > 0; const isLastDataRow = rowIndex === allRows.length - 1; const isLastRow = isLastDataRow && !hasSkeletonBelow; const rowExpandableProps = @@ -822,9 +835,9 @@ const InternalTable = React.forwardRef( ); }) )} - {loading && skeleton && allItems.length > 0 && skeleton.totalRows - allItems.length > 0 && ( + {loading && skeleton && allItems.length > 0 && skeletonRowsCount - allItems.length > 0 && ( ; + tableRootRef: React.RefObject; + tableWrapperRef: React.RefObject; +} + +function getDocumentViewportBottom() { + return document.documentElement.clientHeight || window.innerHeight; +} + +function getScrollContainers(element: HTMLElement) { + const scrollContainers: HTMLElement[] = []; + let { parentElement: ancestor } = element; + + while (ancestor) { + const { overflowY } = window.getComputedStyle(ancestor); + if (overflowY === 'auto' || overflowY === 'scroll') { + scrollContainers.push(ancestor); + } + ({ parentElement: ancestor } = ancestor); + } + + return scrollContainers; +} + +function getViewportBottom(element: HTMLElement) { + return getScrollContainers(element).reduce( + (viewportBottom, scrollContainer) => Math.min(viewportBottom, scrollContainer.getBoundingClientRect().bottom), + getDocumentViewportBottom() + ); +} + +export function calculateAutoSkeletonRows({ + maxRows, + rowHeight, + skeletonRowBottom, + skeletonRowTop, + tableBottom, + viewportBottom, +}: AutoSkeletonRowsCalculation) { + const staticHeightBelowSkeletonRow = Math.max(0, tableBottom - skeletonRowBottom); + const availableHeight = Math.max( + 0, + viewportBottom - skeletonRowTop - staticHeightBelowSkeletonRow - AUTO_SKELETON_VIEWPORT_BUFFER + ); + const rows = Math.floor(availableHeight / rowHeight); + return Math.max(1, Math.min(maxRows ?? Number.POSITIVE_INFINITY, rows)); +} + +export function useAutoSkeletonRows({ + enabled, + maxRows, + tableBodyRef, + tableRootRef, + tableWrapperRef, +}: UseAutoSkeletonRowsProps) { + const [rows, setRows] = useState(1); + const rowHeightRef = useRef(); + + const updateRows = useCallback(() => { + if (!enabled) { + return; + } + + const tableRoot = tableRootRef.current; + const tableWrapper = tableWrapperRef.current; + const skeletonRows = tableBodyRef.current?.querySelectorAll(SKELETON_ROW_SELECTOR); + const firstSkeletonRowRect = skeletonRows?.[0]?.getBoundingClientRect(); + const lastSkeletonRowRect = skeletonRows?.[skeletonRows.length - 1]?.getBoundingClientRect(); + const rowHeight = lastSkeletonRowRect?.height ?? rowHeightRef.current; + + if (!tableRoot || !tableWrapper || !firstSkeletonRowRect || !lastSkeletonRowRect || !rowHeight) { + return; + } + + rowHeightRef.current = rowHeight; + const nextRows = calculateAutoSkeletonRows({ + maxRows, + rowHeight, + skeletonRowBottom: lastSkeletonRowRect.bottom, + skeletonRowTop: firstSkeletonRowRect.top, + tableBottom: tableRoot.getBoundingClientRect().bottom, + viewportBottom: getViewportBottom(tableWrapper), + }); + setRows(currentRows => (currentRows === nextRows ? currentRows : nextRows)); + }, [enabled, maxRows, tableBodyRef, tableRootRef, tableWrapperRef]); + + useLayoutEffect(updateRows, [updateRows]); + + useLayoutEffect(() => { + if (!enabled || typeof ResizeObserver === 'undefined') { + return undefined; + } + + const tableRoot = tableRootRef.current; + const tableWrapper = tableWrapperRef.current; + if (!tableRoot || !tableWrapper) { + return undefined; + } + + const resizeObserver = new ResizeObserver(updateRows); + resizeObserver.observe(tableRoot); + resizeObserver.observe(tableWrapper); + getScrollContainers(tableWrapper).forEach(scrollContainer => resizeObserver.observe(scrollContainer)); + window.addEventListener('resize', updateRows); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', updateRows); + }; + }, [enabled, tableRootRef, tableWrapperRef, updateRows]); + + return rows; +} From 7fcc3cb2a5ac72b4505ca3f85c7f9bbafa948d52 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Wed, 22 Jul 2026 08:22:23 +0000 Subject: [PATCH 02/11] fix(table): prevent auto skeleton overflow --- src/table/__tests__/skeleton.test.tsx | 32 +++++++++++++++++++ src/table/use-auto-skeleton-rows.ts | 45 +++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/table/__tests__/skeleton.test.tsx b/src/table/__tests__/skeleton.test.tsx index e9d3eb6322..6a4d841b3b 100644 --- a/src/table/__tests__/skeleton.test.tsx +++ b/src/table/__tests__/skeleton.test.tsx @@ -102,6 +102,9 @@ describe('Table skeleton loading', () => { beforeEach(() => { Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 400 }); jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this === document.body) { + return { top: 0, bottom: 400, height: 400 } as DOMRect; + } if (this.matches('tr[aria-hidden="true"]')) { return { top: 200, bottom: 240, height: 40 } as DOMRect; } @@ -126,6 +129,35 @@ describe('Table skeleton loading', () => { expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(3); }); + test('removes rows that overflow the viewport', () => { + const originalOverflowY = document.body.style.overflowY; + const originalClientHeight = Object.getOwnPropertyDescriptor(document.body, 'clientHeight'); + const originalScrollHeight = Object.getOwnPropertyDescriptor(document.body, 'scrollHeight'); + document.body.style.overflowY = 'auto'; + Object.defineProperty(document.body, 'clientHeight', { configurable: true, value: 400 }); + Object.defineProperty(document.body, 'scrollHeight', { + configurable: true, + get: () => (document.querySelectorAll('tr[aria-hidden="true"]').length > 2 ? 401 : 400), + }); + + try { + const wrapper = renderTable({ items: [], loading: true, skeleton: { totalRows: 'auto' } }); + expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(2); + } finally { + document.body.style.overflowY = originalOverflowY; + if (originalClientHeight) { + Object.defineProperty(document.body, 'clientHeight', originalClientHeight); + } else { + delete (document.body as { clientHeight?: number }).clientHeight; + } + if (originalScrollHeight) { + Object.defineProperty(document.body, 'scrollHeight', originalScrollHeight); + } else { + delete (document.body as { scrollHeight?: number }).scrollHeight; + } + } + }); + test('respects maxAutoRows', () => { const wrapper = renderTable({ items: [], loading: true, skeleton: { totalRows: 'auto', maxAutoRows: 2 } }); diff --git a/src/table/use-auto-skeleton-rows.ts b/src/table/use-auto-skeleton-rows.ts index 165dc4632c..461cf9282f 100644 --- a/src/table/use-auto-skeleton-rows.ts +++ b/src/table/use-auto-skeleton-rows.ts @@ -14,6 +14,12 @@ interface AutoSkeletonRowsCalculation { viewportBottom: number; } +interface AutoSkeletonRowReductionCalculation { + currentRows: number; + overflowHeight: number; + rowHeight: number; +} + interface UseAutoSkeletonRowsProps { enabled: boolean; maxRows?: number; @@ -48,6 +54,18 @@ function getViewportBottom(element: HTMLElement) { ); } +function getOverflowHeight(element: HTMLElement) { + const scrollContainer = getScrollContainers(element)[0]; + if (scrollContainer) { + return Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight); + } + + return Math.max( + 0, + Math.max(document.documentElement.scrollHeight, document.body.scrollHeight) - getDocumentViewportBottom() + ); +} + export function calculateAutoSkeletonRows({ maxRows, rowHeight, @@ -65,6 +83,17 @@ export function calculateAutoSkeletonRows({ return Math.max(1, Math.min(maxRows ?? Number.POSITIVE_INFINITY, rows)); } +export function calculateAutoSkeletonRowReduction({ + currentRows, + overflowHeight, + rowHeight, +}: AutoSkeletonRowReductionCalculation) { + return Math.min( + currentRows - 1, + Math.max(1, Math.ceil((overflowHeight + AUTO_SKELETON_VIEWPORT_BUFFER) / rowHeight)) + ); +} + export function useAutoSkeletonRows({ enabled, maxRows, @@ -105,6 +134,22 @@ export function useAutoSkeletonRows({ useLayoutEffect(updateRows, [updateRows]); + useLayoutEffect(() => { + const tableWrapper = tableWrapperRef.current; + const rowHeight = rowHeightRef.current; + if (!enabled || rows === 1 || !tableWrapper || !rowHeight) { + return; + } + + const overflowHeight = getOverflowHeight(tableWrapper); + if (overflowHeight === 0) { + return; + } + + const rowsToRemove = calculateAutoSkeletonRowReduction({ currentRows: rows, overflowHeight, rowHeight }); + setRows(currentRows => currentRows - rowsToRemove); + }, [enabled, rows, tableWrapperRef]); + useLayoutEffect(() => { if (!enabled || typeof ResizeObserver === 'undefined') { return undefined; From 6978122d386467dc84ef263838290fccb207a868 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 23 Jul 2026 06:18:22 +0000 Subject: [PATCH 03/11] test(table): cover auto skeleton layout features and add dev pages --- .../auto-skeleton-table-nowrap.page.tsx | 9 + pages/app-layout/auto-skeleton-table.page.tsx | 9 + pages/app-layout/auto-skeleton-table.tsx | 83 +++++++++ pages/table/auto-skeleton-rows.page.tsx | 91 ++++++++-- src/table/__integ__/skeleton.test.ts | 157 ++++++++++++++++++ src/table/__tests__/skeleton.test.tsx | 25 +++ 6 files changed, 362 insertions(+), 12 deletions(-) create mode 100644 pages/app-layout/auto-skeleton-table-nowrap.page.tsx create mode 100644 pages/app-layout/auto-skeleton-table.page.tsx create mode 100644 pages/app-layout/auto-skeleton-table.tsx diff --git a/pages/app-layout/auto-skeleton-table-nowrap.page.tsx b/pages/app-layout/auto-skeleton-table-nowrap.page.tsx new file mode 100644 index 0000000000..081cd11f57 --- /dev/null +++ b/pages/app-layout/auto-skeleton-table-nowrap.page.tsx @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import AutoSkeletonTable from './auto-skeleton-table'; + +export default function AutoSkeletonTableNowrapPage() { + return ; +} diff --git a/pages/app-layout/auto-skeleton-table.page.tsx b/pages/app-layout/auto-skeleton-table.page.tsx new file mode 100644 index 0000000000..96cc6c7a10 --- /dev/null +++ b/pages/app-layout/auto-skeleton-table.page.tsx @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import AutoSkeletonTable from './auto-skeleton-table'; + +export default function AutoSkeletonTablePage() { + return ; +} diff --git a/pages/app-layout/auto-skeleton-table.tsx b/pages/app-layout/auto-skeleton-table.tsx new file mode 100644 index 0000000000..152cf34143 --- /dev/null +++ b/pages/app-layout/auto-skeleton-table.tsx @@ -0,0 +1,83 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import AppLayout from '~components/app-layout'; +import Button from '~components/button'; +import Header from '~components/header'; +import Table, { TableProps } from '~components/table'; + +import ScreenshotArea from '../utils/screenshot-area'; +import { Breadcrumbs, Footer, Navigation, Notifications } from './utils/content-blocks'; +import labels from './utils/labels'; + +interface Item { + id: string; + name: string; + owner: string; +} + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { + id: 'id', + header: 'Resource identifier used to locate the item in the inventory', + cell: item => item.id, + }, + { + id: 'name', + header: 'Resource name shown to customers in the management console', + cell: item => item.name, + }, + { + id: 'owner', + header: 'Owning team responsible for operating this resource', + cell: item => item.owner, + }, +]; + +interface AutoSkeletonTableProps { + wrapLines: boolean; +} + +export default function AutoSkeletonTable({ wrapLines }: AutoSkeletonTableProps) { + const scenarioId = `auto-skeleton-app-layout-${wrapLines ? 'wrap' : 'nowrap'}`; + + return ( + +
+ } + contentType="table" + footerSelector="#f" + headerSelector="#h" + navigation={} + notifications={} + content={ + + columnDefinitions={columnDefinitions} + footer={
Table footer
} + header={ +
Create resource} + description="Automatic skeleton rows fill the AppLayout content viewport." + variant="awsui-h1-sticky" + > + Resources +
+ } + items={[]} + loading={true} + loadingText="Loading resources" + skeleton={{ totalRows: 'auto' }} + stickyHeader={true} + variant="full-page" + wrapLines={wrapLines} + /> + } + /> +
+
+
+ ); +} diff --git a/pages/table/auto-skeleton-rows.page.tsx b/pages/table/auto-skeleton-rows.page.tsx index f2ae0f3b32..7f57d77f99 100644 --- a/pages/table/auto-skeleton-rows.page.tsx +++ b/pages/table/auto-skeleton-rows.page.tsx @@ -3,11 +3,13 @@ import React from 'react'; import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; import Table, { TableProps } from '~components/table'; interface Item { id: string; name: string; + owner: string; } const columnDefinitions: TableProps.ColumnDefinition[] = [ @@ -15,20 +17,85 @@ const columnDefinitions: TableProps.ColumnDefinition[] = [ { id: 'name', header: 'Name', cell: item => item.name }, ]; +const longHeaderColumnDefinitions: TableProps.ColumnDefinition[] = [ + { + id: 'id', + header: 'Resource identifier used to locate the item in the inventory', + cell: item => item.id, + }, + { + id: 'name', + header: 'Resource name shown to customers in the management console', + cell: item => item.name, + }, + { + id: 'owner', + header: 'Owning team responsible for operating this resource', + cell: item => item.owner, + }, +]; + +const partialItems: Item[] = [ + { id: '1', name: 'First resource', owner: 'Team A' }, + { id: '2', name: 'Second resource', owner: 'Team B' }, + { id: '3', name: 'Third resource', owner: 'Team C' }, +]; + +interface SkeletonScenarioProps { + footer?: boolean; + id: string; + items?: readonly Item[]; + longHeaders?: boolean; + stickyHeader?: boolean; + wrapLines?: boolean; +} + +function SkeletonScenario({ footer, id, items = [], longHeaders, stickyHeader, wrapLines }: SkeletonScenarioProps) { + return ( +
+
Footer content : undefined} + header={
Items
} + /> + + ); +} + export default function AutoSkeletonRowsPage() { return ( -
-
-
-
Items} - /> + +
+
+
+
Items} + /> + - + + + + + + + ); } diff --git a/src/table/__integ__/skeleton.test.ts b/src/table/__integ__/skeleton.test.ts index 6f98bf2f3d..35fc7a1c6e 100644 --- a/src/table/__integ__/skeleton.test.ts +++ b/src/table/__integ__/skeleton.test.ts @@ -8,6 +8,14 @@ import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; const scrollContainerSelector = '#auto-skeleton-scroll-container'; const innerScrollContainerSelector = '#auto-skeleton-inner-scroll-container'; const skeletonRowSelector = `${innerScrollContainerSelector} tr[aria-hidden="true"]`; +const featureScenarioSelectors = [ + '#auto-skeleton-sticky-header', + '#auto-skeleton-footer', + '#auto-skeleton-long-headers-nowrap', + '#auto-skeleton-long-headers-wrap', + '#auto-skeleton-all-features', + '#auto-skeleton-mixed-rows', +]; async function expectNoVerticalOverflow(browser: Browser, selector: string) { await expect( @@ -18,6 +26,31 @@ async function expectNoVerticalOverflow(browser: Browser, selector: string) { ).resolves.toBeLessThanOrEqual(0); } +function getScenarioLayout(browser: Browser, selector: string) { + return browser.execute(selector => { + const scenario = document.querySelector(selector)!; + const skeletonRows = scenario.querySelectorAll('tr[aria-hidden="true"]'); + const footer = scenario.querySelector(`[id="${scenario.id}-footer"]`); + const lastSkeletonRow = skeletonRows[skeletonRows.length - 1]; + const scenarioRect = scenario.getBoundingClientRect(); + const footerRect = footer?.getBoundingClientRect(); + const table = scenario.querySelector('table'); + + return { + footerIsVisible: + !footerRect || !lastSkeletonRow + ? true + : footerRect.top >= lastSkeletonRow.getBoundingClientRect().bottom && + footerRect.bottom <= scenarioRect.bottom, + headerTableWidth: table?.getBoundingClientRect().width, + headerText: Array.from(scenario.querySelectorAll('th')).map(header => header.textContent), + horizontalOverflow: scenario.scrollWidth - scenario.clientWidth, + skeletonRowCount: skeletonRows.length, + stickyHeaderTableCount: scenario.querySelectorAll('table').length, + }; + }, selector); +} + test( 'fits automatic skeleton rows inside nested scroll viewports', useBrowser(async browser => { @@ -29,3 +62,127 @@ test( await expectNoVerticalOverflow(browser, innerScrollContainerSelector); }) ); + +test( + 'fits automatic skeleton rows with table layout features', + useBrowser(async browser => { + const page = new BasePageObject(browser); + await page.setWindowSize({ width: 1024, height: 800 }); + await browser.url('#/light/table/auto-skeleton-rows'); + + for (const selector of featureScenarioSelectors) { + await page.waitForVisible(`${selector} tr[aria-hidden="true"]`); + await expectNoVerticalOverflow(browser, selector); + } + + const stickyHeader = await getScenarioLayout(browser, '#auto-skeleton-sticky-header'); + const footer = await getScenarioLayout(browser, '#auto-skeleton-footer'); + const longHeadersWithoutWrapping = await getScenarioLayout(browser, '#auto-skeleton-long-headers-nowrap'); + const longHeadersWithWrapping = await getScenarioLayout(browser, '#auto-skeleton-long-headers-wrap'); + const combined = await getScenarioLayout(browser, '#auto-skeleton-all-features'); + + expect(stickyHeader.skeletonRowCount).toBeGreaterThan(0); + expect(stickyHeader.stickyHeaderTableCount).toBeGreaterThan(1); + expect(footer.footerIsVisible).toBe(true); + expect(longHeadersWithoutWrapping.headerText).toContain( + 'Resource identifier used to locate the item in the inventory' + ); + expect(longHeadersWithWrapping.headerText).toContain( + 'Resource identifier used to locate the item in the inventory' + ); + expect(longHeadersWithoutWrapping.headerTableWidth).toBeGreaterThan(longHeadersWithWrapping.headerTableWidth!); + expect(combined.footerIsVisible).toBe(true); + expect(combined.stickyHeaderTableCount).toBeGreaterThan(1); + }) +); + +function getMixedRowsLayout(browser: Browser, selector: string) { + return browser.execute(selector => { + const scenario = document.querySelector(selector)!; + const bodyRows = Array.from(scenario.querySelectorAll('tbody > tr')); + const skeletonFlags = bodyRows.map(row => row.getAttribute('aria-hidden') === 'true'); + + return { + dataRowText: bodyRows + .filter((_, index) => !skeletonFlags[index]) + .map(row => row.textContent?.trim()) + .filter(Boolean), + firstSkeletonRowIndex: skeletonFlags.indexOf(true), + lastDataRowIndex: skeletonFlags.lastIndexOf(false), + skeletonRowCount: skeletonFlags.filter(Boolean).length, + }; + }, selector); +} + +test( + 'renders data rows alongside automatic skeleton rows', + useBrowser(async browser => { + const page = new BasePageObject(browser); + await page.setWindowSize({ width: 1024, height: 800 }); + await browser.url('#/light/table/auto-skeleton-rows'); + + await page.waitForVisible('#auto-skeleton-mixed-rows tr[aria-hidden="true"]'); + await expectNoVerticalOverflow(browser, '#auto-skeleton-mixed-rows'); + + const mixed = await getMixedRowsLayout(browser, '#auto-skeleton-mixed-rows'); + + // Real data rows render actual content, and automatic skeleton rows fill the rest. + expect(mixed.dataRowText).toContain('1First resource'); + expect(mixed.skeletonRowCount).toBeGreaterThan(0); + // Data rows always precede skeleton rows. + expect(mixed.firstSkeletonRowIndex).toBeGreaterThan(mixed.lastDataRowIndex); + }) +); + +function getAppLayoutScenarioLayout(browser: Browser, selector: string) { + return browser.execute(selector => { + const scenario = document.querySelector(selector)!; + const externalHeader = document.querySelector('#h')!; + const externalFooter = scenario.querySelector('#f')!; + const tableFooter = scenario.querySelector(`#${scenario.id}-table-footer`)!; + const table = scenario.querySelector('table')!; + const skeletonRows = scenario.querySelectorAll('tr[aria-hidden="true"]'); + const externalHeaderRect = externalHeader.getBoundingClientRect(); + const externalFooterRect = externalFooter.getBoundingClientRect(); + const tableFooterRect = tableFooter.getBoundingClientRect(); + + return { + documentOverflow: document.documentElement.scrollHeight - document.documentElement.clientHeight, + externalFooterIsVisible: externalFooterRect.top >= 0 && externalFooterRect.bottom <= window.innerHeight, + externalHeaderIsVisible: externalHeaderRect.top >= 0 && externalHeaderRect.bottom <= window.innerHeight, + headerTableWidth: table.getBoundingClientRect().width, + skeletonRowCount: skeletonRows.length, + tableFooterIsVisible: tableFooterRect.top >= 0 && tableFooterRect.bottom <= window.innerHeight, + }; + }, selector); +} + +test( + 'fits automatic skeleton rows inside AppLayout with external page chrome', + useBrowser(async browser => { + const page = new BasePageObject(browser); + await page.setWindowSize({ width: 1024, height: 800 }); + + const scenarios = [ + { id: '#auto-skeleton-app-layout-nowrap', route: '#/light/app-layout/auto-skeleton-table-nowrap' }, + { id: '#auto-skeleton-app-layout-wrap', route: '#/light/app-layout/auto-skeleton-table' }, + ]; + + const layouts = []; + for (const scenario of scenarios) { + await browser.url(scenario.route); + await page.waitForVisible(`${scenario.id} tr[aria-hidden="true"]`); + layouts.push(await getAppLayoutScenarioLayout(browser, scenario.id)); + } + + for (const layout of layouts) { + expect(layout.documentOverflow).toBeLessThanOrEqual(0); + expect(layout.externalHeaderIsVisible).toBe(true); + expect(layout.externalFooterIsVisible).toBe(true); + expect(layout.skeletonRowCount).toBeGreaterThan(0); + expect(layout.tableFooterIsVisible).toBe(true); + } + + expect(layouts[0].headerTableWidth).toBeGreaterThan(layouts[1].headerTableWidth); + }) +); diff --git a/src/table/__tests__/skeleton.test.tsx b/src/table/__tests__/skeleton.test.tsx index 6a4d841b3b..c00a9d0a69 100644 --- a/src/table/__tests__/skeleton.test.tsx +++ b/src/table/__tests__/skeleton.test.tsx @@ -79,6 +79,16 @@ describe('Table skeleton loading', () => { expect(skeletonRows).toHaveLength(0); }); + test('renders data rows before skeleton rows', () => { + const wrapper = renderTable({ items: defaultItems, loading: true, skeleton: { totalRows: 6 } }); + const lastDataRow = wrapper.findTable()!.findRows().slice(-1)[0].getElement(); + const skeletonRows = wrapper.findAll('tr[aria-hidden="true"]'); + expect(skeletonRows).toHaveLength(3); + for (const skeletonRow of skeletonRows) { + expect(lastDataRow.compareDocumentPosition(skeletonRow.getElement())).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + } + }); + test('does not render skeleton rows when loading is false', () => { const wrapper = renderTable({ items: defaultItems, loading: false, skeleton: { totalRows: 6 } }); const skeletonRows = wrapper.findAll('tr[aria-hidden="true"]'); @@ -173,5 +183,20 @@ describe('Table skeleton loading', () => { expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(2); }); + + test('renders data rows before automatic skeleton rows', () => { + const wrapper = renderTable({ + items: defaultItems, + loading: true, + skeleton: { totalRows: 'auto', maxAutoRows: 2 }, + }); + + const lastDataRow = wrapper.findTable()!.findRows().slice(-1)[0].getElement(); + const skeletonRows = wrapper.findAll('tr[aria-hidden="true"]'); + expect(skeletonRows.length).toBeGreaterThan(0); + for (const skeletonRow of skeletonRows) { + expect(lastDataRow.compareDocumentPosition(skeletonRow.getElement())).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + } + }); }); }); From 7ccb252fb89758c6e9adabb286f4bc974267bd3d Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 23 Jul 2026 07:41:45 +0000 Subject: [PATCH 04/11] test(table): update documenter snapshot for auto skeleton rows --- .../__snapshots__/documenter.test.ts.snap | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..cb2a5add4e 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -28964,17 +28964,16 @@ the table items array is empty.", "description": "Renders skeleton placeholder rows to fill the table while data is loading. Accepts: - \`totalRows\` (number) - The total number of rows that should be rendered. If \`items\` are also provided, those items will be rendered first, and \`totalRows - items.length\` - additional skeleton rows rendered after.", + additional skeleton rows rendered after. +- \`totalRows\` ('auto') - The number of skeleton rows is calculated from the available viewport height. +- \`maxAutoRows\` (number) - Limits the number of rows rendered when \`totalRows\` is set to \`'auto'\`.", "inlineType": { "name": "TableProps.SkeletonConfig", - "properties": [ - { - "name": "totalRows", - "optional": false, - "type": "number", - }, + "type": "union", + "values": [ + "{ totalRows: number; maxAutoRows?: undefined; }", + "{ totalRows: "auto"; maxAutoRows?: number | undefined; }", ], - "type": "object", }, "name": "skeleton", "optional": true, From e6780dc569dc642beb58519fa279ae09a0a9e981 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 23 Jul 2026 09:28:30 +0000 Subject: [PATCH 05/11] test(table): cover auto skeleton document overflow fallback --- src/table/__tests__/skeleton.test.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/table/__tests__/skeleton.test.tsx b/src/table/__tests__/skeleton.test.tsx index c00a9d0a69..163d377ca8 100644 --- a/src/table/__tests__/skeleton.test.tsx +++ b/src/table/__tests__/skeleton.test.tsx @@ -174,6 +174,25 @@ describe('Table skeleton loading', () => { expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(2); }); + test('removes rows using document overflow when no ancestor scrolls', () => { + const originalScrollHeight = Object.getOwnPropertyDescriptor(document.documentElement, 'scrollHeight'); + Object.defineProperty(document.documentElement, 'scrollHeight', { + configurable: true, + get: () => (document.querySelectorAll('tr[aria-hidden="true"]').length > 2 ? 401 : 400), + }); + + try { + const wrapper = renderTable({ items: [], loading: true, skeleton: { totalRows: 'auto' } }); + expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(2); + } finally { + if (originalScrollHeight) { + Object.defineProperty(document.documentElement, 'scrollHeight', originalScrollHeight); + } else { + delete (document.documentElement as { scrollHeight?: number }).scrollHeight; + } + } + }); + test('adds automatic skeleton rows after partial data', () => { const wrapper = renderTable({ items: defaultItems, From c7ecd284f893ceccb1a35908d51c9a55524cf3fb Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 23 Jul 2026 10:48:03 +0000 Subject: [PATCH 06/11] test(table): fix a11y violations on auto skeleton dev page --- pages/table/auto-skeleton-rows.page.tsx | 39 ++++++++++++++++++++----- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/pages/table/auto-skeleton-rows.page.tsx b/pages/table/auto-skeleton-rows.page.tsx index 7f57d77f99..a55a3dd57e 100644 --- a/pages/table/auto-skeleton-rows.page.tsx +++ b/pages/table/auto-skeleton-rows.page.tsx @@ -47,10 +47,19 @@ interface SkeletonScenarioProps { items?: readonly Item[]; longHeaders?: boolean; stickyHeader?: boolean; + title: string; wrapLines?: boolean; } -function SkeletonScenario({ footer, id, items = [], longHeaders, stickyHeader, wrapLines }: SkeletonScenarioProps) { +function SkeletonScenario({ + footer, + id, + items = [], + longHeaders, + stickyHeader, + title, + wrapLines, +}: SkeletonScenarioProps) { return (
Footer content : undefined} - header={
Items
} + header={
{title}
} /> ); @@ -71,6 +80,7 @@ function SkeletonScenario({ footer, id, items = [], longHeaders, stickyHeader, w export default function AutoSkeletonRowsPage() { return ( +
Automatic skeleton rows
@@ -80,20 +90,33 @@ export default function AutoSkeletonRowsPage() { loading={true} loadingText="Loading items" skeleton={{ totalRows: 'auto' }} - header={
Items
} + header={ +
Nested scroll viewport
+ } />
- - - - - + + + + + From 8fa5da2587e1c30980cec166bc3626e3f15da62b Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 23 Jul 2026 12:55:38 +0000 Subject: [PATCH 07/11] test(table): keep single wrapped AppLayout skeleton demo --- .../auto-skeleton-table-nowrap.page.tsx | 9 -- pages/app-layout/auto-skeleton-table.page.tsx | 74 ++++++++++++++++- pages/app-layout/auto-skeleton-table.tsx | 83 ------------------- src/table/__integ__/skeleton.test.ts | 30 ++----- 4 files changed, 80 insertions(+), 116 deletions(-) delete mode 100644 pages/app-layout/auto-skeleton-table-nowrap.page.tsx delete mode 100644 pages/app-layout/auto-skeleton-table.tsx diff --git a/pages/app-layout/auto-skeleton-table-nowrap.page.tsx b/pages/app-layout/auto-skeleton-table-nowrap.page.tsx deleted file mode 100644 index 081cd11f57..0000000000 --- a/pages/app-layout/auto-skeleton-table-nowrap.page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; - -import AutoSkeletonTable from './auto-skeleton-table'; - -export default function AutoSkeletonTableNowrapPage() { - return ; -} diff --git a/pages/app-layout/auto-skeleton-table.page.tsx b/pages/app-layout/auto-skeleton-table.page.tsx index 96cc6c7a10..3d88ce7d53 100644 --- a/pages/app-layout/auto-skeleton-table.page.tsx +++ b/pages/app-layout/auto-skeleton-table.page.tsx @@ -2,8 +2,78 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import AutoSkeletonTable from './auto-skeleton-table'; +import AppLayout from '~components/app-layout'; +import Button from '~components/button'; +import Header from '~components/header'; +import Table, { TableProps } from '~components/table'; + +import ScreenshotArea from '../utils/screenshot-area'; +import { Breadcrumbs, Footer, Navigation, Notifications } from './utils/content-blocks'; +import labels from './utils/labels'; + +interface Item { + id: string; + name: string; + owner: string; +} + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { + id: 'id', + header: 'Resource identifier used to locate the item in the inventory', + cell: item => item.id, + }, + { + id: 'name', + header: 'Resource name shown to customers in the management console', + cell: item => item.name, + }, + { + id: 'owner', + header: 'Owning team responsible for operating this resource', + cell: item => item.owner, + }, +]; + +const scenarioId = 'auto-skeleton-app-layout'; export default function AutoSkeletonTablePage() { - return ; + return ( + +
+ } + contentType="table" + footerSelector="#f" + headerSelector="#h" + navigation={} + notifications={} + content={ + + columnDefinitions={columnDefinitions} + footer={
Table footer
} + header={ +
Create resource} + description="Automatic skeleton rows fill the AppLayout content viewport." + variant="awsui-h1-sticky" + > + Resources +
+ } + items={[]} + loading={true} + loadingText="Loading resources" + skeleton={{ totalRows: 'auto' }} + stickyHeader={true} + variant="full-page" + wrapLines={true} + /> + } + /> +
+
+
+ ); } diff --git a/pages/app-layout/auto-skeleton-table.tsx b/pages/app-layout/auto-skeleton-table.tsx deleted file mode 100644 index 152cf34143..0000000000 --- a/pages/app-layout/auto-skeleton-table.tsx +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; - -import AppLayout from '~components/app-layout'; -import Button from '~components/button'; -import Header from '~components/header'; -import Table, { TableProps } from '~components/table'; - -import ScreenshotArea from '../utils/screenshot-area'; -import { Breadcrumbs, Footer, Navigation, Notifications } from './utils/content-blocks'; -import labels from './utils/labels'; - -interface Item { - id: string; - name: string; - owner: string; -} - -const columnDefinitions: TableProps.ColumnDefinition[] = [ - { - id: 'id', - header: 'Resource identifier used to locate the item in the inventory', - cell: item => item.id, - }, - { - id: 'name', - header: 'Resource name shown to customers in the management console', - cell: item => item.name, - }, - { - id: 'owner', - header: 'Owning team responsible for operating this resource', - cell: item => item.owner, - }, -]; - -interface AutoSkeletonTableProps { - wrapLines: boolean; -} - -export default function AutoSkeletonTable({ wrapLines }: AutoSkeletonTableProps) { - const scenarioId = `auto-skeleton-app-layout-${wrapLines ? 'wrap' : 'nowrap'}`; - - return ( - -
- } - contentType="table" - footerSelector="#f" - headerSelector="#h" - navigation={} - notifications={} - content={ - - columnDefinitions={columnDefinitions} - footer={
Table footer
} - header={ -
Create resource} - description="Automatic skeleton rows fill the AppLayout content viewport." - variant="awsui-h1-sticky" - > - Resources -
- } - items={[]} - loading={true} - loadingText="Loading resources" - skeleton={{ totalRows: 'auto' }} - stickyHeader={true} - variant="full-page" - wrapLines={wrapLines} - /> - } - /> -
-
-
- ); -} diff --git a/src/table/__integ__/skeleton.test.ts b/src/table/__integ__/skeleton.test.ts index 35fc7a1c6e..198a7fcefe 100644 --- a/src/table/__integ__/skeleton.test.ts +++ b/src/table/__integ__/skeleton.test.ts @@ -140,7 +140,6 @@ function getAppLayoutScenarioLayout(browser: Browser, selector: string) { const externalHeader = document.querySelector('#h')!; const externalFooter = scenario.querySelector('#f')!; const tableFooter = scenario.querySelector(`#${scenario.id}-table-footer`)!; - const table = scenario.querySelector('table')!; const skeletonRows = scenario.querySelectorAll('tr[aria-hidden="true"]'); const externalHeaderRect = externalHeader.getBoundingClientRect(); const externalFooterRect = externalFooter.getBoundingClientRect(); @@ -150,7 +149,6 @@ function getAppLayoutScenarioLayout(browser: Browser, selector: string) { documentOverflow: document.documentElement.scrollHeight - document.documentElement.clientHeight, externalFooterIsVisible: externalFooterRect.top >= 0 && externalFooterRect.bottom <= window.innerHeight, externalHeaderIsVisible: externalHeaderRect.top >= 0 && externalHeaderRect.bottom <= window.innerHeight, - headerTableWidth: table.getBoundingClientRect().width, skeletonRowCount: skeletonRows.length, tableFooterIsVisible: tableFooterRect.top >= 0 && tableFooterRect.bottom <= window.innerHeight, }; @@ -162,27 +160,15 @@ test( useBrowser(async browser => { const page = new BasePageObject(browser); await page.setWindowSize({ width: 1024, height: 800 }); + await browser.url('#/light/app-layout/auto-skeleton-table'); + await page.waitForVisible('#auto-skeleton-app-layout tr[aria-hidden="true"]'); - const scenarios = [ - { id: '#auto-skeleton-app-layout-nowrap', route: '#/light/app-layout/auto-skeleton-table-nowrap' }, - { id: '#auto-skeleton-app-layout-wrap', route: '#/light/app-layout/auto-skeleton-table' }, - ]; + const layout = await getAppLayoutScenarioLayout(browser, '#auto-skeleton-app-layout'); - const layouts = []; - for (const scenario of scenarios) { - await browser.url(scenario.route); - await page.waitForVisible(`${scenario.id} tr[aria-hidden="true"]`); - layouts.push(await getAppLayoutScenarioLayout(browser, scenario.id)); - } - - for (const layout of layouts) { - expect(layout.documentOverflow).toBeLessThanOrEqual(0); - expect(layout.externalHeaderIsVisible).toBe(true); - expect(layout.externalFooterIsVisible).toBe(true); - expect(layout.skeletonRowCount).toBeGreaterThan(0); - expect(layout.tableFooterIsVisible).toBe(true); - } - - expect(layouts[0].headerTableWidth).toBeGreaterThan(layouts[1].headerTableWidth); + expect(layout.documentOverflow).toBeLessThanOrEqual(0); + expect(layout.externalHeaderIsVisible).toBe(true); + expect(layout.externalFooterIsVisible).toBe(true); + expect(layout.skeletonRowCount).toBeGreaterThan(0); + expect(layout.tableFooterIsVisible).toBe(true); }) ); From 95c275fd4504fa643be7f2b3e0af856fdf301a69 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 23 Jul 2026 12:55:41 +0000 Subject: [PATCH 08/11] refactor(table): reuse shared getScrollableParents for auto skeleton layout --- src/internal/utils/scrollable-containers.ts | 18 ++++++++++++++ src/table/use-auto-skeleton-rows.ts | 27 ++++++--------------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/internal/utils/scrollable-containers.ts b/src/internal/utils/scrollable-containers.ts index b10774ac6b..fab6aa1cca 100644 --- a/src/internal/utils/scrollable-containers.ts +++ b/src/internal/utils/scrollable-containers.ts @@ -3,6 +3,8 @@ import { findUpUntil } from '@cloudscape-design/component-toolkit/dom'; +import { isHTMLElement } from './dom'; + export interface BoundingBox { blockSize: number; inlineSize: number; @@ -117,6 +119,22 @@ export function scrollRectangleIntoView(box: BoundingBox, scrollableParent?: HTM } } +/** + * Returns the element's ancestors whose computed `overflow-y` is `auto` or `scroll`, + * ordered nearest-first. These are the ancestors capable of producing a vertical scrollbar. + */ +export function getScrollableParents(element: HTMLElement): HTMLElement[] { + const parents: HTMLElement[] = []; + let node = element.parentElement; + while (node) { + if (isHTMLElement(node) && ['auto', 'scroll'].includes(getComputedStyle(node).overflowY)) { + parents.push(node); + } + node = node.parentElement; + } + return parents; +} + export function getFirstScrollableParent(element: HTMLElement): HTMLElement | undefined { return ( findUpUntil(element, el => { diff --git a/src/table/use-auto-skeleton-rows.ts b/src/table/use-auto-skeleton-rows.ts index 461cf9282f..a6546f62ba 100644 --- a/src/table/use-auto-skeleton-rows.ts +++ b/src/table/use-auto-skeleton-rows.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { getScrollableParents } from '../internal/utils/scrollable-containers'; + const AUTO_SKELETON_VIEWPORT_BUFFER = 16; const SKELETON_ROW_SELECTOR = 'tr[aria-hidden="true"]'; @@ -32,30 +34,15 @@ function getDocumentViewportBottom() { return document.documentElement.clientHeight || window.innerHeight; } -function getScrollContainers(element: HTMLElement) { - const scrollContainers: HTMLElement[] = []; - let { parentElement: ancestor } = element; - - while (ancestor) { - const { overflowY } = window.getComputedStyle(ancestor); - if (overflowY === 'auto' || overflowY === 'scroll') { - scrollContainers.push(ancestor); - } - ({ parentElement: ancestor } = ancestor); - } - - return scrollContainers; -} - function getViewportBottom(element: HTMLElement) { - return getScrollContainers(element).reduce( + return getScrollableParents(element).reduce( (viewportBottom, scrollContainer) => Math.min(viewportBottom, scrollContainer.getBoundingClientRect().bottom), getDocumentViewportBottom() ); } function getOverflowHeight(element: HTMLElement) { - const scrollContainer = getScrollContainers(element)[0]; + const scrollContainer = getScrollableParents(element)[0]; if (scrollContainer) { return Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight); } @@ -66,7 +53,7 @@ function getOverflowHeight(element: HTMLElement) { ); } -export function calculateAutoSkeletonRows({ +function calculateAutoSkeletonRows({ maxRows, rowHeight, skeletonRowBottom, @@ -83,7 +70,7 @@ export function calculateAutoSkeletonRows({ return Math.max(1, Math.min(maxRows ?? Number.POSITIVE_INFINITY, rows)); } -export function calculateAutoSkeletonRowReduction({ +function calculateAutoSkeletonRowReduction({ currentRows, overflowHeight, rowHeight, @@ -164,7 +151,7 @@ export function useAutoSkeletonRows({ const resizeObserver = new ResizeObserver(updateRows); resizeObserver.observe(tableRoot); resizeObserver.observe(tableWrapper); - getScrollContainers(tableWrapper).forEach(scrollContainer => resizeObserver.observe(scrollContainer)); + getScrollableParents(tableWrapper).forEach(scrollContainer => resizeObserver.observe(scrollContainer)); window.addEventListener('resize', updateRows); return () => { From 4b754514f152bcdc3bf7a54117c924f010a9e70b Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 27 Jul 2026 14:24:26 +0000 Subject: [PATCH 09/11] feat: add minAutoRows to Table auto skeleton; name SkeletonConfig union; test getScrollableParents --- .../__snapshots__/documenter.test.ts.snap | 9 ++-- .../__tests__/scrollable-containers.test.tsx | 52 ++++++++++++++++++- src/table/__tests__/skeleton.test.tsx | 35 +++++++++++++ .../__tests__/table-feature-metrics.test.tsx | 11 +++- src/table/index.tsx | 1 + src/table/interfaces.tsx | 17 +++++- src/table/internal.tsx | 1 + src/table/use-auto-skeleton-rows.ts | 27 +++++++--- 8 files changed, 140 insertions(+), 13 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index cb2a5add4e..a9ce8befdb 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -28966,13 +28966,16 @@ the table items array is empty.", are also provided, those items will be rendered first, and \`totalRows - items.length\` additional skeleton rows rendered after. - \`totalRows\` ('auto') - The number of skeleton rows is calculated from the available viewport height. -- \`maxAutoRows\` (number) - Limits the number of rows rendered when \`totalRows\` is set to \`'auto'\`.", +- \`maxAutoRows\` (number) - Limits the number of rows rendered when \`totalRows\` is set to \`'auto'\`. +- \`minAutoRows\` (number) - Sets the minimum number of rows rendered when \`totalRows\` is set to \`'auto'\`. + Defaults to 1. Useful for tables rendered off-screen, where the calculated available height would + otherwise yield a single row.", "inlineType": { "name": "TableProps.SkeletonConfig", "type": "union", "values": [ - "{ totalRows: number; maxAutoRows?: undefined; }", - "{ totalRows: "auto"; maxAutoRows?: number | undefined; }", + "TableProps.FixedSkeletonConfig", + "TableProps.AutoSkeletonConfig", ], }, "name": "skeleton", diff --git a/src/internal/utils/__tests__/scrollable-containers.test.tsx b/src/internal/utils/__tests__/scrollable-containers.test.tsx index d93d0556f7..4d877cf2cb 100644 --- a/src/internal/utils/__tests__/scrollable-containers.test.tsx +++ b/src/internal/utils/__tests__/scrollable-containers.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { getFirstScrollableParent, scrollRectangleIntoView } from '../scrollable-containers'; +import { getFirstScrollableParent, getScrollableParents, scrollRectangleIntoView } from '../scrollable-containers'; const originalScrollBy = window.scrollBy; @@ -66,3 +66,53 @@ describe('getFirstScrollableParent', () => { expect(getFirstScrollableParent(inner)).toBe(undefined); }); }); + +describe('getScrollableParents', () => { + const originalGetComputedStyle = window.getComputedStyle; + + afterEach(() => { + window.getComputedStyle = originalGetComputedStyle; + }); + + function mockOverflowByOverflowY(overflowByTestId: Record) { + window.getComputedStyle = ((element: Element, pseudoElt?: string | null) => { + const result = originalGetComputedStyle(element as Element, pseudoElt); + const testId = (element as HTMLElement).dataset?.testid; + if (testId && overflowByTestId[testId]) { + result.overflowY = overflowByTestId[testId]; + } + return result; + }) as Window['getComputedStyle']; + } + + test('returns scrollable ancestors nearest-first, excluding non-scrollable ones', () => { + const { container } = render( +
+
+
+
+
+
+
+ ); + mockOverflowByOverflowY({ outer: 'scroll', middle: 'visible', 'scroll-parent': 'auto' }); + + const target = container.querySelector('[data-testid="target"]')!; + const outer = container.querySelector('[data-testid="outer"]')!; + const scrollParent = container.querySelector('[data-testid="scroll-parent"]')!; + + expect(getScrollableParents(target)).toEqual([scrollParent, outer]); + }); + + test('returns an empty array when no ancestor is scrollable', () => { + const { container } = render( +
+
+
+ ); + mockOverflowByOverflowY({ outer: 'visible' }); + + const target = container.querySelector('[data-testid="target"]')!; + expect(getScrollableParents(target)).toEqual([]); + }); +}); diff --git a/src/table/__tests__/skeleton.test.tsx b/src/table/__tests__/skeleton.test.tsx index 163d377ca8..1f6ac42ec0 100644 --- a/src/table/__tests__/skeleton.test.tsx +++ b/src/table/__tests__/skeleton.test.tsx @@ -174,6 +174,41 @@ describe('Table skeleton loading', () => { expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(2); }); + test('respects minAutoRows when the calculated height yields fewer rows', () => { + const wrapper = renderTable({ items: [], loading: true, skeleton: { totalRows: 'auto', minAutoRows: 5 } }); + + expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(5); + }); + + test('does not reduce below minAutoRows when the viewport overflows', () => { + const originalOverflowY = document.body.style.overflowY; + const originalClientHeight = Object.getOwnPropertyDescriptor(document.body, 'clientHeight'); + const originalScrollHeight = Object.getOwnPropertyDescriptor(document.body, 'scrollHeight'); + document.body.style.overflowY = 'auto'; + Object.defineProperty(document.body, 'clientHeight', { configurable: true, value: 400 }); + Object.defineProperty(document.body, 'scrollHeight', { + configurable: true, + get: () => (document.querySelectorAll('tr[aria-hidden="true"]').length > 2 ? 401 : 400), + }); + + try { + const wrapper = renderTable({ items: [], loading: true, skeleton: { totalRows: 'auto', minAutoRows: 3 } }); + expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(3); + } finally { + document.body.style.overflowY = originalOverflowY; + if (originalClientHeight) { + Object.defineProperty(document.body, 'clientHeight', originalClientHeight); + } else { + delete (document.body as { clientHeight?: number }).clientHeight; + } + if (originalScrollHeight) { + Object.defineProperty(document.body, 'scrollHeight', originalScrollHeight); + } else { + delete (document.body as { scrollHeight?: number }).scrollHeight; + } + } + }); + test('removes rows using document overflow when no ancestor scrolls', () => { const originalScrollHeight = Object.getOwnPropertyDescriptor(document.documentElement, 'scrollHeight'); Object.defineProperty(document.documentElement, 'scrollHeight', { diff --git a/src/table/__tests__/table-feature-metrics.test.tsx b/src/table/__tests__/table-feature-metrics.test.tsx index 88f4dabb8b..076642c6c1 100644 --- a/src/table/__tests__/table-feature-metrics.test.tsx +++ b/src/table/__tests__/table-feature-metrics.test.tsx @@ -45,7 +45,14 @@ test('reports cellVerticalAlign and columnDefinitionsVerticalAlign correctly', ( ); }); test('reports automatic skeleton configuration', () => { - render(
); + render( +
+ ); expect(useBaseComponentSpy).toHaveBeenCalledWith( 'Table', @@ -55,6 +62,7 @@ test('reports automatic skeleton configuration', () => { hasSkeleton: true, skeletonAutoRows: true, skeletonMaxAutoRows: 10, + skeletonMinAutoRows: 2, skeletonTotalRows: null, }), }, @@ -72,6 +80,7 @@ test('does not report an automatic row cap for fixed skeleton rows', () => { metadata: expect.objectContaining({ skeletonAutoRows: false, skeletonMaxAutoRows: null, + skeletonMinAutoRows: null, skeletonTotalRows: 5, }), }, diff --git a/src/table/index.tsx b/src/table/index.tsx index 2aedb63f82..bf4e609f7c 100644 --- a/src/table/index.tsx +++ b/src/table/index.tsx @@ -58,6 +58,7 @@ const Table = React.forwardRef( skeletonTotalRows: typeof props.skeleton?.totalRows === 'number' ? props.skeleton.totalRows : null, skeletonAutoRows: props.skeleton?.totalRows === 'auto', skeletonMaxAutoRows: props.skeleton?.totalRows === 'auto' ? (props.skeleton.maxAutoRows ?? null) : null, + skeletonMinAutoRows: props.skeleton?.totalRows === 'auto' ? (props.skeleton.minAutoRows ?? null) : null, groupSelection: !!props.expandableRows?.groupSelection, columnGroups: !!props.groupDefinitions?.length, columnGroupsDepth: getColumnGroupsDepth(props.columnDisplay), diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index 191e4bf932..2cfa02d646 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -69,6 +69,9 @@ export interface TableProps extends BaseComponentProps { * additional skeleton rows rendered after. * - `totalRows` ('auto') - The number of skeleton rows is calculated from the available viewport height. * - `maxAutoRows` (number) - Limits the number of rows rendered when `totalRows` is set to `'auto'`. + * - `minAutoRows` (number) - Sets the minimum number of rows rendered when `totalRows` is set to `'auto'`. + * Defaults to 1. Useful for tables rendered off-screen, where the calculated available height would + * otherwise yield a single row. */ skeleton?: TableProps.SkeletonConfig; @@ -708,7 +711,19 @@ export namespace TableProps { item: T; } - export type SkeletonConfig = { totalRows: number; maxAutoRows?: never } | { totalRows: 'auto'; maxAutoRows?: number }; + export interface FixedSkeletonConfig { + totalRows: number; + maxAutoRows?: never; + minAutoRows?: never; + } + + export interface AutoSkeletonConfig { + totalRows: 'auto'; + maxAutoRows?: number; + minAutoRows?: number; + } + + export type SkeletonConfig = FixedSkeletonConfig | AutoSkeletonConfig; } export type TableRow = TableDataRow | TableLoaderRow; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index 27d057795b..28d843e2e2 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -455,6 +455,7 @@ const InternalTable = React.forwardRef( const autoSkeletonRows = useAutoSkeletonRows({ enabled: !!loading && skeleton?.totalRows === 'auto', maxRows: skeleton?.maxAutoRows, + minRows: skeleton?.minAutoRows, tableBodyRef, tableRootRef: tableRootRefObject, tableWrapperRef: wrapperRefObject, diff --git a/src/table/use-auto-skeleton-rows.ts b/src/table/use-auto-skeleton-rows.ts index a6546f62ba..c30f24d94b 100644 --- a/src/table/use-auto-skeleton-rows.ts +++ b/src/table/use-auto-skeleton-rows.ts @@ -9,6 +9,7 @@ const SKELETON_ROW_SELECTOR = 'tr[aria-hidden="true"]'; interface AutoSkeletonRowsCalculation { maxRows?: number; + minRows: number; rowHeight: number; skeletonRowBottom: number; skeletonRowTop: number; @@ -18,6 +19,7 @@ interface AutoSkeletonRowsCalculation { interface AutoSkeletonRowReductionCalculation { currentRows: number; + minRows: number; overflowHeight: number; rowHeight: number; } @@ -25,6 +27,7 @@ interface AutoSkeletonRowReductionCalculation { interface UseAutoSkeletonRowsProps { enabled: boolean; maxRows?: number; + minRows?: number; tableBodyRef: React.RefObject; tableRootRef: React.RefObject; tableWrapperRef: React.RefObject; @@ -55,6 +58,7 @@ function getOverflowHeight(element: HTMLElement) { function calculateAutoSkeletonRows({ maxRows, + minRows, rowHeight, skeletonRowBottom, skeletonRowTop, @@ -67,16 +71,17 @@ function calculateAutoSkeletonRows({ viewportBottom - skeletonRowTop - staticHeightBelowSkeletonRow - AUTO_SKELETON_VIEWPORT_BUFFER ); const rows = Math.floor(availableHeight / rowHeight); - return Math.max(1, Math.min(maxRows ?? Number.POSITIVE_INFINITY, rows)); + return Math.max(minRows, Math.min(maxRows ?? Number.POSITIVE_INFINITY, rows)); } function calculateAutoSkeletonRowReduction({ currentRows, + minRows, overflowHeight, rowHeight, }: AutoSkeletonRowReductionCalculation) { return Math.min( - currentRows - 1, + currentRows - minRows, Math.max(1, Math.ceil((overflowHeight + AUTO_SKELETON_VIEWPORT_BUFFER) / rowHeight)) ); } @@ -84,11 +89,13 @@ function calculateAutoSkeletonRowReduction({ export function useAutoSkeletonRows({ enabled, maxRows, + minRows, tableBodyRef, tableRootRef, tableWrapperRef, }: UseAutoSkeletonRowsProps) { - const [rows, setRows] = useState(1); + const minRowsValue = minRows ?? 1; + const [rows, setRows] = useState(minRowsValue); const rowHeightRef = useRef(); const updateRows = useCallback(() => { @@ -110,6 +117,7 @@ export function useAutoSkeletonRows({ rowHeightRef.current = rowHeight; const nextRows = calculateAutoSkeletonRows({ maxRows, + minRows: minRowsValue, rowHeight, skeletonRowBottom: lastSkeletonRowRect.bottom, skeletonRowTop: firstSkeletonRowRect.top, @@ -117,14 +125,14 @@ export function useAutoSkeletonRows({ viewportBottom: getViewportBottom(tableWrapper), }); setRows(currentRows => (currentRows === nextRows ? currentRows : nextRows)); - }, [enabled, maxRows, tableBodyRef, tableRootRef, tableWrapperRef]); + }, [enabled, maxRows, minRowsValue, tableBodyRef, tableRootRef, tableWrapperRef]); useLayoutEffect(updateRows, [updateRows]); useLayoutEffect(() => { const tableWrapper = tableWrapperRef.current; const rowHeight = rowHeightRef.current; - if (!enabled || rows === 1 || !tableWrapper || !rowHeight) { + if (!enabled || rows <= minRowsValue || !tableWrapper || !rowHeight) { return; } @@ -133,9 +141,14 @@ export function useAutoSkeletonRows({ return; } - const rowsToRemove = calculateAutoSkeletonRowReduction({ currentRows: rows, overflowHeight, rowHeight }); + const rowsToRemove = calculateAutoSkeletonRowReduction({ + currentRows: rows, + minRows: minRowsValue, + overflowHeight, + rowHeight, + }); setRows(currentRows => currentRows - rowsToRemove); - }, [enabled, rows, tableWrapperRef]); + }, [enabled, rows, minRowsValue, tableWrapperRef]); useLayoutEffect(() => { if (!enabled || typeof ResizeObserver === 'undefined') { From c740ae0344bed71ecaa18bb55bfb0277f1c4ff89 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 27 Jul 2026 15:54:31 +0000 Subject: [PATCH 10/11] fix: clamp minAutoRows to >=1 and clarify auto skeleton row docs --- .../snapshot-tests/__snapshots__/documenter.test.ts.snap | 4 ++-- src/table/__tests__/skeleton.test.tsx | 6 ++++++ src/table/interfaces.tsx | 4 ++-- src/table/use-auto-skeleton-rows.ts | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index a9ce8befdb..50cc9e184b 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -28966,8 +28966,8 @@ the table items array is empty.", are also provided, those items will be rendered first, and \`totalRows - items.length\` additional skeleton rows rendered after. - \`totalRows\` ('auto') - The number of skeleton rows is calculated from the available viewport height. -- \`maxAutoRows\` (number) - Limits the number of rows rendered when \`totalRows\` is set to \`'auto'\`. -- \`minAutoRows\` (number) - Sets the minimum number of rows rendered when \`totalRows\` is set to \`'auto'\`. +- \`maxAutoRows\` (number) - Limits the number of skeleton rows rendered when \`totalRows\` is set to \`'auto'\`. +- \`minAutoRows\` (number) - Sets the minimum number of skeleton rows rendered when \`totalRows\` is set to \`'auto'\`. Defaults to 1. Useful for tables rendered off-screen, where the calculated available height would otherwise yield a single row.", "inlineType": { diff --git a/src/table/__tests__/skeleton.test.tsx b/src/table/__tests__/skeleton.test.tsx index 1f6ac42ec0..ac408dfebc 100644 --- a/src/table/__tests__/skeleton.test.tsx +++ b/src/table/__tests__/skeleton.test.tsx @@ -180,6 +180,12 @@ describe('Table skeleton loading', () => { expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(5); }); + test('clamps minAutoRows to at least 1 so auto-sizing is not stalled', () => { + const wrapper = renderTable({ items: [], loading: true, skeleton: { totalRows: 'auto', minAutoRows: 0 } }); + + expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(3); + }); + test('does not reduce below minAutoRows when the viewport overflows', () => { const originalOverflowY = document.body.style.overflowY; const originalClientHeight = Object.getOwnPropertyDescriptor(document.body, 'clientHeight'); diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index 2cfa02d646..cf1f287534 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -68,8 +68,8 @@ export interface TableProps extends BaseComponentProps { * are also provided, those items will be rendered first, and `totalRows - items.length` * additional skeleton rows rendered after. * - `totalRows` ('auto') - The number of skeleton rows is calculated from the available viewport height. - * - `maxAutoRows` (number) - Limits the number of rows rendered when `totalRows` is set to `'auto'`. - * - `minAutoRows` (number) - Sets the minimum number of rows rendered when `totalRows` is set to `'auto'`. + * - `maxAutoRows` (number) - Limits the number of skeleton rows rendered when `totalRows` is set to `'auto'`. + * - `minAutoRows` (number) - Sets the minimum number of skeleton rows rendered when `totalRows` is set to `'auto'`. * Defaults to 1. Useful for tables rendered off-screen, where the calculated available height would * otherwise yield a single row. */ diff --git a/src/table/use-auto-skeleton-rows.ts b/src/table/use-auto-skeleton-rows.ts index c30f24d94b..37b4da19e6 100644 --- a/src/table/use-auto-skeleton-rows.ts +++ b/src/table/use-auto-skeleton-rows.ts @@ -94,7 +94,7 @@ export function useAutoSkeletonRows({ tableRootRef, tableWrapperRef, }: UseAutoSkeletonRowsProps) { - const minRowsValue = minRows ?? 1; + const minRowsValue = Math.max(1, minRows ?? 1); const [rows, setRows] = useState(minRowsValue); const rowHeightRef = useRef(); From c8e171c586f5c4c7e924e0ae4571f2de0ef8f566 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 28 Jul 2026 07:00:44 +0000 Subject: [PATCH 11/11] fix: clamp minAutoRows to maxAutoRows and sanitize maxAutoRows to >=1 --- src/table/__tests__/skeleton.test.tsx | 10 ++++++++++ src/table/use-auto-skeleton-rows.ts | 7 ++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/table/__tests__/skeleton.test.tsx b/src/table/__tests__/skeleton.test.tsx index ac408dfebc..f4b1e00813 100644 --- a/src/table/__tests__/skeleton.test.tsx +++ b/src/table/__tests__/skeleton.test.tsx @@ -186,6 +186,16 @@ describe('Table skeleton loading', () => { expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(3); }); + test('clamps minAutoRows down to maxAutoRows when it exceeds the cap', () => { + const wrapper = renderTable({ + items: [], + loading: true, + skeleton: { totalRows: 'auto', minAutoRows: 5, maxAutoRows: 2 }, + }); + + expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(2); + }); + test('does not reduce below minAutoRows when the viewport overflows', () => { const originalOverflowY = document.body.style.overflowY; const originalClientHeight = Object.getOwnPropertyDescriptor(document.body, 'clientHeight'); diff --git a/src/table/use-auto-skeleton-rows.ts b/src/table/use-auto-skeleton-rows.ts index 37b4da19e6..69a7b82fb1 100644 --- a/src/table/use-auto-skeleton-rows.ts +++ b/src/table/use-auto-skeleton-rows.ts @@ -94,7 +94,8 @@ export function useAutoSkeletonRows({ tableRootRef, tableWrapperRef, }: UseAutoSkeletonRowsProps) { - const minRowsValue = Math.max(1, minRows ?? 1); + const maxRowsValue = maxRows === undefined ? undefined : Math.max(1, maxRows); + const minRowsValue = Math.min(Math.max(1, minRows ?? 1), maxRowsValue ?? Number.POSITIVE_INFINITY); const [rows, setRows] = useState(minRowsValue); const rowHeightRef = useRef(); @@ -116,7 +117,7 @@ export function useAutoSkeletonRows({ rowHeightRef.current = rowHeight; const nextRows = calculateAutoSkeletonRows({ - maxRows, + maxRows: maxRowsValue, minRows: minRowsValue, rowHeight, skeletonRowBottom: lastSkeletonRowRect.bottom, @@ -125,7 +126,7 @@ export function useAutoSkeletonRows({ viewportBottom: getViewportBottom(tableWrapper), }); setRows(currentRows => (currentRows === nextRows ? currentRows : nextRows)); - }, [enabled, maxRows, minRowsValue, tableBodyRef, tableRootRef, tableWrapperRef]); + }, [enabled, maxRowsValue, minRowsValue, tableBodyRef, tableRootRef, tableWrapperRef]); useLayoutEffect(updateRows, [updateRows]);