+
+
+
+
+
+
+ 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 (
+
+
+ )
+ }
+
+ 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 (
+