diff --git a/package-lock.json b/package-lock.json index dd5aaa3a7..05303b347 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@semantic-release/git": "10.0.1", "@storybook/addon-docs": "10.5.7", "@storybook/react-vite": "10.5.7", + "@tanstack/react-table": "9.1.2", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.2", @@ -6437,6 +6438,74 @@ "node": ">=6" } }, + "node_modules/@tanstack/react-store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz", + "integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-table": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-9.1.2.tgz", + "integrity": "sha512-YQPZFJ1nIi/bjjwsPZVouABgahDcl7Gdm33CdTStUJBn0DjEVJ2uhSTVmIoWt9MVKdQziXGAsXipSzy949Hygg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/react-store": "^0.11.0", + "@tanstack/table-core": "9.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@tanstack/store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz", + "integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.1.2.tgz", + "integrity": "sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/store": "^0.11.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index a49e47af8..b3ec42666 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "@semantic-release/git": "10.0.1", "@storybook/addon-docs": "10.5.7", "@storybook/react-vite": "10.5.7", + "@tanstack/react-table": "9.1.2", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.2", diff --git a/src/index.ts b/src/index.ts index 3f0224112..b39061e9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,7 @@ export * from './badge' export * from './expansion-panel' export * from './menu' export * from './modal' +export * from './table' export * from './tabs' export * from './tooltip' diff --git a/src/table/index.ts b/src/table/index.ts new file mode 100644 index 000000000..302847a87 --- /dev/null +++ b/src/table/index.ts @@ -0,0 +1 @@ +export * from './table' diff --git a/src/table/sort-indicator.tsx b/src/table/sort-indicator.tsx new file mode 100644 index 000000000..a14114277 --- /dev/null +++ b/src/table/sort-indicator.tsx @@ -0,0 +1,14 @@ +import * as React from 'react' + +function SortIndicator() { + return ( + + + + ) +} + +export { SortIndicator } diff --git a/src/table/table.mdx b/src/table/table.mdx new file mode 100644 index 000000000..14ac25ae4 --- /dev/null +++ b/src/table/table.mdx @@ -0,0 +1,78 @@ +import { ArgTypes, Canvas, Markdown, Meta, Subtitle, Title } from '@storybook/addon-docs/blocks' + +import { TableCell, TableColumn, TableColumnHeader } from './table' + +import * as TableStories from './table.stories' + + + + + +<Subtitle>Styled primitives for tabular data.</Subtitle> + +## Overview + +The `<Table>` component is a set of styled primitives on top of `<table>`, `<thead>`, `<tr>`, `<th>`, and `<td>`. It +handles the visual layer, e.g. column widths, spacing, dividers, row, and sort affordances, while leaving the data-source, +sort, and selection states to the consumer. + +## Anatomy + +The component is fully composable and consumers only need to include what they need. For example, if column headers are not +required, then `<TableHeader>` can be omitted. + +<Markdown>{` +| Component | Renders | Responsibility | +| --- | --- | --- | +| \`<Table>\` | \`<table>\` | The full width table, and tabular figures styling | +| \`<TableColumnGroup>\` | \`<colgroup>\` | The column definitions, as the first child of \`<Table>\` | +| \`<TableColumn>\` | \`<col>\` | Defining the column's sizing | +| \`<TableHeader>\` | \`<thead>\` and its single \`<tr>\` | The header row | +| \`<TableColumnHeader>\` | \`<th>\` | The column label and its optional sort control | +| \`<TableBody>\` | \`<tbody>\` | The data rows | +| \`<TableRow>\` | \`<tr>\` | Row selection through \`aria-selected\` | +| \`<TableCell>\` | \`<td>\` | The data cells | +`}</Markdown> + +## Basic usage + +Render `<TableRow>`s and `<TableCell>` within `<Table>` and `<TableBody>` to render data. If consistent column sizing is needed, +define the columns in `<TableColumnGroup>`. + +<Canvas of={TableStories.Default} /> + +## Props + +Every component forwards its native attributes, except for the deprecated [`border`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/table#border), +[`width`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/col#width), and +[`align`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/td#align) attributes on `<Table>`, `<TableColumn>`, and +`<TableCell>`/`<TableColumnHeader>`, respectively. All components accept `exceptionallySetClassName` instead of `className`. + +The three components below also include additional props: + +### `<TableColumn>` + +<ArgTypes of={TableColumn} /> + +### `<TableColumnHeader>` + +<ArgTypes of={TableColumnHeader} /> + +### `<TableCell>` + +<ArgTypes of={TableCell} /> + +## Row selection + +Setting `aria-selected` on a `<TableRow>` would mark it as selected. + +<Canvas of={TableStories.SelectedAndClickableRows} /> + +## With TanStack Table + +The `<Table>` components do not dictate what external model layer they are used with. TanStack Table, +for example, would be a good option for driving the data, including +[sorting](https://tanstack.com/table/latest/docs/framework/react/guide/sorting) and +[pagination](https://tanstack.com/table/latest/docs/framework/react/guide/pagination). + +<Canvas of={TableStories.TanStackIntegration} /> diff --git a/src/table/table.module.css b/src/table/table.module.css new file mode 100644 index 000000000..e5663d4f5 --- /dev/null +++ b/src/table/table.module.css @@ -0,0 +1,160 @@ +.table { + font-variant-numeric: tabular-nums; + border-collapse: collapse; +} + +.headerRow { + height: 40px; + border-bottom: 1px solid var(--product-library-border-idle-tint); +} + +.headerCell { + height: 40px; + padding: 0 var(--reactist-spacing-small); + vertical-align: middle; + user-select: none; +} + +.headerCellSortable { + padding: 0; +} + +.sortButton { + display: flex; + gap: var(--reactist-spacing-xsmall); + align-items: center; + width: 100%; + height: 40px; + padding: 0 var(--reactist-spacing-small); + border: 0; + color: inherit; + font: inherit; + text-align: inherit; + background: transparent; + cursor: pointer; +} + +.headerLabel { + flex: 1 0 0; + min-width: 0; +} + +.sortButton:hover { + background-color: var(--product-library-actionable-secondary-hover-fill); +} + +.sortButton:focus-visible { + outline: 2px solid var(--product-library-display-primary-idle-tint); + outline-offset: -2px; +} + +.sortButton:hover .sortIndicatorUnsorted, +.sortButton:focus-visible .sortIndicatorUnsorted { + opacity: 1; +} + +.row { + height: 48px; + background-color: var(--product-library-background-base-primary); + border-bottom: 1px solid var(--product-library-divider-primary); +} + +.row[aria-selected] { + cursor: pointer; +} + +.row[aria-selected]:hover { + background-color: var(--product-library-actionable-secondary-hover-fill); + border-bottom-color: var(--product-library-divider-tertiary); +} + +.row[aria-selected='true'] { + background-color: var(--product-library-selectable-secondary-selected-fill); +} + +.row[aria-selected]:focus-visible { + outline: 2px solid var(--product-library-divider-tertiary); + outline-offset: -2px; +} + +.cell { + padding: var(--reactist-spacing-xsmall) var(--reactist-spacing-small); + vertical-align: middle; +} + +.cell:first-child { + padding-inline-start: var(--reactist-spacing-medium); +} + +.cell:last-child { + padding-inline-end: var(--reactist-spacing-medium); +} + +.sortIndicator { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + color: var(--product-library-display-secondary-idle-tint); +} + +.sortIndicatorUnsorted { + color: var(--product-library-display-tertiary-idle-tint); + opacity: 0; +} + +.sortIndicatorDescending { + transform: rotate(180deg); +} + +.align-start { + text-align: start; +} + +.align-end { + text-align: end; +} + +.columnWidth-auto { + width: auto; +} + +.columnWidth-content { + width: 0; +} + +.columnWidth-1-2 { + width: 50%; +} + +.columnWidth-1-3 { + width: 33.3333%; +} + +.columnWidth-2-3 { + width: 66.6667%; +} + +.columnWidth-1-4 { + width: 25%; +} + +.columnWidth-3-4 { + width: 75%; +} + +.columnWidth-1-5 { + width: 20%; +} + +.columnWidth-2-5 { + width: 40%; +} + +.columnWidth-3-5 { + width: 60%; +} + +.columnWidth-4-5 { + width: 80%; +} diff --git a/src/table/table.stories.tsx b/src/table/table.stories.tsx new file mode 100644 index 000000000..d408d429d --- /dev/null +++ b/src/table/table.stories.tsx @@ -0,0 +1,439 @@ +import * as React from 'react' + +import { + columnVisibilityFeature, + createPaginatedRowModel, + createSortedRowModel, + flexRender, + rowPaginationFeature, + rowSortingFeature, + sortFn_text, + tableFeatures, + useTable, +} from '@tanstack/react-table' + +import { Avatar } from '../avatar' +import { Badge } from '../badge' +import { Box } from '../box' +import { Button } from '../button' +import { Text } from '../text' + +import { + Table, + TableBody, + TableCell, + TableColumn, + TableColumnGroup, + TableColumnHeader, + TableHeader, + TableRow, +} from './table' + +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { ColumnDef } from '@tanstack/react-table' + +type Person = { + id: string + name: string + email: string + role: string + access: 'Admin' | 'Member' | 'Guest' + projects: string + activity: string +} + +const people: Person[] = [ + { + id: 'avery-morgan', + projects: '1,284', + name: 'Avery Morgan', + email: 'avery@example.com', + role: 'Product designer', + access: 'Admin', + activity: 'Active now', + }, + { + id: 'sam-rivera', + projects: '1,037', + name: 'Sam Rivera', + email: 'sam@example.com', + role: 'Frontend engineer', + access: 'Member', + activity: '8 minutes ago', + }, + { + id: 'mika-chen', + projects: '9,102', + name: 'Mika Chen', + email: 'mika@example.com', + role: 'Product manager', + access: 'Admin', + activity: '2 hours ago', + }, + { + id: 'noor-patel', + projects: '1,116', + name: 'Noor Patel', + email: 'noor@example.com', + role: 'Research lead', + access: 'Member', + activity: 'Yesterday', + }, + { + id: 'theo-williams', + projects: '4,411', + name: 'Theo Williams', + email: 'theo@example.com', + role: 'Operations', + access: 'Guest', + activity: '3 days ago', + }, +] + +const meta = { + title: '📊 Data display/Table', + component: Table, + parameters: { + badges: ['accessible'], + figma: { + path: 'Web › Components / Todoist › Table', + url: 'https://www.figma.com/design/LYlWNzvhMDh907l07mPPQk/Product-Library---Web?node-id=26089-87636', + }, + docs: { + description: { + component: + 'Compound primitives for tabular data. Compose Table with TableColumnGroup, TableHeader, TableColumnHeader, TableBody, TableRow, and TableCell. The consumer owns the data, the sort state, and the selection state; pass aria-selected on a row to make it selectable.', + }, + }, + }, +} satisfies Meta<typeof Table> + +export default meta + +type Story = StoryObj<typeof meta> + +function PersonCell({ person }: { person: Person }) { + return ( + <Box display="flex" alignItems="center" gap="small"> + <Avatar size={28} shape="circle" name={person.name} /> + <Box flexGrow={1} minWidth={0} overflow="hidden"> + <Text variant="callout-2" lineClamp={1}> + {person.name} + </Text> + <Text variant="callout-2" tone="secondary" lineClamp={1}> + {person.email} + </Text> + </Box> + </Box> + ) +} + +function ActivityCell({ person }: { person: Person }) { + return ( + <Badge + tone={person.activity === 'Active now' ? 'positive' : 'info'} + label={person.activity} + /> + ) +} + +function getSortAriaLabel(label: string, direction: 'asc' | 'desc' | null) { + if (direction === 'asc') return `${label}, sorted ascending. Activate to sort descending.` + if (direction === 'desc') return `${label}, sorted descending. Activate to sort ascending.` + return `${label}, activate to sort ascending.` +} + +function handleRowKeyDown( + event: React.KeyboardEvent<HTMLTableRowElement>, + personId: string, + onActivate: (personId: string) => void, +) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onActivate(personId) + } +} + +export const Default = { + render: () => ( + <Table aria-label="Workspace people"> + <TableColumnGroup> + <TableColumn width="2/5" /> + <TableColumn width="1/5" /> + <TableColumn width="1/5" /> + <TableColumn width="1/5" /> + </TableColumnGroup> + <TableHeader> + <TableColumnHeader> + <Text variant="body-2">Person</Text> + </TableColumnHeader> + <TableColumnHeader> + <Text variant="body-2">Role</Text> + </TableColumnHeader> + <TableColumnHeader align="end"> + <Text variant="body-2">Projects</Text> + </TableColumnHeader> + <TableColumnHeader align="end"> + <Text variant="body-2">Last active</Text> + </TableColumnHeader> + </TableHeader> + <TableBody> + {people.map((person) => ( + <TableRow key={person.id}> + <TableCell> + <PersonCell person={person} /> + </TableCell> + <TableCell> + <Text variant="callout-2" tone="secondary" lineClamp={1}> + {person.role} + </Text> + </TableCell> + <TableCell align="end"> + <Text variant="callout-2" lineClamp={1}> + {person.projects} + </Text> + </TableCell> + <TableCell align="end"> + <ActivityCell person={person} /> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ), +} satisfies Story + +export const SelectedAndClickableRows = { + name: 'Selected and clickable rows', + render: function SelectedAndClickableRows() { + const [selectedId, setSelectedId] = React.useState(people[1]!.id) + + return ( + <Table aria-label="Selectable workspace people"> + <TableColumnGroup> + <TableColumn width="2/5" /> + <TableColumn width="1/5" /> + <TableColumn width="1/5" /> + <TableColumn width="1/5" /> + </TableColumnGroup> + <TableHeader> + <TableColumnHeader> + <Text variant="body-2">Person</Text> + </TableColumnHeader> + <TableColumnHeader> + <Text variant="body-2">Role</Text> + </TableColumnHeader> + <TableColumnHeader align="end"> + <Text variant="body-2">Projects</Text> + </TableColumnHeader> + <TableColumnHeader align="end"> + <Text variant="body-2">Last active</Text> + </TableColumnHeader> + </TableHeader> + <TableBody> + {people.map((person) => ( + <TableRow + key={person.id} + aria-selected={selectedId === person.id} + tabIndex={0} + onClick={() => setSelectedId(person.id)} + onKeyDown={(event) => handleRowKeyDown(event, person.id, setSelectedId)} + > + <TableCell> + <PersonCell person={person} /> + </TableCell> + <TableCell> + <Text variant="callout-2" tone="secondary" lineClamp={1}> + {person.role} + </Text> + </TableCell> + <TableCell align="end"> + <Text variant="callout-2" lineClamp={1}> + {person.projects} + </Text> + </TableCell> + <TableCell align="end"> + <ActivityCell person={person} /> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) + }, +} satisfies Story + +export const NoHeaderRow = { + name: 'No header row', + render: () => ( + <Table aria-label="Workspace people"> + <TableColumnGroup> + <TableColumn width="1/2" /> + <TableColumn width="1/4" /> + <TableColumn width="1/4" /> + </TableColumnGroup> + <TableBody> + {people.map((person) => ( + <TableRow key={person.id}> + <TableCell> + <PersonCell person={person} /> + </TableCell> + <TableCell> + <Text variant="callout-2" lineClamp={1}> + {person.role} + </Text> + </TableCell> + <TableCell> + <Text variant="callout-2" lineClamp={1}> + {person.activity} + </Text> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ), +} satisfies Story + +export const MultiLineCells = { + name: 'Single and multi-line cells', + render: () => ( + <Table aria-label="Cell content"> + <TableColumnGroup> + <TableColumn width="1/2" /> + <TableColumn width="1/2" /> + </TableColumnGroup> + <TableHeader> + <TableColumnHeader> + <Text variant="body-2">Single line</Text> + </TableColumnHeader> + <TableColumnHeader> + <Text variant="body-2">Two line</Text> + </TableColumnHeader> + </TableHeader> + <TableBody> + <TableRow> + <TableCell> + <Text variant="callout-2" lineClamp={1}> + Cell content long enough that it has to truncate with an ellipsis + </Text> + </TableCell> + <TableCell> + <Text variant="callout-2">Cell content</Text> + <Text variant="callout-2" tone="secondary"> + Secondary line + </Text> + </TableCell> + </TableRow> + </TableBody> + </Table> + ), +} satisfies Story + +const features = tableFeatures({ + columnVisibilityFeature, + rowPaginationFeature, + rowSortingFeature, + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(), + sortFns: { text: sortFn_text }, +}) + +const tanStackColumns: ColumnDef<typeof features, Person, unknown>[] = [ + { accessorKey: 'name', header: 'Person', sortFn: 'text' }, + { accessorKey: 'role', header: 'Role', sortFn: 'text' }, + { accessorKey: 'access', header: 'Access', enableSorting: false }, +] + +export const TanStackIntegration = { + name: 'TanStack Table integration', + render: function TanStackIntegration() { + const table = useTable({ + features, + data: people, + columns: tanStackColumns, + getRowId: (person) => person.id, + initialState: { pagination: { pageIndex: 0, pageSize: 2 } }, + }) + const { pageIndex } = table.state.pagination ?? { pageIndex: 0 } + + return ( + <Box display="flex" flexDirection="column" gap="medium"> + <Table aria-label="TanStack-driven people"> + <TableColumnGroup> + <TableColumn width="1/2" /> + <TableColumn width="1/4" /> + <TableColumn width="1/4" /> + </TableColumnGroup> + <TableHeader> + {table.getHeaderGroups()[0]?.headers.map((header) => { + const direction = header.column.getIsSorted() || null + const label = String(header.column.columnDef.header) + + return header.column.getCanSort() ? ( + <TableColumnHeader + key={header.id} + sortable + sortDirection={direction} + onSort={() => header.column.toggleSorting()} + sortAriaLabel={getSortAriaLabel(label, direction)} + > + <Text variant="body-2"> + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + </Text> + </TableColumnHeader> + ) : ( + <TableColumnHeader key={header.id}> + <Text variant="body-2"> + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + </Text> + </TableColumnHeader> + ) + })} + </TableHeader> + <TableBody> + {table.getRowModel().rows.map((row) => ( + <TableRow key={row.id}> + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id}> + <Text variant="callout-2" lineClamp={1}> + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + </Text> + </TableCell> + ))} + </TableRow> + ))} + </TableBody> + </Table> + <Box display="flex" alignItems="center" gap="small"> + <Button + variant="secondary" + disabled={!table.getCanPreviousPage()} + onClick={() => table.previousPage()} + > + Previous + </Button> + <Button + variant="secondary" + disabled={!table.getCanNextPage()} + onClick={() => table.nextPage()} + > + Next + </Button> + <Text variant="callout-2" tone="secondary"> + Page {pageIndex + 1} of {table.getPageCount()} + </Text> + </Box> + </Box> + ) + }, +} satisfies Story diff --git a/src/table/table.test.tsx b/src/table/table.test.tsx new file mode 100644 index 000000000..4a4894d93 --- /dev/null +++ b/src/table/table.test.tsx @@ -0,0 +1,200 @@ +import * as React from 'react' + +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'jest-axe' + +import { + Table, + TableBody, + TableCell, + TableColumn, + TableColumnGroup, + TableColumnHeader, + TableHeader, + TableRow, +} from './index' + +function BasicTable({ withHeader = true }: { withHeader?: boolean }) { + return ( + <Table aria-label="People"> + {withHeader ? ( + <TableHeader> + <TableColumnHeader>Person</TableColumnHeader> + <TableColumnHeader>Role</TableColumnHeader> + </TableHeader> + ) : null} + <TableBody> + <TableRow> + <TableCell>Avery Morgan</TableCell> + <TableCell>Product designer</TableCell> + </TableRow> + </TableBody> + </Table> + ) +} + +describe('Table primitives', () => { + it('renders native table semantics from composed children', () => { + render(<BasicTable />) + expect(screen.getByRole('table', { name: 'People' })).toBeInTheDocument() + expect(screen.getAllByRole('columnheader')).toHaveLength(2) + expect(screen.getByRole('cell', { name: 'Avery Morgan' })).toBeInTheDocument() + }) + + it('renders no thead when TableHeader is omitted', () => { + const { container } = render(<BasicTable withHeader={false} />) + expect(container.querySelector('thead')).toBeNull() + expect(screen.queryAllByRole('columnheader')).toHaveLength(0) + expect(screen.getByRole('cell', { name: 'Avery Morgan' })).toBeInTheDocument() + }) + + it('forwards refs on every primitive', () => { + const refs = { + table: React.createRef<HTMLTableElement>(), + body: React.createRef<HTMLTableSectionElement>(), + row: React.createRef<HTMLTableRowElement>(), + cell: React.createRef<HTMLTableCellElement>(), + } + render( + <Table ref={refs.table} aria-label="People"> + <TableBody ref={refs.body}> + <TableRow ref={refs.row}> + <TableCell ref={refs.cell}>Avery</TableCell> + </TableRow> + </TableBody> + </Table>, + ) + expect(refs.table.current?.tagName).toBe('TABLE') + expect(refs.body.current?.tagName).toBe('TBODY') + expect(refs.row.current?.tagName).toBe('TR') + expect(refs.cell.current?.tagName).toBe('TD') + }) + + it('marks only rows with aria-selected as selectable', () => { + render( + <Table aria-label="People"> + <TableBody> + <TableRow aria-selected={false}> + <TableCell>Selectable</TableCell> + </TableRow> + <TableRow aria-selected> + <TableCell>Selected</TableCell> + </TableRow> + <TableRow> + <TableCell>Plain</TableCell> + </TableRow> + </TableBody> + </Table>, + ) + const rows = screen.getAllByRole('row') + expect(rows[0]).toHaveAttribute('aria-selected', 'false') + expect(rows[1]).toHaveAttribute('aria-selected', 'true') + expect(rows[2]).not.toHaveAttribute('aria-selected') + }) + + it('maps the column width prop onto the column definition', () => { + const { container } = render( + <Table aria-label="People"> + <TableColumnGroup> + <TableColumn width="2/5" /> + <TableColumn /> + </TableColumnGroup> + <TableBody> + <TableRow> + <TableCell>Avery Morgan</TableCell> + <TableCell>Product designer</TableCell> + </TableRow> + </TableBody> + </Table>, + ) + const [sized, defaulted] = Array.from(container.querySelectorAll('col')) + expect(sized?.className).toContain('columnWidth-2-5') + expect(defaulted?.className).toContain('columnWidth-auto') + }) + + it('has no automated accessibility violations', async () => { + const { container } = render(<BasicTable />) + expect(await axe(container)).toHaveNoViolations() + }) +}) + +describe('TableColumnHeader sorting', () => { + function SortableHeader({ + sortDirection = null, + onSort = jest.fn(), + }: { + sortDirection?: 'asc' | 'desc' | null + onSort?: () => void + }) { + return ( + <Table aria-label="People"> + <TableHeader> + <TableColumnHeader + sortable + sortDirection={sortDirection} + onSort={onSort} + sortAriaLabel="Person, activate to sort ascending." + > + Person + </TableColumnHeader> + </TableHeader> + </Table> + ) + } + + it('omits aria-sort on a non-sortable header', () => { + render( + <Table aria-label="People"> + <TableHeader> + <TableColumnHeader>Person</TableColumnHeader> + </TableHeader> + </Table>, + ) + expect(screen.getByRole('columnheader')).not.toHaveAttribute('aria-sort') + }) + + it.each([ + ['asc' as const, 'ascending'], + ['desc' as const, 'descending'], + ])('maps sortDirection %s to aria-sort %s', (direction, expected) => { + render(<SortableHeader sortDirection={direction} />) + expect(screen.getByRole('columnheader')).toHaveAttribute('aria-sort', expected) + }) + + it('omits aria-sort on a sortable header that is not sorted', () => { + render(<SortableHeader sortDirection={null} />) + expect(screen.getByRole('columnheader')).not.toHaveAttribute('aria-sort') + }) + + it('fires onSort exactly once per activation', async () => { + const onSort = jest.fn() + const user = userEvent.setup() + render(<SortableHeader onSort={onSort} />) + const button = screen.getByRole('button', { name: /Person/ }) + + await user.click(button) + expect(onSort).toHaveBeenCalledTimes(1) + + button.focus() + await user.keyboard('{Enter}') + expect(onSort).toHaveBeenCalledTimes(2) + + await user.keyboard(' ') + expect(onSort).toHaveBeenCalledTimes(3) + }) + it('renders the bundled sort icon', () => { + const { container } = render(<SortableHeader sortDirection="asc" />) + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it.each([ + ['asc' as const, false], + ['desc' as const, true], + [null, true], + ])('rotates the indicator for sortDirection %s: %s', (direction, rotated) => { + const { container } = render(<SortableHeader sortDirection={direction} />) + const indicator = container.querySelector('th span[aria-hidden="true"]') + expect(indicator?.className.includes('sortIndicatorDescending')).toBe(rotated) + }) +}) diff --git a/src/table/table.tsx b/src/table/table.tsx new file mode 100644 index 000000000..8cdb621aa --- /dev/null +++ b/src/table/table.tsx @@ -0,0 +1,269 @@ +import * as React from 'react' + +import classNames from 'classnames' + +import { Box } from '../box' + +import { SortIndicator } from './sort-indicator' + +import styles from './table.module.css' + +import type { ObfuscatedClassName } from '../utils/common-types' + +type TableProps = Omit<React.TableHTMLAttributes<HTMLTableElement>, 'className' | 'border'> & + ObfuscatedClassName + +type TableHeaderProps = Omit<React.HTMLAttributes<HTMLTableSectionElement>, 'className'> & + ObfuscatedClassName + +type TableBodyProps = Omit<React.HTMLAttributes<HTMLTableSectionElement>, 'className'> & + ObfuscatedClassName + +type TableRowProps = Omit<React.HTMLAttributes<HTMLTableRowElement>, 'className'> & + ObfuscatedClassName + +type TableCellProps = Omit<React.TdHTMLAttributes<HTMLTableCellElement>, 'align' | 'className'> & + ObfuscatedClassName & { + /** Horizontal alignment of the cell content. */ + align?: 'start' | 'end' + } + +type TableColumnWidth = + | 'auto' + | 'content' + | '1/2' + | '1/3' + | '2/3' + | '1/4' + | '3/4' + | '1/5' + | '2/5' + | '3/5' + | '4/5' + +type TableColumnGroupProps = Omit<React.HTMLAttributes<HTMLTableColElement>, 'className'> & + ObfuscatedClassName + +type TableColumnProps = Omit<React.ColHTMLAttributes<HTMLTableColElement>, 'className' | 'width'> & + ObfuscatedClassName & { + /** Width of this column, as a fraction of the table. */ + width?: TableColumnWidth + } + +type SortableProps = + | { + /** Renders the sort control and makes the header activatable. */ + sortable: true + + /** Direction for this column, or null when it is sortable but not sorted. */ + sortDirection: 'asc' | 'desc' | null + + /** Called when the sort control is activated. */ + onSort: () => void + + /** Complete localized label for the sort button. */ + sortAriaLabel: string + } + | { + sortable?: false + sortDirection?: never + onSort?: never + sortAriaLabel?: never + } + +type TableColumnHeaderProps = Omit< + React.ThHTMLAttributes<HTMLTableCellElement>, + 'align' | 'className' | 'onSort' +> & + ObfuscatedClassName & + SortableProps & { + /** Horizontal alignment of the header content. */ + align?: 'start' | 'end' + } + +function ariaSortFor(sortDirection: 'asc' | 'desc') { + return sortDirection === 'asc' ? 'ascending' : 'descending' +} + +/** + * Tabular data in native table markup, composed from: + * * {@link TableColumnGroup} + * * {@link TableColumn} + * * {@link TableHeader} + * * {@link TableColumnHeader} + * * {@link TableBody} + * * {@link TableRow} + * * {@link TableCell} + */ +const Table = React.forwardRef<HTMLTableElement, TableProps>(function Table( + { exceptionallySetClassName, ...tableProps }, + ref, +) { + return ( + <Box + {...tableProps} + as="table" + ref={ref} + width="full" + background="default" + className={[styles.table, exceptionallySetClassName]} + /> + ) +}) + +/** Column definitions for the table. Render it as the first child of {@link Table}. */ +const TableColumnGroup = React.forwardRef<HTMLTableColElement, TableColumnGroupProps>( + function TableColumnGroup({ exceptionallySetClassName, ...groupProps }, ref) { + return ( + <colgroup {...groupProps} ref={ref} className={classNames(exceptionallySetClassName)} /> + ) + }, +) + +/** A single column definition. */ +const TableColumn = React.forwardRef<HTMLTableColElement, TableColumnProps>(function TableColumn( + { width = 'auto', exceptionallySetClassName, ...columnProps }, + ref, +) { + return ( + <col + {...columnProps} + ref={ref} + className={classNames( + styles[`columnWidth-${width.replace('/', '-')}`], + exceptionallySetClassName, + )} + /> + ) +}) + +/** A table header row wrapper. Omit it for a table with no header. */ +const TableHeader = React.forwardRef<HTMLTableSectionElement, TableHeaderProps>( + function TableHeader({ children, exceptionallySetClassName, ...headerProps }, ref) { + return ( + <Box + {...headerProps} + as="thead" + ref={ref} + background="default" + className={exceptionallySetClassName} + > + <tr className={styles.headerRow}>{children}</tr> + </Box> + ) + }, +) + +/** A table body that wraps its rows. */ +const TableBody = React.forwardRef<HTMLTableSectionElement, TableBodyProps>(function TableBody( + { exceptionallySetClassName, ...bodyProps }, + ref, +) { + return <tbody {...bodyProps} ref={ref} className={classNames(exceptionallySetClassName)} /> +}) + +/** A table row. Pass `aria-selected` to make it selectable. */ +const TableRow = React.forwardRef<HTMLTableRowElement, TableRowProps>(function TableRow( + { exceptionallySetClassName, ...rowProps }, + ref, +) { + return ( + <tr {...rowProps} ref={ref} className={classNames(styles.row, exceptionallySetClassName)} /> + ) +}) + +/** A table data cell. */ +const TableCell = React.forwardRef<HTMLTableCellElement, TableCellProps>(function TableCell( + { align = 'start', children, exceptionallySetClassName, ...cellProps }, + ref, +) { + return ( + <td + {...cellProps} + ref={ref} + className={classNames(styles.cell, styles[`align-${align}`], exceptionallySetClassName)} + > + {children} + </td> + ) +}) + +/** A table column header, optionally sortable. */ +const TableColumnHeader = React.forwardRef<HTMLTableCellElement, TableColumnHeaderProps>( + function TableColumnHeader( + { + sortable, + sortDirection = null, + onSort, + sortAriaLabel, + align = 'start', + children, + exceptionallySetClassName, + ...headerProps + }, + ref, + ) { + const label = <span className={styles.headerLabel}>{children}</span> + const indicatorDirection = sortDirection ?? 'desc' + const descendingClass = styles.sortIndicatorDescending ?? '' + const unsortedClass = styles.sortIndicatorUnsorted ?? '' + + return ( + <th + {...headerProps} + ref={ref} + aria-sort={sortDirection ? ariaSortFor(sortDirection) : undefined} + className={classNames( + styles.headerCell, + styles[`align-${align}`], + sortable && styles.headerCellSortable, + exceptionallySetClassName, + )} + > + {sortable ? ( + <button + type="button" + className={styles.sortButton} + aria-label={sortAriaLabel} + onClick={onSort} + > + {label} + <span + aria-hidden="true" + className={classNames(styles.sortIndicator, { + [descendingClass]: indicatorDirection === 'desc', + [unsortedClass]: !sortDirection, + })} + > + <SortIndicator /> + </span> + </button> + ) : ( + label + )} + </th> + ) + }, +) + +export { + Table, + TableBody, + TableCell, + TableColumn, + TableColumnGroup, + TableColumnHeader, + TableHeader, + TableRow, +} +export type { + TableBodyProps, + TableCellProps, + TableColumnGroupProps, + TableColumnHeaderProps, + TableColumnProps, + TableColumnWidth, + TableHeaderProps, + TableProps, + TableRowProps, +} diff --git a/stories/components/styles/story.css b/stories/components/styles/story.css index 6e08f70a8..fdfef8779 100644 --- a/stories/components/styles/story.css +++ b/stories/components/styles/story.css @@ -1,4 +1,5 @@ body { + color: var(--product-library-display-primary-idle-tint); font-family: var(--reactist-font-family); }