@handled-ai/design-system 0.14.8 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,12 +10,29 @@ import {
10
10
  type SortingState,
11
11
  type ColumnFiltersState,
12
12
  type VisibilityState,
13
+ type ColumnSizingState,
13
14
  type OnChangeFn,
14
15
  } from "@tanstack/react-table"
15
- import { ArrowDown, ArrowUp, ArrowUpDown, SearchX, Loader2 } from "lucide-react"
16
+ import type { RowData } from "@tanstack/react-table"
17
+ import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, EyeOff, Check, SearchX, Loader2 } from "lucide-react"
18
+ import {
19
+ DropdownMenu,
20
+ DropdownMenuContent,
21
+ DropdownMenuTrigger,
22
+ DropdownMenuItem,
23
+ DropdownMenuSeparator,
24
+ } from "./dropdown-menu"
16
25
 
17
26
  import { cn } from "../lib/utils"
18
27
 
28
+ declare module "@tanstack/react-table" {
29
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
30
+ interface ColumnMeta<TData extends RowData, TValue> {
31
+ /** Server-side sort key for this column. Enables sort in the header menu when onColumnSort is also provided. */
32
+ sortKey?: string
33
+ }
34
+ }
35
+
19
36
  export interface VirtualizedDataTableProps<TData> {
20
37
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
38
  columns: ColumnDef<TData, any>[]
@@ -36,6 +53,12 @@ export interface VirtualizedDataTableProps<TData> {
36
53
  hasMore?: boolean
37
54
  isFetchingMore?: boolean
38
55
 
56
+ // Column resizing
57
+ enableColumnResizing?: boolean
58
+ columnResizeMode?: "onChange" | "onEnd"
59
+ columnSizing?: ColumnSizingState
60
+ onColumnSizingChange?: OnChangeFn<ColumnSizingState>
61
+
39
62
  // Server-driven state (controlled) — omit for internal state
40
63
  sorting?: SortingState
41
64
  onSortingChange?: OnChangeFn<SortingState>
@@ -50,6 +73,16 @@ export interface VirtualizedDataTableProps<TData> {
50
73
  emptyMessage?: string
51
74
  emptyDescription?: string
52
75
 
76
+ // Column header menu
77
+ /** Called when user requests sorting from column header. columnId is the column's meta.sortKey. */
78
+ onColumnSort?: (columnId: string, direction: "asc" | "desc") => void
79
+ /** Called when user hides a column from the header menu. */
80
+ onColumnHide?: (columnId: string) => void
81
+ /** The currently active sort column ID — matches a column's meta.sortKey. Used for visual indicators and aria-sort. */
82
+ activeSortColumn?: string | null
83
+ /** The current sort direction. Used for visual indicators and aria-sort. */
84
+ activeSortDirection?: "asc" | "desc"
85
+
53
86
  // Styling
54
87
  className?: string
55
88
  }
@@ -66,12 +99,20 @@ export function VirtualizedDataTable<TData>({
66
99
  reachBottomThreshold = 5,
67
100
  hasMore = true,
68
101
  isFetchingMore,
102
+ enableColumnResizing = false,
103
+ columnResizeMode = "onEnd",
104
+ columnSizing,
105
+ onColumnSizingChange,
69
106
  sorting,
70
107
  onSortingChange,
71
108
  columnFilters,
72
109
  onColumnFiltersChange,
73
110
  columnVisibility,
74
111
  onColumnVisibilityChange,
112
+ onColumnSort,
113
+ onColumnHide,
114
+ activeSortColumn,
115
+ activeSortDirection,
75
116
  isLoading,
76
117
  emptyIcon,
77
118
  emptyMessage = "No rows found",
@@ -97,6 +138,13 @@ export function VirtualizedDataTable<TData>({
97
138
  const resolvedOnColumnVisibilityChange =
98
139
  onColumnVisibilityChange ?? setInternalColumnVisibility
99
140
 
141
+ // Controlled/uncontrolled state for column sizing
142
+ const [internalColumnSizing, setInternalColumnSizing] =
143
+ React.useState<ColumnSizingState>({})
144
+ const resolvedColumnSizing = columnSizing ?? internalColumnSizing
145
+ const resolvedOnColumnSizingChange =
146
+ onColumnSizingChange ?? setInternalColumnSizing
147
+
100
148
  // TanStack Table setup
101
149
  const table = useReactTable({
102
150
  data,
@@ -106,10 +154,14 @@ export function VirtualizedDataTable<TData>({
106
154
  sorting: resolvedSorting,
107
155
  columnFilters: resolvedColumnFilters,
108
156
  columnVisibility: resolvedColumnVisibility,
157
+ columnSizing: resolvedColumnSizing,
109
158
  },
110
159
  onSortingChange: resolvedOnSortingChange,
111
160
  onColumnFiltersChange: resolvedOnColumnFiltersChange,
112
161
  onColumnVisibilityChange: resolvedOnColumnVisibilityChange,
162
+ onColumnSizingChange: resolvedOnColumnSizingChange,
163
+ enableColumnResizing,
164
+ columnResizeMode,
113
165
  manualSorting: true,
114
166
  manualFiltering: true,
115
167
  manualPagination: true,
@@ -185,52 +237,150 @@ export function VirtualizedDataTable<TData>({
185
237
  className="flex w-max min-w-full border-b border-border/50"
186
238
  role="row"
187
239
  >
188
- {headerGroup.headers.map((header, colIdx) => (
189
- <div
190
- key={header.id}
191
- className="h-9 px-3 flex items-center text-xs font-medium text-muted-foreground whitespace-nowrap"
192
- style={{
193
- width: header.getSize(),
194
- minWidth: header.getSize(),
195
- }}
196
- role="columnheader"
197
- aria-colindex={colIdx + 1}
198
- aria-sort={
199
- header.column.getIsSorted() === "asc"
200
- ? "ascending"
201
- : header.column.getIsSorted() === "desc"
202
- ? "descending"
203
- : header.column.getCanSort()
204
- ? "none"
205
- : undefined
240
+ {headerGroup.headers.map((header, colIdx) => {
241
+ const sortKey = header.column.columnDef.meta?.sortKey
242
+ const canServerSort = Boolean(sortKey && onColumnSort)
243
+
244
+ const resolvedAriaSort = (() => {
245
+ if (activeSortColumn !== undefined) {
246
+ // Server-driven
247
+ if (!sortKey) return undefined
248
+ if (activeSortColumn === sortKey) return activeSortDirection === "asc" ? "ascending" as const : "descending" as const
249
+ return "none" as const
206
250
  }
207
- >
208
- {header.isPlaceholder ? null : header.column.getCanSort() ? (
209
- <button
210
- type="button"
211
- className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
212
- onClick={header.column.getToggleSortingHandler()}
213
- >
214
- {flexRender(
215
- header.column.columnDef.header,
216
- header.getContext(),
217
- )}
218
- {header.column.getIsSorted() === "asc" ? (
219
- <ArrowUp className="w-3 h-3" />
220
- ) : header.column.getIsSorted() === "desc" ? (
221
- <ArrowDown className="w-3 h-3" />
222
- ) : (
223
- <ArrowUpDown className="w-3 h-3 opacity-40" />
224
- )}
225
- </button>
226
- ) : (
227
- flexRender(
228
- header.column.columnDef.header,
229
- header.getContext(),
230
- )
231
- )}
232
- </div>
233
- ))}
251
+ // Fallback to TanStack state
252
+ const sorted = header.column.getIsSorted()
253
+ if (sorted === "asc") return "ascending" as const
254
+ if (sorted === "desc") return "descending" as const
255
+ if (header.column.getCanSort()) return "none" as const
256
+ return undefined
257
+ })()
258
+
259
+ const sortIcon = (() => {
260
+ if (!canServerSort) return null
261
+ if (activeSortColumn === sortKey && activeSortDirection === "asc") return <ArrowUp className="w-3 h-3" />
262
+ if (activeSortColumn === sortKey && activeSortDirection === "desc") return <ArrowDown className="w-3 h-3" />
263
+ return <ArrowUpDown className="w-3 h-3 opacity-40" />
264
+ })()
265
+
266
+ const handleHeaderClick = canServerSort ? () => {
267
+ const newDir = activeSortColumn === sortKey
268
+ ? (activeSortDirection === "asc" ? "desc" : "asc")
269
+ : "asc"
270
+ onColumnSort!(sortKey!, newDir)
271
+ } : undefined
272
+
273
+ return (
274
+ <div
275
+ key={header.id}
276
+ className={cn(
277
+ "group/header h-9 px-3 flex items-center text-xs font-medium text-muted-foreground whitespace-nowrap relative",
278
+ header.column.getCanResize() && "pr-4",
279
+ )}
280
+ style={{
281
+ width: header.getSize(),
282
+ minWidth: header.getSize(),
283
+ }}
284
+ role="columnheader"
285
+ aria-colindex={colIdx + 1}
286
+ aria-sort={resolvedAriaSort}
287
+ >
288
+ {header.isPlaceholder ? null : (
289
+ <>
290
+ {canServerSort ? (
291
+ <button
292
+ type="button"
293
+ className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
294
+ onClick={handleHeaderClick}
295
+ >
296
+ {flexRender(header.column.columnDef.header, header.getContext())}
297
+ {sortIcon}
298
+ </button>
299
+ ) : header.column.getCanSort() ? (
300
+ <button
301
+ type="button"
302
+ className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
303
+ onClick={header.column.getToggleSortingHandler()}
304
+ >
305
+ {flexRender(
306
+ header.column.columnDef.header,
307
+ header.getContext(),
308
+ )}
309
+ {header.column.getIsSorted() === "asc" ? (
310
+ <ArrowUp className="w-3 h-3" />
311
+ ) : header.column.getIsSorted() === "desc" ? (
312
+ <ArrowDown className="w-3 h-3" />
313
+ ) : (
314
+ <ArrowUpDown className="w-3 h-3 opacity-40" />
315
+ )}
316
+ </button>
317
+ ) : (
318
+ flexRender(
319
+ header.column.columnDef.header,
320
+ header.getContext(),
321
+ )
322
+ )}
323
+ {(canServerSort || header.column.getCanSort() || header.column.getCanHide()) && (
324
+ <DropdownMenu>
325
+ <DropdownMenuTrigger asChild>
326
+ <button
327
+ type="button"
328
+ className="ml-1 inline-flex items-center hover:text-foreground transition-all opacity-0 group-hover/header:opacity-100"
329
+ aria-label="Column actions"
330
+ >
331
+ <ChevronDown className="w-3 h-3" />
332
+ </button>
333
+ </DropdownMenuTrigger>
334
+ <DropdownMenuContent align="start" className="w-48">
335
+ <DropdownMenuItem
336
+ disabled={!canServerSort}
337
+ onClick={() => canServerSort && onColumnSort!(sortKey!, "asc")}
338
+ >
339
+ <ArrowUp className="w-3.5 h-3.5 mr-2" />
340
+ Sort ascending
341
+ {activeSortColumn === sortKey && activeSortDirection === "asc" && <Check className="w-3.5 h-3.5 ml-auto" />}
342
+ </DropdownMenuItem>
343
+ <DropdownMenuItem
344
+ disabled={!canServerSort}
345
+ onClick={() => canServerSort && onColumnSort!(sortKey!, "desc")}
346
+ >
347
+ <ArrowDown className="w-3.5 h-3.5 mr-2" />
348
+ Sort descending
349
+ {activeSortColumn === sortKey && activeSortDirection === "desc" && <Check className="w-3.5 h-3.5 ml-auto" />}
350
+ </DropdownMenuItem>
351
+ {header.column.getCanHide() && (
352
+ <>
353
+ <DropdownMenuSeparator />
354
+ <DropdownMenuItem
355
+ onClick={() => onColumnHide ? onColumnHide(header.column.id) : header.column.toggleVisibility(false)}
356
+ >
357
+ <EyeOff className="w-3.5 h-3.5 mr-2" />
358
+ Hide column
359
+ </DropdownMenuItem>
360
+ </>
361
+ )}
362
+ </DropdownMenuContent>
363
+ </DropdownMenu>
364
+ )}
365
+ </>
366
+ )}
367
+ {header.column.getCanResize() && (
368
+ <div
369
+ onMouseDown={header.getResizeHandler()}
370
+ onTouchStart={header.getResizeHandler()}
371
+ className={cn(
372
+ "absolute right-0 top-0 h-full w-3 -mr-1.5 cursor-col-resize select-none touch-none",
373
+ "after:absolute after:right-1.5 after:top-0 after:h-full after:w-px",
374
+ "after:bg-transparent hover:after:bg-primary/30",
375
+ header.column.getIsResizing() && "after:bg-primary/50",
376
+ )}
377
+ role="separator"
378
+ aria-orientation="vertical"
379
+ />
380
+ )}
381
+ </div>
382
+ )
383
+ })}
234
384
  </div>
235
385
  ))}
236
386
  </div>
package/src/index.ts CHANGED
@@ -91,6 +91,7 @@ export * from "./components/tooltip"
91
91
  export * from "./components/variable-autocomplete"
92
92
  export * from "./components/view-mode-toggle"
93
93
  export * from "./components/virtualized-data-table"
94
+ export type { ColumnSizingState } from "@tanstack/react-table"
94
95
 
95
96
  // Charts (re-exported for backward compatibility with root imports)
96
97
  export * from "./charts/index"