Skip to main content

UX Standards — 09: Column Filter Popup

Governs: The per-column filter/sort/pin/hide popup that opens when clicking a column header. Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first.


Overview

Every data table column header opens a per-column popup when clicked. This popup provides:

  • Copy the column data to clipboard
  • Pin the column to left or right (sticky column)
  • Hide the column from view
  • Sort ascending / descending / clear sort
  • Filter with operator selection + value input + multiple conditions
  • Distinct values quick-select for common values

The popup triggers from the column header button (the icon next to the column name).


Visual Anatomy

┌──────────────────────────────────────────────────┐
│ Filter: Email [✕] │ ← Title: "Filter: {ColumnName}" + close
├──────────────────────────────────────────────────┤
│ [📋 Copy Column ] │ ← Full-width button, Copy icon
├──────────────────────────────────────────────────┤
│ [← Pin Left] [🚫 Hide] [Pin Right →] │ ← 3 action buttons in a row
│ [↑↓ Sort A-Z] [↑↓ Sort Z-A] [Clear sort] │ ← 3 sort buttons in a row
├──────────────────────────────────────────────────┤
│ [Contains ▼] [Filter value ] │ ← Operator dropdown + value input
│ │
│ AND + Add condition │ ← multi-condition support
├──────────────────────────────────────────────────┤
│ Distinct values (top 2) │
│ ☐ superadmin@anshinhealth.c... 1 │ ← Checkbox + truncated value + count
│ ☐ system@anshinhealth.internal 1 │
│ │
│ │
├──────────────────────────────────────────────────┤
│ Clear column [Cancel] [Apply] │ ← Footer: left action + right CTAs
└──────────────────────────────────────────────────┘

Column Header Button

The trigger is a ghost button on every column header:

<Button
variant="ghost"
size="sm"
className="-ml-3 flex h-8 items-center gap-1 font-medium data-[state=open]:bg-accent"
>
<span className="truncate">{title}</span>

{/* Active filter indicator */}
{hasFilter && <Filter className="ml-1 h-3 w-3 text-primary" />}

{/* Sort indicator */}
{isSorted === 'desc' && <ArrowDown className="ml-1 h-4 w-4 text-primary" />}
{isSorted === 'asc' && <ArrowUp className="ml-1 h-4 w-4 text-primary" />}
{!isSorted && <ArrowUpDown className="ml-1 h-4 w-4 text-muted-foreground/50" />}

{/* Multi-sort number badge */}
{isMultiSorted && <span className="text-[10px] font-semibold text-primary">{sortIndex + 1}</span>}
</Button>
StateIconColor
UnsortedArrowUpDowntext-muted-foreground/50
AscendingArrowUptext-primary
DescendingArrowDowntext-primary
Active filterFilter h-3 w-3text-primary
Multi-sortIcon + numbertext-primary

Section 1 — Copy Column

A full-width button that copies all visible values from that column to the clipboard.

<Button variant="outline" className="w-full justify-start" onClick={handleCopyColumn}>
<Copy className="mr-2 h-4 w-4" />
Copy Column
</Button>

Copies values as newline-separated text. Shows a toast.success("Column copied to clipboard").


Section 2 — Pin Left / Hide / Pin Right

Three outline buttons in a horizontal row:

ButtonIconAction
Pin LeftPinLeft (or ArrowLeft)column.pin('left') — sticks column to left edge during horizontal scroll
HideEyeOff (or Eye with slash)column.toggleVisibility(false) — hides column (reveal via Manage Columns)
Pin RightPinRight (or ArrowRight)column.pin('right') — sticks column to right edge
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="flex-1" onClick={() => column.pin('left')}>
<PinLeft className="mr-1 h-3.5 w-3.5" />
Pin Left
</Button>
<Button variant="outline" size="sm" className="flex-1" onClick={() => column.toggleVisibility(false)}>
<EyeOff className="mr-1 h-3.5 w-3.5" />
Hide
</Button>
<Button variant="outline" size="sm" className="flex-1" onClick={() => column.pin('right')}>
Pin Right
<PinRight className="ml-1 h-3.5 w-3.5" />
</Button>
</div>

Pinned column behavior:

  • Pinned-left columns freeze to the left side of the table during horizontal scroll
  • Pinned-right columns freeze to the right side (e.g., "Actions" column is always right-pinned)
  • When already pinned, button changes to "Unpin": column.pin(false)
  • TanStack Table v8 supports column pinning via columnPinning state

TanStack Table setup for pinning:

const table = useReactTable({
state: {
columnPinning: { left: ['select', 'username'], right: ['actions'] },
// ...
},
onColumnPinningChange: setColumnPinning,
// ...
});

Section 3 — Sort Controls

Three buttons in a horizontal row for sorting:

ButtonIconAction
Sort A-ZArrowUpDown or SortAsccolumn.toggleSorting(false) — ascending
Sort Z-AArrowUpDown or SortDesccolumn.toggleSorting(true) — descending
Clear sort(text only, grayed when inactive)column.clearSorting()
<div className="flex gap-1.5">
<Button
variant="outline"
size="sm"
className={cn("flex-1", isSorted === 'asc' && "border-primary text-primary")}
onClick={() => column.toggleSorting(false)}
>
<ArrowUp className="mr-1 h-3.5 w-3.5" />
Sort A-Z
</Button>
<Button
variant="outline"
size="sm"
className={cn("flex-1", isSorted === 'desc' && "border-primary text-primary")}
onClick={() => column.toggleSorting(true)}
>
<ArrowDown className="mr-1 h-3.5 w-3.5" />
Sort Z-A
</Button>
<Button
variant="ghost"
size="sm"
disabled={!isSorted}
onClick={() => column.clearSorting()}
className="text-muted-foreground"
>
Clear sort
</Button>
</div>

Active sort button shows border-primary text-primary styling. "Clear sort" is grayed and disabled when no sort is active.

Multi-sort: Use column.toggleSorting(false, true) with multi=true parameter to add to existing sorts without clearing. Show the sort priority number next to the sort arrow.


Section 4 — Filter Conditions

The filter section has:

  1. A row with an operator dropdown + a value input
  2. A connector (AND) + "Add condition" link for multi-condition support

Filter Operators (by column type)

Column TypeAvailable Operators
TextContains, Equals, Starts with, Ends with, Is empty, Is not empty
NumberEquals, Greater than, Less than, Between, Is empty
DateIs, Is after, Is before, Is between, Is empty
BooleanIs true, Is false
Select/EnumIs, Is not, Is empty

Condition Row

<div className="flex gap-2">
{/* Operator dropdown */}
<Select value={operator} onValueChange={setOperator}>
<SelectTrigger className="h-8 w-[130px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="equals">Equals</SelectItem>
<SelectItem value="startsWith">Starts with</SelectItem>
<SelectItem value="endsWith">Ends with</SelectItem>
<SelectItem value="isEmpty">Is empty</SelectItem>
</SelectContent>
</Select>

{/* Value input (hidden when operator is "Is empty"/"Is not empty") */}
{!isEmptyOperator && (
<Input
value={filterValue}
onChange={(e) => setFilterValue(e.target.value)}
placeholder="Filter value"
className="h-8 flex-1 text-xs"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
/>
)}
</div>

Add Condition (multi-condition)

{/* AND connector + add button */}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium">AND</span>
<button
type="button"
className="flex items-center gap-1 text-primary hover:underline"
onClick={addCondition}
>
<Plus className="h-3 w-3" />
Add condition
</button>
</div>

Multiple conditions are combined with AND logic. Each additional condition row has an X remove button on the right.


Section 5 — Distinct Values

A quick-select list showing the most common values in the column (top N, typically 5–10):

Distinct values (top 2)
☐ superadmin@anshinhealth.c... 1
☐ system@anshinhealth.internal 1
{distinctValues.length > 0 && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
Distinct values (top {distinctValues.length})
</p>
<div className="space-y-1 max-h-[120px] overflow-y-auto">
{distinctValues.map(({ value, count }) => (
<label key={value} className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={selectedValues.includes(value)}
onCheckedChange={(checked) => toggleDistinctValue(value, checked)}
/>
<span className="flex-1 truncate text-xs text-foreground">{value}</span>
<span className="text-xs text-muted-foreground">{count}</span>
</label>
))}
</div>
</div>
)}

Clicking a distinct value checkbox adds it to the filter. Multiple selections use OR logic within distinct values, combined with AND for other conditions.


<div className="flex items-center justify-between border-t pt-3">
{/* Left: destructive action */}
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={handleClearColumn}
>
Clear column
</Button>

{/* Right: cancel + apply */}
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleApply}>
Apply
</Button>
</div>
</div>
ActionBehavior
Clear columnRemoves all filter conditions + resets column to unfiltered state
CancelCloses the popup without applying any changes
ApplyApplies all pending filter conditions to the table data

Apply is the primary button (filled/dark). Filter changes do NOT apply in real-time while the popup is open — they apply only on Apply click.


Base Implementation (Simple Version)

For tables that don't need the full-featured popup, the simpler DataTableColumnHeader DropdownMenu is acceptable as a baseline:

// Simple DropdownMenu version (used in referral-automation, basic tables)
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="-ml-3 h-8 ...">
{title}
{/* sort/filter indicators */}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{/* Sort Ascending / Sort Descending / Clear Sort */}
{/* Filter label + Input or Select */}
{/* Clear Filter (when active) */}
{/* Hide Column */}
</DropdownMenuContent>
</DropdownMenu>

The simple version includes:

  • Sort Ascending / Sort Descending / Clear Sort
  • Text input or Select dropdown for filtering
  • Clear Filter (shown only when filter is active)
  • Hide Column

The simple version does NOT include: Pin Left/Right, Copy Column, distinct values, multi-condition, Apply/Cancel footer.

Use the full-featured popup for: Primary list pages with many columns (Users, Bookings, Products). Use the simple DropdownMenu for: Secondary tables, sub-tables, simple inline grids.


Component: DataTableColumnHeader (Shared)

// packages/shared/components/data-table/data-table-column-header.tsx
export function DataTableColumnHeader<TData, TValue>({
column,
title,
enableSorting = true,
enableHiding = true,
enableFiltering = true,
filterType = 'text', // 'text' | 'number' | 'select'
}: DataTableColumnHeaderProps<TData, TValue>)

filterType options:

  • textInput field (default)
  • numberInput type="number" field
  • selectSelect dropdown using column.columnDef.meta.options

meta.options format:

// Simple string array (label = value):
meta: { options: ['active', 'inactive', 'pending'] }

// Object array (separate label/value):
meta: { options: [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
] }

When all capabilities disabled, renders plain text (no trigger):

if (!canSort && !canHide && !canFilter) {
return <div>{title}</div>;
}

Disabling Capabilities Per Column

// In column definition:
{
accessorKey: 'id',
enableSorting: false, // No sort options in popup
enableHiding: false, // No hide option + column excluded from Manage Columns
enableColumnFilter: false, // No filter section
}

// In DataTableColumnHeader:
<DataTableColumnHeader
column={column}
title="Actions"
enableSorting={false}
enableHiding={false}
enableFiltering={false}
/>
// When all false: renders plain text, no popup

Column Pinning State (TanStack Table)

Pinned columns are tracked in columnPinning state:

const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
left: ['select'], // Always pin checkbox left
right: ['actions'], // Always pin actions right
});

const table = useReactTable({
state: { columnPinning, ... },
onColumnPinningChange: setColumnPinning,
enableColumnPinning: true,
});

Pinned columns receive a sticky class and a z-index to stay fixed during scroll:

// TanStack Table provides column.getIsPinned() === 'left' | 'right' | false
// Use column.getPinnedIndex() for the stacking order
className={cn(
column.getIsPinned() === 'left' && 'sticky left-0 z-10 bg-card',
column.getIsPinned() === 'right' && 'sticky right-0 z-10 bg-card',
)}

Violation Checklist

  • Column header button uses variant="ghost" size="sm" -ml-3 h-8
  • Active sort shows text-primary colored arrow icon
  • Unsorted shows ArrowUpDown in text-muted-foreground/50
  • Active filter shows Filter h-3 w-3 text-primary icon
  • Pin Left, Hide, Pin Right are available in the column popup (full-featured version)
  • Sort A-Z / Sort Z-A / Clear sort buttons use outline style
  • Filter operator dropdown is present (not just a text input)
  • "Clear column" appears in popup footer on the left
  • Apply button is primary (filled), Cancel is outline
  • Filter changes only apply when Apply is clicked (not real-time)
  • onClick and onKeyDown have e.stopPropagation() on filter inputs
  • actions column is always enableHiding: false and pinned right
  • select checkbox column is always enableHiding: false and pinned left