UIPackage

Data Table

React data
Edit on GitHub

Full-feature table with sorting, filtering, column pinning, pagination, row selection, and an opinionated header/toolbar. Built on TanStack Table — pass `columns` + `data` and configure as needed.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://react.uipkge.dev/r/react/data-table.json

Or with the named registry: npx shadcn@latest add @uipkge-react/data-table

Examples

Props

Name Type / Values Default Required
columns ColumnDef<TData, TValue>[] required
data TData[] required
filterColumn string optional
filterPlaceholder string optional
filters FilterDefinition[] optional
filterMode 'inline' | 'modal' | 'popover' optional
enableSearch

Show the global search input. Default true.

boolean optional
enableColumnVisibility

Show the View column-visibility dropdown. Default false.

boolean optional
enablePagination

Show the pagination footer. Default true.

boolean optional
hideToolbar

Hide the entire toolbar (search + filters + view). Default false.

boolean optional
enableExport

Show Export-CSV button in toolbar. Default false.

boolean optional
infinite

hides the pagination footer. Combine with append-on-success on the consumer side.

boolean optional
enableResize

Allow drag-to-resize on column borders.

boolean optional
defaultColumnPinning

Initial column pinning. Each column id is pinned to the given side.

{ left?: string[]; right?: string[] } optional
enableReorder

Allow drag-to-reorder on column headers.

boolean optional
defaultGrouping

Initial column ids to group by. Pass an empty array to disable grouping.

string[] optional
virtual

datasets and `maxHeight` for a scroll container.

boolean optional
stickyHeader

Sticky header — keeps `<thead>` visible when scrolling. Pair with `maxHeight`.

boolean optional
density

Treated as the INITIAL density when `enableDensityToggle` is on; the user can override it at runtime from the toolbar.

'compact' | 'cozy' | 'comfortable' optional
enableDensityToggle

Show the density toggle in the toolbar. Default false.

boolean optional
borderless

- `'inner'` removes toolbar bottom-divider, pagination top-divider, and filter-sheet section bgs/borders. The outer container border is kept. - `'full'` additionally removes the outer container border + rounded corners so the table renders completely flat on the canvas.

'inner' | 'full' optional
toolbarPosition

bordered container; `'above'` floats outside it.

'inside' | 'above' optional
paginationPosition

Where the pagination footer renders. `'inside'` (default) or `'below'`.

'inside' | 'below' optional
maxHeight

Max height; enables vertical scroll inside the card.

string optional
onRowClick

Row click — when set, rows become clickable + cursor-pointer.

(row: TData) => void optional
totalRows

Server-side mode: total row count from API (enables manual pagination)

number optional
loading

Server-side mode: loading state indicator

boolean optional
onStateChange

Emitted when server-side state changes (pagination, sorting, filters)

(state: DataTableState) => void optional
onFetchMore

Infinite-scroll: last row entered viewport, time to load more

() => void optional
emptyState

Custom empty-state content (replaces "No results.").

React.ReactNode optional
renderExpanded

Expanded-row content. Receives the original row + the TanStack row.

(row: TData, tanstackRow: Row<TData>) => React.ReactNode optional
renderBulkActions

Bulk action bar content (shown above the table when rows are selected).

(rows: Row<TData>[], clear: () => void) => React.ReactNode optional
renderFooter

Footer (<tfoot>) content.

(rows: Row<TData>[]) => React.ReactNode optional
toolbarExtra

Extra toolbar controls (e.g. group-by selector).

React.ReactNode optional
customFilters

Consumer-supplied custom filter UI inside the filter surface.

React.ReactNode optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

DateRangeValue
interface DateRangeValue {
  from?: string
  to?: string
}
DataTableState
interface DataTableState {
  page: number
  pageSize: number
  sortBy: string
  sortOrder: 'asc' | 'desc'
  filters: Record<string, any>
  search: string
}
FilterOption
interface FilterOption {
  value: string
  label: string
}
FilterDefinition
interface FilterDefinition {
  column: string
  label: string
  type: 'text' | 'select' | 'multiselect' | 'date'
  options?: (string | FilterOption)[]
}
DraftDateValue
type DraftDateValue { from?: string; to?: string }
type DraftValue = string[] | string | DraftDateValue | undefined
type Draft = Record<string, DraftValue>

export interface DataTableFilterPopoverProps {
  table: Table<any>
  filters: FilterDefinition[]
  activeFilterCount: number
  isAnyFilterActive: boolean
  isServerSide: boolean
  getMultiSelectValue: (column: string) => string[]
  getDateRangeValue: (column: string) => { from?: string; to?: string }
  formatDateRange: (column: string) => string
  getCalendarModel: (column: string) => DateRange | undefined
  // Committed draft on Apply -- a `Record<columnId, value>` where each
  // value is the final shape TanStack's `setFilterValue` expects.
  onCommitDraft: (draft: Draft) => void
  onClearAll: () => void
  customFilters?: React.ReactNode
}

Dependencies

Files (9)

  • components/ui/data-table/DataTable.tsx 32.1 kB
    'use client'
    
    import * as React from 'react'
    import type {
      ColumnDef,
      ColumnFiltersState,
      ColumnPinningState,
      FilterFn,
      SortingState,
      VisibilityState,
      ExpandedState,
      GroupingState,
      Table as TanstackTable,
      Row,
    } from '@tanstack/react-table'
    import {
      flexRender,
      getCoreRowModel,
      getExpandedRowModel,
      getFilteredRowModel,
      getGroupedRowModel,
      getPaginationRowModel,
      getSortedRowModel,
      useReactTable,
    } from '@tanstack/react-table'
    import type { DateRange } from 'react-day-picker'
    // `valueUpdater` lives next to the low-level table primitive (which
    // data-table depends on transitively via registryDependencies). Keep
    // `@/lib/utils` reserved for the cn() helper shipped by the init
    // bootstrap -- importing valueUpdater from there would force every
    // consumer to hand-edit lib/utils.ts on install.
    import { valueUpdater } from '@/components/ui/table/utils'
    import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
    
    import { DataTableToolbar } from './DataTableToolbar'
    import { DataTableFilterSheet } from './DataTableFilterSheet'
    import { DataTablePagination } from './DataTablePagination'
    import { type FilterDefinition, type FilterOption, resolveOption } from './types'
    import { isoToDate, dateToIso } from './date-utils'
    
    export type { FilterDefinition, FilterOption }
    
    interface DateRangeValue {
      from?: string
      to?: string
    }
    
    export interface DataTableState {
      page: number
      pageSize: number
      sortBy: string
      sortOrder: 'asc' | 'desc'
      filters: Record<string, any>
      search: string
    }
    
    export interface DataTableProps<TData, TValue> {
      columns: ColumnDef<TData, TValue>[]
      data: TData[]
      filterColumn?: string
      filterPlaceholder?: string
      filters?: FilterDefinition[]
      filterMode?: 'inline' | 'modal' | 'popover'
      /** Show the global search input. Default true. */
      enableSearch?: boolean
      /** Show the View column-visibility dropdown. Default false. */
      enableColumnVisibility?: boolean
      /** Show the pagination footer. Default true. */
      enablePagination?: boolean
      /** Hide the entire toolbar (search + filters + view). Default false. */
      hideToolbar?: boolean
      /** Show Export-CSV button in toolbar. Default false. */
      enableExport?: boolean
      /** Infinite-scroll mode: calls `onFetchMore` when last row enters viewport,
       *  hides the pagination footer. Combine with append-on-success on the
       *  consumer side. */
      infinite?: boolean
      /** Allow drag-to-resize on column borders. */
      enableResize?: boolean
      /** Initial column pinning. Each column id is pinned to the given side. */
      defaultColumnPinning?: { left?: string[]; right?: string[] }
      /** Allow drag-to-reorder on column headers. */
      enableReorder?: boolean
      /** Initial column ids to group by. Pass an empty array to disable grouping. */
      defaultGrouping?: string[]
      /** Virtual-scrolling mode (CSS content-visibility based). Best with large
       *  datasets and `maxHeight` for a scroll container. */
      virtual?: boolean
      /** Sticky header — keeps `<thead>` visible when scrolling. Pair with `maxHeight`. */
      stickyHeader?: boolean
      /** Density of cell padding: 'compact' | 'cozy' | 'comfortable'. Default cozy.
       *  Treated as the INITIAL density when `enableDensityToggle` is on; the user
       *  can override it at runtime from the toolbar. */
      density?: 'compact' | 'cozy' | 'comfortable'
      /** Show the density toggle in the toolbar. Default false. */
      enableDensityToggle?: boolean
      /** Strip the DataTable's borders.
       *  - `'inner'` removes toolbar bottom-divider, pagination top-divider,
       *    and filter-sheet section bgs/borders. The outer container border
       *    is kept.
       *  - `'full'` additionally removes the outer container border + rounded
       *    corners so the table renders completely flat on the canvas. */
      borderless?: 'inner' | 'full'
      /** Where the toolbar renders. `'inside'` (default) lives inside the
       *  bordered container; `'above'` floats outside it. */
      toolbarPosition?: 'inside' | 'above'
      /** Where the pagination footer renders. `'inside'` (default) or `'below'`. */
      paginationPosition?: 'inside' | 'below'
      /** Max height; enables vertical scroll inside the card. */
      maxHeight?: string
      /** Row click — when set, rows become clickable + cursor-pointer. */
      onRowClick?: (row: TData) => void
      /** Server-side mode: total row count from API (enables manual pagination) */
      totalRows?: number
      /** Server-side mode: loading state indicator */
      loading?: boolean
      /** Emitted when server-side state changes (pagination, sorting, filters) */
      onStateChange?: (state: DataTableState) => void
      /** Infinite-scroll: last row entered viewport, time to load more */
      onFetchMore?: () => void
      /** Custom empty-state content (replaces "No results."). */
      emptyState?: React.ReactNode
      /** Expanded-row content. Receives the original row + the TanStack row. */
      renderExpanded?: (row: TData, tanstackRow: Row<TData>) => React.ReactNode
      /** Bulk action bar content (shown above the table when rows are selected). */
      renderBulkActions?: (rows: Row<TData>[], clear: () => void) => React.ReactNode
      /** Footer (<tfoot>) content. */
      renderFooter?: (rows: Row<TData>[]) => React.ReactNode
      /** Extra toolbar controls (e.g. group-by selector). */
      toolbarExtra?: React.ReactNode
      /** Consumer-supplied custom filter UI inside the filter surface. */
      customFilters?: React.ReactNode
    }
    
    export interface DataTableHandle<TData> {
      table: TanstackTable<TData>
      exportCsv: () => void
      exportJson: () => void
    }
    
    function DataTableInner<TData, TValue>(
      props: DataTableProps<TData, TValue>,
      forwardedRef: React.Ref<DataTableHandle<TData>>,
    ) {
      const {
        columns,
        data,
        filterColumn = '',
        filterPlaceholder = 'Filter...',
        filters = [],
        filterMode = 'modal',
        enableSearch = true,
        enableColumnVisibility = false,
        enablePagination = true,
        hideToolbar = false,
        enableExport = false,
        infinite = false,
        enableResize = false,
        defaultColumnPinning = { left: [], right: [] },
        enableReorder = false,
        defaultGrouping = [],
        virtual = false,
        stickyHeader = false,
        density = 'cozy',
        enableDensityToggle = false,
        borderless,
        toolbarPosition = 'inside',
        paginationPosition = 'inside',
        maxHeight = '',
        onRowClick,
        totalRows = -1,
        loading = false,
        onStateChange,
        onFetchMore,
        emptyState,
        renderExpanded,
        renderBulkActions,
        renderFooter,
        toolbarExtra,
        customFilters,
      } = props
    
      // User-mutable density -- initial value from prop, toggleable via toolbar
      // when `enableDensityToggle` is on. Sync the prop so parents can still
      // drive it externally.
      const [currentDensity, setCurrentDensity] = React.useState<'compact' | 'cozy' | 'comfortable'>(density)
      React.useEffect(() => {
        setCurrentDensity(density)
      }, [density])
    
      // `borderless` is an enum (`'inner'` vs `'full'`); children only need a
      // boolean "should I drop my borders?".
      const dropInnerBorders = !!borderless
    
      const densityClass =
        currentDensity === 'compact'
          ? '[&_td]:py-1.5 [&_td]:px-3 [&_td]:text-xs [&_th]:h-8 [&_th]:px-3 [&_th]:text-xs'
          : currentDensity === 'comfortable'
            ? '[&_td]:py-3 [&_th]:h-12'
            : // cozy -- tightens TableCell/TableHead defaults (py-3 / h-12)
              '[&_td]:py-2 [&_th]:h-10'
    
      const isServerSide = totalRows >= 0
      const [isFilterSheetOpen, setIsFilterSheetOpen] = React.useState(false)
    
      // ── Filter snapshot (restore on close without apply) ───────────────────
      const filterSnapshot = React.useRef<ColumnFiltersState | null>(null)
      const dateRangeSnapshot = React.useRef<Record<string, DateRangeValue> | null>(null)
      const filterApplied = React.useRef(false)
    
      // ── Timers ──────────────────────────────────────────────────────────────
      const searchDebounceTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
      const paginationEmitTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
    
      // Custom filter functions for multiselect and date range
      const multiSelectFilterFn: FilterFn<TData> = (row, columnId, filterValue: string[]) => {
        if (!filterValue || filterValue.length === 0) return true
        const cellValue = String(row.getValue(columnId)).toLowerCase()
        return filterValue.some((v: string) => v.toLowerCase() === cellValue)
      }
    
      const dateRangeFilterFn: FilterFn<TData> = (row, columnId, filterValue: DateRangeValue) => {
        if (!filterValue) return true
        const { from, to } = filterValue
        if (!from && !to) return true
        const cellValue = String(row.getValue(columnId))
        if (from && cellValue < from) return false
        if (to && cellValue > to) return false
        return true
      }
    
      // Augment columns with custom filter functions based on filter definitions
      const processedColumns = React.useMemo(() => {
        return columns.map((col) => {
          const colId = (col as any).accessorKey || (col as any).id
          const filter = filters.find((f) => f.column === colId)
          if (!filter) return col
          if (filter.type === 'multiselect') return { ...col, filterFn: multiSelectFilterFn }
          if (filter.type === 'date') return { ...col, filterFn: dateRangeFilterFn }
          return col
        })
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [columns, filters])
    
      const [sorting, setSorting] = React.useState<SortingState>([])
      const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
      const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
      const [rowSelection, setRowSelection] = React.useState({})
      const [expanded, setExpanded] = React.useState<ExpandedState>({})
      const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({
        left: defaultColumnPinning.left ?? [],
        right: defaultColumnPinning.right ?? [],
      })
      const [columnOrder, setColumnOrder] = React.useState<string[]>([])
      const [grouping, setGrouping] = React.useState<GroupingState>(defaultGrouping)
      const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: 10 })
    
      // Per-filter reactive range calendar model (ISO strings), keyed by column.
      const [dateRangeModels, setDateRangeModels] = React.useState<Record<string, DateRangeValue>>({})
    
      const table = useReactTable({
        data,
        columns: processedColumns,
        getCoreRowModel: getCoreRowModel(),
        getPaginationRowModel: isServerSide ? undefined : getPaginationRowModel(),
        getSortedRowModel: isServerSide ? undefined : getSortedRowModel(),
        getFilteredRowModel: isServerSide ? undefined : getFilteredRowModel(),
        getExpandedRowModel: getExpandedRowModel(),
        getGroupedRowModel: getGroupedRowModel(),
        enableColumnResizing: enableResize,
        columnResizeMode: 'onChange',
        manualPagination: isServerSide,
        manualSorting: isServerSide,
        manualFiltering: isServerSide,
        rowCount: isServerSide ? totalRows : undefined,
        onSortingChange: (updaterOrValue) => {
          valueUpdater(updaterOrValue, setSorting)
          if (isServerSide) queueMicrotask(emitStateUpdate)
        },
        onColumnFiltersChange: (updaterOrValue) => {
          // Server-side: never auto-emit on filter change — handled by Apply / search debounce
          // Client-side inline: filters apply locally via TanStack, no emit needed
          valueUpdater(updaterOrValue, setColumnFilters)
        },
        onColumnVisibilityChange: (updaterOrValue) => valueUpdater(updaterOrValue, setColumnVisibility),
        onRowSelectionChange: (updaterOrValue) => valueUpdater(updaterOrValue, setRowSelection),
        onExpandedChange: (updaterOrValue) => valueUpdater(updaterOrValue, setExpanded),
        onPaginationChange: (updaterOrValue) => {
          valueUpdater(updaterOrValue, setPagination)
          if (isServerSide) {
            if (paginationEmitTimer.current) clearTimeout(paginationEmitTimer.current)
            paginationEmitTimer.current = setTimeout(() => emitStateUpdate(), 0)
          }
        },
        onColumnPinningChange: (updaterOrValue) => valueUpdater(updaterOrValue, setColumnPinning),
        onColumnOrderChange: (updaterOrValue) => valueUpdater(updaterOrValue, setColumnOrder),
        onGroupingChange: (updaterOrValue) => valueUpdater(updaterOrValue, setGrouping),
        state: {
          sorting,
          columnFilters,
          columnVisibility,
          pagination,
          rowSelection,
          expanded,
          columnPinning,
          columnOrder,
          grouping,
        },
      })
    
      /** Build and emit current server-side state */
      function emitStateUpdate() {
        const s = sorting[0]
        const filterMap: Record<string, any> = {}
        for (const cf of columnFilters) {
          filterMap[cf.id] = cf.value
        }
        onStateChange?.({
          page: table.getState().pagination.pageIndex + 1,
          pageSize: table.getState().pagination.pageSize,
          sortBy: s?.id || '',
          sortOrder: s?.desc ? 'desc' : 'asc',
          filters: filterMap,
          search: (filterColumn && (table.getColumn(filterColumn)?.getFilterValue() as string)) || '',
        })
      }
    
      function onSearchInput(val: string) {
        if (!filterColumn || !table.getColumn(filterColumn)) return
        table.getColumn(filterColumn)?.setFilterValue(val || undefined)
        if (isServerSide) {
          if (searchDebounceTimer.current) clearTimeout(searchDebounceTimer.current)
          searchDebounceTimer.current = setTimeout(() => {
            table.setPageIndex(0)
            emitStateUpdate()
          }, 500)
        }
      }
    
      // ── Filter snapshot helpers ──────────────────────────────────────────────
      function snapshotFilters() {
        filterApplied.current = false
        filterSnapshot.current = JSON.parse(JSON.stringify(columnFilters))
        dateRangeSnapshot.current = JSON.parse(JSON.stringify(dateRangeModels))
      }
    
      function maybeRestoreFilters() {
        if (!filterApplied.current && filterSnapshot.current) {
          setColumnFilters(filterSnapshot.current)
          setDateRangeModels(dateRangeSnapshot.current ?? {})
        }
        filterSnapshot.current = null
        dateRangeSnapshot.current = null
      }
    
      function onFilterSheetOpen() {
        snapshotFilters()
        setIsFilterSheetOpen(true)
      }
    
      function onFilterSheetClose(open: boolean) {
        if (!open) maybeRestoreFilters()
        setIsFilterSheetOpen(open)
      }
    
      // ── Filter helpers ─────────────────────────────────────────────────────
      function getMultiSelectValue(column: string): string[] {
        return (table.getColumn(column)?.getFilterValue() as string[]) ?? []
      }
    
      function getDateRangeValue(column: string): DateRangeValue {
        return (table.getColumn(column)?.getFilterValue() as DateRangeValue) ?? {}
      }
    
      function getCalendarModel(column: string): DateRange | undefined {
        const dr = getDateRangeValue(column)
        if (!dr.from && !dr.to) return undefined
        return { from: isoToDate(dr.from), to: isoToDate(dr.to) }
      }
    
      function onCalendarUpdate(column: string, val: DateRange | undefined) {
        const from = val?.from ? dateToIso(val.from) : undefined
        const to = val?.to ? dateToIso(val.to) : undefined
        setDateRangeModels((m) => ({ ...m, [column]: { from, to } }))
        const hasValue = from || to
        table.getColumn(column)?.setFilterValue(hasValue ? { from, to } : undefined)
      }
    
      function formatDateRange(column: string): string {
        const dr = getDateRangeValue(column)
        if (dr.from && dr.to) return `${dr.from} - ${dr.to}`
        if (dr.from) return `From ${dr.from}`
        if (dr.to) return `Until ${dr.to}`
        return ''
      }
    
      function toggleMultiSelectValue(column: string, option: string) {
        const current = getMultiSelectValue(column)
        const next = current.includes(option) ? current.filter((v) => v !== option) : [...current, option]
        table.getColumn(column)?.setFilterValue(next.length > 0 ? next : undefined)
      }
    
      function clearAllFilters() {
        filterApplied.current = true
        if (searchDebounceTimer.current) clearTimeout(searchDebounceTimer.current)
        filters.forEach((f) => {
          table.getColumn(f.column)?.setFilterValue(undefined)
          if (f.type === 'date') {
            setDateRangeModels((m) => ({ ...m, [f.column]: {} }))
          }
        })
        if (filterColumn && table.getColumn(filterColumn)) {
          table.getColumn(filterColumn)?.setFilterValue(undefined)
        }
        if (isServerSide) {
          table.setPageIndex(0)
          queueMicrotask(() => emitStateUpdate())
        }
      }
    
      function isFilterActive(filter: FilterDefinition): boolean {
        const value = table.getColumn(filter.column)?.getFilterValue()
        if (value === undefined || value === '') return false
        if (filter.type === 'multiselect') return Array.isArray(value) && value.length > 0
        if (filter.type === 'date') {
          const d = value as DateRangeValue
          return !!(d.from || d.to)
        }
        return true
      }
    
      const hasSearchValue = !!(filterColumn && (table.getColumn(filterColumn)?.getFilterValue() as string))
      const isAnyFilterActive = hasSearchValue || filters.some(isFilterActive)
    
      function getFilterSelectedLabels(filter: FilterDefinition): string[] {
        const vals = getMultiSelectValue(filter.column)
        return vals.map((v) => {
          const opt = filter.options?.find((o) => resolveOption(o).value === v)
          return opt ? resolveOption(opt).label : v
        })
      }
    
      function clearFilter(filter: FilterDefinition) {
        table.getColumn(filter.column)?.setFilterValue(undefined)
      }
    
      const activeFilterCount = filters.filter(isFilterActive).length
    
      function clearDateFilter(filter: FilterDefinition) {
        clearFilter(filter)
        setDateRangeModels((m) => ({ ...m, [filter.column]: {} }))
      }
    
      function onApplyFilters() {
        filterApplied.current = true
        if (isServerSide) {
          if (table.getState().pagination.pageIndex === 0) {
            emitStateUpdate()
          } else {
            table.setPageIndex(0)
          }
        }
        setIsFilterSheetOpen(false)
      }
    
      /**
       * Popover filter mode: handle the staged-edit commit. Walk the draft,
       * write each column's value via `setFilterValue`, and sync the calendar
       * model for date filters so a subsequent reopen reflects the just-applied
       * range.
       */
      function onCommitDraft(draft: Record<string, any>) {
        for (const f of filters) {
          if (!(f.column in draft)) continue
          const val = draft[f.column]
          if (f.type === 'date') {
            const dr = (val ?? {}) as DateRangeValue
            setDateRangeModels((m) => ({ ...m, [f.column]: { from: dr.from, to: dr.to } }))
            table.getColumn(f.column)?.setFilterValue(val)
          } else {
            table.getColumn(f.column)?.setFilterValue(val)
          }
        }
        onApplyFilters()
      }
    
      // ── Column reorder (HTML5 drag/drop swaps dragged column with drop target) ──
      const [dragColId, setDragColId] = React.useState<string | null>(null)
      const [dragOverColId, setDragOverColId] = React.useState<string | null>(null)
    
      function onColDragStart(id: string, e: React.DragEvent) {
        setDragColId(id)
        document.body.style.cursor = 'grabbing'
        if (e.dataTransfer) {
          e.dataTransfer.effectAllowed = 'move'
          e.dataTransfer.setData('text/plain', id)
        }
      }
    
      function onColDragOver(targetId: string, e: React.DragEvent) {
        if (!enableReorder || !dragColId) return
        e.preventDefault()
        setDragOverColId(targetId)
        if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
      }
    
      function onColDragEnd() {
        document.body.style.cursor = ''
        setDragColId(null)
        setDragOverColId(null)
      }
    
      function onColDrop(targetId: string) {
        const src = dragColId
        onColDragEnd()
        if (!src || src === targetId) return
        const order = (columnOrder.length ? columnOrder : table.getAllLeafColumns().map((c) => c.id)).slice()
        const from = order.indexOf(src)
        const to = order.indexOf(targetId)
        if (from === -1 || to === -1) return
        order.splice(to, 0, ...order.splice(from, 1))
        setColumnOrder(order)
      }
    
      // ── Pinning — return style for sticky pinned cells (header or body) ──
      function pinStyle(col: any): React.CSSProperties | undefined {
        const side = col.getIsPinned()
        if (!side) return undefined
        if (side === 'left') return { position: 'sticky', left: `${col.getStart('left')}px`, zIndex: 2 }
        return { position: 'sticky', right: `${col.getAfter('right')}px`, zIndex: 2 }
      }
    
      // ── Infinite scroll — sentinel row triggers fetch-more when visible ──
      const sentinelEl = React.useRef<HTMLDivElement | null>(null)
      React.useEffect(() => {
        if (!infinite || !sentinelEl.current) return
        const io = new IntersectionObserver(
          (entries) => {
            if (entries[0]?.isIntersecting) onFetchMore?.()
          },
          { rootMargin: '100px' },
        )
        io.observe(sentinelEl.current)
        return () => io.disconnect()
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [infinite])
    
      // ── CSV export — currently filtered + visible data, skips select/actions/expander cols. ──
      function exportCsv() {
        const visibleCols = table
          .getVisibleLeafColumns()
          .filter((c) => c.id !== 'select' && c.id !== 'actions' && c.id !== 'expander')
        const headers = visibleCols.map((c) =>
          String(c.columnDef.header && typeof c.columnDef.header === 'string' ? c.columnDef.header : c.id),
        )
        const rows = table.getFilteredRowModel().rows.map((row) =>
          visibleCols.map((c) => {
            const v = row.getValue(c.id)
            const s = v == null ? '' : String(v)
            return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
          }),
        )
        const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n')
        downloadBlob(csv, 'text/csv;charset=utf-8;', `export-${Date.now()}.csv`)
      }
    
      // ── JSON export — same scope as CSV, but emits underlying row.original objects ──
      function exportJson() {
        const visibleColIds = table
          .getVisibleLeafColumns()
          .filter((c) => c.id !== 'select' && c.id !== 'actions' && c.id !== 'expander')
          .map((c) => c.id)
        const rows = table.getFilteredRowModel().rows.map((row) => {
          const original = row.original as Record<string, unknown>
          const out: Record<string, unknown> = {}
          for (const id of visibleColIds) {
            out[id] = id in original ? original[id] : row.getValue(id)
          }
          return out
        })
        const json = JSON.stringify(rows, null, 2)
        downloadBlob(json, 'application/json;charset=utf-8;', `export-${Date.now()}.json`)
      }
    
      function downloadBlob(content: string, type: string, filename: string) {
        const blob = new Blob([content], { type })
        const url = URL.createObjectURL(blob)
        const a = document.createElement('a')
        a.href = url
        a.download = filename
        document.body.appendChild(a)
        a.click()
        document.body.removeChild(a)
        URL.revokeObjectURL(url)
      }
    
      React.useImperativeHandle(forwardedRef, () => ({ table, exportCsv, exportJson }))
    
      const selectedRows = table.getSelectedRowModel().rows
    
      const toolbarProps = {
        table: table as TanstackTable<any>,
        filterColumn,
        filterPlaceholder,
        filters,
        filterMode,
        enableSearch,
        enableColumnVisibility,
        enableExport,
        enableDensityToggle,
        density: currentDensity,
        activeFilterCount,
        isAnyFilterActive,
        isServerSide,
        getMultiSelectValue,
        getDateRangeValue,
        getFilterSelectedLabels,
        formatDateRange,
        getCalendarModel,
        onSearch: onSearchInput,
        onOpenFilterSheet: onFilterSheetOpen,
        onApplyFilters,
        onClearAllFilters: clearAllFilters,
        onToggleMultiselect: toggleMultiSelectValue,
        onClearFilter: clearFilter,
        onClearDateFilter: clearDateFilter,
        onCalendarUpdate,
        onTextFilterUpdate: (col: string, val: string | undefined) => table.getColumn(col)?.setFilterValue(val),
        onCommitFilters: onCommitDraft,
        onExportCsv: exportCsv,
        onExportJson: exportJson,
        onDensityChange: setCurrentDensity,
        toolbarExtra,
        customFilters,
      }
    
      return (
        <div data-uipkge="" data-slot="data-table" className="w-full">
          {/* Toolbar (above-mode: floats outside the bordered card). */}
          {!hideToolbar && toolbarPosition === 'above' && <DataTableToolbar {...toolbarProps} borderless />}
    
          <div
            className={[
              'bg-card text-card-foreground overflow-hidden',
              borderless === 'full' ? '' : 'rounded-md border',
            ].join(' ')}
          >
            {/* Toolbar (inside-mode, default). */}
            {!hideToolbar && toolbarPosition === 'inside' && (
              <DataTableToolbar {...toolbarProps} borderless={dropInnerBorders} />
            )}
    
            {/* Filter Sheet (modal mode) */}
            <DataTableFilterSheet
              open={isFilterSheetOpen}
              onOpenChange={onFilterSheetClose}
              table={table as TanstackTable<any>}
              filters={filters}
              activeFilterCount={activeFilterCount}
              isAnyFilterActive={isAnyFilterActive}
              isServerSide={isServerSide}
              borderless={dropInnerBorders}
              getMultiSelectValue={getMultiSelectValue}
              getDateRangeValue={getDateRangeValue}
              formatDateRange={formatDateRange}
              getCalendarModel={getCalendarModel}
              onApply={onApplyFilters}
              onClearAll={clearAllFilters}
              onToggleMultiselect={toggleMultiSelectValue}
              onClearFilter={clearFilter}
              onClearDateFilter={clearDateFilter}
              onCalendarUpdate={onCalendarUpdate}
              onTextFilterUpdate={(col, val) => table.getColumn(col)?.setFilterValue(val)}
              customFilters={customFilters}
            />
    
            {/* Bulk action bar (shown above table when rows selected) */}
            {renderBulkActions && selectedRows.length > 0 && (
              <div className="bg-muted/40 flex items-center gap-3 border-b px-4 py-2">
                <span className="text-sm font-medium"> {selectedRows.length} selected </span>
                {renderBulkActions(selectedRows, () => table.toggleAllRowsSelected(false))}
                <button
                  type="button"
                  className="text-muted-foreground hover:text-foreground ml-auto text-xs transition"
                  onClick={() => table.toggleAllRowsSelected(false)}
                >
                  Clear selection
                </button>
              </div>
            )}
    
            {/* Table. The Table primitive wraps the <table> in its own overflow-auto
                div; neutralize that inner overflow and let the outer scroll
                container own the scroll region. */}
            <div
              className={[densityClass, 'relative [&_[data-slot=table-container]]:overflow-visible', maxHeight ? 'overflow-auto' : ''].join(' ')}
              style={maxHeight ? { maxHeight } : undefined}
            >
              {/* Loading overlay */}
              {loading && (
                <div className="bg-card/60 absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[1px]">
                  <div className="border-primary size-5 animate-spin rounded-full border-2 border-t-transparent" />
                </div>
              )}
              <Table>
                <TableHeader className={stickyHeader ? 'bg-muted/95 sticky top-0 z-10 backdrop-blur-sm' : ''}>
                  {table.getHeaderGroups().map((headerGroup) => (
                    <TableRow key={headerGroup.id}>
                      {headerGroup.headers.map((header) => {
                        const reorderable =
                          enableReorder &&
                          header.column.id !== 'select' &&
                          header.column.id !== 'actions' &&
                          header.column.id !== 'expander'
                        return (
                          <TableHead
                            key={header.id}
                            className={[
                              'relative transition-colors duration-150',
                              reorderable ? 'cursor-grab active:cursor-grabbing' : '',
                              dragColId === header.column.id ? 'opacity-50' : '',
                              dragOverColId === header.column.id && dragColId !== header.column.id
                                ? 'border-foreground/60 border-l-2'
                                : '',
                            ].join(' ')}
                            style={{
                              ...(enableResize ? { width: `${header.getSize()}px` } : {}),
                              ...(pinStyle(header.column) ?? {}),
                            }}
                            draggable={reorderable}
                            onDragStart={reorderable ? (e) => onColDragStart(header.column.id, e) : undefined}
                            onDragOver={reorderable ? (e) => onColDragOver(header.column.id, e) : undefined}
                            onDragEnd={reorderable ? () => onColDragEnd() : undefined}
                            onDrop={reorderable ? () => onColDrop(header.column.id) : undefined}
                          >
                            {!header.isPlaceholder &&
                              flexRender(header.column.columnDef.header, header.getContext())}
                            {enableResize && header.column.getCanResize() && (
                              <div
                                className={[
                                  'hover:bg-foreground/30 absolute top-0 right-0 h-full w-1 cursor-col-resize touch-none transition-colors select-none',
                                  header.column.getIsResizing() ? 'bg-foreground/60' : '',
                                ].join(' ')}
                                onMouseDown={header.getResizeHandler()}
                                onTouchStart={header.getResizeHandler()}
                              />
                            )}
                          </TableHead>
                        )
                      })}
                    </TableRow>
                  ))}
                </TableHeader>
                <TableBody>
                  {table.getRowModel().rows?.length ? (
                    table.getRowModel().rows.map((row) => (
                      <React.Fragment key={row.id}>
                        <TableRow
                          data-state={row.getIsSelected() ? 'selected' : undefined}
                          className={onRowClick ? 'hover:bg-muted/40 cursor-pointer' : ''}
                          style={
                            virtual ? { contentVisibility: 'auto', containIntrinsicSize: 'auto 48px' } : undefined
                          }
                          onClick={() => onRowClick?.(row.original)}
                        >
                          {row.getVisibleCells().map((cell) => (
                            <TableCell key={cell.id} className="bg-card" style={pinStyle(cell.column)}>
                              {flexRender(cell.column.columnDef.cell, cell.getContext())}
                            </TableCell>
                          ))}
                        </TableRow>
                        {row.getIsExpanded() && renderExpanded && (
                          <TableRow>
                            <TableCell colSpan={row.getVisibleCells().length} className="bg-muted/30 px-6 py-4">
                              {renderExpanded(row.original, row)}
                            </TableCell>
                          </TableRow>
                        )}
                      </React.Fragment>
                    ))
                  ) : (
                    <TableRow>
                      <TableCell colSpan={columns.length} className="h-24 text-center">
                        {emptyState ?? <div className="text-muted-foreground text-sm">No results.</div>}
                      </TableCell>
                    </TableRow>
                  )}
                </TableBody>
                {renderFooter && (
                  <tfoot className="bg-muted/20 sticky bottom-0 border-t">{renderFooter(table.getRowModel().rows)}</tfoot>
                )}
              </Table>
              {/* Infinite scroll sentinel */}
              {infinite && <div ref={sentinelEl} className="h-1" />}
              {infinite && loading && (
                <div className="border-border text-muted-foreground border-t px-4 py-3 text-center text-sm">
                  Loading more…
                </div>
              )}
            </div>
    
            {/* Pagination (inside-mode, default) */}
            {enablePagination && !infinite && paginationPosition === 'inside' && (
              <DataTablePagination
                table={table as TanstackTable<any>}
                totalRows={totalRows}
                isServerSide={isServerSide}
                borderless={dropInnerBorders}
              />
            )}
          </div>
    
          {/* Pagination (below-mode: floats outside the bordered card). */}
          {enablePagination && !infinite && paginationPosition === 'below' && (
            <DataTablePagination
              table={table as TanstackTable<any>}
              totalRows={totalRows}
              isServerSide={isServerSide}
              borderless
            />
          )}
        </div>
      )
    }
    
    // forwardRef + generics: cast the wrapped component back to a generic-aware
    // signature so consumers keep `<DataTable<Row>>` inference.
    export const DataTable = React.forwardRef(DataTableInner) as <TData, TValue = unknown>(
      props: DataTableProps<TData, TValue> & { ref?: React.Ref<DataTableHandle<TData>> },
    ) => React.ReactElement
  • components/ui/data-table/DataTableColumnHeader.tsx 11.5 kB
    'use client'
    
    /**
     * Sortable / hideable header cell for TanStack DataTable columns. Use it from
     * a column definition like:
     *   header: ({ column }) => <DataTableColumnHeader column={column} label="Email" />
     *
     * Click on the label cycles sort asc → desc → none.
     *
     * Optional per-column filter:
     *   header: ({ column }) => (
     *     <DataTableColumnHeader
     *       column={column}
     *       label="Status"
     *       filter={{ column: 'status', label: 'Status', type: 'multiselect', options: [...] }}
     *     />
     *   )
     *
     * When `filter` is provided, a funnel icon renders next to the sort button.
     * Click opens a popover with the right filter UI for the type (text / select
     * / multiselect / date). The funnel shows a primary-coloured dot when the
     * column has an active filter.
     */
    import * as React from 'react'
    import type { Column } from '@tanstack/react-table'
    import { ArrowUp, ArrowUpDown, Check, Filter, FilterX } from 'lucide-react'
    import type { DateRange } from 'react-day-picker'
    import { cn } from '@/lib/utils'
    import { Button } from '@/components/ui/button'
    import { Input } from '@/components/ui/input'
    import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
    import { RangeCalendar } from '@/components/ui/range-calendar'
    import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
    import { isoToDate, dateToIso } from './date-utils'
    
    interface FilterOption {
      value: string
      label: string
    }
    interface FilterDefinition {
      column: string
      label: string
      type: 'text' | 'select' | 'multiselect' | 'date'
      options?: (string | FilterOption)[]
    }
    
    export interface DataTableColumnHeaderProps<TData, TValue> {
      column: Column<TData, TValue>
      label: string
      align?: 'left' | 'right' | 'center'
      filter?: FilterDefinition
      className?: string
    }
    
    function resolveOption(opt: string | FilterOption): FilterOption {
      return typeof opt === 'string' ? { value: opt, label: opt } : opt
    }
    
    export function DataTableColumnHeader<TData, TValue>({
      column,
      label,
      align = 'left',
      filter,
      className,
    }: DataTableColumnHeaderProps<TData, TValue>) {
      const [open, setOpen] = React.useState(false)
    
      function next() {
        const current = column.getIsSorted()
        if (!current) column.toggleSorting(false)
        else if (current === 'asc') column.toggleSorting(true)
        else column.clearSorting()
      }
    
      const filterValue = column.getFilterValue()
    
      const isFilterActive = (() => {
        const v = filterValue
        if (v === undefined || v === null || v === '') return false
        if (Array.isArray(v)) return v.length > 0
        if (typeof v === 'object') return Object.keys(v).length > 0
        return true
      })()
    
      // Text-input bound separately so we can apply on blur / Enter rather than
      // thrashing the column filter on every keystroke.
      const [textDraft, setTextDraft] = React.useState('')
      React.useEffect(() => {
        if (open && filter?.type === 'text') {
          setTextDraft((filterValue as string) ?? '')
        }
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [open])
    
      function applyText() {
        column.setFilterValue(textDraft || undefined)
      }
    
      function clearText() {
        setTextDraft('')
        column.setFilterValue(undefined)
      }
    
      function toggleMultiselect(value: string) {
        const current = (filterValue as string[]) ?? []
        const nextVal = current.includes(value) ? current.filter((v) => v !== value) : [...current, value]
        column.setFilterValue(nextVal.length > 0 ? nextVal : undefined)
      }
    
      function selectOne(value: string) {
        column.setFilterValue(value || undefined)
        setOpen(false)
      }
    
      function clearFilter() {
        column.setFilterValue(undefined)
        setTextDraft('')
      }
    
      // Date-range bridging. The column stores ISO strings; the calendar wants
      // native Date instances. Reads/writes both shapes.
      const dateModel: DateRange | undefined = (() => {
        const v = filterValue as { from?: string; to?: string } | undefined
        if (!v?.from && !v?.to) return undefined
        return {
          from: v.from ? isoToDate(v.from) : undefined,
          to: v.to ? isoToDate(v.to) : undefined,
        }
      })()
    
      function onDateSelect(range: DateRange | undefined) {
        const isoFrom = range?.from ? dateToIso(range.from) : undefined
        const isoTo = range?.to ? dateToIso(range.to) : undefined
        if (!isoFrom && !isoTo) {
          column.setFilterValue(undefined)
        } else {
          column.setFilterValue({ from: isoFrom, to: isoTo })
        }
      }
    
      function selectedLabels(): string[] {
        if (!filter?.options) return []
        const selected = (filterValue as string[]) ?? []
        return filter.options
          .map(resolveOption)
          .filter((o) => selected.includes(o.value))
          .map((o) => o.label)
      }
    
      return (
        <div
          className={cn(
            'flex items-center gap-0.5',
            align === 'right' && 'justify-end',
            align === 'center' && 'justify-center',
            className,
          )}
        >
          {column.getCanSort() ? (
            <button
              type="button"
              className="group text-muted-foreground hover:text-foreground hover:bg-muted/60 -mx-2 inline-flex items-center gap-1.5 rounded px-2 py-1 text-sm font-medium transition-colors duration-150"
              aria-label={`Sort by ${label}`}
              onClick={next}
            >
              <span>{label}</span>
              {column.getIsSorted() ? (
                <ArrowUp
                  className={cn(
                    'text-foreground size-3.5 transition-transform duration-200 ease-in-out',
                    column.getIsSorted() === 'desc' ? 'rotate-180' : 'rotate-0',
                  )}
                />
              ) : (
                <ArrowUpDown className="size-3.5 opacity-40 transition-opacity duration-150 group-hover:opacity-70" />
              )}
            </button>
          ) : (
            <span className="text-muted-foreground text-sm font-medium">{label}</span>
          )}
    
          {/* Optional per-column header filter */}
          {filter && (
            <Popover open={open} onOpenChange={setOpen}>
              <PopoverTrigger asChild>
                <button
                  type="button"
                  className={cn(
                    'text-muted-foreground relative inline-flex size-6 items-center justify-center rounded transition-colors',
                    'hover:text-foreground hover:bg-muted/60',
                    isFilterActive && 'text-foreground',
                  )}
                  aria-label={`Filter ${label}`}
                >
                  <Filter className="size-3.5" />
                  {isFilterActive && (
                    <span aria-hidden className="bg-primary absolute -top-0.5 -right-0.5 size-1.5 rounded-full" />
                  )}
                </button>
              </PopoverTrigger>
              <PopoverContent className="w-72 p-0" align="start">
                <div className="border-border flex items-center justify-between border-b px-3 py-2 text-xs font-medium">
                  <span>Filter · {filter.label}</span>
                  {isFilterActive && (
                    <button
                      type="button"
                      className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 transition-colors"
                      onClick={clearFilter}
                    >
                      <FilterX className="size-3" />
                      Clear
                    </button>
                  )}
                </div>
    
                {/* TEXT */}
                {filter.type === 'text' && (
                  <div className="space-y-2 p-3">
                    <Input
                      value={textDraft}
                      placeholder={`Search ${filter.label.toLowerCase()}`}
                      className="h-9"
                      onChange={(e) => setTextDraft(e.target.value)}
                      onKeyDown={(e) => {
                        if (e.key === 'Enter') {
                          applyText()
                          setOpen(false)
                        }
                      }}
                      onBlur={applyText}
                    />
                    <div className="flex gap-2">
                      <Button
                        size="sm"
                        className="h-8 flex-1"
                        onClick={() => {
                          applyText()
                          setOpen(false)
                        }}
                      >
                        Apply
                      </Button>
                      <Button size="sm" variant="outline" className="h-8" onClick={clearText}>
                        Clear
                      </Button>
                    </div>
                  </div>
                )}
    
                {/* SELECT / MULTISELECT */}
                {(filter.type === 'select' || filter.type === 'multiselect') && (
                  <Command className="max-h-[280px]">
                    <CommandInput placeholder={`Search ${filter.label.toLowerCase()}`} className="h-9" />
                    <CommandList>
                      <CommandEmpty>No matches.</CommandEmpty>
                      <CommandGroup>
                        {(filter.options ?? []).map((opt) => {
                          const o = resolveOption(opt)
                          return (
                            <CommandItem
                              key={o.value}
                              value={o.value}
                              onSelect={() =>
                                filter.type === 'multiselect' ? toggleMultiselect(o.value) : selectOne(o.value)
                              }
                            >
                              {filter.type === 'multiselect' ? (
                                <div
                                  className={cn(
                                    'border-primary/50 mr-2 flex size-4 items-center justify-center rounded-sm border transition-colors',
                                    ((filterValue as string[]) ?? []).includes(o.value)
                                      ? 'bg-primary text-primary-foreground'
                                      : 'opacity-50',
                                  )}
                                >
                                  <Check className="size-3" />
                                </div>
                              ) : (
                                <Check className={cn('mr-2 size-4', filterValue === o.value ? 'opacity-100' : 'opacity-0')} />
                              )}
                              <span>{o.label}</span>
                            </CommandItem>
                          )
                        })}
                      </CommandGroup>
                    </CommandList>
                  </Command>
                )}
    
                {/* DATE RANGE */}
                {filter.type === 'date' && (
                  <div className="p-2">
                    <RangeCalendar selected={dateModel} onSelect={onDateSelect} />
                    <div className="flex gap-2 px-1 pt-2">
                      <Button size="sm" className="h-8 flex-1" onClick={() => setOpen(false)}>
                        Apply
                      </Button>
                      <Button size="sm" variant="outline" className="h-8" onClick={clearFilter}>
                        Clear
                      </Button>
                    </div>
                  </div>
                )}
    
                {/* Active selection summary */}
                {isFilterActive && (filter.type === 'multiselect' || filter.type === 'select') && (
                  <div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
                    {filter.type === 'multiselect' ? (
                      <span>
                        {selectedLabels().length} selected:{' '}
                        <span className="text-foreground">{selectedLabels().join(', ')}</span>
                      </span>
                    ) : (
                      <span>
                        <span className="text-foreground">{filterValue as string}</span>
                      </span>
                    )}
                  </div>
                )}
              </PopoverContent>
            </Popover>
          )}
        </div>
      )
    }
  • components/ui/data-table/DataTableFilterPopover.tsx 16.8 kB
    'use client'
    
    /**
     * Popover variant of DataTableFilterSheet. Same filter UI (text /
     * multiselect / date) packed into a Popover instead of a side Sheet.
     * Slot in via `filterMode="popover"` on <DataTable>.
     *
     * Trigger button is rendered inline by the toolbar; this component owns
     * the popover surface and content.
     *
     * ── Draft semantics ──────────────────────────────────────────────────
     * Unlike the Sheet (which mutates live TanStack column filters and rolls
     * back on cancel via a parent snapshot), this popover stages every edit
     * inside a local `draft` map. Real `columnFilters` are never touched
     * until the user clicks Apply -- at which point we emit `onCommitDraft`
     * with the full draft and the parent walks the entries calling
     * `setFilterValue` per column. Closing the popover (Escape / click out)
     * simply discards the draft.
     */
    import * as React from 'react'
    import type { Table } from '@tanstack/react-table'
    import type { DateRange } from 'react-day-picker'
    import { Check, SlidersHorizontal, X } from 'lucide-react'
    import { Input } from '@/components/ui/input'
    import { Badge } from '@/components/ui/badge'
    import { Button } from '@/components/ui/button'
    import { Label } from '@/components/ui/label'
    import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
    import { RangeCalendar } from '@/components/ui/range-calendar'
    import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
    import { type FilterDefinition, resolveOption } from './types'
    import { isoToDate, dateToIso } from './date-utils'
    
    type DraftDateValue = { from?: string; to?: string }
    type DraftValue = string[] | string | DraftDateValue | undefined
    type Draft = Record<string, DraftValue>
    
    export interface DataTableFilterPopoverProps {
      table: Table<any>
      filters: FilterDefinition[]
      activeFilterCount: number
      isAnyFilterActive: boolean
      isServerSide: boolean
      getMultiSelectValue: (column: string) => string[]
      getDateRangeValue: (column: string) => { from?: string; to?: string }
      formatDateRange: (column: string) => string
      getCalendarModel: (column: string) => DateRange | undefined
      // Committed draft on Apply -- a `Record<columnId, value>` where each
      // value is the final shape TanStack's `setFilterValue` expects.
      onCommitDraft: (draft: Draft) => void
      onClearAll: () => void
      customFilters?: React.ReactNode
    }
    
    export function DataTableFilterPopover({
      table,
      filters,
      activeFilterCount,
      isAnyFilterActive: _isAnyFilterActive,
      isServerSide,
      getMultiSelectValue,
      getDateRangeValue,
      formatDateRange: _formatDateRange,
      getCalendarModel: _getCalendarModel,
      onCommitDraft,
      onClearAll: _onClearAll,
      customFilters,
    }: DataTableFilterPopoverProps) {
      const [open, setOpen] = React.useState(false)
      const [draft, setDraft] = React.useState<Draft>({})
      const filterScrollRef = React.useRef<HTMLDivElement | null>(null)
    
      function seedDraft(): Draft {
        const next: Draft = {}
        for (const f of filters) {
          if (f.type === 'multiselect' || f.type === 'select') {
            next[f.column] = [...getMultiSelectValue(f.column)]
          } else if (f.type === 'date') {
            const dr = getDateRangeValue(f.column)
            next[f.column] = { from: dr.from, to: dr.to }
          } else if (f.type === 'text') {
            const v = table.getColumn(f.column)?.getFilterValue() as string | undefined
            next[f.column] = v ?? ''
          }
        }
        return next
      }
    
      function getDraftMulti(column: string): string[] {
        const v = draft[column]
        return Array.isArray(v) ? v : []
      }
    
      function getDraftText(column: string): string {
        const v = draft[column]
        return typeof v === 'string' ? v : ''
      }
    
      function getDraftDate(column: string): DraftDateValue {
        const v = draft[column]
        if (v && typeof v === 'object' && !Array.isArray(v)) return v as DraftDateValue
        return {}
      }
    
      function toggleDraftMulti(column: string, option: string) {
        const current = getDraftMulti(column)
        const next = current.includes(option) ? current.filter((v) => v !== option) : [...current, option]
        setDraft((d) => ({ ...d, [column]: next }))
      }
    
      function setDraftText(column: string, value: string) {
        setDraft((d) => ({ ...d, [column]: value }))
      }
    
      function clearDraftSection(filter: FilterDefinition) {
        if (filter.type === 'multiselect' || filter.type === 'select') {
          setDraft((d) => ({ ...d, [filter.column]: [] }))
        } else if (filter.type === 'date') {
          setDraft((d) => ({ ...d, [filter.column]: {} }))
        } else if (filter.type === 'text') {
          setDraft((d) => ({ ...d, [filter.column]: '' }))
        }
      }
    
      function resetDraft() {
        const next: Draft = {}
        for (const f of filters) {
          if (f.type === 'multiselect' || f.type === 'select') next[f.column] = []
          else if (f.type === 'date') next[f.column] = {}
          else if (f.type === 'text') next[f.column] = ''
        }
        setDraft(next)
      }
    
      // ── Date helpers (local; popover is fully self-contained for draft) ──
      function getDraftCalendarModel(column: string): DateRange | undefined {
        const dr = getDraftDate(column)
        if (!dr.from && !dr.to) return undefined
        return {
          from: isoToDate(dr.from),
          to: isoToDate(dr.to),
        }
      }
    
      function onDraftCalendarUpdate(column: string, val: DateRange | undefined) {
        const from = val?.from ? dateToIso(val.from) : undefined
        const to = val?.to ? dateToIso(val.to) : undefined
        setDraft((d) => ({ ...d, [column]: { from, to } }))
      }
    
      function formatDraftDateRange(column: string): string {
        const dr = getDraftDate(column)
        if (dr.from && dr.to) return `${dr.from} - ${dr.to}`
        if (dr.from) return `From ${dr.from}`
        if (dr.to) return `Until ${dr.to}`
        return ''
      }
    
      function isDraftSectionActive(filter: FilterDefinition): boolean {
        if (filter.type === 'multiselect' || filter.type === 'select') {
          return getDraftMulti(filter.column).length > 0
        }
        if (filter.type === 'date') {
          const dr = getDraftDate(filter.column)
          return !!(dr.from || dr.to)
        }
        if (filter.type === 'text') {
          return !!getDraftText(filter.column)
        }
        return false
      }
    
      // `Reset` is disabled when the draft has nothing to clear.
      const isDraftDirty = filters.some(isDraftSectionActive)
    
      function handleOpenChange(isOpen: boolean) {
        if (isOpen) {
          setDraft(seedDraft())
          setOpen(true)
          setTimeout(() => filterScrollRef.current?.scrollTo({ top: 0 }), 50)
        } else {
          // Close-without-apply: nothing to commit. Real column filters were never
          // mutated by the popover; the draft is local and discarded.
          setDraft({})
          setOpen(false)
        }
      }
    
      function applyAndClose() {
        const snapshot: Draft = {}
        for (const f of filters) {
          const v = draft[f.column]
          if (f.type === 'multiselect' || f.type === 'select') {
            const arr = Array.isArray(v) ? v : []
            snapshot[f.column] = arr.length > 0 ? arr : undefined
          } else if (f.type === 'date') {
            const dr = v && typeof v === 'object' && !Array.isArray(v) ? (v as DraftDateValue) : {}
            snapshot[f.column] = dr.from || dr.to ? { from: dr.from, to: dr.to } : undefined
          } else if (f.type === 'text') {
            const s = typeof v === 'string' ? v : ''
            snapshot[f.column] = s || undefined
          }
        }
        onCommitDraft(snapshot)
        setOpen(false)
      }
    
      const filteredRowCount = table.getFilteredRowModel().rows.length
    
      return (
        <Popover open={open} onOpenChange={handleOpenChange}>
          <PopoverTrigger asChild>
            <Button variant="outline" size="sm" className="h-9 gap-2">
              <SlidersHorizontal className="size-3.5" />
              Filters
              {activeFilterCount > 0 && (
                <Badge variant="secondary" className="ml-1 h-5 min-w-5 rounded-full px-1.5 text-xs font-semibold">
                  {activeFilterCount}
                </Badge>
              )}
            </Button>
          </PopoverTrigger>
          <PopoverContent align="start" className="flex max-h-[min(560px,80vh)] w-[380px] flex-col overflow-hidden p-0">
            {/* Header */}
            <div className="border-b px-4 pt-3.5 pb-3">
              <div className="flex items-center gap-2.5">
                <div className="bg-muted flex size-7 items-center justify-center rounded-md">
                  <SlidersHorizontal className="text-muted-foreground size-3.5" />
                </div>
                <div className="flex-1">
                  <p className="text-sm leading-none font-semibold">Filters</p>
                  <p className="text-muted-foreground mt-1 text-xs">
                    {activeFilterCount > 0 ? (
                      <>
                        {activeFilterCount} active
                        {!isServerSide && (
                          <>
                            {' '}&middot; {filteredRowCount} result{filteredRowCount !== 1 ? 's' : ''}
                          </>
                        )}
                      </>
                    ) : (
                      'Narrow down results'
                    )}
                  </p>
                </div>
              </div>
            </div>
    
            {/* Scrollable filter sections */}
            <div ref={filterScrollRef} className="flex-1 overflow-y-auto">
              <div className="space-y-2 p-3">
                {filters.map((filter) => {
                  if (filter.type === 'text') {
                    return (
                      <div key={filter.column} className="bg-muted/40 rounded-lg p-2.5">
                        <div className="mb-2 flex items-center justify-between">
                          <Label className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
                            {filter.label}
                          </Label>
                          {getDraftText(filter.column) && (
                            <button
                              type="button"
                              className="text-muted-foreground hover:text-foreground text-xs transition-colors"
                              onClick={() => clearDraftSection(filter)}
                            >
                              Clear
                            </button>
                          )}
                        </div>
                        <Input
                          placeholder={`Filter by ${filter.label.toLowerCase()}...`}
                          value={getDraftText(filter.column)}
                          className="h-8 text-sm"
                          onChange={(e) => setDraftText(filter.column, e.target.value ?? '')}
                        />
                      </div>
                    )
                  }
    
                  if (filter.type === 'multiselect' || filter.type === 'select') {
                    const multi = getDraftMulti(filter.column)
                    return (
                      <div
                        key={filter.column}
                        className={[
                          'rounded-lg p-2.5 transition-colors',
                          multi.length > 0 ? 'bg-primary/[0.04] ring-primary/20 ring-1' : 'bg-muted/40',
                        ].join(' ')}
                      >
                        <div className="mb-2 flex items-center justify-between">
                          <div className="flex items-center gap-2">
                            <Label className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
                              {filter.label}
                            </Label>
                            {multi.length > 0 && (
                              <Badge
                                variant="secondary"
                                className="bg-primary/15 text-primary h-4 rounded-full px-1.5 text-xs font-semibold"
                              >
                                {multi.length}
                              </Badge>
                            )}
                          </div>
                          {multi.length > 0 && (
                            <button
                              type="button"
                              className="text-muted-foreground hover:text-foreground text-xs transition-colors"
                              onClick={() => clearDraftSection(filter)}
                            >
                              Clear
                            </button>
                          )}
                        </div>
                        <Command className="[&_[data-slot=command-input-wrapper]]:border-input overflow-visible bg-transparent [&_[data-slot=command-input-wrapper]]:h-8 [&_[data-slot=command-input-wrapper]]:rounded-md [&_[data-slot=command-input-wrapper]]:border [&_[data-slot=command-input-wrapper]]:px-2.5">
                          <CommandInput className="h-7 text-sm" placeholder={`Search ${filter.label.toLowerCase()}...`} />
                          <CommandList className="mt-1 max-h-[132px]">
                            <CommandEmpty>No results.</CommandEmpty>
                            <CommandGroup className="p-0">
                              {filter.options?.map((rawOpt) => {
                                const opt = resolveOption(rawOpt)
                                const OptIcon = opt.icon
                                return (
                                  <CommandItem
                                    key={opt.value}
                                    value={opt.label}
                                    className="rounded-md px-2 py-1.5 text-sm"
                                    onSelect={() => toggleDraftMulti(filter.column, opt.value)}
                                  >
                                    <div
                                      className={[
                                        'flex size-4 shrink-0 items-center justify-center rounded-sm border transition-colors',
                                        multi.includes(opt.value)
                                          ? 'border-primary bg-primary text-primary-foreground'
                                          : 'border-muted-foreground/40 [&_svg]:invisible',
                                      ].join(' ')}
                                    >
                                      <Check className="size-3" />
                                    </div>
                                    {OptIcon && <OptIcon className="text-muted-foreground size-4" />}
                                    <span>{opt.label}</span>
                                  </CommandItem>
                                )
                              })}
                            </CommandGroup>
                          </CommandList>
                        </Command>
                      </div>
                    )
                  }
    
                  if (filter.type === 'date') {
                    const dr = getDraftDate(filter.column)
                    const hasDate = !!(dr.from || dr.to)
                    return (
                      <div
                        key={filter.column}
                        className={[
                          'rounded-lg p-2.5 transition-colors',
                          hasDate ? 'bg-primary/[0.04] ring-primary/20 ring-1' : 'bg-muted/40',
                        ].join(' ')}
                      >
                        <div className="mb-2 flex items-center justify-between">
                          <div className="flex items-center gap-2">
                            <Label className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
                              {filter.label}
                            </Label>
                            {hasDate && (
                              <Badge
                                variant="secondary"
                                className="bg-primary/15 text-primary h-auto rounded-full px-1.5 py-0 text-xs font-medium"
                              >
                                {formatDraftDateRange(filter.column)}
                              </Badge>
                            )}
                          </div>
                          {hasDate && (
                            <button
                              type="button"
                              className="text-muted-foreground hover:text-foreground text-xs transition-colors"
                              onClick={() => clearDraftSection(filter)}
                            >
                              Clear
                            </button>
                          )}
                        </div>
                        <div className="flex justify-center overflow-hidden rounded-md border">
                          <RangeCalendar
                            selected={getDraftCalendarModel(filter.column)}
                            numberOfMonths={1}
                            className="p-2"
                            onSelect={(range) => onDraftCalendarUpdate(filter.column, range)}
                          />
                        </div>
                      </div>
                    )
                  }
    
                  return null
                })}
    
                {/* Consumer-supplied custom filter UI */}
                {customFilters}
              </div>
            </div>
    
            {/* Footer */}
            <div className="flex gap-2 border-t px-3 py-2.5">
              <Button variant="outline" size="sm" className="h-8 flex-1" disabled={!isDraftDirty} onClick={resetDraft}>
                <X className="size-3.5" />
                Reset
              </Button>
              <Button size="sm" className="h-8 flex-1" onClick={applyAndClose}>
                Apply
              </Button>
            </div>
          </PopoverContent>
        </Popover>
      )
    }
  • components/ui/data-table/DataTableFilterSheet.tsx 12.2 kB
    'use client'
    
    import * as React from 'react'
    import type { Table } from '@tanstack/react-table'
    import type { DateRange } from 'react-day-picker'
    import { Check, SlidersHorizontal, X } from 'lucide-react'
    import { Input } from '@/components/ui/input'
    import { Badge } from '@/components/ui/badge'
    import { Button } from '@/components/ui/button'
    import { Label } from '@/components/ui/label'
    import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
    import { RangeCalendar } from '@/components/ui/range-calendar'
    import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
    import { type FilterDefinition, resolveOption } from './types'
    
    export interface DataTableFilterSheetProps {
      open: boolean
      onOpenChange: (open: boolean) => void
      table: Table<any>
      filters: FilterDefinition[]
      activeFilterCount: number
      isAnyFilterActive: boolean
      isServerSide: boolean
      /** Strip section bgs / rings / dividers / SheetContent side border. */
      borderless?: boolean
      getMultiSelectValue: (column: string) => string[]
      getDateRangeValue: (column: string) => { from?: string; to?: string }
      formatDateRange: (column: string) => string
      getCalendarModel: (column: string) => DateRange | undefined
      onApply: () => void
      onClearAll: () => void
      onToggleMultiselect: (column: string, value: string) => void
      onClearFilter: (filter: FilterDefinition) => void
      onClearDateFilter: (filter: FilterDefinition) => void
      onCalendarUpdate: (column: string, value: DateRange | undefined) => void
      onTextFilterUpdate: (column: string, value: string | undefined) => void
      customFilters?: React.ReactNode
    }
    
    export function DataTableFilterSheet({
      open,
      onOpenChange,
      table,
      filters,
      activeFilterCount,
      isAnyFilterActive,
      isServerSide,
      borderless = false,
      getMultiSelectValue,
      getDateRangeValue,
      formatDateRange,
      getCalendarModel,
      onApply,
      onClearAll,
      onToggleMultiselect,
      onClearFilter,
      onClearDateFilter,
      onCalendarUpdate,
      onTextFilterUpdate,
      customFilters,
    }: DataTableFilterSheetProps) {
      const filterScrollRef = React.useRef<HTMLDivElement | null>(null)
    
      React.useEffect(() => {
        if (open) {
          setTimeout(() => {
            filterScrollRef.current?.scrollTo({ top: 0 })
          }, 50)
        }
      }, [open])
    
      const filteredRowCount = table.getFilteredRowModel().rows.length
    
      return (
        <Sheet open={open} onOpenChange={onOpenChange}>
          <SheetContent className={['flex flex-col gap-0 p-0 sm:max-w-[400px]', borderless ? 'border-0' : ''].join(' ')}>
            {/* Header */}
            <div className={borderless ? 'px-5 pt-5 pb-2' : 'border-b px-5 pt-5 pb-4'}>
              <div className="flex items-center gap-3">
                <div className="bg-muted flex size-9 items-center justify-center rounded-lg">
                  <SlidersHorizontal className="text-muted-foreground size-4" />
                </div>
                <div className="flex-1">
                  <SheetHeader className="space-y-0.5 p-0">
                    <SheetTitle className="text-sm font-semibold">Filters</SheetTitle>
                    <SheetDescription className="text-xs">
                      {activeFilterCount > 0 ? (
                        <>
                          {activeFilterCount} active filter{activeFilterCount > 1 ? 's' : ''}
                          {!isServerSide && (
                            <>
                              {' '}&middot; {filteredRowCount} result{filteredRowCount !== 1 ? 's' : ''}
                            </>
                          )}
                        </>
                      ) : (
                        'Narrow down results'
                      )}
                    </SheetDescription>
                  </SheetHeader>
                </div>
              </div>
            </div>
    
            {/* Scrollable filter sections */}
            <div ref={filterScrollRef} className="flex-1 overflow-y-auto">
              <div className="space-y-2 p-4">
                {filters.map((filter) => {
                  const textValue = (table.getColumn(filter.column)?.getFilterValue() as string) ?? ''
                  const multi = getMultiSelectValue(filter.column)
                  const dr = getDateRangeValue(filter.column)
                  const hasDate = !!(dr.from || dr.to)
    
                  if (filter.type === 'text') {
                    return (
                      <div key={filter.column} className={borderless ? 'py-2' : 'bg-muted/40 rounded-lg p-3'}>
                        <div className="mb-2 flex items-center justify-between">
                          <Label className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
                            {filter.label}
                          </Label>
                          {textValue && (
                            <button
                              type="button"
                              className="text-muted-foreground hover:text-foreground text-xs transition-colors"
                              onClick={() => onTextFilterUpdate(filter.column, undefined)}
                            >
                              Clear
                            </button>
                          )}
                        </div>
                        <Input
                          placeholder={`Filter by ${filter.label.toLowerCase()}...`}
                          value={textValue}
                          className="h-8 text-sm"
                          onChange={(e) => onTextFilterUpdate(filter.column, e.target.value || undefined)}
                        />
                      </div>
                    )
                  }
    
                  if (filter.type === 'multiselect' || filter.type === 'select') {
                    return (
                      <div
                        key={filter.column}
                        className={
                          borderless
                            ? 'py-2 transition-colors'
                            : [
                                'rounded-lg p-3 transition-colors',
                                multi.length > 0 ? 'bg-primary/[0.04] ring-primary/20 ring-1' : 'bg-muted/40',
                              ].join(' ')
                        }
                      >
                        <div className="mb-2 flex items-center justify-between">
                          <div className="flex items-center gap-2">
                            <Label className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
                              {filter.label}
                            </Label>
                            {multi.length > 0 && (
                              <Badge
                                variant="secondary"
                                className="bg-primary/15 text-primary h-4 rounded-full px-1.5 text-xs font-semibold"
                              >
                                {multi.length}
                              </Badge>
                            )}
                          </div>
                          {multi.length > 0 && (
                            <button
                              type="button"
                              className="text-muted-foreground hover:text-foreground text-xs transition-colors"
                              onClick={() => onClearFilter(filter)}
                            >
                              Clear
                            </button>
                          )}
                        </div>
                        <Command className="[&_[data-slot=command-input-wrapper]]:border-input overflow-visible bg-transparent [&_[data-slot=command-input-wrapper]]:h-8 [&_[data-slot=command-input-wrapper]]:rounded-md [&_[data-slot=command-input-wrapper]]:border [&_[data-slot=command-input-wrapper]]:px-2.5">
                          <CommandInput className="h-7 text-sm" placeholder={`Search ${filter.label.toLowerCase()}...`} />
                          <CommandList className="mt-1 max-h-[132px]">
                            <CommandEmpty>No results.</CommandEmpty>
                            <CommandGroup className="p-0">
                              {filter.options?.map((rawOpt) => {
                                const opt = resolveOption(rawOpt)
                                const OptIcon = opt.icon
                                return (
                                  <CommandItem
                                    key={opt.value}
                                    value={opt.label}
                                    className="rounded-md px-2 py-1.5 text-sm"
                                    onSelect={() => onToggleMultiselect(filter.column, opt.value)}
                                  >
                                    <div
                                      className={[
                                        'flex size-4 shrink-0 items-center justify-center rounded-sm border transition-colors',
                                        multi.includes(opt.value)
                                          ? 'border-primary bg-primary text-primary-foreground'
                                          : 'border-muted-foreground/40 [&_svg]:invisible',
                                      ].join(' ')}
                                    >
                                      <Check className="size-3" />
                                    </div>
                                    {OptIcon && <OptIcon className="text-muted-foreground size-4" />}
                                    <span>{opt.label}</span>
                                  </CommandItem>
                                )
                              })}
                            </CommandGroup>
                          </CommandList>
                        </Command>
                      </div>
                    )
                  }
    
                  if (filter.type === 'date') {
                    return (
                      <div
                        key={filter.column}
                        className={
                          borderless
                            ? 'py-2 transition-colors'
                            : [
                                'rounded-lg p-3 transition-colors',
                                hasDate ? 'bg-primary/[0.04] ring-primary/20 ring-1' : 'bg-muted/40',
                              ].join(' ')
                        }
                      >
                        <div className="mb-2 flex items-center justify-between">
                          <div className="flex items-center gap-2">
                            <Label className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
                              {filter.label}
                            </Label>
                            {hasDate && (
                              <Badge
                                variant="secondary"
                                className="bg-primary/15 text-primary h-auto rounded-full px-1.5 py-0 text-xs font-medium"
                              >
                                {formatDateRange(filter.column)}
                              </Badge>
                            )}
                          </div>
                          {hasDate && (
                            <button
                              type="button"
                              className="text-muted-foreground hover:text-foreground text-xs transition-colors"
                              onClick={() => onClearDateFilter(filter)}
                            >
                              Clear
                            </button>
                          )}
                        </div>
                        <div className={['flex justify-center overflow-hidden', borderless ? '' : 'rounded-md border'].join(' ')}>
                          <RangeCalendar
                            selected={getCalendarModel(filter.column)}
                            numberOfMonths={1}
                            className="p-2"
                            onSelect={(range) => onCalendarUpdate(filter.column, range)}
                          />
                        </div>
                      </div>
                    )
                  }
    
                  return null
                })}
    
                {/* Consumer-supplied custom filter UI */}
                {customFilters}
              </div>
            </div>
    
            {/* Footer */}
            <div className={['flex gap-2 px-4 py-3', borderless ? '' : 'border-t'].join(' ')}>
              <Button variant="outline" size="sm" className="flex-1" disabled={!isAnyFilterActive} onClick={onClearAll}>
                <X className="size-3.5" />
                Reset All
              </Button>
              <Button size="sm" className="flex-1" onClick={onApply}>
                {isServerSide ? (
                  'Apply Filters'
                ) : (
                  <>
                    Show {filteredRowCount} result{filteredRowCount !== 1 ? 's' : ''}
                  </>
                )}
              </Button>
            </div>
          </SheetContent>
        </Sheet>
      )
    }
  • components/ui/data-table/DataTablePagination.tsx 3.5 kB
    'use client'
    
    import type { Table } from '@tanstack/react-table'
    import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
    import { Button } from '@/components/ui/button'
    import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
    
    export interface DataTablePaginationProps {
      table: Table<any>
      totalRows: number
      isServerSide: boolean
      borderless?: boolean
    }
    
    export function DataTablePagination({ table, totalRows, isServerSide, borderless = false }: DataTablePaginationProps) {
      return (
        <div className={['flex items-center justify-between py-3', borderless ? '' : 'border-t px-4'].join(' ')}>
          <div className="text-muted-foreground flex-1 text-sm">
            {table.getFilteredSelectedRowModel().rows.length} of{' '}
            {isServerSide ? totalRows : table.getFilteredRowModel().rows.length} row(s) selected.
          </div>
          <div className="flex items-center gap-6 lg:gap-8">
            <div className="flex items-center gap-2">
              <p className="text-sm font-medium whitespace-nowrap">Rows per page</p>
              <Select
                value={`${table.getState().pagination.pageSize}`}
                onValueChange={(value) => {
                  table.setPageSize(Number(value))
                  table.setPageIndex(0)
                }}
              >
                <SelectTrigger className="h-8 w-[70px]">
                  <SelectValue placeholder={`${table.getState().pagination.pageSize}`} />
                </SelectTrigger>
                <SelectContent side="top">
                  {[10, 20, 30, 40, 50].map((pageSize) => (
                    <SelectItem key={pageSize} value={`${pageSize}`}>
                      {pageSize}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
            <div className="flex items-center justify-center text-sm font-medium whitespace-nowrap">
              Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
            </div>
            <div className="flex items-center gap-1">
              <Button
                variant="outline"
                size="icon"
                className="size-8"
                disabled={!table.getCanPreviousPage()}
                onClick={() => table.setPageIndex(0)}
              >
                <ChevronsLeft className="size-4" aria-hidden="true" />
                <span className="sr-only">First page</span>
              </Button>
              <Button
                variant="outline"
                size="icon"
                className="size-8"
                disabled={!table.getCanPreviousPage()}
                onClick={() => table.previousPage()}
              >
                <ChevronLeft className="size-4" aria-hidden="true" />
                <span className="sr-only">Previous page</span>
              </Button>
              <Button
                variant="outline"
                size="icon"
                className="size-8"
                disabled={!table.getCanNextPage()}
                onClick={() => table.nextPage()}
              >
                <ChevronRight className="size-4" aria-hidden="true" />
                <span className="sr-only">Next page</span>
              </Button>
              <Button
                variant="outline"
                size="icon"
                className="size-8"
                disabled={!table.getCanNextPage()}
                onClick={() => table.setPageIndex(table.getPageCount() - 1)}
              >
                <ChevronsRight className="size-4" aria-hidden="true" />
                <span className="sr-only">Last page</span>
              </Button>
            </div>
          </div>
        </div>
      )
    }
  • components/ui/data-table/DataTableToolbar.tsx 16 kB
    'use client'
    
    import * as React from 'react'
    import type { Table } from '@tanstack/react-table'
    import type { DateRange } from 'react-day-picker'
    import {
      CalendarIcon,
      Check,
      ChevronDown,
      Download,
      ListFilter,
      Plus,
      Rows3,
      SlidersHorizontal,
      X,
    } from 'lucide-react'
    import { Input } from '@/components/ui/input'
    import { Badge } from '@/components/ui/badge'
    import { Button } from '@/components/ui/button'
    import { Separator } from '@/components/ui/separator'
    import {
      DropdownMenu,
      DropdownMenuCheckboxItem,
      DropdownMenuContent,
      DropdownMenuItem,
      DropdownMenuRadioGroup,
      DropdownMenuRadioItem,
      DropdownMenuTrigger,
    } from '@/components/ui/dropdown-menu'
    import {
      Command,
      CommandEmpty,
      CommandGroup,
      CommandInput,
      CommandItem,
      CommandList,
      CommandSeparator,
    } from '@/components/ui/command'
    import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
    import { RangeCalendar } from '@/components/ui/range-calendar'
    import { DataTableFilterPopover } from './DataTableFilterPopover'
    import { type FilterDefinition, resolveOption } from './types'
    
    export interface DataTableToolbarProps {
      table: Table<any>
      filterColumn: string
      filterPlaceholder: string
      filters: FilterDefinition[]
      filterMode: 'inline' | 'modal' | 'popover'
      enableSearch?: boolean
      enableColumnVisibility?: boolean
      enableExport?: boolean
      enableDensityToggle?: boolean
      density?: 'compact' | 'cozy' | 'comfortable'
      borderless?: boolean
      activeFilterCount: number
      isAnyFilterActive: boolean
      isServerSide: boolean
      getMultiSelectValue: (column: string) => string[]
      getDateRangeValue: (column: string) => { from?: string; to?: string }
      getFilterSelectedLabels: (filter: FilterDefinition) => string[]
      formatDateRange: (column: string) => string
      getCalendarModel: (column: string) => DateRange | undefined
      onSearch: (value: string) => void
      onOpenFilterSheet: () => void
      onApplyFilters: () => void
      onClearAllFilters: () => void
      onToggleMultiselect: (column: string, value: string) => void
      onClearFilter: (filter: FilterDefinition) => void
      onClearDateFilter: (filter: FilterDefinition) => void
      onCalendarUpdate: (column: string, value: DateRange | undefined) => void
      onTextFilterUpdate: (column: string, value: string | undefined) => void
      onCommitFilters: (draft: Record<string, any>) => void
      onExportCsv: () => void
      onExportJson: () => void
      onDensityChange: (value: 'compact' | 'cozy' | 'comfortable') => void
      toolbarExtra?: React.ReactNode
      customFilters?: React.ReactNode
    }
    
    export function DataTableToolbar({
      table,
      filterColumn,
      filterPlaceholder,
      filters,
      filterMode,
      enableSearch = true,
      enableColumnVisibility = true,
      enableExport = false,
      enableDensityToggle = false,
      density = 'cozy',
      borderless = false,
      activeFilterCount,
      isAnyFilterActive,
      isServerSide,
      getMultiSelectValue,
      getDateRangeValue,
      getFilterSelectedLabels,
      formatDateRange,
      getCalendarModel,
      onSearch,
      onOpenFilterSheet,
      onApplyFilters: _onApplyFilters,
      onClearAllFilters,
      onToggleMultiselect,
      onClearFilter,
      onClearDateFilter,
      onCalendarUpdate,
      onTextFilterUpdate,
      onCommitFilters,
      onExportCsv,
      onExportJson,
      onDensityChange,
      toolbarExtra,
      customFilters,
    }: DataTableToolbarProps) {
      return (
        <div className={['flex flex-col gap-2 py-3', borderless ? '' : 'border-b px-4'].join(' ')}>
          <div className="flex items-center gap-2">
            {/* Search only renders when filterColumn maps to an actual column. */}
            {enableSearch && filterColumn && table.getColumn(filterColumn) && (
              <Input
                className="h-9 max-w-xs"
                placeholder={filterPlaceholder}
                value={(table.getColumn(filterColumn)?.getFilterValue() as string) ?? ''}
                onChange={(e) => onSearch(e.target.value)}
              />
            )}
    
            {/* ── INLINE filter mode ── */}
            {filterMode === 'inline' &&
              filters.map((filter) => {
                if (filter.type === 'multiselect' || filter.type === 'select') {
                  const multi = getMultiSelectValue(filter.column)
                  return (
                    <Popover key={filter.column}>
                      <PopoverTrigger asChild>
                        <Button variant="outline" size="sm" className="h-9 border-dashed">
                          <Plus className="size-4" aria-hidden="true" />
                          {filter.label}
                          {multi.length > 0 && (
                            <>
                              <Separator orientation="vertical" className="mx-1 h-4" />
                              <div className="flex gap-1">
                                {multi.length > 2 ? (
                                  <Badge variant="secondary" className="rounded-sm px-1 font-normal">
                                    {multi.length} selected
                                  </Badge>
                                ) : (
                                  getFilterSelectedLabels(filter).map((label) => (
                                    <Badge key={label} variant="secondary" className="rounded-sm px-1 font-normal">
                                      {label}
                                    </Badge>
                                  ))
                                )}
                              </div>
                            </>
                          )}
                        </Button>
                      </PopoverTrigger>
                      <PopoverContent className="w-52 p-0" align="start">
                        <Command>
                          <CommandInput placeholder={`Search ${filter.label.toLowerCase()}...`} />
                          <CommandList>
                            <CommandEmpty>No results.</CommandEmpty>
                            <CommandGroup>
                              {filter.options?.map((rawOpt) => {
                                const opt = resolveOption(rawOpt)
                                const OptIcon = opt.icon
                                return (
                                  <CommandItem
                                    key={opt.value}
                                    value={opt.label}
                                    onSelect={() => onToggleMultiselect(filter.column, opt.value)}
                                  >
                                    <div
                                      className={[
                                        'border-primary flex size-4 shrink-0 items-center justify-center rounded-sm border',
                                        multi.includes(opt.value)
                                          ? 'bg-primary text-primary-foreground'
                                          : 'opacity-50 [&_svg]:invisible',
                                      ].join(' ')}
                                    >
                                      <Check className="size-3" />
                                    </div>
                                    {OptIcon && <OptIcon className="text-muted-foreground size-4" />}
                                    <span>{opt.label}</span>
                                  </CommandItem>
                                )
                              })}
                            </CommandGroup>
                            {multi.length > 0 && (
                              <>
                                <CommandSeparator />
                                <CommandGroup>
                                  <CommandItem
                                    value="__clear__"
                                    className="justify-center text-center"
                                    onSelect={() => onClearFilter(filter)}
                                  >
                                    Clear filter
                                  </CommandItem>
                                </CommandGroup>
                              </>
                            )}
                          </CommandList>
                        </Command>
                      </PopoverContent>
                    </Popover>
                  )
                }
    
                if (filter.type === 'date') {
                  const dr = getDateRangeValue(filter.column)
                  const hasDate = !!(dr.from || dr.to)
                  return (
                    <Popover key={filter.column}>
                      <PopoverTrigger asChild>
                        <Button variant="outline" size="sm" className="h-9 border-dashed">
                          <CalendarIcon className="size-4" aria-hidden="true" />
                          {filter.label}
                          {hasDate && (
                            <>
                              <Separator orientation="vertical" className="mx-1 h-4" />
                              <Badge variant="secondary" className="rounded-sm px-1 font-normal">
                                {formatDateRange(filter.column)}
                              </Badge>
                            </>
                          )}
                        </Button>
                      </PopoverTrigger>
                      <PopoverContent className="w-auto p-0" align="start">
                        <RangeCalendar
                          selected={getCalendarModel(filter.column)}
                          numberOfMonths={2}
                          onSelect={(range) => onCalendarUpdate(filter.column, range)}
                        />
                        {hasDate && (
                          <div className="border-t p-2">
                            <Button
                              variant="ghost"
                              size="sm"
                              className="h-7 w-full text-xs"
                              onClick={() => onClearDateFilter(filter)}
                            >
                              Clear dates
                            </Button>
                          </div>
                        )}
                      </PopoverContent>
                    </Popover>
                  )
                }
    
                if (filter.type === 'text') {
                  const textValue = table.getColumn(filter.column)?.getFilterValue() as string | undefined
                  return (
                    <Popover key={filter.column}>
                      <PopoverTrigger asChild>
                        <Button variant="outline" size="sm" className="h-9 border-dashed">
                          <Plus className="size-4" aria-hidden="true" />
                          {filter.label}
                          {textValue && (
                            <>
                              <Separator orientation="vertical" className="mx-1 h-4" />
                              <Badge variant="secondary" className="rounded-sm px-1 font-normal">
                                {textValue}
                              </Badge>
                            </>
                          )}
                        </Button>
                      </PopoverTrigger>
                      <PopoverContent className="w-60 p-3" align="start">
                        <div className="space-y-2">
                          <p className="text-sm font-medium">{filter.label}</p>
                          <Input
                            placeholder={`Filter by ${filter.label.toLowerCase()}...`}
                            value={(table.getColumn(filter.column)?.getFilterValue() as string) ?? ''}
                            className="h-8 text-sm"
                            onChange={(e) => onTextFilterUpdate(filter.column, e.target.value || undefined)}
                          />
                        </div>
                      </PopoverContent>
                    </Popover>
                  )
                }
    
                return null
              })}
    
            {/* ── MODAL filter mode (Sheet from the right) ── */}
            {filterMode === 'modal' && (
              <Button
                variant="outline"
                size="sm"
                className={[
                  'h-9',
                  activeFilterCount > 0 ? 'border-primary/40 bg-primary/5 text-primary hover:bg-primary/10' : '',
                ].join(' ')}
                onClick={onOpenFilterSheet}
              >
                <SlidersHorizontal className="size-4" aria-hidden="true" />
                Filters
                {activeFilterCount > 0 && (
                  <Badge className="bg-primary text-primary-foreground ml-0.5 size-5 rounded-full p-0 text-xs font-semibold">
                    {activeFilterCount}
                  </Badge>
                )}
              </Button>
            )}
    
            {/* ── POPOVER filter mode ── */}
            {filterMode === 'popover' && filters.length > 0 && (
              <DataTableFilterPopover
                table={table}
                filters={filters}
                activeFilterCount={activeFilterCount}
                isAnyFilterActive={isAnyFilterActive}
                isServerSide={isServerSide}
                getMultiSelectValue={getMultiSelectValue}
                getDateRangeValue={getDateRangeValue}
                formatDateRange={formatDateRange}
                getCalendarModel={getCalendarModel}
                onCommitDraft={(d) => onCommitFilters(d)}
                onClearAll={onClearAllFilters}
                customFilters={customFilters}
              />
            )}
    
            {/* Inline custom filters (when filterMode === 'inline') */}
            {filterMode === 'inline' && customFilters}
    
            {/* Toolbar extras (e.g. group-by selector, density toggle) */}
            {toolbarExtra}
    
            {/* Reset button */}
            {isAnyFilterActive && (
              <Button variant="ghost" size="sm" className="h-9" onClick={onClearAllFilters}>
                Reset
                <X className="size-4" aria-hidden="true" />
              </Button>
            )}
    
            {/* Export (CSV / JSON) */}
            {enableExport && (
              <div className="ml-auto">
                <DropdownMenu>
                  <DropdownMenuTrigger asChild>
                    <Button variant="outline" size="sm" className="h-9">
                      <Download className="size-3.5" />
                      Export
                      <ChevronDown className="size-3.5 opacity-60" />
                    </Button>
                  </DropdownMenuTrigger>
                  <DropdownMenuContent align="end">
                    <DropdownMenuItem onSelect={onExportCsv}>Export as CSV</DropdownMenuItem>
                    <DropdownMenuItem onSelect={onExportJson}>Export as JSON</DropdownMenuItem>
                  </DropdownMenuContent>
                </DropdownMenu>
              </div>
            )}
    
            {/* Density toggle */}
            {enableDensityToggle && (
              <div className={enableExport ? '' : 'ml-auto'}>
                <DropdownMenu>
                  <DropdownMenuTrigger asChild>
                    <Button variant="outline" size="sm" className="h-9" aria-label={`Row density: ${density}`}>
                      <Rows3 className="size-4" aria-hidden="true" />
                      <span className="capitalize">{density}</span>
                    </Button>
                  </DropdownMenuTrigger>
                  <DropdownMenuContent align="end">
                    <DropdownMenuRadioGroup
                      value={density}
                      onValueChange={(v) => onDensityChange(v as 'compact' | 'cozy' | 'comfortable')}
                    >
                      <DropdownMenuRadioItem value="compact">Compact</DropdownMenuRadioItem>
                      <DropdownMenuRadioItem value="cozy">Cozy</DropdownMenuRadioItem>
                      <DropdownMenuRadioItem value="comfortable">Comfortable</DropdownMenuRadioItem>
                    </DropdownMenuRadioGroup>
                  </DropdownMenuContent>
                </DropdownMenu>
              </div>
            )}
    
            {/* Column Visibility */}
            {enableColumnVisibility && (
              <div className={enableExport || enableDensityToggle ? '' : 'ml-auto'}>
                <DropdownMenu>
                  <DropdownMenuTrigger asChild>
                    <Button variant="outline" size="sm" className="h-9">
                      <ListFilter className="size-4" aria-hidden="true" />
                      View
                    </Button>
                  </DropdownMenuTrigger>
                  <DropdownMenuContent align="end">
                    {table
                      .getAllColumns()
                      .filter((column) => column.getCanHide())
                      .map((column) => (
                        <DropdownMenuCheckboxItem
                          key={column.id}
                          className="capitalize"
                          checked={column.getIsVisible()}
                          onCheckedChange={(value) => column.toggleVisibility(!!value)}
                        >
                          {column.id}
                        </DropdownMenuCheckboxItem>
                      ))}
                  </DropdownMenuContent>
                </DropdownMenu>
              </div>
            )}
          </div>
        </div>
      )
    }
  • components/ui/data-table/types.ts 0.5 kB
    import type * as React from 'react'
    
    export interface FilterOption {
      value: string
      label: string
      icon?: React.ComponentType<{ className?: string }>
    }
    
    export interface FilterDefinition {
      column: string
      label: string
      type: 'text' | 'select' | 'multiselect' | 'date'
      options?: (string | FilterOption)[]
    }
    
    export function resolveOption(opt: string | FilterOption): FilterOption {
      if (typeof opt === 'string') return { value: opt, label: opt }
      return opt
    }
  • components/ui/data-table/date-utils.ts 1 kB
    /**
     * ISO-string ↔ native Date helpers shared across the DataTable filter
     * surfaces. The Vue registry leaned on `@internationalized/date`'s
     * CalendarDate because its RangeCalendar (reka-ui) speaks DateValue; the
     * React RangeCalendar wraps react-day-picker, which speaks native `Date`.
     * So instead of pulling `@internationalized/date` into the React tree we
     * keep these two tiny converters local. Column filter values are still
     * stored as `YYYY-MM-DD` ISO strings on the wire, identical to Vue, so
     * server-side payloads match 1:1.
     */
    export function isoToDate(str: string | undefined): Date | undefined {
      if (!str) return undefined
      const [y, m, d] = str.split('-').map(Number)
      if (y === undefined || m === undefined || d === undefined) return undefined
      return new Date(y, m - 1, d)
    }
    
    export function dateToIso(date: Date | undefined): string {
      if (!date) return ''
      return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
    }
  • components/ui/data-table/index.ts 0.6 kB
    export { DataTable, type DataTableProps, type DataTableHandle, type DataTableState } from './DataTable'
    export { DataTableColumnHeader, type DataTableColumnHeaderProps } from './DataTableColumnHeader'
    export { DataTableToolbar, type DataTableToolbarProps } from './DataTableToolbar'
    export { DataTableFilterSheet, type DataTableFilterSheetProps } from './DataTableFilterSheet'
    export { DataTableFilterPopover, type DataTableFilterPopoverProps } from './DataTableFilterPopover'
    export { DataTablePagination, type DataTablePaginationProps } from './DataTablePagination'
    export type { FilterDefinition, FilterOption } from './types'

Raw manifest: https://react.uipkge.dev/r/react/data-table.json