diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 1737a6c5549..220629c6f92 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -94,7 +94,7 @@ import {GridNode} from 'react-stately/private/grid/GridCollection'; import {IconContext} from './Icon'; import intlMessages from '../intl/*.json'; import {Key} from '@react-types/shared'; -import {LayoutInfo, Rect, TableLayout, Virtualizer} from 'react-aria-components/Virtualizer'; +import {LayoutInfo, TableLayout, Virtualizer} from 'react-aria-components/Virtualizer'; import {LayoutNode} from 'react-stately/useVirtualizerState'; import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu'; import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg'; @@ -257,6 +257,8 @@ const table = style< minWidth: 0, fontFamily: 'sans', fontWeight: 'normal', + display: 'flex', + flexDirection: 'column', overflow: 'auto', backgroundColor: { default: 'gray-25', @@ -343,13 +345,6 @@ export class S2TableLayout extends TableLayout { if (rowGroups.length < 2) { return []; } - let {layoutInfo} = rowGroups[1]; - // TableLayout's buildCollection always sets the body width to the max width between the header width, but - // we want the body to be sticky and only as wide as the table so it is always in view if loading/empty - let isEmptyOrLoading = this.virtualizer?.collection.size === 0; - if (isEmptyOrLoading) { - layoutInfo.rect.width = this.virtualizer!.size.width - 80; - } return rowGroups; } @@ -359,11 +354,6 @@ export class S2TableLayout extends TableLayout { let {layoutInfo} = layoutNode; layoutInfo.allowOverflow = true; layoutInfo.rect.width = this.virtualizer!.size.width; - // If performing first load or empty, the body will be sticky so we don't want to apply sticky to the loader, otherwise it will - // affect the positioning of the empty state renderer - let collection = this.virtualizer!.collection; - let isEmptyOrLoading = collection?.size === 0; - layoutInfo.isSticky = !isEmptyOrLoading; return layoutNode; } @@ -375,16 +365,15 @@ export class S2TableLayout extends TableLayout { let {layoutInfo} = layoutNode; // Needs overflow for sticky loader layoutInfo.allowOverflow = true; - // If loading or empty, we'll want the body to be sticky and centered + // The base TableLayout.buildRowGroup sizes the empty/loading body from the virtualizer's + // current on-screen size, which is circular when no explicit height is set (the empty-state + // content is rendered outside this virtualized rect entirely, see Table.tsx's TableInner). + // Reset it back to the real (near-zero) computed height so it doesn't feed back into the + // ResizeObserver that measures that same on-screen size. let isEmptyOrLoading = this.virtualizer?.collection.size === 0; if (isEmptyOrLoading) { - layoutInfo.rect = new Rect( - 40, - 40, - this.virtualizer!.size.width - 80, - this.virtualizer!.size.height - 80 - ); - layoutInfo.isSticky = true; + let maxY = layoutNode.children?.reduce((max, child) => Math.max(max, child.layoutInfo.rect.maxY), layoutInfo.rect.y) ?? layoutInfo.rect.y; + layoutInfo.rect.height = maxY - layoutInfo.rect.y; } return {...layoutNode, layoutInfo}; diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 3f4e1da7a65..b8d79b467d6 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -438,7 +438,7 @@ function renderEmptyState() { } const EmptyStateTable = (args: TableViewProps): ReactElement => ( - + {column => ( @@ -459,6 +459,31 @@ export const EmptyState: StoryObj = { } }; +// No explicit height set at all: https://github.com/adobe/react-spectrum/issues/10235 +// The table should still auto-size to show the column headers and the empty state. +const EmptyStateTableNoHeight = (args: TableViewProps): ReactElement => ( + + + {column => ( + + {column.name} + + )} + + + {[]} + + +); + +export const EmptyStateNoHeight: StoryObj = { + render: EmptyStateTableNoHeight, + args: { + ...Example.args + }, + name: 'empty state, no explicit height' +}; + export const LoadingStateNoItems: StoryObj = { render: EmptyStateTable, args: { diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 45eb1d488bf..a9f72fee678 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -888,6 +888,21 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl let DOMProps = filterDOMProps(props, {global: true}); + let bodyNode = filteredState.collection.body; + let isBodyEmpty = filteredState.collection.size === 0; + let renderEmptyState = bodyNode?.props?.renderEmptyState; + let {rowGroupProps: emptyRowGroupProps} = useTableRowGroup(); + let emptyStateRowGroup = isVirtualized && isBodyEmpty && renderEmptyState && ( + + {renderTableEmptyState( + renderEmptyState, + {isDropTarget: isRootDropTarget, isEmpty: isBodyEmpty}, + filteredState.collection.columnCount, + isVirtualized + )} + + ); + return ( + {emptyStateRowGroup} @@ -1500,6 +1516,31 @@ let TableBodyElementType = forwardRef(function TableBodyElementType( return ; }); +function renderTableEmptyState( + renderEmptyState: (props: TableBodyRenderProps) => ReactNode, + renderValues: TableBodyRenderProps, + numColumns: number, + isVirtualized: boolean +) { + let rowProps = {}; + let rowHeaderProps = {}; + let style = {}; + if (isVirtualized) { + rowHeaderProps['aria-colspan'] = numColumns; + style = {display: 'contents'}; + } else { + rowHeaderProps['colSpan'] = numColumns; + } + + return ( + + + {renderEmptyState(renderValues)} + + + ); +} + /** * The body of a ``, containing the table rows. */ @@ -1535,24 +1576,11 @@ export const TableBody = /*#__PURE__*/ createBranchComponent( let emptyState; let numColumns = collection.columnCount; - if (isEmpty && props.renderEmptyState && state) { - let rowProps = {}; - let rowHeaderProps = {}; - let style = {}; - if (isVirtualized) { - rowHeaderProps['aria-colspan'] = numColumns; - style = {display: 'contents'}; - } else { - rowHeaderProps['colSpan'] = numColumns; - } - - emptyState = ( - - - {props.renderEmptyState(renderValues)} - - - ); + // When virtualized, the empty state is rendered as a sibling of the CollectionRoot + // in TableInner instead, so that it can be sized by its own content rather than being + // trapped in a `contain: size` virtualizer-positioned box. See TableInner. + if (!isVirtualized && isEmpty && props.renderEmptyState && state) { + emptyState = renderTableEmptyState(props.renderEmptyState, renderValues, numColumns, isVirtualized); } let {rowGroupProps} = useTableRowGroup();