From 942e04c1d740d3250f0c699b499adcbdd9183016 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Fri, 24 Apr 2026 05:54:37 +1000 Subject: [PATCH 1/2] fix: repeated yarn installs (#9966) --- patches/@mdx-js+react+2.0.0-rc.2.patch | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/patches/@mdx-js+react+2.0.0-rc.2.patch b/patches/@mdx-js+react+2.0.0-rc.2.patch index a69fc2a1da8..4abe813ee6d 100644 --- a/patches/@mdx-js+react+2.0.0-rc.2.patch +++ b/patches/@mdx-js+react+2.0.0-rc.2.patch @@ -4,6 +4,7 @@ index be47704..c7ca78e 100644 +++ b/node_modules/@mdx-js/react/lib/index.d.ts @@ -1,3 +1,4 @@ +import type {JSX} from 'react'; - /** +-/** ++/** * @param {import('react').ComponentType} Component * @deprecated From 918f0aeb2b7aa048ba163c2c6a0d759870db6646 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Thu, 23 Apr 2026 16:22:26 -0700 Subject: [PATCH 2/2] feat: Add TableFooter component (#9943) * feat: Add TableFooter component * Add docs * fix test * fix docs * Support multiple table bodies * Fix S2 test * updates from testing * extra ; Co-authored-by: Daniel Lu --------- Co-authored-by: Daniel Lu Co-authored-by: Daniel Lu --- packages/@react-aria/test-utils/src/table.ts | 3 +- .../@react-spectrum/s2/exports/TableView.ts | 4 +- packages/@react-spectrum/s2/exports/index.ts | 4 +- packages/@react-spectrum/s2/src/TableView.tsx | 46 ++++- .../s2/stories/TableView.stories.tsx | 42 ++++ .../s2/test/TableView.test.tsx | 69 ++++++- .../dev/s2-docs/pages/react-aria/Table.mdx | 52 +++-- packages/dev/s2-docs/pages/s2/TableView.mdx | 58 ++++-- .../react-aria-components/exports/Table.ts | 4 +- .../react-aria-components/exports/index.ts | 4 +- packages/react-aria-components/src/Table.tsx | 117 +++++++++-- .../stories/Table.stories.tsx | 178 +++++++++++++++++ .../react-aria-components/test/Table.test.js | 185 ++++++++++++++++++ .../src/grid/GridKeyboardDelegate.ts | 2 +- .../src/selection/ListKeyboardDelegate.ts | 2 +- .../react-stately/src/layout/ListLayout.ts | 4 +- .../react-stately/src/layout/TableLayout.ts | 104 ++++++---- .../src/selection/SelectionManager.ts | 3 +- starters/docs/src/Table.css | 5 + starters/docs/src/Table.tsx | 6 + starters/tailwind/src/Table.tsx | 6 + 21 files changed, 778 insertions(+), 120 deletions(-) diff --git a/packages/@react-aria/test-utils/src/table.ts b/packages/@react-aria/test-utils/src/table.ts index 2401beeed1b..e5820e01dac 100644 --- a/packages/@react-aria/test-utils/src/table.ts +++ b/packages/@react-aria/test-utils/src/table.ts @@ -504,8 +504,7 @@ export class TableTester { * Returns the rows within the table if any. */ get rows(): HTMLElement[] { - let bodyRowGroup = this.rowGroups[1]; - return bodyRowGroup ? within(bodyRowGroup).queryAllByRole('row') : []; + return this.rowGroups.slice(1).flatMap(rowGroup => within(rowGroup).queryAllByRole('row')); } /** diff --git a/packages/@react-spectrum/s2/exports/TableView.ts b/packages/@react-spectrum/s2/exports/TableView.ts index 73ee751e580..35da04fa658 100644 --- a/packages/@react-spectrum/s2/exports/TableView.ts +++ b/packages/@react-spectrum/s2/exports/TableView.ts @@ -1,4 +1,4 @@ -export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext, EditableCell} from '../src/TableView'; +export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext, EditableCell, TableFooter} from '../src/TableView'; export {Collection} from 'react-aria/Collection'; -export type {TableViewProps, TableHeaderProps, TableBodyProps, RowProps, CellProps, ColumnProps} from '../src/TableView'; +export type {TableViewProps, TableHeaderProps, TableBodyProps, RowProps, CellProps, ColumnProps, TableFooterProps} from '../src/TableView'; export type {Selection, Key, SelectionMode, SortDescriptor, SortDirection} from '@react-types/shared'; diff --git a/packages/@react-spectrum/s2/exports/index.ts b/packages/@react-spectrum/s2/exports/index.ts index 57dbf4fdde1..92cfa26bf27 100644 --- a/packages/@react-spectrum/s2/exports/index.ts +++ b/packages/@react-spectrum/s2/exports/index.ts @@ -77,7 +77,7 @@ export {Skeleton, useIsSkeleton} from '../src/Skeleton'; export {SkeletonCollection} from '../src/SkeletonCollection'; export {StatusLight, StatusLightContext} from '../src/StatusLight'; export {Switch, SwitchContext} from '../src/Switch'; -export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext, EditableCell} from '../src/TableView'; +export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext, EditableCell, TableFooter} from '../src/TableView'; export {Tabs, TabList, Tab, TabPanel, TabsContext} from '../src/Tabs'; export {TagGroup, Tag, TagGroupContext} from '../src/TagGroup'; export {TextArea, TextField, TextAreaContext, TextFieldContext} from '../src/TextField'; @@ -164,7 +164,7 @@ export type {SkeletonProps} from '../src/Skeleton'; export type {SkeletonCollectionProps} from '../src/SkeletonCollection'; export type {StatusLightProps} from '../src/StatusLight'; export type {SwitchProps} from '../src/Switch'; -export type {TableViewProps, TableHeaderProps, TableBodyProps, RowProps, CellProps, ColumnProps} from '../src/TableView'; +export type {TableViewProps, TableHeaderProps, TableBodyProps, RowProps, CellProps, ColumnProps, TableFooterProps} from '../src/TableView'; export type {TabsProps, TabProps, TabListProps, TabPanelProps} from '../src/Tabs'; export type {TagGroupProps, TagProps} from '../src/TagGroup'; export type {TextFieldProps, TextAreaProps, TextFieldRef} from '../src/TextField'; diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 6dd2ef7b926..00a349a6871 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -27,6 +27,8 @@ import { Table as RACTable, TableBody as RACTableBody, TableBodyProps as RACTableBodyProps, + TableFooter as RACTableFooter, + TableFooterProps as RACTableFooterProps, TableHeader as RACTableHeader, TableHeaderProps as RACTableHeaderProps, TableProps as RACTableProps, @@ -206,11 +208,11 @@ export class S2TableLayout extends TableLayout { } protected buildCollection(): LayoutNode[] { - let [header, body] = super.buildCollection(); - if (!header) { + let rowGroups = super.buildCollection(); + if (rowGroups.length < 2) { return []; } - let {layoutInfo} = body; + 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; @@ -218,10 +220,7 @@ export class S2TableLayout extends TableLayout { layoutInfo.rect.width = this.virtualizer!.size.width - 80; } - return [ - header, - body - ]; + return rowGroups; } protected buildLoader(node: Node, x: number, y: number): LayoutNode { @@ -1511,6 +1510,7 @@ const rowBackgroundColor = { isHovered: selectedActiveBackground, // table-selected-row-background-color, opacity /15 isPressed: selectedActiveBackground // table-selected-row-background-color, opacity /15 }, + isInFooter: 'gray-200', forcedColors: { default: 'Background' } @@ -1523,10 +1523,11 @@ const rowTextColor = { default: 'disabled', forcedColors: 'GrayText' }, + isInFooter: 'neutral', forcedColors: 'ButtonText' } as const; -const row = style({ +const row = style({ height: 'full', position: 'relative', boxSizing: 'border-box', @@ -1580,6 +1581,10 @@ const row = style({ default: 'gray-300', forcedColors: 'ButtonBorder' }, + fontWeight: { + default: 'normal', + isInFooter: 'bold' + }, forcedColorAdjust: 'none' }); @@ -1599,6 +1604,7 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row row({ ...renderProps, - ...tableVisualOptions + ...tableVisualOptions, + isInFooter }) + (renderProps.isFocusVisible ? ' ' + css('&:before { content: ""; display: inline-block; position: sticky; inset-inline-start: 0; width: 3px; height: 100%; margin-inline-end: -3px; margin-block-end: 1px; z-index: 3; background-color: var(--rowFocusIndicatorColor)') : '')} {...otherProps}> {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( @@ -1625,3 +1634,22 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row) ); }); + +export interface TableFooterProps extends Omit, 'style' | 'className' | 'render' | 'onHoverChange' | 'onHoverStart' | 'onHoverEnd' | keyof GlobalDOMAttributes> {} + +const FooterContext = createContext(false); + +/** + * A footer within a ``, containing summary rows. + */ +export const TableFooter = /*#__PURE__*/ (forwardRef as forwardRefType)(function TableFooter(props: TableFooterProps, ref: DOMRef) { + let domRef = useDOMRef(ref); + + return ( + + + + ); +}); diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 3ac2f3b5d8c..77d3c111805 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -22,6 +22,7 @@ import { EditableCell, Row, TableBody, + TableFooter, TableHeader, TableView, TableViewProps @@ -1887,3 +1888,44 @@ function NestedInlineEditExample(args) { export const TableWithNestedRowsAndInlineEditing: StoryObj = { render: (args) => }; + +const invoices = [ + {title: 'Website Design', status: 'Paid', paymentMethod: 'Credit Card', price: '$1,200'}, + {title: 'Logo Creation', status: 'Pending', paymentMethod: 'PayPal', price: '$350'}, + {title: 'SEO Optimization', status: 'Overdue', paymentMethod: 'Bank Transfer', price: '$800'}, + {title: 'Social Media Setup', status: 'Paid', paymentMethod: 'Debit Card', price: '$450'}, + {title: 'Content Writing', status: 'Pending', paymentMethod: 'Credit Card', price: '$600'}, + {title: 'App Development', status: 'Paid', paymentMethod: 'Wire Transfer', price: '$5,000'}, + {title: 'Maintenance Plan', status: 'Overdue', paymentMethod: 'PayPal', price: '$200'} +]; + +export const TableFooterExample: StoryObj = { + render: (args) => { + return ( + + + Title + Status + Payment Method + Price + + + {item => ( + + {item.title} + {item.status} + {item.paymentMethod} + {item.price} + + )} + + + + Total: + {invoices.reduce((p, item) => p + Number(item.price.replace(/[$,]/g, '')), 0).toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0})} + + + + ); + } +}; diff --git a/packages/@react-spectrum/s2/test/TableView.test.tsx b/packages/@react-spectrum/s2/test/TableView.test.tsx index e6d01c5662f..a14c2267f25 100644 --- a/packages/@react-spectrum/s2/test/TableView.test.tsx +++ b/packages/@react-spectrum/s2/test/TableView.test.tsx @@ -12,8 +12,8 @@ jest.mock('react-aria/src/live-announcer/LiveAnnouncer'); jest.mock('react-aria/src/utils/scrollIntoView'); -import {act, render} from '@react-spectrum/test-utils-internal'; -import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../src/TableView'; +import {act, render, within} from '@react-spectrum/test-utils-internal'; +import {Cell, Column, Row, TableBody, TableFooter, TableHeader, TableView} from '../src/TableView'; import {DisabledBehavior} from '@react-types/shared'; import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg'; import {MenuItem, MenuSection} from '../src/Menu'; @@ -33,7 +33,7 @@ describe('TableView', () => { beforeAll(function () { user = userEvent.setup({delay: null, pointerMap}); offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 400); - offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 200); + offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); window.CSSTransition = jest.fn(({children}) => children); // Mock the getAnimations method @@ -196,4 +196,67 @@ describe('TableView', () => { expect(tabs[0]).toHaveAttribute('aria-selected', 'false'); expect(tabs[1]).toHaveAttribute('aria-selected', 'true'); }); + + it('should support table footer', async () => { + const invoices = [ + {title: 'Website Design', status: 'Paid', paymentMethod: 'Credit Card', price: '$1,200'}, + {title: 'Logo Creation', status: 'Pending', paymentMethod: 'PayPal', price: '$350'}, + {title: 'SEO Optimization', status: 'Overdue', paymentMethod: 'Bank Transfer', price: '$800'}, + {title: 'Social Media Setup', status: 'Paid', paymentMethod: 'Debit Card', price: '$450'}, + {title: 'Content Writing', status: 'Pending', paymentMethod: 'Credit Card', price: '$600'}, + {title: 'App Development', status: 'Paid', paymentMethod: 'Wire Transfer', price: '$5,000'}, + {title: 'Maintenance Plan', status: 'Overdue', paymentMethod: 'PayPal', price: '$200'} + ]; + + let onSelectionChange = jest.fn(); + let {container: root} = render( + + + Title + Status + Payment Method + Price + + + {item => ( + + {item.title} + {item.status} + {item.paymentMethod} + {item.price} + + )} + + + + Total: + {invoices.reduce((p, item) => p + Number(item.price.replace(/[$,]/g, '')), 0).toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0})} + + + + ); + + let tableTester = testUtilUser.createTester('Table', {root}); + + let groups = tableTester.rowGroups; + expect(groups).toHaveLength(3); + + await user.tab(); + for (let row of tableTester.rows) { + expect(document.activeElement).toBe(row); + await user.keyboard('{ArrowDown}'); + } + + for (let row of tableTester.rows.toReversed().slice(1)) { + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(row); + } + + let footerRows = within(groups[2]).getAllByRole('row'); + await user.click(footerRows[0]); + expect(onSelectionChange).not.toHaveBeenCalled(); + + await user.click(tableTester.rows[0]); + expect(onSelectionChange).toHaveBeenCalled(); + }); }); diff --git a/packages/dev/s2-docs/pages/react-aria/Table.mdx b/packages/dev/s2-docs/pages/react-aria/Table.mdx index b162687f58d..465f29684b3 100644 --- a/packages/dev/s2-docs/pages/react-aria/Table.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Table.mdx @@ -159,7 +159,7 @@ In this example, both the columns and the rows are provided to the table via a r ```tsx render "use client"; -import {Table, TableHeader, Column, Row, TableBody, Cell} from 'vanilla-starter/Table'; +import {Table, TableHeader, Column, Row, TableBody, Cell, TableFooter} from 'vanilla-starter/Table'; import {CheckboxGroup} from 'vanilla-starter/CheckboxGroup'; import {Checkbox} from 'vanilla-starter/Checkbox'; import {Button} from 'vanilla-starter/Button'; @@ -167,39 +167,40 @@ import {useState} from 'react'; ///- begin collapse -/// const columns = [ - {name: 'Name', id: 'name', isRowHeader: true}, - {name: 'Type', id: 'type'}, - {name: 'Date Modified', id: 'date'} + {name: 'Title', id: 'title', isRowHeader: true}, + {name: 'Status', id: 'status'}, + {name: 'Payment Method', id: 'paymentMethod'}, + {name: 'Price', id: 'price'} ]; ///- end collapse -/// ///- begin collapse -/// const initialRows = [ - {id: 1, name: 'Games', date: '6/7/2020', type: 'File folder'}, - {id: 2, name: 'Program Files', date: '4/7/2021', type: 'File folder'}, - {id: 3, name: 'bootmgr', date: '11/20/2010', type: 'System file'}, - {id: 4, name: 'log.txt', date: '1/18/2016', type: 'Text Document'} + {id: 1, title: 'Website Design', status: 'Paid', paymentMethod: 'Credit Card', price: 1200}, + {id: 2, title: 'Logo Creation', status: 'Pending', paymentMethod: 'PayPal', price: 350}, + {id: 3, title: 'SEO Optimization', status: 'Overdue', paymentMethod: 'Bank Transfer', price: 800}, + {id: 4, title: 'Social Media Setup', status: 'Paid', paymentMethod: 'Debit Card', price: 450}, + {id: 5, title: 'Content Writing', status: 'Pending', paymentMethod: 'Credit Card', price: 600} ]; ///- end collapse -/// function FileTable() { - let [showColumns, setShowColumns] = useState(['name', 'type', 'date']); + let [showColumns, setShowColumns] = useState(['title', 'status', 'paymentMethod', 'price']); let visibleColumns = columns.filter(column => showColumns.includes(column.id)); let [rows, setRows] = useState(initialRows); let addRow = () => { - let date = new Date().toLocaleDateString(); setRows(rows => [ ...rows, - {id: rows.length + 1, name: 'file.txt', date, type: 'Text Document'} + {id: rows.length + 1, title: 'New Invoice', status: 'Pending', paymentMethod: 'Credit Card', price: 250} ]); }; return (
- Type - Date Modified + Status + Payment Method
@@ -214,10 +215,22 @@ function FileTable() { {item => ( /*- end highlight -*/ - {column => {item[column.id]}} + {column => ( + + {column.id === 'price' + ? item.price.toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}) + : item[column.id]} + + )} )} + + + Total + {rows.reduce((p, row) => p + row.price, 0).toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0})} + +
@@ -804,7 +817,7 @@ function ReorderableTable() { -```tsx links={{ResizableTableContainer: '#resizabletablecontainer', Table: '#table', TableHeader: '#tableheader', Column: '#column', Checkbox: 'Checkbox', ColumnResizer: '#columnresizer', TableBody: '#tablebody', Row: '#row', Cell: '#cell', Button: 'Button', TableLoadMoreItem: '#tableloadmoreitem', SelectionIndicator: 'selection#animated-selectionindicator'}} +```tsx links={{ResizableTableContainer: '#resizabletablecontainer', Table: '#table', TableHeader: '#tableheader', Column: '#column', Checkbox: 'Checkbox', ColumnResizer: '#columnresizer', TableBody: '#tablebody', Row: '#row', Cell: '#cell', TableFooter: '#tablefooter', Button: 'Button', TableLoadMoreItem: '#tableloadmoreitem', SelectionIndicator: 'selection#animated-selectionindicator'}} @@ -829,6 +842,11 @@ function ReorderableTable() { + + + + +
``` @@ -863,6 +881,10 @@ function ReorderableTable() { +### TableFooter + + + ### ResizableTableContainer diff --git a/packages/dev/s2-docs/pages/s2/TableView.mdx b/packages/dev/s2-docs/pages/s2/TableView.mdx index 6b40b756d0e..09b8cef931b 100644 --- a/packages/dev/s2-docs/pages/s2/TableView.mdx +++ b/packages/dev/s2-docs/pages/s2/TableView.mdx @@ -89,7 +89,7 @@ In this example, both the columns and the rows are provided to the table via a r ```tsx render type="s2" "use client"; -import {TableView, TableHeader, Column, TableBody, Row, Cell} from '@react-spectrum/s2/TableView'; +import {TableView, TableHeader, Column, TableBody, Row, Cell, TableFooter} from '@react-spectrum/s2/TableView'; import {CheckboxGroup, Checkbox} from '@react-spectrum/s2/CheckboxGroup'; import {ActionButton} from '@react-spectrum/s2/ActionButton'; import {useState} from 'react'; @@ -97,39 +97,40 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; ///- begin collapse -/// const columns = [ - {name: 'Name', id: 'name', isRowHeader: true}, - {name: 'Type', id: 'type'}, - {name: 'Date Modified', id: 'date'} + {name: 'Title', id: 'title', isRowHeader: true}, + {name: 'Status', id: 'status'}, + {name: 'Payment Method', id: 'paymentMethod'}, + {name: 'Price', id: 'price'} ]; ///- end collapse -/// ///- begin collapse -/// const initialRows = [ - {id: 1, name: 'Games', date: '6/7/2020', type: 'File folder'}, - {id: 2, name: 'Program Files', date: '4/7/2021', type: 'File folder'}, - {id: 3, name: 'bootmgr', date: '11/20/2010', type: 'System file'}, - {id: 4, name: 'log.txt', date: '1/18/2016', type: 'Text Document'} + {id: 1, title: 'Website Design', status: 'Paid', paymentMethod: 'Credit Card', price: 1200}, + {id: 2, title: 'Logo Creation', status: 'Pending', paymentMethod: 'PayPal', price: 350}, + {id: 3, title: 'SEO Optimization', status: 'Overdue', paymentMethod: 'Bank Transfer', price: 800}, + {id: 4, title: 'Social Media Setup', status: 'Paid', paymentMethod: 'Debit Card', price: 450}, + {id: 5, title: 'Content Writing', status: 'Pending', paymentMethod: 'Credit Card', price: 600} ]; ///- end collapse -/// function FileTable() { - let [showColumns, setShowColumns] = useState(['name', 'type', 'date']); + let [showColumns, setShowColumns] = useState(['title', 'status', 'paymentMethod', 'price']); let visibleColumns = columns.filter(column => showColumns.includes(column.id)); let [rows, setRows] = useState(initialRows); let addRow = () => { - let date = new Date().toLocaleDateString(); setRows(rows => [ ...rows, - {id: rows.length + 1, name: 'file.txt', date, type: 'Text Document'} + {id: rows.length + 1, title: 'New Invoice', status: 'Pending', paymentMethod: 'Credit Card', price: 250} ]); }; return (
- Type - Date Modified + Status + Payment Method @@ -144,10 +145,22 @@ function FileTable() { {item => ( /*- end highlight -*/ - {column => {item[column.id]}} + {column => ( + + {column.id === 'price' + ? item.price.toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}) + : item[column.id]} + + )} )} + + + Total + {rows.reduce((p, row) => p + row.price, 0).toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0})} + + Add row
@@ -370,7 +383,7 @@ Use the `align` prop on a Column and Cell to set the text alignment. `showDivide ```tsx render hideImports type="s2" "use client"; -import {TableView, TableHeader, Column, TableBody, Row, Cell, Collection} from '@react-spectrum/s2/TableView'; +import {TableView, TableHeader, Column, TableBody, Row, Cell, Collection, TableFooter} from '@react-spectrum/s2/TableView'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; ///- begin collapse -/// @@ -421,13 +434,15 @@ function TableWithDividers() { )} + + {/*- begin highlight -*/} Total: {/*- end highlight -*/} {rows.reduce((p, v) => p + v.level, 0)} - + ); } @@ -934,7 +949,7 @@ function subscribe(fn) { ## API -```tsx links={{TableView: '#tableview', TableHeader: '#tableheader', Column: '#column', TableBody: '#tablebody', Row: '#row', Cell: '#cell', 'EditableCell': '#editablecell'}} +```tsx links={{TableView: '#tableview', TableHeader: '#tableheader', Column: '#column', TableBody: '#tablebody', Row: '#row', Cell: '#cell', TableFooter: '#tablefooter', 'EditableCell': '#editablecell'}} @@ -945,6 +960,11 @@ function subscribe(fn) { + + + + + ``` @@ -972,6 +992,10 @@ function subscribe(fn) { +### TableFooter + + + ### EditableCell diff --git a/packages/react-aria-components/exports/Table.ts b/packages/react-aria-components/exports/Table.ts index 6f1791c431f..4e4d2138669 100644 --- a/packages/react-aria-components/exports/Table.ts +++ b/packages/react-aria-components/exports/Table.ts @@ -14,8 +14,8 @@ // to import it from a React Server Component in a framework like Next.js. import 'client-only'; -export {TableLoadMoreItem, Table, Row, Cell, Column, ColumnResizer, TableHeader, TableBody, TableContext, ResizableTableContainer, useTableOptions, TableStateContext, TableColumnResizeStateContext} from '../src/Table'; -export type {TableProps, TableRenderProps, TableHeaderProps, TableBodyProps, TableBodyRenderProps, ResizableTableContainerProps, ColumnProps, ColumnRenderProps, ColumnResizerProps, ColumnResizerRenderProps, RowProps, RowRenderProps, CellProps, CellRenderProps, TableLoadMoreItemProps} from '../src/Table'; +export {TableLoadMoreItem, Table, Row, Cell, Column, ColumnResizer, TableHeader, TableBody, TableContext, ResizableTableContainer, useTableOptions, TableStateContext, TableColumnResizeStateContext, TableFooter} from '../src/Table'; +export type {TableProps, TableRenderProps, TableHeaderProps, TableBodyProps, TableBodyRenderProps, ResizableTableContainerProps, ColumnProps, ColumnRenderProps, ColumnResizerProps, ColumnResizerRenderProps, RowProps, RowRenderProps, CellProps, CellRenderProps, TableLoadMoreItemProps, TableFooterProps} from '../src/Table'; export type {Key, Selection, SortDescriptor, SortDirection, SelectionMode, DirectoryDropItem, DraggableCollectionEndEvent, DraggableCollectionMoveEvent, DraggableCollectionStartEvent, DragPreviewRenderer, DragTypes, DropItem, DropOperation, DroppableCollectionDropEvent, DroppableCollectionEnterEvent, DroppableCollectionExitEvent, DroppableCollectionInsertDropEvent, DroppableCollectionMoveEvent, DroppableCollectionOnItemDropEvent, DroppableCollectionReorderEvent, DroppableCollectionRootDropEvent, DropPosition, DropTarget, FileDropItem, ItemDropTarget, RootDropTarget, TextDropItem} from '@react-types/shared'; export type {TableState} from 'react-stately/useTableState'; export {Collection, type CollectionProps} from 'react-aria/Collection'; diff --git a/packages/react-aria-components/exports/index.ts b/packages/react-aria-components/exports/index.ts index cb24054d65b..35fd80f54e7 100644 --- a/packages/react-aria-components/exports/index.ts +++ b/packages/react-aria-components/exports/index.ts @@ -64,7 +64,7 @@ export {Separator, SeparatorContext} from '../src/Separator'; export {SharedElementTransition, SharedElement} from '../src/SharedElementTransition'; export {Slider, SliderOutput, SliderTrack, SliderThumb, SliderContext, SliderOutputContext, SliderTrackContext, SliderStateContext} from '../src/Slider'; export {Switch, SwitchField, SwitchButton, SwitchContext, SwitchFieldContext} from '../src/Switch'; -export {TableLoadMoreItem, Table, Row, Cell, Column, ColumnResizer, TableHeader, TableBody, TableContext, ResizableTableContainer, useTableOptions, TableStateContext, TableColumnResizeStateContext, TableLayout} from '../src/Table'; +export {TableLoadMoreItem, Table, Row, Cell, Column, ColumnResizer, TableHeader, TableBody, TableContext, ResizableTableContainer, useTableOptions, TableStateContext, TableColumnResizeStateContext, TableLayout, TableFooter} from '../src/Table'; export {Tabs, TabList, TabPanels, TabPanel, Tab, TabsContext, TabListStateContext} from '../src/Tabs'; export {TagGroup, TagGroupContext, TagList, TagListContext, Tag} from '../src/TagGroup'; export {Text, TextContext} from '../src/Text'; @@ -147,7 +147,7 @@ export type {SharedElementTransitionProps, SharedElementProps, SharedElementRend export type {SeparatorProps} from '../src/Separator'; export type {SliderOutputProps, SliderProps, SliderRenderProps, SliderThumbProps, SliderTrackProps, SliderTrackRenderProps, SliderThumbRenderProps} from '../src/Slider'; export type {SwitchProps, SwitchRenderProps, SwitchFieldProps, SwitchFieldRenderProps, SwitchButtonProps, SwitchButtonRenderProps} from '../src/Switch'; -export type {TableProps, TableRenderProps, TableHeaderProps, TableBodyProps, TableBodyRenderProps, ResizableTableContainerProps, ColumnProps, ColumnRenderProps, ColumnResizerProps, ColumnResizerRenderProps, RowProps, RowRenderProps, CellProps, CellRenderProps, TableLoadMoreItemProps} from '../src/Table'; +export type {TableProps, TableRenderProps, TableHeaderProps, TableBodyProps, TableBodyRenderProps, ResizableTableContainerProps, ColumnProps, ColumnRenderProps, ColumnResizerProps, ColumnResizerRenderProps, RowProps, RowRenderProps, CellProps, CellRenderProps, TableLoadMoreItemProps, TableFooterProps} from '../src/Table'; export type {TabListProps, TabListRenderProps, TabPanelsProps, TabPanelProps, TabPanelRenderProps, TabProps, TabsProps, TabRenderProps, TabsRenderProps} from '../src/Tabs'; export type {TagGroupProps, TagListProps, TagListRenderProps, TagProps, TagRenderProps} from '../src/TagGroup'; export type {TextAreaProps} from '../src/TextArea'; diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index e98302b8e0d..0baeb3ce6a3 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -88,7 +88,6 @@ class TableCollection extends BaseCollection implements ITableCollection[] = []; rowHeaderColumnKeys: Set = new Set(); head = new TableHeaderNode(-1); - body = new TableBodyNode(-2); columnsDirty = true; expandedKeys: Set = new Set(); @@ -96,7 +95,7 @@ class TableCollection extends BaseCollection implements ITableCollection extends BaseCollection implements ITableCollection[] { + let rows: GridNode[] = []; + for (let child of this) { + if (child.type === 'tablebody' || child.type === 'tablefooter') { + rows.push(...this.getChildren(child.key)); + } + } + return rows; + } - if (node.type === 'tablebody') { - this.body = node; + // backward compatibility + get body() { + for (let child of this) { + if (child.type === 'tablebody') { + return child; + } } + return new TableBodyNode(-2); } commit(firstKey: Key, lastKey: Key, isSSR = false) { this.updateColumns(isSSR); + this.firstKey = firstKey; + this.lastKey = lastKey; this.rows = []; - for (let row of this.getChildren(this.body.key)) { + for (let row of this.getRows()) { let lastChildKey = (row as CollectionNode).lastChildKey; if (lastChildKey != null) { let lastCell = this.getItem(lastChildKey) as GridNode | null; @@ -180,20 +197,27 @@ class TableCollection extends BaseCollection implements ITableCollection extends BaseCollection implements ITableCollection extends BaseCollection implements ITableCollection> { - if (!this.getItem(key)) { + let item = this.getItem(key); + if (!item) { for (let row of this.headerRows) { if (row.key === key) { return row.childNodes; @@ -276,16 +301,19 @@ class TableCollection extends BaseCollection implements ITableCollection | null = firstKey != null ? self.getItem(firstKey) : null; while (node) { yield node as Node; let key = self.getKeyAfter(node.key); node = key ? self.getItem(key) : null; + if (node && node.parentKey === item.parentKey) { + break; + } } } }; @@ -315,7 +343,6 @@ class TableCollection extends BaseCollection implements ITableCollection`, containing the table rows. */ -export const TableBody = /*#__PURE__*/ createBranchComponent(TableBodyNode, (props: TableBodyProps, ref: ForwardedRef) => { +export const TableBody = /*#__PURE__*/ createBranchComponent(TableBodyNode, (props: TableBodyProps, ref: ForwardedRef, node: Node) => { let state = useContext(TableStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); let collection = state.collection; @@ -1275,13 +1302,63 @@ export const TableBody = /*#__PURE__*/ createBranchComponent(TableBodyNode, } {emptyState} ); }); +class TableFooterNode extends FilterableNode { + static readonly type = 'tablefooter'; +} + +export interface TableFooterProps extends Omit, 'disabledKeys'>, StyleProps, GlobalDOMAttributes { + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. + * @default 'react-aria-TableFooter' + */ + className?: string +} + +let TableFooterElementType = forwardRef(function TableFooterElementType(props: any, ref: ForwardedRef) { + let {isVirtualized} = useContext(CollectionRendererContext); + if (isVirtualized) { + return ; + } + return ; +}); + +/** + * The footer of a ``, containing table rows. + */ +export const TableFooter = /*#__PURE__*/ createBranchComponent(TableFooterNode, (props: TableFooterProps, ref: ForwardedRef, node: Node) => { + let state = useContext(TableStateContext)!; + let collection = state.collection as TableCollection; + let {CollectionBranch} = useContext(CollectionRendererContext); + let {dragAndDropHooks, dropState} = useContext(DragAndDropContext); + + let {rowGroupProps} = useTableRowGroup(); + let DOMProps = filterDOMProps(props, {global: true}); + let renderProps = useRenderProps({ + style: props.style, + className: props.className, + defaultClassName: 'react-aria-TableFooter', + values: {} + }); + + return ( + + + + ); +}); + export interface RowRenderProps extends ItemRenderProps { /** Whether the row's children have keyboard focus. */ isFocusVisibleWithin: boolean, @@ -1322,6 +1399,8 @@ export interface RowProps extends StyleRenderProps {
); }; + +const invoices = [ + {title: 'Website Design', status: 'Paid', paymentMethod: 'Credit Card', price: '$1,200'}, + {title: 'Logo Creation', status: 'Pending', paymentMethod: 'PayPal', price: '$350'}, + {title: 'SEO Optimization', status: 'Overdue', paymentMethod: 'Bank Transfer', price: '$800'}, + {title: 'Social Media Setup', status: 'Paid', paymentMethod: 'Debit Card', price: '$450'}, + {title: 'Content Writing', status: 'Pending', paymentMethod: 'Credit Card', price: '$600'}, + {title: 'App Development', status: 'Paid', paymentMethod: 'Wire Transfer', price: '$5,000'}, + {title: 'Maintenance Plan', status: 'Overdue', paymentMethod: 'PayPal', price: '$200'} +]; + +export const TableFooterExample: TableStory = (args) => { + return ( + + + Title + Status + Payment Method + Price + + + {item => ( + + {item.title} + {item.status} + {item.paymentMethod} + {item.price} + + )} + + + + Total: + {invoices.reduce((p, item) => p + Number(item.price.replace(/[$,]/g, '')), 0).toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0})} + + +
+ ); +}; + +export const VirtualizedTableFooter: TableStory = (args) => { + return ( + + + + Title + Status + Payment Method + Price + + + {item => ( + + {item.title} + {item.status} + {item.paymentMethod} + {item.price} + + )} + + + + Total: + {invoices.reduce((p, item) => p + Number(item.price.replace(/[$,]/g, '')), 0).toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0})} + + +
+
+ ); +}; + +export const TableSectionExample: TableStory = (args) => { + let sections = ['Overdue', 'Pending', 'Paid']; + + return ( + + + Title + Payment Method + Price + + {sections.map(section => ( + + + {section} + + invoice.status === section)}> + {item => ( + + {item.title} + {item.paymentMethod} + {item.price} + + )} + + + + ))} +
+ ); +}; + +export const TableSectionDnd: TableStory = (args) => { + let sections = ['Overdue', 'Pending', 'Paid']; + let tree = useTreeData({ + initialItems: sections.map(section => ({ + title: section, + children: invoices.filter(invoice => invoice.status === section) + })), + getKey: item => item.title + }); + + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys, items: typeof tree.items) => items.map(item => ({'text/plain': item.value.title})), + getDropOperation(target) { + // Prevent dropping on section headers or at the top-level (turning an item into a section) + if (target.type === 'item' && target.dropPosition !== 'on') { + let item = tree.getItem(target.key); + return item?.parentKey ? 'move' : 'cancel'; + } + return 'cancel'; + }, + onMove(e) { + if (e.target.dropPosition === 'before') { + tree.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + tree.moveAfter(e.target.key, e.keys); + } else if (e.target.dropPosition === 'on') { + let targetNode = tree.getItem(e.target.key); + if (targetNode) { + let targetIndex = targetNode.children ? targetNode.children.length : 0; + let keyArray = Array.from(e.keys); + for (let i = 0; i < keyArray.length; i++) { + tree.move(keyArray[i], e.target.key, targetIndex + i); + } + } + } + } + }); + + return ( + + + + Title + Payment Method + Price + + + {section => ( + + + {section.value.title} + + + {(item: any) => ( + + + {item.value.title} + {item.value.paymentMethod} + {item.value.price} + + )} + + + )} + + + + Total + {invoices.reduce((p, item) => p + Number(item.price.replace(/[$,]/g, '')), 0).toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0})} + + +
+ ); +}; diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 718817c850d..d900f920338 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -21,6 +21,7 @@ import { Row, Table, TableBody, + TableFooter, TableHeader, TableLayout, TableLoadMoreItem, @@ -2892,6 +2893,190 @@ describe('Table', () => { expect(onClick).toHaveBeenCalledTimes(1); }); }); + + it('should support table footer', async () => { + const invoices = [ + {title: 'Website Design', status: 'Paid', paymentMethod: 'Credit Card', price: '$1,200'}, + {title: 'Logo Creation', status: 'Pending', paymentMethod: 'PayPal', price: '$350'}, + {title: 'SEO Optimization', status: 'Overdue', paymentMethod: 'Bank Transfer', price: '$800'}, + {title: 'Social Media Setup', status: 'Paid', paymentMethod: 'Debit Card', price: '$450'}, + {title: 'Content Writing', status: 'Pending', paymentMethod: 'Credit Card', price: '$600'}, + {title: 'App Development', status: 'Paid', paymentMethod: 'Wire Transfer', price: '$5,000'}, + {title: 'Maintenance Plan', status: 'Overdue', paymentMethod: 'PayPal', price: '$200'} + ]; + + let {container: root} = render( + + + Title + Status + Payment Method + Price + + + {item => ( + + {item.title} + {item.status} + {item.paymentMethod} + {item.price} + + )} + + + + Total: + {invoices.reduce((p, item) => p + Number(item.price.replace(/[$,]/g, '')), 0).toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0})} + + +
+ ); + + let tableTester = testUtilUser.createTester('Table', {root}); + + let groups = tableTester.rowGroups; + expect(groups).toHaveLength(3); + expect(groups[0].tagName).toBe('THEAD'); + expect(groups[1].tagName).toBe('TBODY'); + expect(groups[2].tagName).toBe('TFOOT'); + expect(tableTester.rows).toHaveLength(8); + + await user.tab(); + for (let row of tableTester.rows) { + expect(document.activeElement).toBe(row); + await user.keyboard('{ArrowDown}'); + } + + for (let row of tableTester.rows.toReversed().slice(1)) { + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(row); + } + }); + + it('should support table footer with virtualizer', async () => { + const invoices = [ + {title: 'Website Design', status: 'Paid', paymentMethod: 'Credit Card', price: '$1,200'}, + {title: 'Logo Creation', status: 'Pending', paymentMethod: 'PayPal', price: '$350'}, + {title: 'SEO Optimization', status: 'Overdue', paymentMethod: 'Bank Transfer', price: '$800'}, + {title: 'Social Media Setup', status: 'Paid', paymentMethod: 'Debit Card', price: '$450'}, + {title: 'Content Writing', status: 'Pending', paymentMethod: 'Credit Card', price: '$600'}, + {title: 'App Development', status: 'Paid', paymentMethod: 'Wire Transfer', price: '$5,000'}, + {title: 'Maintenance Plan', status: 'Overdue', paymentMethod: 'PayPal', price: '$200'} + ]; + + let clientWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000); + let clientHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); + + let {container: root} = render( + + + + Title + Status + Payment Method + Price + + + {item => ( + + {item.title} + {item.status} + {item.paymentMethod} + {item.price} + + )} + + + + Total: + {invoices.reduce((p, item) => p + Number(item.price.replace(/[$,]/g, '')), 0).toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0})} + + +
+
+ ); + + let tableTester = testUtilUser.createTester('Table', {root}); + + let groups = tableTester.rowGroups; + expect(groups).toHaveLength(3); + expect(tableTester.rows).toHaveLength(8); + + await user.tab(); + for (let row of tableTester.rows) { + expect(document.activeElement).toBe(row); + await user.keyboard('{ArrowDown}'); + } + + for (let row of tableTester.rows.toReversed().slice(1)) { + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(row); + } + + clientWidth.mockRestore(); + clientHeight.mockRestore(); + }); + + it('should support multiple table bodies', async () => { + let sections = ['Overdue', 'Pending', 'Paid']; + let invoices = [ + {title: 'Website Design', status: 'Paid', paymentMethod: 'Credit Card', price: '$1,200'}, + {title: 'Logo Creation', status: 'Pending', paymentMethod: 'PayPal', price: '$350'}, + {title: 'SEO Optimization', status: 'Overdue', paymentMethod: 'Bank Transfer', price: '$800'}, + {title: 'Social Media Setup', status: 'Paid', paymentMethod: 'Debit Card', price: '$450'}, + {title: 'Content Writing', status: 'Pending', paymentMethod: 'Credit Card', price: '$600'}, + {title: 'App Development', status: 'Paid', paymentMethod: 'Wire Transfer', price: '$5,000'}, + {title: 'Maintenance Plan', status: 'Overdue', paymentMethod: 'PayPal', price: '$200'} + ]; + + let {container: root} = render( + + + Title + Payment Method + Price + + {sections.map(section => ( + + + {section} + + invoice.status === section)}> + {item => ( + + {item.title} + {item.paymentMethod} + {item.price} + + )} + + + + ))} +
+ ); + + let tableTester = testUtilUser.createTester('Table', {root}); + + let groups = tableTester.rowGroups; + expect(groups).toHaveLength(4); + expect(groups[0].tagName).toBe('THEAD'); + expect(groups[1].tagName).toBe('TBODY'); + expect(groups[2].tagName).toBe('TBODY'); + expect(groups[3].tagName).toBe('TBODY'); + expect(tableTester.rows).toHaveLength(10); + + await user.tab(); + for (let row of tableTester.rows) { + expect(document.activeElement).toBe(row); + await user.keyboard('{ArrowDown}'); + } + + for (let row of tableTester.rows.toReversed().slice(1)) { + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(row); + } + }); }); function HidingColumnsExample({dynamic = false}) { diff --git a/packages/react-aria/src/grid/GridKeyboardDelegate.ts b/packages/react-aria/src/grid/GridKeyboardDelegate.ts index 8216abbc0f4..b14a597d7cc 100644 --- a/packages/react-aria/src/grid/GridKeyboardDelegate.ts +++ b/packages/react-aria/src/grid/GridKeyboardDelegate.ts @@ -59,7 +59,7 @@ export class GridKeyboardDelegate> implements Key } private isDisabled(item: Node) { - return this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key)); + return this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key)) && item.props?.disabledBehavior !== 'selection'; } protected findPreviousKey(fromKey?: Key, pred?: (item: Node) => boolean): Key | null { diff --git a/packages/react-aria/src/selection/ListKeyboardDelegate.ts b/packages/react-aria/src/selection/ListKeyboardDelegate.ts index 5a75a4129ae..65533fe7044 100644 --- a/packages/react-aria/src/selection/ListKeyboardDelegate.ts +++ b/packages/react-aria/src/selection/ListKeyboardDelegate.ts @@ -71,7 +71,7 @@ export class ListKeyboardDelegate implements KeyboardDelegate { } private isDisabled(item: Node) { - return this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key)); + return this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key)) && item.props?.disabledBehavior !== 'selection'; } private findNextNonDisabled(key: Key | null, getNext: (key: Key) => Key | null): Key | null { diff --git a/packages/react-stately/src/layout/ListLayout.ts b/packages/react-stately/src/layout/ListLayout.ts index 8265068a62c..b5840c37ba6 100644 --- a/packages/react-stately/src/layout/ListLayout.ts +++ b/packages/react-stately/src/layout/ListLayout.ts @@ -597,7 +597,9 @@ export class ListLayout exte this.validRect[heightProperty] = Math.min(this.validRect[heightProperty], layoutInfo.rect[offsetProperty] - this.validRect[offsetProperty]); // The requestedRect also needs to be adjusted to account for the height difference. - this.requestedRect[heightProperty] += newLayoutInfo.rect[heightProperty] - layoutInfo.rect[heightProperty]; + if (layoutNode.node?.type === 'item') { + this.requestedRect[heightProperty] += newLayoutInfo.rect[heightProperty] - layoutInfo.rect[heightProperty]; + } // Invalidate layout for this layout node and all parents this.updateLayoutNode(key, layoutInfo, newLayoutInfo); diff --git a/packages/react-stately/src/layout/TableLayout.ts b/packages/react-stately/src/layout/TableLayout.ts index dca6d1fdfba..cad8b6762ab 100644 --- a/packages/react-stately/src/layout/TableLayout.ts +++ b/packages/react-stately/src/layout/TableLayout.ts @@ -11,7 +11,7 @@ */ import {DropTarget, ItemDropTarget, Key} from '@react-types/shared'; -import {getChildNodes, getLastItem} from '../collections/getChildNodes'; +import {getChildNodes} from '../collections/getChildNodes'; import {GridNode} from '../grid/GridCollection'; import {InvalidationContext} from '../virtualizer/types'; import {LayoutInfo} from '../virtualizer/LayoutInfo'; @@ -135,10 +135,6 @@ export class TableLayout exten this.stickyColumnIndices = []; let collection = this.virtualizer!.collection as TableCollection; - if (collection.head?.key === -1) { - return []; - } - for (let column of collection.columns) { // The selection cell and any other sticky columns always need to be visible. // In addition, row headers need to be in the DOM for accessibility labeling. @@ -147,17 +143,53 @@ export class TableLayout exten } } - let header = this.buildTableHeader(); - this.layoutNodes.set(header.layoutInfo.key, header); - let body = this.buildBody(header.layoutInfo.rect.maxY + this.gap); - this.lastPersistedKeys = null; + let layoutNodes: LayoutNode[] = []; + let y = 0; + let width = 0; - body.layoutInfo.rect.width = Math.max(header.layoutInfo.rect.width, body.layoutInfo.rect.width); - this.contentSize = new Size(body.layoutInfo.rect.width + this.padding * 2, body.layoutInfo.rect.maxY + this.padding); - return [ - header, - body - ]; + if (!collection.head) { + let header = this.buildTableHeader(); + this.layoutNodes.set(header.layoutInfo.key, header); + let body = this.buildBody(header.layoutInfo.rect.maxY + this.gap); + body.layoutInfo.rect.width = Math.max(header.layoutInfo.rect.width, body.layoutInfo.rect.width); + y = body.layoutInfo.rect.maxY; + width = body.layoutInfo.rect.width; + layoutNodes = [header, body]; + } else { + for (let node of collection) { + switch (node.type) { + case 'tableheader': { + let header = this.buildTableHeader(); + y = header.layoutInfo.rect.maxY + this.gap; + width = Math.max(width, header.layoutInfo.rect.width); + this.layoutNodes.set(header.layoutInfo.key, header); + layoutNodes.push(header); + break; + } + case 'tablebody': + case 'tablefooter': { + let body = this.buildRowGroup(y, node); + y = body.layoutInfo.rect.maxY + this.gap; + width = Math.max(width, body.layoutInfo.rect.width); + this.layoutNodes.set(body.layoutInfo.key, body); + layoutNodes.push(body); + break; + } + } + } + + if (y > 0) { + y -= this.gap; + } + + for (let layoutNode of layoutNodes) { + layoutNode.layoutInfo.rect.width = width; + } + } + + this.lastPersistedKeys = null; + this.contentSize = new Size(width + this.padding * 2, y + this.padding); + return layoutNodes; } protected buildTableHeader(): LayoutNode { @@ -296,21 +328,27 @@ export class TableLayout exten } protected buildBody(y: number): LayoutNode { + let collection = this.virtualizer!.collection as TableCollection; + return this.buildRowGroup(y, collection.body); + } + + protected buildRowGroup(y: number, node: GridNode): LayoutNode { let collection = this.virtualizer!.collection as TableCollection; let rect = new Rect(this.padding, y, 0, 0); - let layoutInfo = new LayoutInfo('rowgroup', collection.body.key, rect); + let layoutInfo = new LayoutInfo('rowgroup', node.key, rect); let startY = y; - let skipped = 0; let width = 0; let children: LayoutNode[] = []; let rowHeight = this.getEstimatedRowHeight() + this.gap; - let childNodes = getChildNodes(collection.body, collection); + let childNodes = getChildNodes(node, collection); for (let node of childNodes) { - // Skip rows before the valid rectangle unless they are already cached. - if (y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) { + // Skip rows outside the valid rectangle unless they are already cached. + if ( + (y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) || + (y > this.requestedRect.maxY && node.type !== 'loader') + ) { y += rowHeight; - skipped++; continue; } @@ -320,26 +358,6 @@ export class TableLayout exten y = layoutNode.layoutInfo.rect.maxY + this.gap; width = Math.max(width, layoutNode.layoutInfo.rect.width); children.push(layoutNode); - - if (y > this.requestedRect.maxY) { - let rowsAfterRect = collection.size - (children.length + skipped); - let lastNode = getLastItem(childNodes); - - // Estimate the remaining height for rows that we don't need to layout right now. - y += rowsAfterRect * rowHeight; - - // Always add the loader sentinel if present. This assumes the loader is the last row in the body, - // will need to refactor when handling multi section loading - if (lastNode?.type === 'loader' && children.at(-1)?.layoutInfo.type !== 'loader') { - let loader = this.buildChild(lastNode, this.padding, y, layoutInfo.key); - loader.layoutInfo.parentKey = layoutInfo.key; - loader.index = collection.size; - width = Math.max(width, loader.layoutInfo.rect.width); - children.push(loader); - y = loader.layoutInfo.rect.maxY; - } - break; - } } // Make sure that the table body gets a height if empty or performing initial load @@ -357,7 +375,7 @@ export class TableLayout exten layoutInfo, children, validRect: layoutInfo.rect.intersection(this.requestedRect), - node: collection.body + node }; } @@ -686,7 +704,7 @@ export class TableLayout exten getDropTargetLayoutInfo(target: ItemDropTarget): LayoutInfo { let layoutInfo = super.getDropTargetLayoutInfo(target); - layoutInfo.parentKey = (this.virtualizer!.collection as TableCollection).body.key; + layoutInfo.parentKey = this.virtualizer!.collection.getItem(target.key)?.parentKey ?? null; return layoutInfo; } } diff --git a/packages/react-stately/src/selection/SelectionManager.ts b/packages/react-stately/src/selection/SelectionManager.ts index 331505ae365..5d7b30a4a81 100644 --- a/packages/react-stately/src/selection/SelectionManager.ts +++ b/packages/react-stately/src/selection/SelectionManager.ts @@ -512,7 +512,8 @@ export class SelectionManager implements MultipleSelectionManager { } isDisabled(key: Key): boolean { - return this.state.disabledBehavior === 'all' && (this.state.disabledKeys.has(key) || !!this.collection.getItem(key)?.props?.isDisabled); + let item = this.collection.getItem(key); + return this.state.disabledBehavior === 'all' && (this.state.disabledKeys.has(key) || !!item?.props?.isDisabled) && item?.props?.disabledBehavior !== 'selection'; } isLink(key: Key): boolean { diff --git a/starters/docs/src/Table.css b/starters/docs/src/Table.css index bf634557bbb..68563297932 100644 --- a/starters/docs/src/Table.css +++ b/starters/docs/src/Table.css @@ -362,3 +362,8 @@ height: 32px; position: relative; } + +.react-aria-TableFooter { + font-weight: bold; + background: var(--gray-200); +} diff --git a/starters/docs/src/Table.tsx b/starters/docs/src/Table.tsx index 5c07382c394..4a69ad9e89e 100644 --- a/starters/docs/src/Table.tsx +++ b/starters/docs/src/Table.tsx @@ -18,6 +18,8 @@ import { ColumnResizer, TableLoadMoreItem as AriaTableLoadMoreItem, type TableLoadMoreItemProps, + TableFooter as AriaTableFooter, + type TableFooterProps } from 'react-aria-components/Table'; import { Group } from 'react-aria-components/Group'; import { composeRenderProps } from 'react-aria-components/composeRenderProps'; @@ -112,6 +114,10 @@ export function TableBody(props: TableBodyProps) { return ; } +export function TableFooter(props: TableFooterProps) { + return ; +} + export function Cell(props: CellProps) { return ( diff --git a/starters/tailwind/src/Table.tsx b/starters/tailwind/src/Table.tsx index 4ccd9fb1927..4e9c7d9484f 100644 --- a/starters/tailwind/src/Table.tsx +++ b/starters/tailwind/src/Table.tsx @@ -19,6 +19,8 @@ import { type TableProps as AriaTableProps, useTableOptions, type TableBodyProps, + TableFooter as AriaTableFooter, + TableFooterProps } from 'react-aria-components/Table'; import { Group } from 'react-aria-components/Group'; import { composeRenderProps } from 'react-aria-components/composeRenderProps'; @@ -104,6 +106,10 @@ export function TableBody(props: TableBodyProps) { ); } +export function TableFooter(props: TableFooterProps) { + return ; +} + const rowStyles = tv({ extend: focusRing, base: 'group/row relative cursor-default select-none -outline-offset-2 text-neutral-900 disabled:text-neutral-300 dark:text-neutral-200 dark:disabled:text-neutral-600 text-sm hover:bg-neutral-100 pressed:bg-neutral-100 dark:hover:bg-neutral-800 dark:pressed:bg-neutral-800 selected:bg-blue-100 selected:hover:bg-blue-200 selected:pressed:bg-blue-200 dark:selected:bg-blue-700/30 dark:selected:hover:bg-blue-700/40 dark:selected:pressed:bg-blue-700/40 last:rounded-b-lg'