refactor(ui-patterns): Standardise TanStack sort headers (#44212)

## What kind of change does this PR introduce?

Component update.

## What is the current behaviour?

TanStack tables in the repo are split between the shared `TableHeadSort`
primitive and the older Studio-local `DataTableColumnHeader` helper,
which makes the sorting UI and integration path inconsistent.

If you were to just use `DataTableColumnHeader` in `ui-patterns/Table`,
you’d get a very different visual result to the `TableHeadSort` UI you
see in most other tables.

## What is the new behaviour?

Adds a shared `TanStackTableHeadSort` adapter in `ui-patterns/Table`,
backed by the existing `TableHeadSort` primitive, and switches the
webhook table plus the design-system TanStack demo to that canonical
path. `DataTableColumnHeader` stays as a deprecated wrapper for now,
Studio gets a lint guard to block new imports of it, and the table docs
now point TanStack tables at the shared adapter explicitly.

## To test

Check out column sorting on the Platform Webhook endpoint deliveries
table.
This commit is contained in:
Danny White
2026-03-30 21:48:52 +11:00
committed by GitHub
parent ee8eae7309
commit cca4e52dd0
11 changed files with 270 additions and 136 deletions
@@ -144,6 +144,23 @@ The component displays:
<ComponentPreview name="table-sort" />
For TanStack tables, prefer the shared adapter instead of reimplementing the `TableHeadSort` bridge in each table.
```tsx
import { TanStackTableHeadSort } from 'ui-patterns/Table'
```
```tsx showLineNumbers
const columns: ColumnDef<Row>[] = [
{
accessorKey: 'name',
header: ({ column }) => <TanStackTableHeadSort column={column}>Name</TanStackTableHeadSort>,
},
]
```
This keeps TanStack tables aligned with the same `TableHeadSort` visual treatment and sorting cycle used by manual tables.
### Row icons
When adding icon columns to your table, use [Accessibility](../accessibility) markup by including a screen reader-only label in the corresponding Table Head using the `sr-only` class. This ensures that assistive technologies can properly identify the column's purpose. Remove these icon cells when loading or displaying zero results to maintain a clean and consistent table structure.
@@ -30,9 +30,9 @@ import {
TableCell,
TableHead,
TableHeader,
TableHeadSort,
TableRow,
} from 'ui'
import { TanStackTableHeadSort } from 'ui-patterns/Table'
const data: Payment[] = [
{
@@ -99,19 +99,23 @@ export const columns: ColumnDef<Payment>[] = [
},
{
accessorKey: 'status',
header: 'Status',
header: ({ column }) => <TanStackTableHeadSort column={column}>Status</TanStackTableHeadSort>,
enableSorting: true,
cell: ({ row }) => <div className="capitalize">{row.getValue('status')}</div>,
},
{
accessorKey: 'email',
header: 'Email',
header: ({ column }) => <TanStackTableHeadSort column={column}>Email</TanStackTableHeadSort>,
enableSorting: true,
cell: ({ row }) => <div className="lowercase">{row.getValue('email')}</div>,
},
{
accessorKey: 'amount',
header: () => <div className="text-right">Amount</div>,
header: ({ column }) => (
<TanStackTableHeadSort column={column} className="justify-end">
Amount
</TanStackTableHeadSort>
),
enableSorting: true,
cell: ({ row }) => {
const amount = parseFloat(row.getValue('amount'))
@@ -164,33 +168,6 @@ export default function DataTableDemo() {
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [rowSelection, setRowSelection] = React.useState({})
// Convert TanStack Table's SortingState to the string format expected by TableHeadSort
const getSortString = React.useMemo(() => {
if (sorting.length === 0) return ''
const sort = sorting[0]
return `${sort.id}:${sort.desc ? 'desc' : 'asc'}`
}, [sorting])
// Handle sort changes from TableHeadSort and convert to TanStack Table's SortingState
const handleSortChange = React.useCallback(
(column: string) => {
const currentSort = sorting.find((s) => s.id === column)
if (currentSort) {
if (currentSort.desc) {
// Cycle: desc -> remove sort
setSorting([])
} else {
// Cycle: asc -> desc
setSorting([{ id: column, desc: true }])
}
} else {
// New column, start with asc
setSorting([{ id: column, desc: false }])
}
},
[sorting]
)
const table = useReactTable({
data,
columns,
@@ -254,11 +231,20 @@ export default function DataTableDemo() {
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const columnId = header.column.id
const canSort = header.column.getCanSort()
const sort = header.column.getIsSorted()
return (
<TableHead
key={header.id}
aria-sort={
header.column.getCanSort()
? sort === 'asc'
? 'ascending'
: sort === 'desc'
? 'descending'
: 'none'
: undefined
}
className={
columnId === 'amount'
? 'text-right'
@@ -267,18 +253,9 @@ export default function DataTableDemo() {
: undefined
}
>
{header.isPlaceholder ? null : canSort ? (
<TableHeadSort
column={columnId}
currentSort={getSortString}
onSortChange={handleSortChange}
className={columnId === 'amount' ? 'justify-end' : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHeadSort>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
@@ -24,11 +24,11 @@ import {
TableCell,
TableHead,
TableHeader,
TableHeadSort,
TableRow,
} from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import { TanStackTableHeadSort } from 'ui-patterns/Table'
import type { WebhookDelivery, WebhookEndpoint } from './PlatformWebhooks.types'
import { statusBadgeVariant } from './PlatformWebhooksView.utils'
@@ -59,39 +59,24 @@ const DELIVERIES_PAGE_SIZE = 5
const DELIVERY_ACTIONS_COLUMN_ID = 'actions'
const DEFAULT_DELIVERY_SORTING: SortingState = [{ id: 'attemptAt', desc: true }]
const getCurrentSort = (sorting: SortingState) => {
if (sorting.length === 0) return ''
const [currentSort] = sorting
return `${currentSort.id}:${currentSort.desc ? 'desc' : 'asc'}`
}
const getAriaSort = (
sorting: SortingState,
columnId: string
): 'ascending' | 'descending' | 'none' => {
const currentSort = sorting.find((sort) => sort.id === columnId)
if (!currentSort) return 'none'
return currentSort.desc ? 'descending' : 'ascending'
}
const DELIVERY_COLUMNS: ColumnDef<WebhookDelivery>[] = [
{
accessorKey: 'status',
header: 'Status',
header: ({ column }) => <TanStackTableHeadSort column={column}>Status</TanStackTableHeadSort>,
cell: ({ row }) => (
<Badge variant={statusBadgeVariant[row.original.status]}>{row.original.status}</Badge>
),
},
{
accessorKey: 'eventType',
header: 'Event type',
header: ({ column }) => (
<TanStackTableHeadSort column={column}>Event type</TanStackTableHeadSort>
),
cell: ({ row }) => <code className="text-code-inline">{row.original.eventType}</code>,
},
{
accessorKey: 'responseCode',
header: 'Response',
header: ({ column }) => <TanStackTableHeadSort column={column}>Response</TanStackTableHeadSort>,
sortingFn: (rowA, rowB, columnId) => {
const responseA = rowA.getValue<number | undefined>(columnId) ?? -1
const responseB = rowB.getValue<number | undefined>(columnId) ?? -1
@@ -110,7 +95,9 @@ const DELIVERY_COLUMNS: ColumnDef<WebhookDelivery>[] = [
},
{
accessorKey: 'attemptAt',
header: 'Attempted',
header: ({ column }) => (
<TanStackTableHeadSort column={column}>Attempted</TanStackTableHeadSort>
),
cell: ({ row }) => (
<TimestampInfo
className="text-sm text-foreground-lighter"
@@ -175,18 +162,6 @@ export const PlatformWebhooksEndpointDetails = ({
pageIndex: 0,
pageSize: DELIVERIES_PAGE_SIZE,
})
const currentSort = getCurrentSort(sorting)
const handleSortChange = (columnId: string) => {
const currentColumnSort = sorting.find((sort) => sort.id === columnId)
if (!currentColumnSort) {
setSorting([{ id: columnId, desc: false }])
return
}
setSorting([{ id: columnId, desc: !currentColumnSort.desc }])
}
const table = useReactTable({
data: filteredDeliveries,
@@ -290,25 +265,25 @@ export const PlatformWebhooksEndpointDetails = ({
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const columnId = header.column.id
const canSort = header.column.getCanSort()
const sort = header.column.getIsSorted()
return (
<TableHead
key={header.id}
aria-sort={canSort ? getAriaSort(sorting, columnId) : undefined}
aria-sort={
header.column.getCanSort()
? sort === 'asc'
? 'ascending'
: sort === 'desc'
? 'descending'
: 'none'
: undefined
}
className={columnId === DELIVERY_ACTIONS_COLUMN_ID ? 'w-1' : ''}
>
{header.isPlaceholder ? null : canSort ? (
<TableHeadSort
column={columnId}
currentSort={currentSort}
onSortChange={handleSortChange}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHeadSort>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
@@ -1,11 +1,10 @@
import { ColumnDef } from '@tanstack/react-table'
import { DataTableColumnHeader } from 'components/ui/DataTable/DataTableColumn/DataTableColumnHeader'
import { DataTableColumnLevelIndicator } from 'components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator'
import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode'
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { ColumnFilterSchema, ColumnSchema } from '../UnifiedLogs.schema'
import { STATUS_CODE_LABELS } from '../UnifiedLogs.constants'
import { ColumnFilterSchema, ColumnSchema } from '../UnifiedLogs.schema'
import { AuthUserHoverCard } from './AuthUserHoverCard'
import { HoverCardTimestamp } from './HoverCardTimestamp'
import { LogTypeIcon } from './LogTypeIcon'
@@ -64,7 +63,7 @@ export function generateDynamicColumns(data: ColumnSchema[]): {
// Date column - always visible
{
accessorKey: 'date',
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
header: 'Date',
cell: ({ row }) => {
const date = new Date(row.getValue<ColumnSchema['date']>('date'))
return <HoverCardTimestamp date={date} />
@@ -195,7 +194,7 @@ export function generateDynamicColumns(data: ColumnSchema[]): {
// Event message column - controlled by columnVisibility
{
accessorKey: 'event_message',
header: ({ column }) => <DataTableColumnHeader column={column} title="Event message" />,
header: 'Event message',
cell: ({ row }) => {
const value = row.getValue<ColumnSchema['event_message']>('event_message')
const logCount = row.original.log_count
@@ -1,54 +1,23 @@
import { type Column } from '@tanstack/react-table'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { TanStackTableHeadSort } from 'ui-patterns/Table'
import { Button, cn, type ButtonProps } from 'ui'
interface DataTableColumnHeaderProps<TData, TValue> extends ButtonProps {
interface DataTableColumnHeaderProps<TData, TValue> {
column: Column<TData, TValue>
title: string
className?: string
}
/**
* @deprecated Use `TanStackTableHeadSort` from `ui-patterns/Table` instead.
*/
export const DataTableColumnHeader = <TData, TValue>({
column,
title,
className,
...props
}: DataTableColumnHeaderProps<TData, TValue>) => {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
return (
<Button
type="text"
size="small"
onClick={() => {
column.toggleSorting(undefined)
}}
className={cn(
'text-xs',
'py-0 px-0 h-7 hover:bg-transparent flex gap-2 items-center justify-between w-full',
className
)}
iconRight={
<span className="flex flex-col">
<ChevronUp
className={cn(
'-mb-1 hover:text-foreground-lighter',
column.getIsSorted() === 'asc' ? 'text-foreground' : 'text-foreground-muted'
)}
/>
<ChevronDown
className={cn(
'-mt-1 hover:text-foreground-lighter',
column.getIsSorted() === 'desc' ? 'text-foreground' : 'text-foreground-muted'
)}
/>
</span>
}
{...props}
>
<span>{title}</span>
</Button>
<TanStackTableHeadSort column={column} className={className}>
{title}
</TanStackTableHeadSort>
)
}
+11
View File
@@ -17,6 +17,17 @@ module.exports = defineConfig([
'react/display-name': 'warn',
'react/no-unstable-nested-components': 'warn',
'react/jsx-key': 'error',
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'components/ui/DataTable/DataTableColumn/DataTableColumnHeader',
message: 'Use TanStackTableHeadSort from ui-patterns/Table instead.',
},
],
},
],
'barrel-files/avoid-re-export-all': 'error',
'jsx-a11y/alt-text': 'warn',
'jsx-a11y/role-has-required-aria-props': 'error',
+5
View File
@@ -666,6 +666,10 @@
"import": "./src/TimestampInfo/index.tsx",
"types": "./src/TimestampInfo/index.tsx"
},
"./Table": {
"import": "./src/Table/index.ts",
"types": "./src/Table/index.ts"
},
"./Toc": {
"import": "./src/Toc/index.ts",
"types": "./src/Toc/index.ts"
@@ -769,6 +773,7 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-visually-hidden": "^1.1.3",
"@std/toml": "jsr:^1.0.11",
"@tanstack/react-table": "^8.21.3",
"@supabase/sql-to-rest": "^0.1.6",
"@supabase/supabase-js": "catalog:",
"@vitest/coverage-v8": "^3.2.0",
@@ -0,0 +1,124 @@
import {
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type SortingState,
} from '@tanstack/react-table'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
import { describe, expect, it } from 'vitest'
import { TanStackTableHeadSort } from './TanStackTableHeadSort'
type Row = {
name: string
amount: number
}
const data: Row[] = [
{ name: 'Bravo', amount: 200 },
{ name: 'Alpha', amount: 100 },
]
const columns: ColumnDef<Row>[] = [
{
accessorKey: 'name',
header: ({ column }) => <TanStackTableHeadSort column={column}>Name</TanStackTableHeadSort>,
cell: ({ row }) => row.getValue('name'),
},
{
accessorKey: 'amount',
enableSorting: false,
header: ({ column }) => <TanStackTableHeadSort column={column}>Amount</TanStackTableHeadSort>,
cell: ({ row }) => row.getValue('amount'),
},
]
const classNameColumns: ColumnDef<Row>[] = [
{
accessorKey: 'name',
header: ({ column }) => (
<TanStackTableHeadSort column={column} className="justify-end">
Name
</TanStackTableHeadSort>
),
cell: ({ row }) => row.getValue('name'),
},
]
const TestTable = ({ tableColumns = columns }: { tableColumns?: ColumnDef<Row>[] }) => {
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns: tableColumns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
return (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)
}
describe('TanStackTableHeadSort', () => {
it('cycles unsorted, ascending, descending, and cleared', async () => {
const user = userEvent.setup()
render(<TestTable />)
expect(screen.getAllByRole('row')[1]).toHaveTextContent('Bravo')
await user.click(screen.getByRole('button', { name: 'Name' }))
expect(screen.getAllByRole('row')[1]).toHaveTextContent('Alpha')
await user.click(screen.getByRole('button', { name: 'Name' }))
expect(screen.getAllByRole('row')[1]).toHaveTextContent('Bravo')
await user.click(screen.getByRole('button', { name: 'Name' }))
expect(screen.getAllByRole('row')[1]).toHaveTextContent('Bravo')
})
it('renders non-sortable columns as plain content', () => {
render(<TestTable />)
expect(screen.getByText('Amount')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Amount' })).not.toBeInTheDocument()
})
it('passes className through to the rendered sort control', () => {
render(<TestTable tableColumns={classNameColumns} />)
expect(screen.getByRole('button', { name: 'Name' })).toHaveClass('justify-end')
})
})
@@ -0,0 +1,53 @@
'use client'
import { type Column } from '@tanstack/react-table'
import type { ReactNode } from 'react'
import { cn, TableHeadSort } from 'ui'
interface TanStackTableHeadSortProps<TData, TValue> {
column: Column<TData, TValue>
children: ReactNode
className?: string
}
/**
* Shared TanStack adapter for the `TableHeadSort` primitive.
* Prefer this in TanStack tables instead of wiring `TableHeadSort` manually.
*/
export const TanStackTableHeadSort = <TData, TValue>({
column,
children,
className,
}: TanStackTableHeadSortProps<TData, TValue>) => {
if (!column.getCanSort()) {
return <div className={cn(className)}>{children}</div>
}
const sort = column.getIsSorted()
const currentSort = sort ? `${column.id}:${sort}` : ''
const handleSortChange = () => {
if (!sort) {
column.toggleSorting(false)
return
}
if (sort === 'asc') {
column.toggleSorting(true)
return
}
column.clearSorting()
}
return (
<TableHeadSort
column={column.id}
currentSort={currentSort}
onSortChange={handleSortChange}
className={className}
>
{children}
</TableHeadSort>
)
}
+1
View File
@@ -0,0 +1 @@
export * from './TanStackTableHeadSort'
+3
View File
@@ -2607,6 +2607,9 @@ importers:
'@supabase/supabase-js':
specifier: 'catalog:'
version: 2.100.0
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@vitest/coverage-v8':
specifier: ^3.2.0
version: 3.2.4(supports-color@8.1.1)(vitest@3.2.4)