diff --git a/package-lock.json b/package-lock.json index dd5aaa3a..05303b34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@semantic-release/git": "10.0.1", "@storybook/addon-docs": "10.5.7", "@storybook/react-vite": "10.5.7", + "@tanstack/react-table": "9.1.2", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.2", @@ -6437,6 +6438,74 @@ "node": ">=6" } }, + "node_modules/@tanstack/react-store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz", + "integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-table": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-9.1.2.tgz", + "integrity": "sha512-YQPZFJ1nIi/bjjwsPZVouABgahDcl7Gdm33CdTStUJBn0DjEVJ2uhSTVmIoWt9MVKdQziXGAsXipSzy949Hygg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/react-store": "^0.11.0", + "@tanstack/table-core": "9.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@tanstack/store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz", + "integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.1.2.tgz", + "integrity": "sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/store": "^0.11.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index a49e47af..b3ec4266 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "@semantic-release/git": "10.0.1", "@storybook/addon-docs": "10.5.7", "@storybook/react-vite": "10.5.7", + "@tanstack/react-table": "9.1.2", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.2", diff --git a/src/index.ts b/src/index.ts index 3f022411..b39061e9 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 00000000..302847a8 --- /dev/null +++ b/src/table/index.ts @@ -0,0 +1 @@ +export * from './table' diff --git a/src/table/sort-indicator.tsx b/src/table/sort-indicator.tsx new file mode 100644 index 00000000..a1411427 --- /dev/null +++ b/src/table/sort-indicator.tsx @@ -0,0 +1,14 @@ +import * as React from 'react' + +function SortIndicator() { + return ( + + + + ) +} + +export { SortIndicator } diff --git a/src/table/table.module.css b/src/table/table.module.css new file mode 100644 index 00000000..fff6a5e7 --- /dev/null +++ b/src/table/table.module.css @@ -0,0 +1,167 @@ +.table { + 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; +} + +.header { + background-color: var(--product-library-background-base-primary); +} + +.headerRow { + height: 40px; + border-bottom: 1px solid var(--product-library-border-idle-tint); +} + +.headerCell { + height: 40px; + padding: 0 var(--reactist-spacing-small); + user-select: none; +} + +.headerCellSortable { + padding: 0; +} + +.sortButton { + display: flex; + gap: var(--reactist-spacing-xsmall); + align-items: center; + width: 100%; + height: 40px; + padding: 0 var(--reactist-spacing-small); + border: 0; + color: inherit; + font: inherit; + text-align: inherit; + background: transparent; + cursor: pointer; +} + +.headerLabel { + flex: 1 0 0; + min-width: 0; +} + +.sortButton:hover { + background-color: var(--product-library-actionable-secondary-hover-fill); +} + +.sortButton:focus-visible { + outline: 2px solid var(--product-library-display-primary-idle-tint); + outline-offset: -2px; +} + +.sortButton:hover .sortIndicatorUnsorted, +.sortButton:focus-visible .sortIndicatorUnsorted { + opacity: 1; +} + +.row { + height: 48px; + background-color: var(--product-library-background-base-primary); + border-bottom: 1px solid var(--product-library-divider-primary); +} + +.row[aria-selected] { + cursor: pointer; +} + +.row[aria-selected]:hover { + background-color: var(--product-library-actionable-secondary-hover-fill); + border-bottom-color: var(--product-library-divider-tertiary); +} + +.row[aria-selected='true'] { + background-color: var(--product-library-selectable-secondary-selected-fill); +} + +.row[aria-selected]:focus-visible { + outline: 2px solid var(--product-library-divider-tertiary); + outline-offset: -2px; +} + +.cell { + padding: var(--reactist-spacing-xsmall) var(--reactist-spacing-small); + vertical-align: middle; +} + +.cell:first-child { + padding-left: var(--reactist-spacing-medium); +} + +.cell:last-child { + padding-right: var(--reactist-spacing-medium); +} + +.sortIndicator { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + color: var(--product-library-display-secondary-idle-tint); +} + +.sortIndicatorUnsorted { + color: var(--product-library-display-tertiary-idle-tint); + opacity: 0; +} + +.sortIndicatorDescending { + transform: rotate(180deg); +} + +.align-start { + text-align: start; +} + +.align-end { + text-align: end; +} + +.columnWidth-auto { + width: auto; +} + +.columnWidth-content { + width: 0; +} + +.columnWidth-1-2 { + width: 50%; +} + +.columnWidth-1-3 { + width: 33.3333%; +} + +.columnWidth-2-3 { + width: 66.6667%; +} + +.columnWidth-1-4 { + width: 25%; +} + +.columnWidth-3-4 { + width: 75%; +} + +.columnWidth-1-5 { + width: 20%; +} + +.columnWidth-2-5 { + width: 40%; +} + +.columnWidth-3-5 { + width: 60%; +} + +.columnWidth-4-5 { + width: 80%; +} diff --git a/src/table/table.stories.tsx b/src/table/table.stories.tsx new file mode 100644 index 00000000..5e96f3b7 --- /dev/null +++ b/src/table/table.stories.tsx @@ -0,0 +1,446 @@ +import * as React from 'react' + +import { + columnVisibilityFeature, + createPaginatedRowModel, + createSortedRowModel, + flexRender, + rowPaginationFeature, + rowSortingFeature, + sortFn_text, + tableFeatures, + useTable, +} from '@tanstack/react-table' + +import { Avatar } from '../avatar' +import { Badge } from '../badge' +import { Box } from '../box' +import { Button } from '../button' +import { Text } from '../text' + +import { + Table, + TableBody, + TableCell, + TableColumn, + TableColumnGroup, + TableColumnHeader, + TableHeader, + TableRow, +} from './table' + +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { ColumnDef } from '@tanstack/react-table' + +type Person = { + id: string + name: string + email: string + role: string + access: 'Admin' | 'Member' | 'Guest' + projects: string + activity: string +} + +const people: Person[] = [ + { + id: 'avery-morgan', + projects: '1,284', + name: 'Avery Morgan', + email: 'avery@example.com', + role: 'Product designer', + access: 'Admin', + activity: 'Active now', + }, + { + id: 'sam-rivera', + projects: '1,037', + name: 'Sam Rivera', + email: 'sam@example.com', + role: 'Frontend engineer', + access: 'Member', + activity: '8 minutes ago', + }, + { + id: 'mika-chen', + projects: '9,102', + name: 'Mika Chen', + email: 'mika@example.com', + role: 'Product manager', + access: 'Admin', + activity: '2 hours ago', + }, + { + id: 'noor-patel', + projects: '1,116', + name: 'Noor Patel', + email: 'noor@example.com', + role: 'Research lead', + access: 'Member', + activity: 'Yesterday', + }, + { + id: 'theo-williams', + projects: '4,411', + name: 'Theo Williams', + email: 'theo@example.com', + role: 'Operations', + access: 'Guest', + activity: '3 days ago', + }, +] + +const meta = { + title: '📊 Data display/Table', + component: Table, + parameters: { + badges: ['accessible'], + figma: { + path: 'Web › Components / Todoist › Table', + url: 'https://www.figma.com/design/LYlWNzvhMDh907l07mPPQk/Product-Library---Web?node-id=26089-87636', + }, + docs: { + description: { + component: + 'Compound primitives for tabular data. Compose Table with TableColumnGroup, TableHeader, TableColumnHeader, TableBody, TableRow, and TableCell. The consumer owns the data, the sort state, and the selection state; pass aria-selected on a row to make it selectable.', + }, + }, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +function PersonCell({ person }: { person: Person }) { + return ( + + + + + {person.name} + + + {person.email} + + + + ) +} + +function ActivityCell({ person }: { person: Person }) { + return ( + + ) +} + +function getSortAriaLabel(label: string, direction: 'asc' | 'desc' | null) { + if (direction === 'asc') return `${label}, sorted ascending. Activate to sort descending.` + if (direction === 'desc') return `${label}, sorted descending. Activate to sort ascending.` + return `${label}, activate to sort ascending.` +} + +function handleRowKeyDown( + event: React.KeyboardEvent, + personId: string, + onActivate: (personId: string) => void, +) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onActivate(personId) + } +} + +export const Default = { + render: () => ( + + + + + + + + + + Person + + + Role + + + Projects + + + Last active + + + + {people.map((person) => ( + + + + + + + {person.role} + + + + + {person.projects} + + + + + + + ))} + +
+ ), +} satisfies Story + +export const SelectedAndClickableRows = { + name: 'Selected and clickable rows', + render: function SelectedAndClickableRows() { + const [selectedId, setSelectedId] = React.useState(people[1]!.id) + + return ( + + + + + + + + + + Person + + + Role + + + Projects + + + Last active + + + + {people.map((person) => ( + setSelectedId(person.id)} + onKeyDown={(event) => handleRowKeyDown(event, person.id, setSelectedId)} + > + + + + + + {person.role} + + + + + {person.projects} + + + + + + + ))} + +
+ ) + }, +} satisfies Story + +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: () => ( + + + + + + + + Single line + + + Two line + + + + + + + Cell content long enough that it has to truncate with an ellipsis + + + + Cell content + + Secondary line + + + + +
+ ), +} 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)} + > + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + + ) : ( + + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + + ) + })} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + + ))} + + ))} + +
+ + + + + Page {pageIndex + 1} of {table.getPageCount()} + + +
+ ) + }, +} satisfies Story diff --git a/src/table/table.test.tsx b/src/table/table.test.tsx new file mode 100644 index 00000000..ea22cc97 --- /dev/null +++ b/src/table/table.test.tsx @@ -0,0 +1,217 @@ +import * as React from 'react' + +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'jest-axe' + +import { + Table, + TableBody, + TableCell, + TableColumn, + TableColumnGroup, + TableColumnHeader, + TableHeader, + TableRow, +} from './index' + +function BasicTable({ withHeader = true }: { withHeader?: boolean }) { + return ( + + {withHeader ? ( + + Person + Role + + ) : null} + + + Avery Morgan + Product designer + + +
+ ) +} + +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.getByRole('cell', { name: 'Avery Morgan' })).toBeInTheDocument() + }) + + 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('forwards refs on every primitive', () => { + const refs = { + table: React.createRef(), + body: React.createRef(), + row: React.createRef(), + cell: React.createRef(), + } + render( + + + + Avery + + +
, + ) + expect(refs.table.current?.tagName).toBe('TABLE') + expect(refs.body.current?.tagName).toBe('TBODY') + expect(refs.row.current?.tagName).toBe('TR') + expect(refs.cell.current?.tagName).toBe('TD') + }) + + it('marks only rows with aria-selected as selectable', () => { + render( + + + + 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('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() + }) +}) + +describe('TableColumnHeader sorting', () => { + function SortableHeader({ + sortDirection = null, + onSort = jest.fn(), + sortIcon, + }: { + sortDirection?: 'asc' | 'desc' | null + onSort?: () => void + sortIcon?: React.ReactNode + }) { + return ( + + + + Person + + +
+ ) + } + + it('omits aria-sort on a non-sortable header', () => { + render( + + + Person + +
, + ) + expect(screen.getByRole('columnheader')).not.toHaveAttribute('aria-sort') + }) + + it.each([ + ['asc' as const, 'ascending'], + ['desc' as const, 'descending'], + ])('maps sortDirection %s to aria-sort %s', (direction, expected) => { + render() + 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() + 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) + }) + 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() + }, + ) + + 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 new file mode 100644 index 00000000..fe2a8995 --- /dev/null +++ b/src/table/table.tsx @@ -0,0 +1,275 @@ +import * as React from 'react' + +import classNames from 'classnames' + +import { SortIndicator } from './sort-indicator' + +import styles from './table.module.css' + +import type { ObfuscatedClassName } from '../utils/common-types' + +type TableProps = Omit, 'className'> & + ObfuscatedClassName + +type TableHeaderProps = Omit, 'className'> & + ObfuscatedClassName + +type TableBodyProps = Omit, 'className'> & + ObfuscatedClassName + +type TableRowProps = Omit, 'className'> & + ObfuscatedClassName + +type TableCellProps = Omit, 'align' | 'className'> & + ObfuscatedClassName & { + /** Horizontal alignment of the cell content. */ + align?: 'start' | 'end' + } + +type TableColumnWidth = + | 'auto' + | 'content' + | '1/2' + | '1/3' + | '2/3' + | '1/4' + | '3/4' + | '1/5' + | '2/5' + | '3/5' + | '4/5' + +type TableColumnGroupProps = Omit, '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. */ + sortable: true + + /** Direction for this column, or null when it is sortable but not sorted. */ + sortDirection: 'asc' | 'desc' | null + + /** Called when the sort control is activated. */ + onSort: () => void + + /** Complete localized label for the sort button. */ + sortAriaLabel: string + + /** 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< + React.ThHTMLAttributes, + 'align' | 'className' | 'onSort' +> & + ObfuscatedClassName & + SortableProps & { + /** Horizontal alignment of the header content. */ + align?: 'start' | 'end' + } + +function ariaSortFor(sortDirection: 'asc' | 'desc') { + return sortDirection === 'asc' ? 'ascending' : 'descending' +} + +/** + * Tabular data in native table markup, composed from: + * * {@link TableColumnGroup} + * * {@link TableColumn} + * * {@link TableHeader} + * * {@link TableColumnHeader} + * * {@link TableBody} + * * {@link TableRow} + * * {@link TableCell} + */ +const Table = React.forwardRef(function Table( + { exceptionallySetClassName, ...tableProps }, + ref, +) { + return ( + + ) +}) + +/** 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) { + return ( + + {children} + + ) + }, +) + +/** A table body that wraps its rows. */ +const TableBody = React.forwardRef(function TableBody( + { exceptionallySetClassName, ...bodyProps }, + ref, +) { + return +}) + +/** 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 ( + + ) +}) + +/** A table column header, optionally sortable. */ +const TableColumnHeader = React.forwardRef( + function TableColumnHeader( + { + sortable, + sortDirection = null, + onSort, + sortAriaLabel, + sortIcon, + align = 'start', + children, + exceptionallySetClassName, + ...headerProps + }, + ref, + ) { + const label = {children} + const indicatorDirection = sortDirection ?? 'desc' + + return ( + + ) + }, +) + +Table.displayName = 'Table' +TableColumnGroup.displayName = 'TableColumnGroup' +TableColumn.displayName = 'TableColumn' +TableHeader.displayName = 'TableHeader' +TableBody.displayName = 'TableBody' +TableRow.displayName = 'TableRow' +TableCell.displayName = 'TableCell' +TableColumnHeader.displayName = 'TableColumnHeader' + +export { + Table, + TableBody, + TableCell, + TableColumn, + TableColumnGroup, + TableColumnHeader, + TableHeader, + TableRow, +} +export type { + TableBodyProps, + TableCellProps, + TableColumnGroupProps, + TableColumnHeaderProps, + TableColumnProps, + TableColumnWidth, + TableHeaderProps, + TableProps, + TableRowProps, +}
+ {children} + + {sortable ? ( + + ) : ( + label + )} +