From f6789d865e0a7fa5d98ad35879dbb8e78b0a6966 Mon Sep 17 00:00:00 2001 From: Rui Martins Date: Thu, 23 Jul 2026 11:57:54 +0200 Subject: [PATCH 01/19] 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 dd5aaa3a7..2aa51097a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "MIT", "dependencies": { "@babel/runtime": "7.29.7", + "@tanstack/react-table": "8.21.3", "aria-hidden": "1.2.6", "dayjs": "1.11.21", "patch-package": "8.0.1", @@ -6437,6 +6438,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", @@ -21463,7 +21497,6 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", - "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -23279,7 +23312,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 a49e47af8..b4fc4db2b 100644 --- a/package.json +++ b/package.json @@ -159,6 +159,7 @@ }, "dependencies": { "@babel/runtime": "7.29.7", + "@tanstack/react-table": "8.21.3", "aria-hidden": "1.2.6", "dayjs": "1.11.21", "patch-package": "8.0.1", 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 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/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 ( + + + + ) +} From d67c6026d2249889d3688ec8f8b630c6cca7c284 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Tue, 18 Aug 2026 22:55:54 -0700 Subject: [PATCH 02/19] feat: rebuild Table as compound presentational primitives Co-Authored-By: Claude --- .react-compiler.rec.json | 3 - src/table/sort-indicator.tsx | 13 + src/table/table.module.css | 30 +- src/table/table.stories.module.css | 118 ++------ src/table/table.stories.tsx | 466 ++++++++++++++++++----------- src/table/table.test.tsx | 218 +++----------- src/table/table.tsx | 400 +++++++++---------------- 7 files changed, 538 insertions(+), 710 deletions(-) create mode 100644 src/table/sort-indicator.tsx diff --git a/.react-compiler.rec.json b/.react-compiler.rec.json index 26183356a..e0fc09102 100644 --- a/.react-compiler.rec.json +++ b/.react-compiler.rec.json @@ -17,9 +17,6 @@ "src/menu/menu.tsx": { "CompileError": 2 }, - "src/table/table.tsx": { - "CompileError": 1 - }, "src/tooltip/tooltip.tsx": { "CompileError": 1 } diff --git a/src/table/sort-indicator.tsx b/src/table/sort-indicator.tsx new file mode 100644 index 000000000..825ce2fa2 --- /dev/null +++ b/src/table/sort-indicator.tsx @@ -0,0 +1,13 @@ +import * as React from 'react' + +import styles from './table.module.css' + +function SortIndicator({ direction }: { direction: 'asc' | 'desc' }) { + return ( + + + + ) +} + +export { SortIndicator } diff --git a/src/table/table.module.css b/src/table/table.module.css index cf572fbc8..cc937872f 100644 --- a/src/table/table.module.css +++ b/src/table/table.module.css @@ -1,13 +1,12 @@ .table { width: 100%; border-collapse: collapse; - color: var(--reactist-table-content, var(--reactist-content-primary)); + color: var(--product-library-display-primary-idle-tint); 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)); + border-bottom: 1px solid var(--product-library-divider-primary); } .headerCell { @@ -29,29 +28,28 @@ 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)); + background-color: var(--product-library-actionable-secondary-hover-fill); } .sortButton:focus-visible { outline: 2px solid - var(--reactist-table-focus-ring, var(--reactist-actionable-primary-idle-fill)); + var(--product-library-display-primary-idle-tint); outline-offset: -2px; } -.headerCellContent { +.headerLabel { display: inline-flex; align-items: center; } .row { - border-bottom: 1px solid var(--reactist-table-divider, var(--reactist-divider-secondary)); + border-bottom: 1px solid var(--product-library-divider-primary); } .row.clickable { @@ -68,7 +66,7 @@ } .row[aria-selected='true'] { - background-color: var(--reactist-table-selected-fill, var(--reactist-framework-fill-selected)); + background-color: var(--product-library-selectable-secondary-selected-fill); } .row[aria-selected='true'] .cell:first-child { @@ -83,7 +81,7 @@ .row.clickable:focus-visible { outline: 2px solid - var(--reactist-table-focus-ring, var(--reactist-actionable-primary-idle-fill)); + var(--product-library-display-primary-idle-tint); outline-offset: -2px; } @@ -94,7 +92,7 @@ .emptyCell { padding-block: var(--reactist-spacing-xxlarge); - color: var(--reactist-table-empty-content, var(--reactist-content-secondary)); + color: var(--product-library-display-secondary-idle-tint); text-align: center; } @@ -108,10 +106,18 @@ .sortIndicatorIcon { width: 16px; height: 16px; - color: var(--reactist-table-sort-indicator, var(--reactist-content-secondary)); + color: var(--product-library-display-secondary-idle-tint); fill: none; stroke: currentcolor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; } + +.align-start { + text-align: left; +} + +.align-end { + text-align: right; +} diff --git a/src/table/table.stories.module.css b/src/table/table.stories.module.css index a3399bb2d..df9eb6b5a 100644 --- a/src/table/table.stories.module.css +++ b/src/table/table.stories.module.css @@ -1,64 +1,3 @@ -.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%; @@ -79,6 +18,20 @@ width: 24%; } +.narrowScroll { + width: 420px; + max-width: 100%; + overflow-x: auto; +} + +.narrowScroll .presentationTable { + min-width: 760px; +} + +.clickableRow { + cursor: pointer; +} + .person { display: flex; gap: 10px; @@ -100,16 +53,12 @@ .personEmail { overflow: hidden; margin-top: 2px; - color: var(--reactist-content-secondary); + color: var(--product-library-display-secondary-idle-tint); 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; @@ -122,51 +71,28 @@ } .statusMuted { - color: var(--reactist-content-secondary); - background: var(--reactist-bg-highlight); + color: var(--product-library-display-secondary-idle-tint); + background: var(--product-library-actionable-secondary-hover-fill); } -.emptyState { - padding: 12px; +.emptyCell { + padding-block: var(--reactist-spacing-xxlarge); + text-align: center; } .emptyTitle { margin: 0; - color: var(--reactist-content-primary); + color: var(--product-library-display-primary-idle-tint); font-size: var(--reactist-font-size-subtitle); font-weight: var(--reactist-font-weight-medium); } .emptyDescription { margin: 6px 0 0; - color: var(--reactist-content-secondary); + color: var(--product-library-display-secondary-idle-tint); } .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 index 6dd2e7d37..d2c879617 100644 --- a/src/table/table.stories.tsx +++ b/src/table/table.stories.tsx @@ -3,13 +3,13 @@ import * as React from 'react' import classNames from 'classnames' import { Avatar } from '../avatar' +import { Text } from '../text' -import { Table, TableCell, TableRow } from './table' +import { Table, TableBody, TableCell, TableColumnHeader, TableHeader, 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 @@ -21,6 +21,11 @@ type Person = { placeholder?: boolean } +type Sorting = { + columnId: string + direction: 'asc' | 'desc' +} + const people: Person[] = [ { id: 'avery-morgan', @@ -95,37 +100,6 @@ const placeholderPeople: Person[] = [ }, ] -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'], @@ -138,7 +112,6 @@ const meta = { component: Table, parameters: { badges: ['partiallyAccessible'], - layout: 'fullscreen', docs: { description: { component: @@ -164,32 +137,16 @@ function PersonCell({ person }: { person: Person }) { ) } -function PresentationFrame({ - children, - count, - narrow = false, -}: { - children: React.ReactNode - count: number - narrow?: boolean -}) { +function ActivityCell({ person }: { person: Person }) { return ( -
-
-
-
-

People

-

Everyone with access to this workspace

-
- - {count} {count === 1 ? 'person' : 'people'} - -
-
-
{children}
-
-
-
+ + {person.activity} + ) } @@ -206,7 +163,7 @@ function getSortAriaLabel({ return `${label}, activate to sort ascending.` } -function sortPeople(data: Person[], sorting: Exclude) { +function sortPeople(data: Person[], sorting: Sorting) { return [...data].sort((first, second) => { const firstValue = String(first[sorting.columnId as keyof Person] ?? '') const secondValue = String(second[sorting.columnId as keyof Person] ?? '') @@ -215,150 +172,323 @@ function sortPeople(data: Person[], sorting: Exclude) { }) } -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', - })) +function nextSorting(current: Sorting, columnId: string): Sorting { + return { + 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, + personId: string, + onActivate: (personId: string) => void, ) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() - onActivate(row.id) + onActivate(personId) } } -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) => ( - - ))} +export const Default = { + render: () => ( +
+ + + Person + + + Role + + + Access + + + Last active + + + + {people.map((person) => ( + + + + + + + {person.role} + + + + + {person.access} + + + + + - )} - /> - - ) -} + ))} + +
+ ), +} satisfies Story -function PlaceholderTable() { - return ( - +export const ControlledSorting = { + name: 'Controlled sorting', + render: function ControlledSorting() { + const [sorting, setSorting] = React.useState({ + columnId: 'name', + direction: 'asc', + }) + const sorted = sortPeople(people, sorting) + + return ( person.id} + aria-label="Workspace people" exceptionallySetClassName={styles.presentationTable} - renderRow={(row) => { - const isPlaceholder = Boolean(row.original.placeholder) - return ( - - {row.getVisibleCells().map((cell) => ( - - ))} + > + + setSorting(nextSorting(sorting, 'name'))} + sortAriaLabel={getSortAriaLabel({ + columnId: 'name', + direction: sorting.columnId === 'name' ? sorting.direction : null, + })} + > + Person + + setSorting(nextSorting(sorting, 'role'))} + sortAriaLabel={getSortAriaLabel({ + columnId: 'role', + direction: sorting.columnId === 'role' ? sorting.direction : null, + })} + > + Role + + + Access + + + Last active + + + + {sorted.map((person) => ( + + + + + + + {person.role} + + + + + {person.access} + + + + + - ) - }} - /> -

- 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: () => , + render: function SelectedAndClickableRows() { + const [selectedId, setSelectedId] = React.useState(people[1]!.id) + + return ( + + + + Person + + + Role + + + Access + + + Last active + + + + {people.map((person) => ( + setSelectedId(person.id)} + onKeyDown={(event) => handleRowKeyDown(event, person.id, setSelectedId)} + > + + + + + + {person.role} + + + + + {person.access} + + + + + + + ))} + +
+ ) + }, } satisfies Story export const EmptyState = { name: 'Empty state', render: () => ( - - +
+ + + Person + + + Role + + + + +

No people to show

People with workspace access will appear here.

- - } - /> - +
+
+
+
), } satisfies Story export const CustomPlaceholderRows = { name: 'Custom placeholder rows', - render: () => , + render: () => ( + + + + Person + + + Role + + + Access + + + Last active + + + + {placeholderPeople.map((person) => ( + + + + + + + {person.role} + + + + + {person.access} + + + + + + + ))} + +
+ ), } satisfies Story export const NarrowViewport = { name: 'Narrow viewport (current behavior)', - render: () => , + render: () => ( +
+ + + + Person + + + Role + + + Access + + + Last active + + + + {people.map((person) => ( + + + + + + + {person.role} + + + + + {person.access} + + + + + + + ))} + +
+
+ ), } satisfies Story diff --git a/src/table/table.test.tsx b/src/table/table.test.tsx index f2862032a..3e824a149 100644 --- a/src/table/table.test.tsx +++ b/src/table/table.test.tsx @@ -1,194 +1,68 @@ 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 +import { Table, TableBody, TableCell, TableColumnHeader, TableHeader, TableRow } from './index' + +function BasicTable({ withHeader = true }: { withHeader?: boolean }) { + return ( + + {withHeader ? ( + + Person + Role + + ) : null} + + + Avery Morgan + Product designer + + +
+ ) } -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() +describe('Table primitives', () => { + it('renders native table semantics from composed children', () => { + render() + expect(screen.getByRole('table', { name: 'People' })).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('renders no thead when TableHeader is omitted', () => { + const { container } = render() + expect(container.querySelector('thead')).toBeNull() + expect(screen.queryAllByRole('columnheader')).toHaveLength(0) + expect(screen.getByRole('cell', { name: 'Avery Morgan' })).toBeInTheDocument() }) - 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} - - ))} - - )} - /> - ) + it('forwards refs on every primitive', () => { + const refs = { + table: React.createRef(), + body: React.createRef(), + row: React.createRef(), + cell: React.createRef(), } - - 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) => ( - - ))} +
+ + + Avery - )} - />, + +
, ) - - expect(tableRef.current).toHaveClass('custom-table') - expect(rowRef.current).toHaveClass('custom-row') - expect(cellRef.current).toHaveClass('custom-cell') + 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('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` - } - />, - ) - + it('has no automated accessibility violations', async () => { + const { container } = render() expect(await axe(container)).toHaveNoViolations() }) }) diff --git a/src/table/table.tsx b/src/table/table.tsx index b5323a2f0..17b08e177 100644 --- a/src/table/table.tsx +++ b/src/table/table.tsx @@ -1,316 +1,198 @@ -'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 { SortIndicator } from './sort-indicator' 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 +type TableProps = Omit, 'className'> & + ObfuscatedClassName -/** The row model passed to `renderRow`. */ -type TableRowModel = Row +type TableHeaderProps = Omit, 'className'> & + ObfuscatedClassName -/** The cell model rendered by `TableCell`. */ -type TableCellModel = Cell +type TableBodyProps = Omit, 'className'> & + ObfuscatedClassName -/** 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' -> & +type TableCellProps = Omit, 'align' | 'className'> & ObfuscatedClassName & { - /** TanStack cell model used for default cell rendering. */ - cell: TableCellModel - - /** Removes this custom cell and its descendants from interaction. */ - inert?: boolean + /** Horizontal alignment of the cell content. */ + align?: 'start' | 'end' } -/** 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 = +type SortableProps = | { - /** Controlled sorting state. Pass `null` when no column is sorted. */ - sorting: TableSorting + /** 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 a sortable column header is activated. */ - onSort: (columnId: string) => void + /** Called when the sort control is activated. */ + onSort: () => void - /** Returns the complete localized accessible label for a sort button. */ - getSortAriaLabel: (input: TableSortLabelInput) => string + /** Complete localized label for the sort button. */ + sortAriaLabel: string } | { - sorting?: never + sortable?: false + sortDirection?: never onSort?: never - getSortAriaLabel?: never + sortAriaLabel?: never } -/** Props for the provisional `Table` presentation prototype. */ -type TableProps = Omit< - React.TableHTMLAttributes, - 'children' | 'className' +type TableColumnHeaderProps = Omit< + React.ThHTMLAttributes, + 'align' | 'className' | 'onSort' > & 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 + SortableProps & { + /** Horizontal alignment of the header content. */ + align?: 'start' | 'end' } -type TableComponent = { - ( - props: TableProps & React.RefAttributes, - ): React.ReactElement | null - displayName?: string +function ariaSortFor(sortDirection: 'asc' | 'desc' | null) { + if (sortDirection === 'asc') return 'ascending' + if (sortDirection === 'desc') return 'descending' + return 'none' } -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 }, +/** + * Tabular data in native table markup, composed from: + * * {@link TableHeader} + * * {@link TableColumnHeader} + * * {@link TableBody} + * * {@link TableRow} + * * {@link TableCell} + */ +const Table = React.forwardRef(function Table( + { exceptionallySetClassName, ...tableProps }, 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, +/** A table header row wrapper. Omit it for a table with no header. */ +const TableHeader = React.forwardRef( + function TableHeader({ children, exceptionallySetClassName, ...headerProps }, ref) { + return ( + + {children} + + ) + }, +) + +/** A table body that wraps its rows. */ +const TableBody = React.forwardRef(function TableBody( + { exceptionallySetClassName, ...bodyProps }, + ref, ) { - const internalRef = React.useRef(null) - const combinedRef = useMergeRefs([internalRef, ref]) + return +}) - React.useEffect( - function syncInertAttribute() { - internalRef.current?.toggleAttribute('inert', inert) - }, - [inert], +/** A table row. Pass `aria-selected` to make it selectable. */ +const TableRow = React.forwardRef(function TableRow( + { exceptionallySetClassName, ...rowProps }, + ref, +) { + return ( + ) +}) +/** A table data cell. */ +const TableCell = React.forwardRef(function TableCell( + { align = 'start', children, exceptionallySetClassName, ...cellProps }, + ref, +) { 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' }] : [], +/** A table column header, optionally sortable. */ +const TableColumnHeader = React.forwardRef( + function TableColumnHeader( + { + sortable, + sortDirection = null, + onSort, + sortAriaLabel, + align = 'start', + children, + exceptionallySetClassName, + ...headerProps }, - }) - const rows = table.getRowModel().rows - - return ( -
- {children ?? flexRender(cell.column.columnDef.cell, cell.getContext())} + {children}
- - {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) => ( - - ))} - - ), - ) + label )} - -
- {canSort && onSort && getSortAriaLabel ? ( - - ) : ( - content - )} -
{children} + + return ( + + {sortable ? ( +
- ) -}) as TableComponent + + ) + }, +) Table.displayName = 'Table' +TableHeader.displayName = 'TableHeader' +TableBody.displayName = 'TableBody' +TableRow.displayName = 'TableRow' +TableCell.displayName = 'TableCell' +TableColumnHeader.displayName = 'TableColumnHeader' -export { Table, TableCell, TableRow } -export type { - TableCellModel, - TableCellProps, - TableColumn, - TableProps, - TableRowModel, - TableRowProps, - TableSorting, -} - -function SortIndicator({ direction }: { direction: 'asc' | 'desc' }) { - return ( - - - - ) -} +export { Table, TableBody, TableCell, TableColumnHeader, TableHeader, TableRow } From 9b546ccd2a347aca60167b26526d4fa4c11b3185 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Tue, 18 Aug 2026 22:57:30 -0700 Subject: [PATCH 03/19] feat: make @tanstack/react-table a devDependency used only by stories Co-Authored-By: Claude --- package-lock.json | 61 +++++++++++++++++++++++++++++++++++++---------- package.json | 2 +- rollup.config.mjs | 1 - 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2aa51097a..05303b347 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "license": "MIT", "dependencies": { "@babel/runtime": "7.29.7", - "@tanstack/react-table": "8.21.3", "aria-hidden": "1.2.6", "dayjs": "1.11.21", "patch-package": "8.0.1", @@ -44,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", @@ -6438,33 +6438,68 @@ "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": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", - "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "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/table-core": "8.21.3" + "@tanstack/react-store": "^0.11.0", + "@tanstack/table-core": "9.1.2" }, "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "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": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", - "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "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": ">=12" + "node": ">=20" }, "funding": { "type": "github", @@ -21497,6 +21532,7 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -23312,6 +23348,7 @@ "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 b4fc4db2b..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", @@ -159,7 +160,6 @@ }, "dependencies": { "@babel/runtime": "7.29.7", - "@tanstack/react-table": "8.21.3", "aria-hidden": "1.2.6", "dayjs": "1.11.21", "patch-package": "8.0.1", diff --git a/rollup.config.mjs b/rollup.config.mjs index 96c4bdfbd..bd27b6eba 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -20,7 +20,6 @@ const external = [ 'classnames', 'prop-types', '@ariakit/react', - '@tanstack/react-table', 'aria-hidden', 'dayjs', 'dayjs/plugin/localizedFormat', From d3d3be6dd0aeadddb90a8feb4ba325430b3afcbe Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Tue, 18 Aug 2026 22:58:16 -0700 Subject: [PATCH 04/19] feat: match Table cell typography and spacing to the Figma Co-Authored-By: Claude --- src/table/table.module.css | 3 ++- src/table/table.stories.module.css | 16 ---------------- src/table/table.stories.tsx | 6 ++++-- src/table/table.test.tsx | 22 ++++++++++++++++++++++ 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/table/table.module.css b/src/table/table.module.css index cc937872f..5e782834d 100644 --- a/src/table/table.module.css +++ b/src/table/table.module.css @@ -86,7 +86,8 @@ } .cell { - padding: var(--reactist-spacing-small) var(--reactist-spacing-medium); + min-height: 48px; + padding: var(--reactist-spacing-small); vertical-align: middle; } diff --git a/src/table/table.stories.module.css b/src/table/table.stories.module.css index df9eb6b5a..b46548186 100644 --- a/src/table/table.stories.module.css +++ b/src/table/table.stories.module.css @@ -43,22 +43,6 @@ 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(--product-library-display-secondary-idle-tint); - font-size: var(--reactist-font-size-caption); - text-overflow: ellipsis; - white-space: nowrap; -} - .status { display: inline-flex; align-items: center; diff --git a/src/table/table.stories.tsx b/src/table/table.stories.tsx index d2c879617..c8f5ee540 100644 --- a/src/table/table.stories.tsx +++ b/src/table/table.stories.tsx @@ -130,8 +130,10 @@ function PersonCell({ person }: { person: Person }) {
-
{person.name}
-
{person.email}
+ {person.name} + + {person.email} +
) diff --git a/src/table/table.test.tsx b/src/table/table.test.tsx index 3e824a149..9deb39498 100644 --- a/src/table/table.test.tsx +++ b/src/table/table.test.tsx @@ -3,6 +3,8 @@ import * as React from 'react' import { render, screen } from '@testing-library/react' import { axe } from 'jest-axe' +import { Text } from '../text' + import { Table, TableBody, TableCell, TableColumnHeader, TableHeader, TableRow } from './index' function BasicTable({ withHeader = true }: { withHeader?: boolean }) { @@ -61,6 +63,26 @@ describe('Table primitives', () => { expect(refs.cell.current?.tagName).toBe('TD') }) + it('renders cell content untouched, imposing no type style of its own', () => { + render( + + + + + + Avery Morgan + + + + +
, + ) + const cellText = screen.getByText('Avery Morgan') + expect(cellText.className).toContain('variant-callout-2') + expect(cellText.className).toContain('lineClamp-1') + expect(cellText.parentElement?.tagName).toBe('TD') + }) + it('has no automated accessibility violations', async () => { const { container } = render() expect(await axe(container)).toHaveNoViolations() From 59f17011ad37cdd2cb5cd3a365b6e67e3c136247 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Tue, 18 Aug 2026 22:59:17 -0700 Subject: [PATCH 05/19] feat: apply Figma row states to selectable Table rows only Co-Authored-By: Claude --- src/table/table.module.css | 29 +++++++++-------------------- src/table/table.stories.module.css | 4 ---- src/table/table.stories.tsx | 1 - src/table/table.test.tsx | 22 ++++++++++++++++++++++ 4 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/table/table.module.css b/src/table/table.module.css index 5e782834d..95de0b8f7 100644 --- a/src/table/table.module.css +++ b/src/table/table.module.css @@ -49,39 +49,28 @@ } .row { + height: 48px; + padding-inline: var(--reactist-spacing-xsmall); + background-color: var(--product-library-background-base-primary); border-bottom: 1px solid var(--product-library-divider-primary); } -.row.clickable { +.row[aria-selected] { 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]: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='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 { +.row[aria-selected]:focus-visible { outline: 2px solid - var(--product-library-display-primary-idle-tint); + var(--product-library-divider-tertiary); outline-offset: -2px; } diff --git a/src/table/table.stories.module.css b/src/table/table.stories.module.css index b46548186..a780ad826 100644 --- a/src/table/table.stories.module.css +++ b/src/table/table.stories.module.css @@ -28,10 +28,6 @@ min-width: 760px; } -.clickableRow { - cursor: pointer; -} - .person { display: flex; gap: 10px; diff --git a/src/table/table.stories.tsx b/src/table/table.stories.tsx index c8f5ee540..dc90b4a3f 100644 --- a/src/table/table.stories.tsx +++ b/src/table/table.stories.tsx @@ -336,7 +336,6 @@ export const SelectedAndClickableRows = { key={person.id} aria-selected={selectedId === person.id} tabIndex={0} - exceptionallySetClassName={styles.clickableRow} onClick={() => setSelectedId(person.id)} onKeyDown={(event) => handleRowKeyDown(event, person.id, setSelectedId)} > diff --git a/src/table/table.test.tsx b/src/table/table.test.tsx index 9deb39498..d2c961f1d 100644 --- a/src/table/table.test.tsx +++ b/src/table/table.test.tsx @@ -83,6 +83,28 @@ describe('Table primitives', () => { expect(cellText.parentElement?.tagName).toBe('TD') }) + it('marks only rows with aria-selected as selectable', () => { + render( + + + + Selectable + + + Selected + + + Plain + + +
, + ) + 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('has no automated accessibility violations', async () => { const { container } = render() expect(await axe(container)).toHaveNoViolations() From 70e07cb074a4ff9462643b72d49e2b686ef1e35b Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Tue, 18 Aug 2026 23:01:54 -0700 Subject: [PATCH 06/19] feat: match Table column header styling and sort states to the Figma Co-Authored-By: Claude --- src/table/table.module.css | 28 +++++++++++------ src/table/table.test.tsx | 63 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/table/table.module.css b/src/table/table.module.css index 95de0b8f7..5a723ddea 100644 --- a/src/table/table.module.css +++ b/src/table/table.module.css @@ -10,8 +10,8 @@ } .headerCell { - padding: var(--reactist-spacing-medium); - text-align: left; + height: 40px; + padding: 0 var(--reactist-spacing-small); user-select: none; } @@ -24,7 +24,8 @@ gap: var(--reactist-spacing-xsmall); align-items: center; width: 100%; - padding: var(--reactist-spacing-medium); + height: 40px; + padding: 0 var(--reactist-spacing-small); border: 0; color: inherit; font: inherit; @@ -33,19 +34,27 @@ 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: 2px solid var(--product-library-display-primary-idle-tint); outline-offset: -2px; } -.headerLabel { - display: inline-flex; - align-items: center; +.sortIndicatorUnsorted { + opacity: 0; +} + +.sortButton:hover .sortIndicatorUnsorted, +.sortButton:focus-visible .sortIndicatorUnsorted { + opacity: 1; } .row { @@ -69,8 +78,7 @@ } .row[aria-selected]:focus-visible { - outline: 2px solid - var(--product-library-divider-tertiary); + outline: 2px solid var(--product-library-divider-tertiary); outline-offset: -2px; } diff --git a/src/table/table.test.tsx b/src/table/table.test.tsx index d2c961f1d..fb6948c6e 100644 --- a/src/table/table.test.tsx +++ b/src/table/table.test.tsx @@ -1,6 +1,7 @@ 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 { Text } from '../text' @@ -110,3 +111,65 @@ describe('Table primitives', () => { expect(await axe(container)).toHaveNoViolations() }) }) + +describe('TableColumnHeader sorting', () => { + function SortableHeader({ + sortDirection = null, + onSort = jest.fn(), + }: { + sortDirection?: 'asc' | 'desc' | null + onSort?: () => void + }) { + return ( + + + + Person + + +
+ ) + } + + it('omits aria-sort on a non-sortable header', () => { + render( + + + Person + +
, + ) + expect(screen.getByRole('columnheader')).not.toHaveAttribute('aria-sort') + }) + + it.each([ + [null, 'none'], + ['asc' as const, 'ascending'], + ['desc' as const, 'descending'], + ])('maps sortDirection %s to aria-sort %s', (direction, expected) => { + render() + expect(screen.getByRole('columnheader')).toHaveAttribute('aria-sort', expected) + }) + + it('fires onSort exactly once per activation', async () => { + const onSort = jest.fn() + const user = userEvent.setup() + render() + 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) + }) +}) From 11a926077431b717441901fdc3d8007da8a29990 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Tue, 18 Aug 2026 23:08:14 -0700 Subject: [PATCH 07/19] docs: rebuild Table stories on the compound API and link the Figma designs Co-Authored-By: Claude --- src/table/table.module.css | 6 - src/table/table.stories.module.css | 17 -- src/table/table.stories.tsx | 288 +++++++++++++++++------------ 3 files changed, 165 insertions(+), 146 deletions(-) diff --git a/src/table/table.module.css b/src/table/table.module.css index 5a723ddea..803412c47 100644 --- a/src/table/table.module.css +++ b/src/table/table.module.css @@ -88,12 +88,6 @@ vertical-align: middle; } -.emptyCell { - padding-block: var(--reactist-spacing-xxlarge); - color: var(--product-library-display-secondary-idle-tint); - text-align: center; -} - .sortIndicator { display: inline-flex; align-items: center; diff --git a/src/table/table.stories.module.css b/src/table/table.stories.module.css index a780ad826..2974daaf8 100644 --- a/src/table/table.stories.module.css +++ b/src/table/table.stories.module.css @@ -55,23 +55,6 @@ background: var(--product-library-actionable-secondary-hover-fill); } -.emptyCell { - padding-block: var(--reactist-spacing-xxlarge); - text-align: center; -} - -.emptyTitle { - margin: 0; - color: var(--product-library-display-primary-idle-tint); - font-size: var(--reactist-font-size-subtitle); - font-weight: var(--reactist-font-weight-medium); -} - -.emptyDescription { - margin: 6px 0 0; - color: var(--product-library-display-secondary-idle-tint); -} - .placeholderRow { opacity: 0.42; filter: grayscale(0.5); diff --git a/src/table/table.stories.tsx b/src/table/table.stories.tsx index dc90b4a3f..55e1cd9ef 100644 --- a/src/table/table.stories.tsx +++ b/src/table/table.stories.tsx @@ -1,8 +1,21 @@ import * as React from 'react' +import { + columnVisibilityFeature, + createPaginatedRowModel, + createSortedRowModel, + FlexRender, + rowPaginationFeature, + rowSortingFeature, + sortFn_text, + tableFeatures, + useTable, +} from '@tanstack/react-table' import classNames from 'classnames' import { Avatar } from '../avatar' +import { Box } from '../box' +import { Button } from '../button' import { Text } from '../text' import { Table, TableBody, TableCell, TableColumnHeader, TableHeader, TableRow } from './table' @@ -10,6 +23,7 @@ import { Table, TableBody, TableCell, TableColumnHeader, TableHeader, TableRow } import styles from './table.stories.module.css' import type { Meta, StoryObj } from '@storybook/react-vite' +import type { ColumnDef } from '@tanstack/react-table' type Person = { id: string @@ -21,11 +35,6 @@ type Person = { placeholder?: boolean } -type Sorting = { - columnId: string - direction: 'asc' | 'desc' -} - const people: Person[] = [ { id: 'avery-morgan', @@ -100,22 +109,19 @@ const placeholderPeople: Person[] = [ }, ] -const columnLabels = new Map([ - ['name', 'Person'], - ['role', 'Role'], - ['access', 'Access'], - ['activity', 'Last active'], -]) - const meta = { title: '📊 Data display/Table', component: Table, parameters: { - badges: ['partiallyAccessible'], + 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: - 'Presentation prototype based on Todoist’s current flat data table. The API remains provisional while sorting, selection, dependency placement, and responsive behavior are reviewed.', + 'Compound primitives for tabular data. Compose Table with 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.', }, }, }, @@ -152,35 +158,12 @@ function ActivityCell({ person }: { person: Person }) { ) } -function getSortAriaLabel({ - columnId, - direction, -}: { - columnId: string - direction: 'asc' | 'desc' | null -}) { - const label = columnLabels.get(columnId) ?? columnId +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 sortPeople(data: Person[], sorting: Sorting) { - 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 nextSorting(current: Sorting, columnId: string): Sorting { - return { - columnId, - direction: current.columnId === columnId && current.direction === 'asc' ? 'desc' : 'asc', - } -} - function handleRowKeyDown( event: React.KeyboardEvent, personId: string, @@ -235,77 +218,6 @@ export const Default = { ), } satisfies Story -export const ControlledSorting = { - name: 'Controlled sorting', - render: function ControlledSorting() { - const [sorting, setSorting] = React.useState({ - columnId: 'name', - direction: 'asc', - }) - const sorted = sortPeople(people, sorting) - - return ( - - - setSorting(nextSorting(sorting, 'name'))} - sortAriaLabel={getSortAriaLabel({ - columnId: 'name', - direction: sorting.columnId === 'name' ? sorting.direction : null, - })} - > - Person - - setSorting(nextSorting(sorting, 'role'))} - sortAriaLabel={getSortAriaLabel({ - columnId: 'role', - direction: sorting.columnId === 'role' ? sorting.direction : null, - })} - > - Role - - - Access - - - Last active - - - - {sorted.map((person) => ( - - - - - - - {person.role} - - - - - {person.access} - - - - - - - ))} - -
- ) - }, -} satisfies Story - export const SelectedAndClickableRows = { name: 'Selected and clickable rows', render: function SelectedAndClickableRows() { @@ -363,28 +275,57 @@ export const SelectedAndClickableRows = { }, } satisfies Story -export const EmptyState = { - name: 'Empty state', +export const NoHeaderRow = { + name: 'No header row', render: () => ( - +
+ + {people.map((person) => ( + + + + + + + {person.role} + + + + + {person.activity} + + + + ))} + +
+ ), +} satisfies Story + +export const MultiLineCells = { + name: 'Single and multi-line cells', + render: () => ( + - Person + Single line - Role + Two line - -

No people to show

-

- People with workspace access will appear here. -

+ + + Cell content long enough that it has to truncate with an ellipsis + + + + Cell content + + Secondary line +
@@ -446,7 +387,7 @@ export const CustomPlaceholderRows = { } satisfies Story export const NarrowViewport = { - name: 'Narrow viewport (current behavior)', + name: 'Narrow viewport', render: () => (
), } satisfies Story + +const features = tableFeatures({ + columnVisibilityFeature, + rowPaginationFeature, + rowSortingFeature, + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(), + sortFns: { text: sortFn_text }, +}) + +const tanStackColumns: ColumnDef[] = [ + { 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', + parameters: { + docs: { + description: { + story: '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).', + }, + }, + }, + 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 ( + +
+ + {table.getHeaderGroups()[0]?.headers.map((header) => { + const direction = header.column.getIsSorted() || null + const label = String(header.column.columnDef.header) + + return header.column.getCanSort() ? ( + header.column.toggleSorting()} + sortAriaLabel={getSortAriaLabel(label, direction)} + > + + + + + ) : ( + + + + + + ) + })} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + + + + + ))} + + ))} + +
+ + + + + Page {pageIndex + 1} of {table.getPageCount()} + + + + ) + }, +} satisfies Story \ No newline at end of file From a3d69c3db76547d1a9ca308eb4ace9fb53a835ef Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Tue, 18 Aug 2026 23:17:24 -0700 Subject: [PATCH 08/19] feat: align Table styling with the Figma table design Co-Authored-By: Claude --- src/table/table.module.css | 24 ++++++++++++++++++------ src/table/table.stories.module.css | 5 +++-- src/table/table.stories.tsx | 2 +- src/table/table.tsx | 2 +- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/table/table.module.css b/src/table/table.module.css index 803412c47..74b3f7785 100644 --- a/src/table/table.module.css +++ b/src/table/table.module.css @@ -1,12 +1,18 @@ .table { width: 100%; - border-collapse: collapse; color: var(--product-library-display-primary-idle-tint); font-family: var(--reactist-font-family); + background-color: var(--product-library-background-base-primary); + border-collapse: collapse; } .header { - border-bottom: 1px solid var(--product-library-divider-primary); + background-color: var(--product-library-background-base-primary); +} + +.headerRow { + height: 40px; + border-bottom: 1px solid var(--product-library-border-idle-tint); } .headerCell { @@ -29,7 +35,7 @@ border: 0; color: inherit; font: inherit; - text-align: left; + text-align: inherit; background: transparent; cursor: pointer; } @@ -59,7 +65,6 @@ .row { height: 48px; - padding-inline: var(--reactist-spacing-xsmall); background-color: var(--product-library-background-base-primary); border-bottom: 1px solid var(--product-library-divider-primary); } @@ -83,11 +88,18 @@ } .cell { - min-height: 48px; - padding: var(--reactist-spacing-small); + padding: var(--reactist-spacing-xsmall) var(--reactist-spacing-small); vertical-align: middle; } +.cell:first-child { + padding-left: var(--reactist-spacing-medium); +} + +.cell:last-child { + padding-right: var(--reactist-spacing-medium); +} + .sortIndicator { display: inline-flex; align-items: center; diff --git a/src/table/table.stories.module.css b/src/table/table.stories.module.css index 2974daaf8..4987f9209 100644 --- a/src/table/table.stories.module.css +++ b/src/table/table.stories.module.css @@ -30,13 +30,14 @@ .person { display: flex; - gap: 10px; + gap: 8px; align-items: center; - min-height: 36px; } .personText { + flex: 1 0 0; min-width: 0; + overflow: hidden; } .status { diff --git a/src/table/table.stories.tsx b/src/table/table.stories.tsx index 55e1cd9ef..5e5580e96 100644 --- a/src/table/table.stories.tsx +++ b/src/table/table.stories.tsx @@ -134,7 +134,7 @@ type Story = StoryObj function PersonCell({ person }: { person: Person }) { return (
- +
{person.name} diff --git a/src/table/table.tsx b/src/table/table.tsx index 17b08e177..8fc42938a 100644 --- a/src/table/table.tsx +++ b/src/table/table.tsx @@ -93,7 +93,7 @@ const TableHeader = React.forwardRef( ref={ref} className={classNames(styles.header, exceptionallySetClassName)} > - {children} + {children} ) }, From f6ab054b2dab8604a777de5ca43908b991c57778 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Tue, 18 Aug 2026 23:37:53 -0700 Subject: [PATCH 09/19] docs: build Table stories from Reactist components and drop the story CSS Co-Authored-By: Claude --- src/table/table.stories.module.css | 62 ------------ src/table/table.stories.tsx | 149 +++++++---------------------- 2 files changed, 32 insertions(+), 179 deletions(-) delete mode 100644 src/table/table.stories.module.css diff --git a/src/table/table.stories.module.css b/src/table/table.stories.module.css deleted file mode 100644 index 4987f9209..000000000 --- a/src/table/table.stories.module.css +++ /dev/null @@ -1,62 +0,0 @@ -.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%; -} - -.narrowScroll { - width: 420px; - max-width: 100%; - overflow-x: auto; -} - -.narrowScroll .presentationTable { - min-width: 760px; -} - -.person { - display: flex; - gap: 8px; - align-items: center; -} - -.personText { - flex: 1 0 0; - min-width: 0; - overflow: hidden; -} - -.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(--product-library-display-secondary-idle-tint); - background: var(--product-library-actionable-secondary-hover-fill); -} - -.placeholderRow { - opacity: 0.42; - filter: grayscale(0.5); -} diff --git a/src/table/table.stories.tsx b/src/table/table.stories.tsx index 5e5580e96..a45eb5f02 100644 --- a/src/table/table.stories.tsx +++ b/src/table/table.stories.tsx @@ -11,17 +11,15 @@ import { tableFeatures, useTable, } from '@tanstack/react-table' -import classNames from 'classnames' import { Avatar } from '../avatar' +import { Badge } from '../badge' import { Box } from '../box' import { Button } from '../button' import { Text } from '../text' import { Table, TableBody, TableCell, TableColumnHeader, TableHeader, TableRow } from './table' -import styles from './table.stories.module.css' - import type { Meta, StoryObj } from '@storybook/react-vite' import type { ColumnDef } from '@tanstack/react-table' @@ -32,7 +30,6 @@ type Person = { role: string access: 'Admin' | 'Member' | 'Guest' activity: string - placeholder?: boolean } const people: Person[] = [ @@ -78,37 +75,6 @@ const people: Person[] = [ }, ] -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 meta = { title: '📊 Data display/Table', component: Table, @@ -133,28 +99,26 @@ type Story = StoryObj function PersonCell({ person }: { person: Person }) { return ( -
+ -
- {person.name} - + + + {person.name} + + {person.email} -
-
+ + ) } function ActivityCell({ person }: { person: Person }) { return ( - - {person.activity} - + ) } @@ -177,7 +141,7 @@ function handleRowKeyDown( export const Default = { render: () => ( - +
Person @@ -224,10 +188,7 @@ export const SelectedAndClickableRows = { const [selectedId, setSelectedId] = React.useState(people[1]!.id) return ( -
+
Person @@ -333,13 +294,22 @@ export const MultiLineCells = { ), } satisfies Story -export const CustomPlaceholderRows = { - name: 'Custom placeholder rows', +export const NarrowViewport = { + name: 'Narrow viewport', + parameters: { + viewport: { + options: { + narrow: { + name: 'Narrow', + styles: { width: '420px', height: '720px' }, + type: 'mobile', + }, + }, + }, + }, + globals: { viewport: { value: 'narrow' } }, render: () => ( -
+
Person @@ -355,14 +325,8 @@ export const CustomPlaceholderRows = { - {placeholderPeople.map((person) => ( - + {people.map((person) => ( + @@ -386,55 +350,6 @@ export const CustomPlaceholderRows = { ), } satisfies Story -export const NarrowViewport = { - name: 'Narrow viewport', - render: () => ( -
-
- - - Person - - - Role - - - Access - - - Last active - - - - {people.map((person) => ( - - - - - - - {person.role} - - - - - {person.access} - - - - - - - ))} - -
-
- ), -} satisfies Story - const features = tableFeatures({ columnVisibilityFeature, rowPaginationFeature, From 47263ba1461239c06f687ce345087e8ae8c2c0d5 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Tue, 18 Aug 2026 23:50:59 -0700 Subject: [PATCH 10/19] feat: add TableColumnGroup and TableColumn for sizing columns Co-Authored-By: Claude --- src/table/table.module.css | 44 ++++++++++++++++++++++++++ src/table/table.stories.tsx | 47 +++++++++++++++++++++++++-- src/table/table.test.tsx | 31 +++++++++++++++++- src/table/table.tsx | 63 ++++++++++++++++++++++++++++++++++++- 4 files changed, 180 insertions(+), 5 deletions(-) diff --git a/src/table/table.module.css b/src/table/table.module.css index 74b3f7785..ab541bd55 100644 --- a/src/table/table.module.css +++ b/src/table/table.module.css @@ -125,3 +125,47 @@ .align-end { text-align: right; } + +.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 index a45eb5f02..92477e429 100644 --- a/src/table/table.stories.tsx +++ b/src/table/table.stories.tsx @@ -18,7 +18,16 @@ import { Box } from '../box' import { Button } from '../button' import { Text } from '../text' -import { Table, TableBody, TableCell, TableColumnHeader, TableHeader, TableRow } from './table' +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' @@ -87,7 +96,7 @@ const meta = { docs: { description: { component: - 'Compound primitives for tabular data. Compose Table with 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.', + '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.', }, }, }, @@ -142,6 +151,12 @@ function handleRowKeyDown( export const Default = { render: () => ( + + + + + + Person @@ -189,6 +204,12 @@ export const SelectedAndClickableRows = { return (
+ + + + + + Person @@ -240,6 +261,11 @@ export const NoHeaderRow = { name: 'No header row', render: () => (
+ + + + + {people.map((person) => ( @@ -267,6 +293,10 @@ export const MultiLineCells = { name: 'Single and multi-line cells', render: () => (
+ + + + Single line @@ -310,6 +340,12 @@ export const NarrowViewport = { globals: { viewport: { value: 'narrow' } }, render: () => (
+ + + + + + Person @@ -387,6 +423,11 @@ export const TanStackIntegration = { return (
+ + + + + {table.getHeaderGroups()[0]?.headers.map((header) => { const direction = header.column.getIsSorted() || null @@ -449,4 +490,4 @@ export const TanStackIntegration = { ) }, -} satisfies Story \ No newline at end of file +} satisfies Story diff --git a/src/table/table.test.tsx b/src/table/table.test.tsx index fb6948c6e..4b9796881 100644 --- a/src/table/table.test.tsx +++ b/src/table/table.test.tsx @@ -6,7 +6,16 @@ import { axe } from 'jest-axe' import { Text } from '../text' -import { Table, TableBody, TableCell, TableColumnHeader, TableHeader, TableRow } from './index' +import { + Table, + TableBody, + TableCell, + TableColumn, + TableColumnGroup, + TableColumnHeader, + TableHeader, + TableRow, +} from './index' function BasicTable({ withHeader = true }: { withHeader?: boolean }) { return ( @@ -106,6 +115,26 @@ describe('Table primitives', () => { expect(rows[2]).not.toHaveAttribute('aria-selected') }) + it('maps the column width prop onto the column definition', () => { + const { container } = render( +
+ + + + + + + Avery Morgan + Product designer + + +
, + ) + 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() expect(await axe(container)).toHaveNoViolations() diff --git a/src/table/table.tsx b/src/table/table.tsx index 8fc42938a..5985a364d 100644 --- a/src/table/table.tsx +++ b/src/table/table.tsx @@ -26,6 +26,28 @@ type TableCellProps = Omit, 'align' 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, 'className'> & + ObfuscatedClassName + +type TableColumnProps = Omit, '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. */ @@ -65,6 +87,8 @@ function ariaSortFor(sortDirection: 'asc' | 'desc' | null) { /** * Tabular data in native table markup, composed from: + * * {@link TableColumnGroup} + * * {@link TableColumn} * * {@link TableHeader} * * {@link TableColumnHeader} * * {@link TableBody} @@ -84,6 +108,32 @@ const Table = React.forwardRef(function Table( ) }) +/** Column definitions for the table. Render it as the first child of {@link Table}. */ +const TableColumnGroup = React.forwardRef( + function TableColumnGroup({ exceptionallySetClassName, ...groupProps }, ref) { + return ( + + ) + }, +) + +/** A single column definition. */ +const TableColumn = React.forwardRef(function TableColumn( + { width = 'auto', exceptionallySetClassName, ...columnProps }, + ref, +) { + return ( + + ) +}) + /** A table header row wrapper. Omit it for a table with no header. */ const TableHeader = React.forwardRef( function TableHeader({ children, exceptionallySetClassName, ...headerProps }, ref) { @@ -189,10 +239,21 @@ const TableColumnHeader = React.forwardRef Date: Wed, 19 Aug 2026 00:02:13 -0700 Subject: [PATCH 11/19] feat: default Table to tabular numerals Co-Authored-By: Claude --- src/table/table.module.css | 1 + src/table/table.stories.tsx | 30 ++++++++++++++++++------------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/table/table.module.css b/src/table/table.module.css index ab541bd55..97f34d0d8 100644 --- a/src/table/table.module.css +++ b/src/table/table.module.css @@ -2,6 +2,7 @@ width: 100%; color: var(--product-library-display-primary-idle-tint); font-family: var(--reactist-font-family); + font-variant-numeric: tabular-nums; background-color: var(--product-library-background-base-primary); border-collapse: collapse; } diff --git a/src/table/table.stories.tsx b/src/table/table.stories.tsx index 92477e429..3a770f0fa 100644 --- a/src/table/table.stories.tsx +++ b/src/table/table.stories.tsx @@ -38,12 +38,14 @@ type Person = { 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', @@ -52,6 +54,7 @@ const people: Person[] = [ }, { id: 'sam-rivera', + projects: '1,037', name: 'Sam Rivera', email: 'sam@example.com', role: 'Frontend engineer', @@ -60,6 +63,7 @@ const people: Person[] = [ }, { id: 'mika-chen', + projects: '9,102', name: 'Mika Chen', email: 'mika@example.com', role: 'Product manager', @@ -68,6 +72,7 @@ const people: Person[] = [ }, { id: 'noor-patel', + projects: '1,116', name: 'Noor Patel', email: 'noor@example.com', role: 'Research lead', @@ -76,6 +81,7 @@ const people: Person[] = [ }, { id: 'theo-williams', + projects: '4,411', name: 'Theo Williams', email: 'theo@example.com', role: 'Operations', @@ -164,8 +170,8 @@ export const Default = { Role - - Access + + Projects Last active @@ -182,9 +188,9 @@ export const Default = { {person.role} - + - {person.access} + {person.projects} @@ -217,8 +223,8 @@ export const SelectedAndClickableRows = { Role - - Access + + Projects Last active @@ -241,9 +247,9 @@ export const SelectedAndClickableRows = { {person.role} - + - {person.access} + {person.projects} @@ -353,8 +359,8 @@ export const NarrowViewport = { Role - - Access + + Projects Last active @@ -371,9 +377,9 @@ export const NarrowViewport = { {person.role} - + - {person.access} + {person.projects} From 5179521c11225013fcbf7abf6c8fed72acf342ff Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Wed, 19 Aug 2026 00:21:25 -0700 Subject: [PATCH 12/19] feat: allow consumers to replace the Table sort icons Co-Authored-By: Claude --- src/table/table.module.css | 4 ++-- src/table/table.test.tsx | 21 +++++++++++++++++++++ src/table/table.tsx | 8 +++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/table/table.module.css b/src/table/table.module.css index 97f34d0d8..7e768a5dd 100644 --- a/src/table/table.module.css +++ b/src/table/table.module.css @@ -103,9 +103,9 @@ .sortIndicator { display: inline-flex; + flex: none; align-items: center; - width: 16px; - height: 16px; + justify-content: center; } .sortIndicatorIcon { diff --git a/src/table/table.test.tsx b/src/table/table.test.tsx index 4b9796881..c958fcf62 100644 --- a/src/table/table.test.tsx +++ b/src/table/table.test.tsx @@ -145,9 +145,11 @@ describe('TableColumnHeader sorting', () => { function SortableHeader({ sortDirection = null, onSort = jest.fn(), + sortIcon, }: { sortDirection?: 'asc' | 'desc' | null onSort?: () => void + sortIcon?: React.ReactNode }) { return ( @@ -157,6 +159,7 @@ describe('TableColumnHeader sorting', () => { sortDirection={sortDirection} onSort={onSort} sortAriaLabel="Person, activate to sort ascending." + sortIcon={sortIcon} > Person @@ -201,4 +204,22 @@ describe('TableColumnHeader sorting', () => { await user.keyboard(' ') expect(onSort).toHaveBeenCalledTimes(3) }) + it('renders the bundled sort icon when no slot is given', () => { + const { container } = render() + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it.each([['asc' as const], ['desc' as const], [null]])( + 'replaces the bundled icon with the sortIcon slot when sorted %s', + (direction) => { + const { container } = render( + } + />, + ) + expect(screen.getByTestId('custom-icon')).toBeInTheDocument() + expect(container.querySelector('svg')).not.toBeInTheDocument() + }, + ) }) diff --git a/src/table/table.tsx b/src/table/table.tsx index 5985a364d..0e57719b6 100644 --- a/src/table/table.tsx +++ b/src/table/table.tsx @@ -61,12 +61,16 @@ type SortableProps = /** Complete localized label for the sort button. */ sortAriaLabel: string + + /** Render a custom sort indicator icon. It should default to ascending and pointing upwards */ + sortIcon?: React.ReactNode } | { sortable?: false sortDirection?: never onSort?: never sortAriaLabel?: never + sortIcon?: never } type TableColumnHeaderProps = Omit< @@ -191,6 +195,7 @@ const TableColumnHeader = React.forwardRef{children} + const indicatorDirection = sortDirection ?? 'asc' return (
- + {sortIcon ?? } ) : ( From 630ff8463da0dd9bf1b97d3224da801a1eb1284c Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Wed, 19 Aug 2026 00:35:30 -0700 Subject: [PATCH 13/19] feat: replace the Table sort arrow and lighten the unsorted state Co-Authored-By: Claude --- src/table/sort-indicator.tsx | 11 ++++++----- src/table/table.module.css | 21 ++++++++------------- src/table/table.test.tsx | 10 ++++++++++ src/table/table.tsx | 5 +++-- 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/table/sort-indicator.tsx b/src/table/sort-indicator.tsx index 825ce2fa2..a14114277 100644 --- a/src/table/sort-indicator.tsx +++ b/src/table/sort-indicator.tsx @@ -1,11 +1,12 @@ import * as React from 'react' -import styles from './table.module.css' - -function SortIndicator({ direction }: { direction: 'asc' | 'desc' }) { +function SortIndicator() { return ( - - + + ) } diff --git a/src/table/table.module.css b/src/table/table.module.css index 7e768a5dd..f881e5ed1 100644 --- a/src/table/table.module.css +++ b/src/table/table.module.css @@ -55,10 +55,6 @@ outline-offset: -2px; } -.sortIndicatorUnsorted { - opacity: 0; -} - .sortButton:hover .sortIndicatorUnsorted, .sortButton:focus-visible .sortIndicatorUnsorted { opacity: 1; @@ -106,17 +102,16 @@ flex: none; align-items: center; justify-content: center; + color: var(--product-library-display-secondary-idle-tint); } -.sortIndicatorIcon { - width: 16px; - height: 16px; - color: var(--product-library-display-secondary-idle-tint); - fill: none; - stroke: currentcolor; - stroke-width: 1.5; - stroke-linecap: round; - stroke-linejoin: round; +.sortIndicatorUnsorted { + color: var(--product-library-display-tertiary-idle-tint); + opacity: 0; +} + +.sortIndicatorDescending { + transform: rotate(180deg); } .align-start { diff --git a/src/table/table.test.tsx b/src/table/table.test.tsx index c958fcf62..b9580a943 100644 --- a/src/table/table.test.tsx +++ b/src/table/table.test.tsx @@ -222,4 +222,14 @@ describe('TableColumnHeader sorting', () => { expect(container.querySelector('svg')).not.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() + 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 index 0e57719b6..50af655ad 100644 --- a/src/table/table.tsx +++ b/src/table/table.tsx @@ -204,7 +204,7 @@ const TableColumnHeader = React.forwardRef{children} - const indicatorDirection = sortDirection ?? 'asc' + const indicatorDirection = sortDirection ?? 'desc' return ( - - - - - - - - - Person - - - Role - - - Projects - - - Last active - - - - {people.map((person) => ( - - - - - - - {person.role} - - - - - {person.projects} - - - - - - - ))} - -
- ), -} satisfies Story - const features = tableFeatures({ columnVisibilityFeature, rowPaginationFeature, From 273a6a49a4e8c07632396f5a1f4482cf4da71e9d Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Wed, 19 Aug 2026 01:17:03 -0700 Subject: [PATCH 16/19] test: drop the Table cell type style assertion Co-Authored-By: Claude --- src/table/table.test.tsx | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/table/table.test.tsx b/src/table/table.test.tsx index b9580a943..19cf6f341 100644 --- a/src/table/table.test.tsx +++ b/src/table/table.test.tsx @@ -4,8 +4,6 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { axe } from 'jest-axe' -import { Text } from '../text' - import { Table, TableBody, @@ -73,26 +71,6 @@ describe('Table primitives', () => { expect(refs.cell.current?.tagName).toBe('TD') }) - it('renders cell content untouched, imposing no type style of its own', () => { - render( - - - - - - Avery Morgan - - - - -
, - ) - const cellText = screen.getByText('Avery Morgan') - expect(cellText.className).toContain('variant-callout-2') - expect(cellText.className).toContain('lineClamp-1') - expect(cellText.parentElement?.tagName).toBe('TD') - }) - it('marks only rows with aria-selected as selectable', () => { render( From e0ed63439c807740ce0908765277f60fb48b6c2a Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Wed, 19 Aug 2026 01:17:38 -0700 Subject: [PATCH 17/19] fix: align Table content with logical start and end values Co-Authored-By: Claude --- src/table/table.module.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/table/table.module.css b/src/table/table.module.css index f881e5ed1..fff6a5e75 100644 --- a/src/table/table.module.css +++ b/src/table/table.module.css @@ -115,11 +115,11 @@ } .align-start { - text-align: left; + text-align: start; } .align-end { - text-align: right; + text-align: end; } .columnWidth-auto { From d750fb0d6d527fa06d86c37487fd12418b9f1fe7 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Wed, 19 Aug 2026 01:18:20 -0700 Subject: [PATCH 18/19] fix: set aria-sort only on the sorted Table column Co-Authored-By: Claude --- src/table/table.test.tsx | 6 +++++- src/table/table.tsx | 8 +++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/table/table.test.tsx b/src/table/table.test.tsx index 19cf6f341..ea22cc97e 100644 --- a/src/table/table.test.tsx +++ b/src/table/table.test.tsx @@ -158,7 +158,6 @@ describe('TableColumnHeader sorting', () => { }) it.each([ - [null, 'none'], ['asc' as const, 'ascending'], ['desc' as const, 'descending'], ])('maps sortDirection %s to aria-sort %s', (direction, expected) => { @@ -166,6 +165,11 @@ describe('TableColumnHeader sorting', () => { expect(screen.getByRole('columnheader')).toHaveAttribute('aria-sort', expected) }) + it('omits aria-sort on a sortable header that is not sorted', () => { + render() + expect(screen.getByRole('columnheader')).not.toHaveAttribute('aria-sort') + }) + it('fires onSort exactly once per activation', async () => { const onSort = jest.fn() const user = userEvent.setup() diff --git a/src/table/table.tsx b/src/table/table.tsx index 50af655ad..38409be2a 100644 --- a/src/table/table.tsx +++ b/src/table/table.tsx @@ -83,10 +83,8 @@ type TableColumnHeaderProps = Omit< align?: 'start' | 'end' } -function ariaSortFor(sortDirection: 'asc' | 'desc' | null) { - if (sortDirection === 'asc') return 'ascending' - if (sortDirection === 'desc') return 'descending' - return 'none' +function ariaSortFor(sortDirection: 'asc' | 'desc') { + return sortDirection === 'asc' ? 'ascending' : 'descending' } /** @@ -210,7 +208,7 @@ const TableColumnHeader = React.forwardRef Date: Wed, 19 Aug 2026 01:19:12 -0700 Subject: [PATCH 19/19] feat: export the Table prop types Co-Authored-By: Claude --- src/table/table.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/table/table.tsx b/src/table/table.tsx index 38409be2a..fe2a89954 100644 --- a/src/table/table.tsx +++ b/src/table/table.tsx @@ -262,3 +262,14 @@ export { TableHeader, TableRow, } +export type { + TableBodyProps, + TableCellProps, + TableColumnGroupProps, + TableColumnHeaderProps, + TableColumnProps, + TableColumnWidth, + TableHeaderProps, + TableProps, + TableRowProps, +}