Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/@react-spectrum/s2/exports/TableView.ts
Original file line number Diff line number Diff line change
@@ -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';
4 changes: 2 additions & 2 deletions packages/@react-spectrum/s2/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down
46 changes: 37 additions & 9 deletions packages/@react-spectrum/s2/src/TableView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -206,22 +208,19 @@ export class S2TableLayout<T> extends TableLayout<T> {
}

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;
if (isEmptyOrLoading) {
layoutInfo.rect.width = this.virtualizer!.size.width - 80;
}

return [
header,
body
];
return rowGroups;
}

protected buildLoader(node: Node<T>, x: number, y: number): LayoutNode {
Expand Down Expand Up @@ -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'
}
Expand All @@ -1523,10 +1523,11 @@ const rowTextColor = {
default: 'disabled',
forcedColors: 'GrayText'
},
isInFooter: 'neutral',
forcedColors: 'ButtonText'
} as const;

const row = style<RowRenderProps & S2TableProps>({
const row = style<RowRenderProps & S2TableProps & {isInFooter?: boolean}>({
height: 'full',
position: 'relative',
boxSizing: 'border-box',
Expand Down Expand Up @@ -1580,6 +1581,10 @@ const row = style<RowRenderProps & S2TableProps>({
default: 'gray-300',
forcedColors: 'ButtonBorder'
},
fontWeight: {
default: 'normal',
isInFooter: 'bold'
},
forcedColorAdjust: 'none'
});

Expand All @@ -1599,16 +1604,20 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row<T e
let {selectionBehavior, selectionMode} = useTableOptions();
let tableVisualOptions = useContext(InternalTableContext);
let domRef = useDOMRef(ref);
let isInFooter = useContext(FooterContext);

return (
(<RACRow
// @ts-ignore
ref={domRef}
id={id}
dependencies={[...dependencies, columns]}
isDisabled={isInFooter}
disabledBehavior="selection"
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New feature: support for disabledBehavior at the individual Row level, not just at the Table level. This is so that the summary rows are not selectable but still focusable, regardless of the disabledBehavior of the rest of the table. Is this something we want to support?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that makes sense.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess its fine, but feels a bit iffy to me to make it public api if it really is only used for the summary row case. Could we not just do a private prop or context or somethingto set this?

className={renderProps => 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' && (
Expand All @@ -1625,3 +1634,22 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row<T e
</RACRow>)
);
});

export interface TableFooterProps<T> extends Omit<RACTableFooterProps<T>, 'style' | 'className' | 'render' | 'onHoverChange' | 'onHoverStart' | 'onHoverEnd' | keyof GlobalDOMAttributes> {}

const FooterContext = createContext(false);

/**
* A footer within a `<Table>`, containing summary rows.
*/
export const TableFooter = /*#__PURE__*/ (forwardRef as forwardRefType)(function TableFooter<T extends object>(props: TableFooterProps<T>, ref: DOMRef<HTMLDivElement>) {
let domRef = useDOMRef(ref);

return (
<FooterContext.Provider value>
<RACTableFooter
{...props}
ref={domRef} />
</FooterContext.Provider>
);
});
42 changes: 42 additions & 0 deletions packages/@react-spectrum/s2/stories/TableView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
EditableCell,
Row,
TableBody,
TableFooter,
TableHeader,
TableView,
TableViewProps
Expand Down Expand Up @@ -1887,3 +1888,44 @@ function NestedInlineEditExample(args) {
export const TableWithNestedRowsAndInlineEditing: StoryObj<typeof TableView> = {
render: (args) => <NestedInlineEditExample {...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<typeof TableView> = {
render: (args) => {
return (
<TableView aria-label="Files" selectionMode="multiple" styles={style({width: 700})} {...args}>
<TableHeader>
<Column isRowHeader>Title</Column>
<Column>Status</Column>
<Column>Payment Method</Column>
<Column>Price</Column>
</TableHeader>
<TableBody items={invoices}>
{item => (
<Row id={item.title}>
<Cell>{item.title}</Cell>
<Cell>{item.status}</Cell>
<Cell>{item.paymentMethod}</Cell>
<Cell>{item.price}</Cell>
</Row>
)}
</TableBody>
<TableFooter>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked and multiple footer rows appears to work, though row divider borders aren't a good contrast. I tried nesting rows in the footer as well in case someone wanted to use progressive disclosure for more complicated summaries, that also worked, though the expansion could end up in an odd spot, I made it the title column here.

Image

I'm assuming these aren't officially supported, but nice to know they won't be much trouble to eventually support. Though it might be nice to be able to tell a TableBody/Footer if it is expandable so that the alignment doesn't end up like this if only one of them actually has expansion. I don't think we're doing anything right now that would prevent that in the future though.

<Row>
<Cell colSpan={3} align="end">Total:</Cell>
<Cell>{invoices.reduce((p, item) => p + Number(item.price.replace(/[$,]/g, '')), 0).toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0})}</Cell>
</Row>
</TableFooter>
</TableView>
);
}
};
71 changes: 68 additions & 3 deletions packages/@react-spectrum/s2/test/TableView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -196,4 +196,69 @@ 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(
<TableView aria-label="Files" selectionMode="multiple" onSelectionChange={onSelectionChange}>
<TableHeader>
<Column isRowHeader>Title</Column>
<Column>Status</Column>
<Column>Payment Method</Column>
<Column>Price</Column>
</TableHeader>
<TableBody items={invoices}>
{item => (
<Row id={item.title}>
<Cell>{item.title}</Cell>
<Cell>{item.status}</Cell>
<Cell>{item.paymentMethod}</Cell>
<Cell>{item.price}</Cell>
</Row>
)}
</TableBody>
<TableFooter>
<Row>
<Cell colSpan={3}>Total:</Cell>
<Cell>{invoices.reduce((p, item) => p + Number(item.price.replace(/[$,]/g, '')), 0).toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0})}</Cell>
</Row>
</TableFooter>
</TableView>
);

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}');
}

let footerRows = within(groups[2]).getAllByRole('row');
expect(document.activeElement).toBe(footerRows[0]);

for (let row of tableTester.rows.toReversed()) {
await user.keyboard('{ArrowUp}');
expect(document.activeElement).toBe(row);
}

await user.click(footerRows[0]);
expect(onSelectionChange).not.toHaveBeenCalled();

await user.click(tableTester.rows[0]);
expect(onSelectionChange).toHaveBeenCalled();
});
});
45 changes: 31 additions & 14 deletions packages/dev/s2-docs/pages/react-aria/Table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -159,47 +159,48 @@ 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';
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 (
<div style={{display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'start', width: '100%'}}>
<CheckboxGroup aria-label="Show columns" value={showColumns} onChange={setShowColumns} orientation="horizontal">
<Checkbox value="type">Type</Checkbox>
<Checkbox value="date">Date Modified</Checkbox>
<Checkbox value="status">Status</Checkbox>
<Checkbox value="paymentMethod">Payment Method</Checkbox>
</CheckboxGroup>
<Table aria-label="Files" style={{width: '100%'}}>
<TableHeader columns={visibleColumns}>
Expand All @@ -214,10 +215,22 @@ function FileTable() {
{item => (
/*- end highlight -*/
<Row columns={visibleColumns}>
{column => <Cell>{item[column.id]}</Cell>}
{column => (
<Cell>
{column.id === 'price'
? item.price.toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0})
: item[column.id]}
</Cell>
)}
</Row>
)}
</TableBody>
<TableFooter>
<Row>
<Cell colSpan={showColumns.length - 1}>Total</Cell>
<Cell>{rows.reduce((p, row) => p + row.price, 0).toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0})}</Cell>
</Row>
</TableFooter>
</Table>
<Button onPress={addRow}>Add row</Button>
</div>
Expand Down Expand Up @@ -863,6 +876,10 @@ function ReorderableTable() {

<PropTable component={docs.exports.Cell} links={docs.links} showDescription />

### TableFooter

<PropTable component={docs.exports.TableFooter} links={docs.links} showDescription />

### ResizableTableContainer

<PropTable component={docs.exports.ResizableTableContainer} links={docs.links} showDescription />
Expand Down
Loading
Loading