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..3d88ce7d53 --- /dev/null +++ b/pages/app-layout/auto-skeleton-table.page.tsx @@ -0,0 +1,79 @@ +// 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, + }, +]; + +const scenarioId = 'auto-skeleton-app-layout'; + +export default function AutoSkeletonTablePage() { + 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/table/auto-skeleton-rows.page.tsx b/pages/table/auto-skeleton-rows.page.tsx new file mode 100644 index 0000000000..a55a3dd57e --- /dev/null +++ b/pages/table/auto-skeleton-rows.page.tsx @@ -0,0 +1,124 @@ +// 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 SpaceBetween from '~components/space-between'; +import Table, { TableProps } from '~components/table'; + +interface Item { + id: string; + name: string; + owner: string; +} + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: item => item.id }, + { 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; + title: string; + wrapLines?: boolean; +} + +function SkeletonScenario({ + footer, + id, + items = [], + longHeaders, + stickyHeader, + title, + wrapLines, +}: SkeletonScenarioProps) { + return ( +
+ Footer content : undefined} + header={
{title}
} + /> + + ); +} + +export default function AutoSkeletonRowsPage() { + return ( + +
Automatic skeleton rows
+
+
+
+
Nested scroll viewport + } + /> + + + + + + + + + + ); +} 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/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..50cc9e184b 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -28964,17 +28964,19 @@ 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 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": { "name": "TableProps.SkeletonConfig", - "properties": [ - { - "name": "totalRows", - "optional": false, - "type": "number", - }, + "type": "union", + "values": [ + "TableProps.FixedSkeletonConfig", + "TableProps.AutoSkeletonConfig", ], - "type": "object", }, "name": "skeleton", "optional": true, 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/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/__integ__/skeleton.test.ts b/src/table/__integ__/skeleton.test.ts new file mode 100644 index 0000000000..198a7fcefe --- /dev/null +++ b/src/table/__integ__/skeleton.test.ts @@ -0,0 +1,174 @@ +// 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"]`; +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( + browser.execute(scrollContainerSelector => { + const scrollContainer = document.querySelector(scrollContainerSelector)!; + return scrollContainer.scrollHeight - scrollContainer.clientHeight; + }, selector) + ).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 => { + 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); + }) +); + +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 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, + 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 }); + await browser.url('#/light/app-layout/auto-skeleton-table'); + await page.waitForVisible('#auto-skeleton-app-layout tr[aria-hidden="true"]'); + + const layout = await getAppLayoutScenarioLayout(browser, '#auto-skeleton-app-layout'); + + 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); + }) +); diff --git a/src/table/__tests__/skeleton.test.tsx b/src/table/__tests__/skeleton.test.tsx index 089207d6bf..f4b1e00813 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"]'); @@ -95,4 +105,168 @@ 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 === 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; + } + 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('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 } }); + + 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('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('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'); + 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', { + 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, + loading: true, + skeleton: { totalRows: 'auto', maxAutoRows: 2 }, + }); + + 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); + } + }); + }); }); diff --git a/src/table/__tests__/table-feature-metrics.test.tsx b/src/table/__tests__/table-feature-metrics.test.tsx index 07d09a07f5..076642c6c1 100644 --- a/src/table/__tests__/table-feature-metrics.test.tsx +++ b/src/table/__tests__/table-feature-metrics.test.tsx @@ -44,3 +44,46 @@ 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, + skeletonMinAutoRows: 2, + 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, + skeletonMinAutoRows: null, + skeletonTotalRows: 5, + }), + }, + expect.anything() + ); +}); diff --git a/src/table/index.tsx b/src/table/index.tsx index 28a1fd60ed..bf4e609f7c 100644 --- a/src/table/index.tsx +++ b/src/table/index.tsx @@ -55,7 +55,10 @@ 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, + 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 e00ef8fc17..cf1f287534 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -67,6 +67,11 @@ 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 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. */ skeleton?: TableProps.SkeletonConfig; @@ -706,9 +711,19 @@ export namespace TableProps { item: T; } - export interface SkeletonConfig { + 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 d58a59f1f9..28d843e2e2 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,19 @@ 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, + minRows: skeleton?.minAutoRows, + tableBodyRef, + tableRootRef: tableRootRefObject, + tableWrapperRef: wrapperRefObject, + }); + const skeletonRowsCount = + skeleton?.totalRows === 'auto' ? allItems.length + autoSkeletonRows : (skeleton?.totalRows ?? 0); const wrapperProps = getTableWrapperRoleProps({ tableRole, @@ -486,7 +500,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 +836,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 getViewportBottom(element: HTMLElement) { + return getScrollableParents(element).reduce( + (viewportBottom, scrollContainer) => Math.min(viewportBottom, scrollContainer.getBoundingClientRect().bottom), + getDocumentViewportBottom() + ); +} + +function getOverflowHeight(element: HTMLElement) { + const scrollContainer = getScrollableParents(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() + ); +} + +function calculateAutoSkeletonRows({ + maxRows, + minRows, + 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(minRows, Math.min(maxRows ?? Number.POSITIVE_INFINITY, rows)); +} + +function calculateAutoSkeletonRowReduction({ + currentRows, + minRows, + overflowHeight, + rowHeight, +}: AutoSkeletonRowReductionCalculation) { + return Math.min( + currentRows - minRows, + Math.max(1, Math.ceil((overflowHeight + AUTO_SKELETON_VIEWPORT_BUFFER) / rowHeight)) + ); +} + +export function useAutoSkeletonRows({ + enabled, + maxRows, + minRows, + tableBodyRef, + tableRootRef, + tableWrapperRef, +}: UseAutoSkeletonRowsProps) { + 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(); + + 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: maxRowsValue, + minRows: minRowsValue, + rowHeight, + skeletonRowBottom: lastSkeletonRowRect.bottom, + skeletonRowTop: firstSkeletonRowRect.top, + tableBottom: tableRoot.getBoundingClientRect().bottom, + viewportBottom: getViewportBottom(tableWrapper), + }); + setRows(currentRows => (currentRows === nextRows ? currentRows : nextRows)); + }, [enabled, maxRowsValue, minRowsValue, tableBodyRef, tableRootRef, tableWrapperRef]); + + useLayoutEffect(updateRows, [updateRows]); + + useLayoutEffect(() => { + const tableWrapper = tableWrapperRef.current; + const rowHeight = rowHeightRef.current; + if (!enabled || rows <= minRowsValue || !tableWrapper || !rowHeight) { + return; + } + + const overflowHeight = getOverflowHeight(tableWrapper); + if (overflowHeight === 0) { + return; + } + + const rowsToRemove = calculateAutoSkeletonRowReduction({ + currentRows: rows, + minRows: minRowsValue, + overflowHeight, + rowHeight, + }); + setRows(currentRows => currentRows - rowsToRemove); + }, [enabled, rows, minRowsValue, tableWrapperRef]); + + 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); + getScrollableParents(tableWrapper).forEach(scrollContainer => resizeObserver.observe(scrollContainer)); + window.addEventListener('resize', updateRows); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', updateRows); + }; + }, [enabled, tableRootRef, tableWrapperRef, updateRows]); + + return rows; +}