Skip to content

Commit 2b18256

Browse files
committed
Add data table remote sort header component
1 parent 42a57d6 commit 2b18256

24 files changed

Lines changed: 769 additions & 4 deletions

File tree

apps/virtuoso.dev/registry.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,26 @@
115115
"target": "@ui/data-table/column-resize/resize-handle.tsx"
116116
}
117117
]
118+
},
119+
{
120+
"name": "data-table-sort-header-button",
121+
"type": "registry:component",
122+
"title": "Data Table Sort Header Button",
123+
"description": "Slot-mounted sort button for column headers.",
124+
"dependencies": ["@virtuoso.dev/data-table", "lucide-react"],
125+
"registryDependencies": ["petyosi/react-virtuoso/data-table"],
126+
"files": [
127+
{
128+
"path": "registry/new-york/data-table/column-sort/index.ts",
129+
"type": "registry:component",
130+
"target": "@ui/data-table/column-sort/index.ts"
131+
},
132+
{
133+
"path": "registry/new-york/data-table/column-sort/sort-header-button.tsx",
134+
"type": "registry:component",
135+
"target": "@ui/data-table/column-sort/sort-header-button.tsx"
136+
}
137+
]
118138
}
119139
]
120140
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { SortHeaderButton } from './sort-header-button'
2+
export type { SortDirection, SortHeaderButtonProps, SortPayload } from './sort-header-button'
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use client'
2+
3+
import { dispatchModelAction$, modelActionState$, useCellValue, usePublisher } from '@virtuoso.dev/data-table'
4+
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'
5+
6+
import { cn } from '@/lib/utils'
7+
8+
import type { HeaderSlotRenderParams } from '@virtuoso.dev/data-table'
9+
10+
export type SortDirection = 'asc' | 'desc'
11+
export type SortPayload = {
12+
field: string
13+
direction: SortDirection
14+
}
15+
16+
export interface SortHeaderButtonProps extends Partial<HeaderSlotRenderParams> {
17+
action?: string
18+
className?: string
19+
field?: string
20+
getDirection?: (payload: unknown, field: string) => SortDirection | undefined
21+
getPayload?: (context: { direction: SortDirection | undefined; field: string; previousDirection: SortDirection | undefined }) => unknown
22+
}
23+
24+
function sortDirectionFromPayload(payload: unknown, field: string): SortDirection | undefined {
25+
if (typeof payload !== 'object' || payload === null) {
26+
return undefined
27+
}
28+
29+
const sort = payload as Partial<SortPayload>
30+
return sort.field === field && (sort.direction === 'asc' || sort.direction === 'desc') ? sort.direction : undefined
31+
}
32+
33+
export function SortHeaderButton({
34+
action = 'sort',
35+
className,
36+
column,
37+
field,
38+
getDirection = sortDirectionFromPayload,
39+
getPayload,
40+
}: SortHeaderButtonProps) {
41+
const dispatch = usePublisher(dispatchModelAction$)
42+
const actionState = useCellValue(modelActionState$)
43+
const sortField = field ?? column?.field
44+
45+
if (!sortField) {
46+
return null
47+
}
48+
49+
const direction = getDirection(actionState[action]?.payload, sortField)
50+
const nextDirection: SortDirection | undefined = direction === 'asc' ? 'desc' : direction === 'desc' ? undefined : 'asc'
51+
const label =
52+
nextDirection === 'asc'
53+
? `Sort ${sortField} ascending`
54+
: nextDirection === 'desc'
55+
? `Sort ${sortField} descending`
56+
: `Clear ${sortField} sorting`
57+
const Icon = direction === 'asc' ? ArrowUp : direction === 'desc' ? ArrowDown : ArrowUpDown
58+
59+
return (
60+
<button
61+
aria-label={label}
62+
aria-pressed={direction !== undefined}
63+
className={cn(
64+
'ml-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
65+
direction !== undefined && 'bg-muted text-foreground',
66+
className
67+
)}
68+
data-sort-direction={direction}
69+
onClick={() => {
70+
dispatch({
71+
action,
72+
payload: getPayload
73+
? getPayload({ direction: nextDirection, field: sortField, previousDirection: direction })
74+
: nextDirection === undefined
75+
? undefined
76+
: { field: sortField, direction: nextDirection },
77+
})
78+
}}
79+
title={label}
80+
type="button"
81+
>
82+
<Icon className="size-3.5" />
83+
</button>
84+
)
85+
}

apps/virtuoso.dev/src/components/LiveCodeBlock/extraImports.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as _DataTableStatePersistence from '@virtuoso.dev/data-table/state-pers
1010
import * as _Masonry from '@virtuoso.dev/masonry'
1111
import * as _ML from '@virtuoso.dev/message-list'
1212
import * as _ReactiveEngineReact from '@virtuoso.dev/reactive-engine-react'
13+
import * as _LucideReact from 'lucide-react'
1314
import * as jsxRuntime from 'react/jsx-runtime'
1415

1516
import * as _UiButton from '@/components/ui/button'
@@ -38,6 +39,8 @@ import dataTableColumnReorderDropZoneSource from '../../../registry/new-york/dat
3839
import dataTableColumnReorderGripSource from '../../../registry/new-york/data-table/column-reorder/reorder-grip.tsx?raw'
3940
import * as _DataTableColumnResizeUI from '../../../registry/new-york/data-table/column-resize'
4041
import dataTableColumnResizeHandleSource from '../../../registry/new-york/data-table/column-resize/resize-handle.tsx?raw'
42+
import * as _DataTableColumnSortUI from '../../../registry/new-york/data-table/column-sort'
43+
import dataTableColumnSortHeaderButtonSource from '../../../registry/new-york/data-table/column-sort/sort-header-button.tsx?raw'
4144
import * as _DataTableUI from '../../../registry/new-york/data-table/data-table'
4245
import dataTableUiSource from '../../../registry/new-york/data-table/data-table.tsx?raw'
4346
import utilsSource from '../../lib/utils.ts?raw'
@@ -125,6 +128,23 @@ export { ResizeHandle } from '@/components/ui/data-table/column-resize/resize-ha
125128
filePath: 'file:///src/components/ui/data-table/column-resize/resize-handle.tsx',
126129
sandboxPath: 'src/components/ui/data-table/column-resize/resize-handle.tsx',
127130
},
131+
'@/components/ui/data-table/column-sort': {
132+
content: `
133+
export { SortHeaderButton } from '@/components/ui/data-table/column-sort/sort-header-button'
134+
export type { SortDirection, SortHeaderButtonProps, SortPayload } from '@/components/ui/data-table/column-sort/sort-header-button'
135+
`,
136+
dependencies: ['@virtuoso.dev/data-table', 'lucide-react'],
137+
filePath: 'file:///src/components/ui/data-table/column-sort.ts',
138+
imports: ['@/components/ui/data-table/column-sort/sort-header-button'],
139+
sandboxPath: 'src/components/ui/data-table/column-sort.ts',
140+
},
141+
'@/components/ui/data-table/column-sort/sort-header-button': {
142+
content: dataTableColumnSortHeaderButtonSource,
143+
dependencies: ['@virtuoso.dev/data-table', 'lucide-react'],
144+
filePath: 'file:///src/components/ui/data-table/column-sort/sort-header-button.tsx',
145+
imports: ['@/lib/utils'],
146+
sandboxPath: 'src/components/ui/data-table/column-sort/sort-header-button.tsx',
147+
},
128148
'@/components/ui/tooltip': {
129149
content: tooltipSource,
130150
dependencies: ['@radix-ui/react-tooltip'],
@@ -150,11 +170,13 @@ export const importMap: Record<string, unknown> = {
150170
'@virtuoso.dev/masonry': _Masonry,
151171
'@virtuoso.dev/message-list': _ML,
152172
'@virtuoso.dev/reactive-engine-react': _ReactiveEngineReact,
173+
'lucide-react': _LucideReact,
153174
'@/components/ui/button': _UiButton,
154175
'@/components/ui/card': _UiCard,
155176
'@/components/ui/data-table': _DataTableUI,
156177
'@/components/ui/data-table/column-reorder': _DataTableColumnReorderUI,
157178
'@/components/ui/data-table/column-resize': _DataTableColumnResizeUI,
179+
'@/components/ui/data-table/column-sort': _DataTableColumnSortUI,
158180
'@/components/ui/tooltip': _UiTooltip,
159181
'@/lib/utils': _Utils,
160182
react: React,

packages/data-table/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ Visual customization is covered separately:
9595
- [ambient context](/data-table/customization/ambient-context/) — a bag of values available throughout the table's customizable parts
9696
- [header slots](/data-table/customization/header-slots/) — mount sort buttons, filter menus, and other controls inside column headers
9797
- [inside the shadcn wrapper](/data-table/customization/shadcn-wrapper/) — change app-wide defaults by editing the wrapper file
98+
- [remote column sorting](/data-table/examples/remote-column-sorting/) — connect shadcn header sort buttons to request params
9899

99100
## License
100101

packages/data-table/docs/1.installation/01.shadcn.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ Feature-specific wrapper components use the same GitHub registry address. For ex
2727
npx shadcn@latest add petyosi/react-virtuoso/data-table-resize-handle
2828
```
2929

30+
Add the sort header button when sortable column headers should dispatch model actions:
31+
32+
```bash
33+
npx shadcn@latest add petyosi/react-virtuoso/data-table-sort-header-button
34+
```
35+
3036
The hosted registry URL still works if you prefer the existing virtuoso.dev registry endpoint:
3137

3238
```bash
@@ -35,12 +41,13 @@ npx shadcn@latest add https://virtuoso.dev/r/data-table.json
3541

3642
## Icon library
3743

38-
The wrapper uses [`lucide-react`](https://lucide.dev/) for the loading spinner, error icon, and the drag grip on the reorderable column header. The `npx shadcn add` command installs `lucide-react` as a dependency on first run.
44+
The wrapper uses [`lucide-react`](https://lucide.dev/) for the loading spinner, error icon, drag grip, and sort button icons. The `npx shadcn add` command installs `lucide-react` as a dependency on first run.
3945

4046
shadcn's `iconLibrary` field in `components.json` only rewrites icon imports for the canonical shadcn registry — it does not touch components from third-party registries like ours. If your project uses a different icon set (`@radix-ui/react-icons`, `phosphor-react`, etc.) and you want the table icons to match, open the installed files under `@/components/ui/data-table` and swap the lucide imports for your library's equivalents. The icons appear in:
4147

4248
- `index.tsx``Loader2` (spinner), `AlertCircle` (error state)
4349
- `column-reorder/reorder-grip.tsx``GripVertical` (drag handle)
50+
- `column-sort/sort-header-button.tsx``ArrowUpDown`, `ArrowUp`, `ArrowDown` (sort state)
4451

4552
## Basic styled table
4653

packages/data-table/docs/2.data-model/02.remote-data-model.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ model.send({ action: 'search', payload: 'desk' })
168168

169169
Returning new params triggers a refresh. With `persistence: true`, the payload of each action — the search string, the selected category — is saved alongside the rest of the table's persisted state and replayed through the handler the next time the table loads, so users come back to the same filtered view. Pass `capture` and `restore` instead of `true` when you need a custom serialized shape.
170170

171+
For a complete column-header sort control that sends `{ field, direction }` to a remote model, see [Remote Column Sorting](/data-table/examples/remote-column-sorting/).
172+
171173
## Concurrency and cancellation
172174

173175
`strategy` controls what happens when an action fires while a fetch is still in flight.

packages/data-table/docs/7.customization/06.header-slots.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,5 +110,6 @@ The opt-in column features ship slot components that drop straight into these po
110110

111111
- `ResizeHandle` from `@/components/ui/data-table/column-resize` — mounts in `HeaderEdge`. See [Column Resizing](/data-table/columns/column-resizing/).
112112
- `ReorderGrip` and `ReorderDropZone` from `@/components/ui/data-table/column-reorder` — mount in `HeaderStart` and `HeaderOverlay` respectively. See [Column Reordering](/data-table/columns/column-reordering/).
113+
- `SortHeaderButton` from `@/components/ui/data-table/column-sort` — mounts in `HeaderEnd` and dispatches a model `sort` action. See [Remote Column Sorting](/data-table/examples/remote-column-sorting/).
113114

114115
Custom sort, filter, menu, and selection controls follow the same pattern: a component that takes the slot's render params and returns whatever JSX you need.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
---
2+
title: Remote Column Sorting
3+
description: Use shadcn header buttons to send sort params to a remote data model.
4+
sidebar:
5+
label: Remote Sorting
6+
---
7+
8+
Each sortable column header sends a `sort` action to the remote model. The action updates the request params, the model refetches from the first page, and the active header button reads the current sort from `modelActionState$`.
9+
10+
The shadcn `SortHeaderButton` defaults to a payload shaped as `{ field, direction }`, where `direction` cycles through `'asc'`, `'desc'`, and no sort.
11+
12+
## APIs used
13+
14+
- `remoteModel()` — refetches rows when request params change
15+
- `SortHeaderButton` — shadcn slot component for column-header sorting
16+
- `HeaderEnd` — places the sort button after the header label
17+
- `computeRowKey` — keeps row identity stable when sorting reorders rows
18+
19+
```tsx live wide file=App.tsx
20+
import { useState } from 'react'
21+
22+
import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader, HeaderEnd } from '@/components/ui/data-table'
23+
import { SortHeaderButton } from '@/components/ui/data-table/column-sort'
24+
import { defaultOffsetViewportHandler, remoteModel } from '@virtuoso.dev/data-table'
25+
26+
import { fetchProducts, placeholder } from './api'
27+
28+
import type { Product, Query } from './api'
29+
30+
export default function App() {
31+
const [model] = useState(() =>
32+
remoteModel<Product, Query>({
33+
actions: {
34+
sort: {
35+
strategy: 'supersede',
36+
handler: ({ params, payload }) => ({ ...params, sort: payload as Query['sort'] }),
37+
},
38+
},
39+
fetch: fetchProducts,
40+
initialParams: {},
41+
onViewportChange: defaultOffsetViewportHandler,
42+
pageSize: 40,
43+
placeholder,
44+
})
45+
)
46+
47+
return (
48+
<DataTable className="rounded-xl" computeRowKey={({ data }) => data.id} model={model} style={{ height: 420 }}>
49+
<DataTableColumn field="name">
50+
<DataTableColumnHeader>
51+
<HeaderEnd component={SortHeaderButton} />
52+
{() => 'Product'}
53+
</DataTableColumnHeader>
54+
<DataTableCell className="font-medium">{({ row }) => row.data.name}</DataTableCell>
55+
</DataTableColumn>
56+
<DataTableColumn field="category">
57+
<DataTableColumnHeader>
58+
<HeaderEnd component={SortHeaderButton} />
59+
{() => 'Category'}
60+
</DataTableColumnHeader>
61+
<DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
62+
</DataTableColumn>
63+
<DataTableColumn field="price">
64+
<DataTableColumnHeader className="justify-end">
65+
<HeaderEnd component={SortHeaderButton} />
66+
{() => 'Price'}
67+
</DataTableColumnHeader>
68+
<DataTableCell className="text-right tabular-nums">{({ cellValue }) => `$${cellValue}`}</DataTableCell>
69+
</DataTableColumn>
70+
</DataTable>
71+
)
72+
}
73+
```
74+
75+
```ts live file=api.ts
76+
import type { FetchParams } from '@virtuoso.dev/data-table'
77+
78+
export interface Product {
79+
id: string
80+
name: string
81+
category: 'Office' | 'Peripherals' | 'Audio'
82+
price: number
83+
}
84+
85+
export interface Query {
86+
sort?: {
87+
field: 'name' | 'category' | 'price'
88+
direction: 'asc' | 'desc'
89+
}
90+
}
91+
92+
const categories: Product['category'][] = ['Office', 'Peripherals', 'Audio']
93+
94+
const allProducts: Product[] = Array.from({ length: 240 }, (_, index) => ({
95+
id: `SKU-${String(index + 1).padStart(3, '0')}`,
96+
name: `${categories[index % categories.length]} Item ${index + 1}`,
97+
category: categories[index % categories.length]!,
98+
price: 49 + (index % 11) * 13,
99+
}))
100+
101+
export const placeholder: Product = {
102+
id: 'loading',
103+
name: 'Loading...',
104+
category: 'Office',
105+
price: 0,
106+
}
107+
108+
async function pause(ms: number, signal: AbortSignal) {
109+
await new Promise<void>((resolve, reject) => {
110+
const timer = window.setTimeout(resolve, ms)
111+
signal.addEventListener(
112+
'abort',
113+
() => {
114+
window.clearTimeout(timer)
115+
reject(new Error('aborted'))
116+
},
117+
{ once: true }
118+
)
119+
})
120+
}
121+
122+
function compareProducts(sort: NonNullable<Query['sort']>) {
123+
return (left: Product, right: Product) => {
124+
const leftValue = left[sort.field]
125+
const rightValue = right[sort.field]
126+
const result =
127+
typeof leftValue === 'number' && typeof rightValue === 'number'
128+
? leftValue - rightValue
129+
: String(leftValue).localeCompare(String(rightValue), undefined, { numeric: true })
130+
131+
return sort.direction === 'asc' ? result : -result
132+
}
133+
}
134+
135+
export async function fetchProducts(params: FetchParams<Query>) {
136+
await pause(180, params.signal)
137+
const data = params.params.sort ? allProducts.toSorted(compareProducts(params.params.sort)) : allProducts
138+
139+
return {
140+
rows: data.slice(params.offset, params.offset + params.limit),
141+
totalCount: data.length,
142+
}
143+
}
144+
```
145+
146+
## When this doesn't fit
147+
148+
If your API uses a different query shape, render the button manually in `HeaderEnd` and pass `action`, `field`, `getDirection`, or `getPayload` props to `SortHeaderButton`. Copy the installed component into your app-specific table wrapper when every table in the app should share the same sort payload shape.

packages/virtuoso-skills/skills/data-table/references/1.installation/01.shadcn.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ Feature-specific wrapper components use the same GitHub registry address. For ex
2727
npx shadcn@latest add petyosi/react-virtuoso/data-table-resize-handle
2828
```
2929

30+
Add the sort header button when sortable column headers should dispatch model actions:
31+
32+
```bash
33+
npx shadcn@latest add petyosi/react-virtuoso/data-table-sort-header-button
34+
```
35+
3036
The hosted registry URL still works if you prefer the existing virtuoso.dev registry endpoint:
3137

3238
```bash
@@ -35,12 +41,13 @@ npx shadcn@latest add https://virtuoso.dev/r/data-table.json
3541

3642
## Icon library
3743

38-
The wrapper uses [`lucide-react`](https://lucide.dev/) for the loading spinner, error icon, and the drag grip on the reorderable column header. The `npx shadcn add` command installs `lucide-react` as a dependency on first run.
44+
The wrapper uses [`lucide-react`](https://lucide.dev/) for the loading spinner, error icon, drag grip, and sort button icons. The `npx shadcn add` command installs `lucide-react` as a dependency on first run.
3945

4046
shadcn's `iconLibrary` field in `components.json` only rewrites icon imports for the canonical shadcn registry — it does not touch components from third-party registries like ours. If your project uses a different icon set (`@radix-ui/react-icons`, `phosphor-react`, etc.) and you want the table icons to match, open the installed files under `@/components/ui/data-table` and swap the lucide imports for your library's equivalents. The icons appear in:
4147

4248
- `index.tsx``Loader2` (spinner), `AlertCircle` (error state)
4349
- `column-reorder/reorder-grip.tsx``GripVertical` (drag handle)
50+
- `column-sort/sort-header-button.tsx``ArrowUpDown`, `ArrowUp`, `ArrowDown` (sort state)
4451

4552
## Basic styled table
4653

0 commit comments

Comments
 (0)