UX Standards — 07: Universal Data Table (UDT)
Governs: All data tables across every page in every application.
Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first.
Engine: TanStack React Table v8
Visual Anatomy
┌─────────────────────────────────────────────────────────────────────────┐
│ [☰][⬜][≡][🔍────────────][▽][✕][↕][⬆][⬇][+][✎][🗑] ◀◀ ◀ [1] ▶ ▶▶ Rows[20]│
├──────┬──────────────┬────────────────┬───────────────┬──────────────────┤
│ ☐ │ Name ↑ │ Status │ Created │ Actions │
├──────┼──────────────┼────────────────┼───────────────┼──────────────────┤
│ ☐ │ Jane Smith │ ● Active │ Jan 15, 2025 │ [···] │
│ ☒ │ John Doe │ ○ Inactive │ Jan 10, 2025 │ [···] │ ← Selected (bg-primary/10)
│ ☐ │ Alice Brown │ ⚠ Pending │ Jan 8, 2025 │ [···] │
│ (empty area) │
├─────────────────────────────────────────────────────────────────────────┤
│ Page 1 of 4 • 40 rows • 2 selected │
└─────────────────────────────────────────────────────────────────────────┘
Components
| Component | Location | Purpose |
|---|---|---|
DataTableSortable | components/data-table/data-table-sortable.tsx | The table itself |
DataTableToolbar | components/data-table/data-table-toolbar.tsx | Action bar above table |
DataTablePagination | components/data-table/data-table-pagination.tsx | Pagination footer |
DataTableColumnHeader | components/data-table/data-table-column-header.tsx | Sortable column header |
DataTableColumnSelector | components/data-table/data-table-column-selector.tsx | Column visibility + reorder |
DocTypeListPage | src/components/doctype-list-page.tsx | Complete page shell |
Table Container
// The table fills the remaining height in the content area
<div className="flex min-h-0 flex-1 flex-col">
<DataTableSortable
table={table}
density={density}
onRowDoubleClick={handleRowDoubleClick}
enableColumnDragDrop={true}
enableColumnResizing={false}
enableRangeSelection={true}
/>
</div>
// Inside DataTableSortable:
<div className="flex min-h-0 flex-1 flex-col">
{/* Scrollable table */}
<div className="flex-1 min-h-0 overflow-auto rounded-lg border">
<table className="w-full caption-bottom text-sm" style={{ minWidth: ..., tableLayout: 'fixed' }}>
<TableHeader>...</TableHeader>
<TableBody>...</TableBody>
</table>
</div>
{/* Fixed pagination footer */}
<div className="mt-2 flex-shrink-0 border-t bg-card pt-2">
<DataTablePagination table={table} />
</div>
</div>
| Property | Value | Reason |
|---|---|---|
flex-1 min-h-0 | Fills remaining space | Critical for table-fills-viewport layout |
overflow-auto | Horizontal + vertical scroll | Columns don't truncate; rows scroll |
rounded-lg border | Card-style border | Consistent with card aesthetic |
tableLayout: 'fixed' | Columns have defined widths | Prevents layout shift on data change |
Row Density
Three density modes control row height and cell padding:
const densityClasses = {
compact: { row: 'h-8', cell: 'py-1 text-xs', header: 'h-8 text-xs' },
default: { row: 'h-10', cell: 'py-2 text-sm', header: 'h-10 text-sm' },
large: { row: 'h-14', cell: 'py-3 text-sm', header: 'h-12 text-sm' },
};
| Mode | Row Height | Cell Padding | Header | Use When |
|---|---|---|---|---|
compact | 32px | py-1 | 32px | Dense data, power users |
default | 40px | py-2 | 40px | Standard — default always |
large | 56px | py-3 | 48px | Multi-line cells, readability |
Default is always default. The user controls density via the toolbar density picker.
Column Header
// DataTableColumnHeader — clickable to sort
<DataTableColumnHeader column={column} title="Name" />
Renders:
- Column title text
- Sort icon:
ArrowUp/ArrowDown/ChevronsUpDown(unsorted) - Sort cycle: unsorted → ascending → descending → unsorted
Sticky header: Column headers are position: sticky; top: 0; z-index: 10 within the scroll container. Pinned columns get z-index: 20.
Column header background:
- Normal:
hsl(var(--primary-tint, var(--muted)))— muted tint - Pinned:
hsl(var(--muted))— solid muted
Row States
// Selected row
<TableRow
data-state="selected"
className={cn(
densityClasses[density].row,
'cursor-pointer',
isRowSelected && 'bg-primary/10' // Selected: light primary tint
)}
>
| State | Classes |
|---|---|
| Default | bg-card (inherits) |
| Hover | hover:bg-muted/50 |
| Selected | bg-primary/10 |
| Active (data-state) | data-[state=selected]:bg-primary/10 |
Row Selection
Checkbox Column
Always the first column, left-pinned:
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
onClick={(e) => e.stopPropagation()} // Don't trigger row click
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
}
Range Selection
Shift+click selects a range of rows. Enabled via enableRangeSelection={true} and handled by useRowSelectionRange:
// Click handler with range support
onClick={(e: React.MouseEvent<HTMLTableRowElement>) => {
if (enableRangeSelection) {
handleRangeClick(e, row, rowIndex); // handles shift+click range
}
if (onRowClick) {
onRowClick(row.original);
}
}}
Column Pinning
Columns can be pinned left or right. Pinned columns receive sticky positioning:
// In column definition:
{ id: 'select', enablePinning: true, pin: 'left' }
{ id: 'actions', enablePinning: true, pin: 'right' }
// Pinned styling uses CSS vars for border shadow:
boxShadow: isLastLeftPinnedColumn
? '-4px 0 4px -4px hsl(var(--border)) inset'
: isFirstRightPinnedColumn
? '4px 0 4px -4px hsl(var(--border)) inset'
: undefined
Column Drag-and-Drop Reordering
Users can drag column headers to reorder them. Uses @dnd-kit/core + @dnd-kit/sortable:
<DataTableSortable
table={table}
enableColumnDragDrop={true}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
/>
The drag handle appears on hover as a GripVertical icon on the left edge of each header cell. Pinned columns cannot be reordered.
Column Resizing
Optional — enabled per table:
<DataTableSortable
table={table}
enableColumnResizing={true}
/>
When enabled, a resize handle appears at the right edge of each header cell. Double-click resets to default width.
Empty State
When the table has no rows:
<TableRow>
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
{emptyIcon && <emptyIcon className="mx-auto h-12 w-12 text-muted-foreground mb-2" />}
<p className="text-sm font-medium">{emptyTitle ?? 'No results.'}</p>
{emptyDescription && (
<p className="text-xs text-muted-foreground mt-1">{emptyDescription}</p>
)}
</TableCell>
</TableRow>
| Element | Spec |
|---|---|
| Container height | h-24 minimum |
| Icon | h-12 w-12 text-muted-foreground |
| Title | text-sm font-medium |
| Description | text-xs text-muted-foreground mt-1 |
Expanded Row
Some tables support expanding a row to show detail inline:
<DataTableSortable
table={table}
renderExpandedRow={(row) => (
<div className="p-4 bg-muted/50">
<FaxDetailPreview fax={row.original} />
</div>
)}
/>
The expanded row renders in a <TableRow> with bg-muted/50 p-4. Row click toggles expansion when renderExpandedRow is provided.
Actions Column
The rightmost column with per-row action buttons:
{
id: 'actions',
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleView(row.original)}>
<Eye className="mr-2 h-4 w-4" />
View
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(row.original)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleDelete(row.original)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
enableSorting: false,
enableHiding: false,
size: 50,
}
Pagination Footer
// Rendered by DataTablePagination inside DataTableSortable
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<span>
{selectedCount > 0 && `${selectedCount} of `}
{totalRows} row{totalRows !== 1 ? 's' : ''}
{selectedCount > 0 && ' selected'}
</span>
<span>
Page {currentPage} of {pageCount}
</span>
</div>
The toolbar handles the navigation controls (◀◀ ◀ [n] ▶ ▶▶) and page size selector (Rows [20]). The pagination footer handles the summary text (Page X of Y, N rows selected).
Standard Column Definitions Pattern
// columns.tsx
import type { ColumnDef } from '@tanstack/react-table';
import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header';
export function getColumns(): ColumnDef<MyDataType>[] {
return [
// 1. Select checkbox (always first)
{
id: 'select',
header: ({ table }) => <SelectAllCheckbox table={table} />,
cell: ({ row }) => <SelectRowCheckbox row={row} />,
enableSorting: false,
enableHiding: false,
size: 40,
},
// 2. Data columns
{
accessorKey: 'name',
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
cell: ({ row }) => <span className="font-medium">{row.getValue('name')}</span>,
size: 200,
},
{
accessorKey: 'status',
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => <StatusBadge status={row.getValue('status')} />,
size: 120,
},
// 3. Actions column (always last)
{
id: 'actions',
cell: ({ row }) => <ActionsDropdown row={row} />,
enableSorting: false,
enableHiding: false,
size: 50,
},
];
}
TanStack Table Instance
// useTableSetup hook pattern
const table = useReactTable({
data,
columns,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
pagination,
globalFilter,
columnOrder,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
onGlobalFilterChange: setGlobalFilter,
onColumnOrderChange: setColumnOrder,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
enableRowSelection: true,
enableMultiRowSelection: true,
});
Violation Checklist
- Table container uses
flex min-h-0 flex-1 flex-col - Scroll wrapper uses
flex-1 min-h-0 overflow-auto rounded-lg border - Default density is
default(h-10 rows) - Selected rows use
bg-primary/10 - First column is always the select checkbox
- Last column is the actions dropdown
- Empty state shows icon + title + description, height
h-24 - Column headers are sticky with correct z-index
- Pagination summary (row count, page info) in footer
- No hardcoded colors in table cells — use semantic tokens
- Actions dropdown uses
MoreHorizontaltrigger,h-8 w-8button