From 285ff915132944b8f343a2f75dedffc868cc7e1c Mon Sep 17 00:00:00 2001 From: Rui Martins Date: Thu, 23 Jul 2026 11:57:54 +0200 Subject: [PATCH] feat: add provisional Table component --- .react-compiler.rec.json | 3 + package-lock.json | 36 ++- package.json | 1 + rollup.config.mjs | 1 + src/index.ts | 1 + src/table/index.ts | 1 + src/table/table.module.css | 117 ++++++++++ src/table/table.stories.module.css | 172 ++++++++++++++ src/table/table.stories.tsx | 364 +++++++++++++++++++++++++++++ src/table/table.test.tsx | 194 +++++++++++++++ src/table/table.tsx | 316 +++++++++++++++++++++++++ 11 files changed, 1204 insertions(+), 2 deletions(-) create mode 100644 src/table/index.ts create mode 100644 src/table/table.module.css create mode 100644 src/table/table.stories.module.css create mode 100644 src/table/table.stories.tsx create mode 100644 src/table/table.test.tsx create mode 100644 src/table/table.tsx diff --git a/.react-compiler.rec.json b/.react-compiler.rec.json index e0fc09102..26183356a 100644 --- a/.react-compiler.rec.json +++ b/.react-compiler.rec.json @@ -17,6 +17,9 @@ "src/menu/menu.tsx": { "CompileError": 2 }, + "src/table/table.tsx": { + "CompileError": 1 + }, "src/tooltip/tooltip.tsx": { "CompileError": 1 } diff --git a/package-lock.json b/package-lock.json index 408affc37..95d1de33d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", + "@tanstack/react-table": "8.21.3", "aria-hidden": "^1.2.1", "dayjs": "^1.8.10", "patch-package": "^6.4.6", @@ -6944,6 +6945,39 @@ "node": ">=6" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "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", @@ -22768,7 +22802,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", - "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -23812,7 +23845,6 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, "license": "MIT" }, "node_modules/semantic-release": { diff --git a/package.json b/package.json index e9c72c12c..9885255b3 100644 --- a/package.json +++ b/package.json @@ -155,6 +155,7 @@ }, "dependencies": { "@babel/runtime": "^7.28.4", + "@tanstack/react-table": "8.21.3", "aria-hidden": "^1.2.1", "dayjs": "^1.8.10", "patch-package": "^6.4.6", diff --git a/rollup.config.mjs b/rollup.config.mjs index bd27b6eba..96c4bdfbd 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -20,6 +20,7 @@ const external = [ 'classnames', 'prop-types', '@ariakit/react', + '@tanstack/react-table', 'aria-hidden', 'dayjs', 'dayjs/plugin/localizedFormat', diff --git a/src/index.ts b/src/index.ts index a27472339..57e9cc2fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,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/table.module.css b/src/table/table.module.css new file mode 100644 index 000000000..cf572fbc8 --- /dev/null +++ b/src/table/table.module.css @@ -0,0 +1,117 @@ +.table { + width: 100%; + border-collapse: collapse; + color: var(--reactist-table-content, var(--reactist-content-primary)); + font-family: var(--reactist-font-family); + font-size: var(--reactist-font-size-body); +} + +.header { + border-bottom: 1px solid var(--reactist-table-divider, var(--reactist-divider-secondary)); +} + +.headerCell { + padding: var(--reactist-spacing-medium); + text-align: left; + user-select: none; +} + +.headerCellSortable { + padding: 0; +} + +.sortButton { + display: flex; + gap: var(--reactist-spacing-xsmall); + align-items: center; + width: 100%; + padding: var(--reactist-spacing-medium); + border: 0; + color: inherit; + font: inherit; + font-weight: var(--reactist-font-weight-medium); + text-align: left; + background: transparent; + cursor: pointer; +} + +.sortButton:hover { + background-color: var(--reactist-table-header-hover, var(--reactist-bg-highlight)); +} + +.sortButton:focus-visible { + outline: 2px solid + var(--reactist-table-focus-ring, var(--reactist-actionable-primary-idle-fill)); + outline-offset: -2px; +} + +.headerCellContent { + display: inline-flex; + align-items: center; +} + +.row { + border-bottom: 1px solid var(--reactist-table-divider, var(--reactist-divider-secondary)); +} + +.row.clickable { + cursor: pointer; +} + +.header:has(+ tbody > .row[aria-selected='true']:first-child) { + border-bottom-color: transparent; +} + +.row[aria-selected='true'], +.row:has(+ .row[aria-selected='true']) { + border-bottom-color: transparent; +} + +.row[aria-selected='true'] { + background-color: var(--reactist-table-selected-fill, var(--reactist-framework-fill-selected)); +} + +.row[aria-selected='true'] .cell:first-child { + border-start-start-radius: var(--reactist-border-radius-large); + border-end-start-radius: var(--reactist-border-radius-large); +} + +.row[aria-selected='true'] .cell:last-child { + border-start-end-radius: var(--reactist-border-radius-large); + border-end-end-radius: var(--reactist-border-radius-large); +} + +.row.clickable:focus-visible { + outline: 2px solid + var(--reactist-table-focus-ring, var(--reactist-actionable-primary-idle-fill)); + outline-offset: -2px; +} + +.cell { + padding: var(--reactist-spacing-small) var(--reactist-spacing-medium); + vertical-align: middle; +} + +.emptyCell { + padding-block: var(--reactist-spacing-xxlarge); + color: var(--reactist-table-empty-content, var(--reactist-content-secondary)); + text-align: center; +} + +.sortIndicator { + display: inline-flex; + align-items: center; + width: 16px; + height: 16px; +} + +.sortIndicatorIcon { + width: 16px; + height: 16px; + color: var(--reactist-table-sort-indicator, var(--reactist-content-secondary)); + fill: none; + stroke: currentcolor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} diff --git a/src/table/table.stories.module.css b/src/table/table.stories.module.css new file mode 100644 index 000000000..a3399bb2d --- /dev/null +++ b/src/table/table.stories.module.css @@ -0,0 +1,172 @@ +.canvas { + box-sizing: border-box; + width: 100%; + min-height: 560px; + padding: 32px; + color: var(--reactist-content-primary); + font-family: var(--reactist-font-family); + background: #f7f7f7; +} + +.frame { + width: min(100%, 1040px); + margin: 0 auto; +} + +.pageHeader { + display: flex; + gap: 24px; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 20px; +} + +.title { + margin: 0; + font-size: var(--reactist-font-size-header-large); + line-height: 1.25; +} + +.subtitle { + margin: 5px 0 0; + color: var(--reactist-content-secondary); + font-size: var(--reactist-font-size-body); +} + +.count { + flex: none; + padding: 5px 9px; + border-radius: 999px; + color: var(--reactist-content-secondary); + font-size: var(--reactist-font-size-caption); + font-weight: var(--reactist-font-weight-medium); + background: var(--reactist-bg-highlight); +} + +.surface { + overflow: hidden; + border: 1px solid #e2e2e2; + border-radius: 12px; + background: var(--reactist-bg-default); + box-shadow: 0 1px 2px rgb(0 0 0 / 4%); +} + +.scrollArea { + overflow-x: auto; +} + +.presentationTable { + min-width: 760px; +} + +.presentationTable th:first-child, +.presentationTable td:first-child { + width: 38%; +} + +.presentationTable th:nth-child(2), +.presentationTable td:nth-child(2) { + width: 21%; +} + +.presentationTable th:nth-child(3), +.presentationTable td:nth-child(3) { + width: 17%; +} + +.presentationTable th:last-child, +.presentationTable td:last-child { + width: 24%; +} + +.person { + display: flex; + gap: 10px; + align-items: center; + min-height: 36px; +} + +.personText { + min-width: 0; +} + +.personName { + overflow: hidden; + font-weight: var(--reactist-font-weight-medium); + text-overflow: ellipsis; + white-space: nowrap; +} + +.personEmail { + overflow: hidden; + margin-top: 2px; + color: var(--reactist-content-secondary); + font-size: var(--reactist-font-size-caption); + text-overflow: ellipsis; + white-space: nowrap; +} + +.role { + color: var(--reactist-content-secondary); +} + +.status { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 999px; + color: var(--reactist-content-positive); + font-size: var(--reactist-font-size-caption); + font-weight: var(--reactist-font-weight-medium); + background: #edf8ef; +} + +.statusMuted { + color: var(--reactist-content-secondary); + background: var(--reactist-bg-highlight); +} + +.emptyState { + padding: 12px; +} + +.emptyTitle { + margin: 0; + color: var(--reactist-content-primary); + font-size: var(--reactist-font-size-subtitle); + font-weight: var(--reactist-font-weight-medium); +} + +.emptyDescription { + margin: 6px 0 0; + color: var(--reactist-content-secondary); +} + +.placeholderRow { + opacity: 0.42; + filter: grayscale(0.5); +} + +.placeholderNote { + margin: 12px 0 0; + color: var(--reactist-content-secondary); + font-size: var(--reactist-font-size-caption); +} + +.narrowCanvas { + width: 420px; + max-width: 100%; +} + +@media (max-width: 600px) { + .canvas { + min-height: 520px; + padding: 20px 12px; + } + + .pageHeader { + flex-direction: column; + gap: 10px; + align-items: flex-start; + } +} diff --git a/src/table/table.stories.tsx b/src/table/table.stories.tsx new file mode 100644 index 000000000..6dd2e7d37 --- /dev/null +++ b/src/table/table.stories.tsx @@ -0,0 +1,364 @@ +import * as React from 'react' + +import classNames from 'classnames' + +import { Avatar } from '../avatar' + +import { Table, TableCell, TableRow } from './table' + +import styles from './table.stories.module.css' + +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { TableColumn, TableRowModel, TableSorting } from './table' + +type Person = { + id: string + name: string + email: string + role: string + access: 'Admin' | 'Member' | 'Guest' + activity: string + placeholder?: boolean +} + +const people: Person[] = [ + { + id: 'avery-morgan', + name: 'Avery Morgan', + email: 'avery@example.com', + role: 'Product designer', + access: 'Admin', + activity: 'Active now', + }, + { + id: 'sam-rivera', + name: 'Sam Rivera', + email: 'sam@example.com', + role: 'Frontend engineer', + access: 'Member', + activity: '8 minutes ago', + }, + { + id: 'mika-chen', + name: 'Mika Chen', + email: 'mika@example.com', + role: 'Product manager', + access: 'Admin', + activity: '2 hours ago', + }, + { + id: 'noor-patel', + name: 'Noor Patel', + email: 'noor@example.com', + role: 'Research lead', + access: 'Member', + activity: 'Yesterday', + }, + { + id: 'theo-williams', + name: 'Theo Williams', + email: 'theo@example.com', + role: 'Operations', + access: 'Guest', + activity: '3 days ago', + }, +] + +const placeholderPeople: Person[] = [ + ...people.slice(0, 1), + { + id: 'placeholder-1', + name: 'Jordan Lee', + email: 'jordan@example.com', + role: 'Design', + access: 'Member', + activity: 'Example', + placeholder: true, + }, + { + id: 'placeholder-2', + name: 'Taylor Brooks', + email: 'taylor@example.com', + role: 'Engineering', + access: 'Member', + activity: 'Example', + placeholder: true, + }, + { + id: 'placeholder-3', + name: 'Morgan Silva', + email: 'morgan@example.com', + role: 'Marketing', + access: 'Guest', + activity: 'Example', + placeholder: true, + }, +] + +const columns: TableColumn[] = [ + { + accessorKey: 'name', + header: 'Person', + cell: ({ row }) => , + }, + { + accessorKey: 'role', + header: 'Role', + cell: ({ getValue }) => {String(getValue())}, + }, + { accessorKey: 'access', header: 'Access' }, + { + accessorKey: 'activity', + header: 'Last active', + cell: ({ getValue }) => { + const activity = String(getValue()) + return ( + + {activity} + + ) + }, + }, +] + +const columnLabels = new Map([ + ['name', 'Person'], + ['role', 'Role'], + ['access', 'Access'], + ['activity', 'Last active'], +]) + +const meta = { + title: '📊 Data display/Table', + component: Table, + parameters: { + badges: ['partiallyAccessible'], + layout: 'fullscreen', + docs: { + description: { + component: + 'Presentation prototype based on Todoist’s current flat data table. The API remains provisional while sorting, selection, dependency placement, and responsive behavior are reviewed.', + }, + }, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +function PersonCell({ person }: { person: Person }) { + return ( +
+ +
+
{person.name}
+
{person.email}
+
+
+ ) +} + +function PresentationFrame({ + children, + count, + narrow = false, +}: { + children: React.ReactNode + count: number + narrow?: boolean +}) { + return ( +
+
+
+
+

People

+

Everyone with access to this workspace

+
+ + {count} {count === 1 ? 'person' : 'people'} + +
+
+
{children}
+
+
+
+ ) +} + +function getSortAriaLabel({ + columnId, + direction, +}: { + columnId: string + direction: 'asc' | 'desc' | null +}) { + const label = columnLabels.get(columnId) ?? columnId + 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 sortPeople(data: Person[], sorting: Exclude) { + return [...data].sort((first, second) => { + const firstValue = String(first[sorting.columnId as keyof Person] ?? '') + const secondValue = String(second[sorting.columnId as keyof Person] ?? '') + const result = firstValue.localeCompare(secondValue) + return sorting.direction === 'asc' ? result : -result + }) +} + +function SortableTable({ narrow = false }: { narrow?: boolean }) { + const [sorting, setSorting] = React.useState>({ + columnId: 'name', + direction: 'asc', + }) + const sortedPeople = sortPeople(people, sorting) + + function handleSort(columnId: string) { + setSorting((current) => ({ + columnId, + direction: + current.columnId === columnId && current.direction === 'asc' ? 'desc' : 'asc', + })) + } + + return ( + + person.id} + sorting={sorting} + onSort={handleSort} + getSortAriaLabel={getSortAriaLabel} + exceptionallySetClassName={styles.presentationTable} + /> + + ) +} + +function handleRowKeyDown( + event: React.KeyboardEvent, + row: TableRowModel, + onActivate: (rowId: string) => void, +) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onActivate(row.id) + } +} + +function SelectableTable() { + const [selectedId, setSelectedId] = React.useState(people[1]!.id) + return ( + +
person.id} + exceptionallySetClassName={styles.presentationTable} + renderRow={(row) => ( + setSelectedId(row.id)} + onKeyDown={(event) => handleRowKeyDown(event, row, setSelectedId)} + > + {row.getVisibleCells().map((cell) => ( + + ))} + + )} + /> + + ) +} + +function PlaceholderTable() { + return ( + +
person.id} + exceptionallySetClassName={styles.presentationTable} + renderRow={(row) => { + const isPlaceholder = Boolean(row.original.placeholder) + return ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ) + }} + /> +

+ Faded rows demonstrate inert, assistive-technology-hidden examples without exposing + the component’s CSS module. +

+ + ) +} + +export const Default = { render: () => } satisfies Story + +export const ControlledSorting = { + name: 'Controlled sorting', + render: () => , +} satisfies Story + +export const SelectedAndClickableRows = { + name: 'Selected and clickable rows', + render: () => , +} satisfies Story + +export const EmptyState = { + name: 'Empty state', + render: () => ( + +
+

No people to show

+

+ People with workspace access will appear here. +

+ + } + /> + + ), +} satisfies Story + +export const CustomPlaceholderRows = { + name: 'Custom placeholder rows', + render: () => , +} satisfies Story + +export const NarrowViewport = { + name: 'Narrow viewport (current behavior)', + render: () => , +} satisfies Story diff --git a/src/table/table.test.tsx b/src/table/table.test.tsx new file mode 100644 index 000000000..f2862032a --- /dev/null +++ b/src/table/table.test.tsx @@ -0,0 +1,194 @@ +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, TableCell, TableRow } from './' + +import type { TableColumn } from './' + +type Person = { + id: string + name: string + role: string +} + +const people: Person[] = [ + { id: 'person-1', name: 'Avery Morgan', role: 'Product designer' }, + { id: 'person-2', name: 'Sam Rivera', role: 'Engineer' }, +] + +const columns: TableColumn[] = [ + { accessorKey: 'name', header: 'Name' }, + { accessorKey: 'role', header: 'Role' }, +] + +describe('Table', () => { + it('renders typed columns and data with native table semantics', () => { + render(
person.id} />) + + expect(screen.getByRole('table')).toBeInTheDocument() + expect(screen.getAllByRole('columnheader')).toHaveLength(2) + expect(screen.getAllByRole('row')).toHaveLength(3) + expect(screen.getByRole('cell', { name: 'Avery Morgan' })).toBeInTheDocument() + expect(screen.getByRole('cell', { name: 'Engineer' })).toBeInTheDocument() + }) + + it('exposes controlled sorting through labeled header buttons', async () => { + const user = userEvent.setup() + const onSort = jest.fn() + + render( +
+ direction ? `${columnId}, sorted ${direction}` : `${columnId}, activate to sort` + } + />, + ) + + expect(screen.getByRole('columnheader', { name: 'Name' })).toHaveAttribute( + 'aria-sort', + 'ascending', + ) + expect(screen.getByRole('columnheader', { name: 'Role' })).toHaveAttribute( + 'aria-sort', + 'none', + ) + + await user.click(screen.getByRole('button', { name: 'name, sorted asc' })) + screen.getByRole('button', { name: 'role, activate to sort' }).focus() + await user.keyboard('{Enter}') + + expect(onSort).toHaveBeenNthCalledWith(1, 'name') + expect(onSort).toHaveBeenNthCalledWith(2, 'role') + expect(onSort).toHaveBeenCalledTimes(2) + }) + + it('renders empty-state content across all visible columns', () => { + render(
) + + expect(screen.getByRole('cell', { name: 'No people yet' })).toHaveAttribute('colspan', '2') + }) + + it('supports consumer-wired row activation, selection, and custom cells', async () => { + const user = userEvent.setup() + + function SelectablePeopleTable() { + const [selectedId, setSelectedId] = React.useState(null) + + return ( +
person.id} + renderRow={(row) => ( + setSelectedId(row.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setSelectedId(row.id) + } + }} + > + {row.getVisibleCells().map((cell) => ( + + {cell.column.id === 'name' ? ( + {row.original.name} + ) : undefined} + + ))} + + )} + /> + ) + } + + render() + + const averyRow = screen.getByRole('row', { name: 'Avery Morgan Product designer' }) + const samRow = screen.getByRole('row', { name: 'Sam Rivera Engineer' }) + + await user.click(averyRow) + expect(averyRow).toHaveAttribute('aria-selected', 'true') + + samRow.focus() + await user.keyboard(' ') + expect(samRow).toHaveAttribute('aria-selected', 'true') + expect(screen.getByText('Avery Morgan', { selector: 'strong' })).toBeInTheDocument() + }) + + it('forwards refs and styling escape hatches from the public building blocks', () => { + const tableRef = React.createRef() + const rowRef = React.createRef() + const cellRef = React.createRef() + + render( +
( + + {row.getVisibleCells().map((cell, index) => ( + + ))} + + )} + />, + ) + + expect(tableRef.current).toHaveClass('custom-table') + expect(rowRef.current).toHaveClass('custom-row') + expect(cellRef.current).toHaveClass('custom-cell') + }) + + it('applies inert to custom cells without relying on React version support', () => { + render( +
( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )} + />, + ) + + expect(screen.getByRole('cell', { name: 'Avery Morgan' })).toHaveAttribute('inert') + expect(screen.getByRole('cell', { name: 'Product designer' })).toHaveAttribute('inert') + }) + + it('has no automated accessibility violations in its sortable state', async () => { + const { container } = render( +
+ direction ? `${columnId}, sorted ${direction}` : `${columnId}, sortable` + } + />, + ) + + expect(await axe(container)).toHaveNoViolations() + }) +}) diff --git a/src/table/table.tsx b/src/table/table.tsx new file mode 100644 index 000000000..b5323a2f0 --- /dev/null +++ b/src/table/table.tsx @@ -0,0 +1,316 @@ +'use no memo' + +import * as React from 'react' + +import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import classNames from 'classnames' +import { useMergeRefs } from 'use-callback-ref' + +import styles from './table.module.css' + +import type { Cell, ColumnDef, Row } from '@tanstack/react-table' +import type { ObfuscatedClassName } from '../utils/common-types' + +/** A Reactist-owned TanStack column definition for a table row. */ +type TableColumn = ColumnDef + +/** The row model passed to `renderRow`. */ +type TableRowModel = Row + +/** The cell model rendered by `TableCell`. */ +type TableCellModel = Cell + +/** Props for a styled native table row. */ +type TableRowProps = Omit, 'className'> & + ObfuscatedClassName + +/** Props for a styled native table cell. */ +type TableCellProps = Omit< + React.TdHTMLAttributes, + 'className' | 'inert' +> & + ObfuscatedClassName & { + /** TanStack cell model used for default cell rendering. */ + cell: TableCellModel + + /** Removes this custom cell and its descendants from interaction. */ + inert?: boolean + } + +/** Controlled sorting state for a Reactist table. */ +type TableSorting = { + /** The id of the column currently used for sorting. */ + columnId: string + + /** The current sort direction. */ + direction: 'asc' | 'desc' +} | null + +type TableSortLabelInput = { + columnId: string + direction: 'asc' | 'desc' | null +} + +type TableSortingProps = + | { + /** Controlled sorting state. Pass `null` when no column is sorted. */ + sorting: TableSorting + + /** Called when a sortable column header is activated. */ + onSort: (columnId: string) => void + + /** Returns the complete localized accessible label for a sort button. */ + getSortAriaLabel: (input: TableSortLabelInput) => string + } + | { + sorting?: never + onSort?: never + getSortAriaLabel?: never + } + +/** Props for the provisional `Table` presentation prototype. */ +type TableProps = Omit< + React.TableHTMLAttributes, + 'children' | 'className' +> & + ObfuscatedClassName & + TableSortingProps & { + /** Row data. Reactist never sorts or mutates this controlled input. */ + data: TData[] + + /** Typed definitions for the table's columns. */ + columns: TableColumn[] + + /** Returns a stable identifier for a row. */ + getRowId?: (row: TData, index: number) => string + + /** Content rendered across the visible columns when `data` is empty. */ + emptyState?: React.ReactNode + + /** + * Renders a structurally custom row. Use `TableRow` and `TableCell` to retain the baseline + * styling without importing the component's CSS module. + */ + renderRow?: (row: TableRowModel) => React.ReactNode + } + +type TableComponent = { + ( + props: TableProps & React.RefAttributes, + ): React.ReactElement | null + displayName?: string +} + +type TableCellComponent = { + ( + props: TableCellProps & React.RefAttributes, + ): React.ReactElement | null + displayName?: string +} + +/** Styled native row building block for custom `Table` rows. */ +const TableRow = React.forwardRef(function TableRow( + { exceptionallySetClassName, onClick, ...rowProps }, + ref, +) { + return ( + + ) +}) + +/** Styled native cell building block for custom `Table` rows. */ +const TableCell = React.forwardRef(function TableCell( + { + cell, + children, + inert = false, + exceptionallySetClassName, + ...cellProps + }: TableCellProps, + ref: React.ForwardedRef, +) { + const internalRef = React.useRef(null) + const combinedRef = useMergeRefs([internalRef, ref]) + + React.useEffect( + function syncInertAttribute() { + internalRef.current?.toggleAttribute('inert', inert) + }, + [inert], + ) + + return ( + + ) +}) as TableCellComponent + +TableRow.displayName = 'TableRow' +TableCell.displayName = 'TableCell' + +/** + * Displays typed row data using native table markup. + * + * This API is provisional while the Reactist Table proposal is being reviewed. + */ +const Table = React.forwardRef(function Table( + { + data, + columns, + getRowId, + emptyState, + renderRow, + sorting, + onSort, + getSortAriaLabel, + exceptionallySetClassName, + ...tableProps + }: TableProps, + ref: React.ForwardedRef, +) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getRowId, + enableSorting: false, + manualSorting: true, + state: { + sorting: sorting ? [{ id: sorting.columnId, desc: sorting.direction === 'desc' }] : [], + }, + }) + const rows = table.getRowModel().rows + + return ( +
+ {children ?? flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const canSort = Boolean(onSort && getSortAriaLabel) + const direction = + sorting?.columnId === header.column.id ? sorting.direction : null + const ariaSort = canSort + ? direction === 'asc' + ? 'ascending' + : direction === 'desc' + ? 'descending' + : 'none' + : undefined + const content = header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext()) + + return ( + + ) + })} + + ))} + + + {rows.length === 0 && emptyState !== undefined ? ( + + + + ) : ( + rows.map((row) => + renderRow ? ( + renderRow(row) + ) : ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ), + ) + )} + +
+ {canSort && onSort && getSortAriaLabel ? ( + + ) : ( + content + )} +
+ {emptyState} +
+ ) +}) as TableComponent + +Table.displayName = 'Table' + +export { Table, TableCell, TableRow } +export type { + TableCellModel, + TableCellProps, + TableColumn, + TableProps, + TableRowModel, + TableRowProps, + TableSorting, +} + +function SortIndicator({ direction }: { direction: 'asc' | 'desc' }) { + return ( + + + + ) +}