UIPackage

Data Table

Vue 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 React ->

Installation

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

Or with the named registry: npx shadcn-vue@latest add @uipkge/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 true.

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

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

Schema

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

FilterOption
interface FilterOption {
  value: string
  label: string
  icon?: any
}
FilterDefinition
interface FilterDefinition {
  column: string
  label: string
  type: 'text' | 'select' | 'multiselect' | 'date'
  options?: (string | FilterOption)[]
}
DateRange
interface DateRange {
  from?: string
  to?: string
}
DraftDateValue
type DraftDateValue { from?: string, to?: string }
type DraftValue = string[] | string | DraftDateValue | undefined
type Draft = Record<string, DraftValue>

function resolveOption(opt: string | FilterOption): FilterOption {
  if (typeof opt === 'string') return { value: opt, label: opt }
  return opt
}

Dependencies

Files (7)

  • app/components/ui/data-table/DataTable.vue 37.5 kB
    <script setup lang="ts" generic="TData, TValue">
    import type { ColumnDef, ColumnFiltersState, FilterFn, SortingState, VisibilityState, ExpandedState, GroupingState } from '@tanstack/vue-table'
    import {
      FlexRender,
      getCoreRowModel,
      getExpandedRowModel,
      getFilteredRowModel,
      getGroupedRowModel,
      getPaginationRowModel,
      getSortedRowModel,
      useVueTable,
    } from '@tanstack/vue-table'
    
    // `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 { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
    import { CalendarDate, type DateValue } from '@internationalized/date'
    import type { DateRange as RekaDateRange } from 'reka-ui'
    import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
    
    import DataTableToolbar from './DataTableToolbar.vue'
    import DataTableFilterSheet from './DataTableFilterSheet.vue'
    import DataTablePagination from './DataTablePagination.vue'
    
    export interface FilterOption {
      value: string
      label: string
      icon?: any
    }
    
    export interface FilterDefinition {
      column: string
      label: string
      type: 'text' | 'select' | 'multiselect' | 'date'
      options?: (string | FilterOption)[]
    }
    
    interface DateRange {
      from?: string
      to?: string
    }
    
    const props = withDefaults(
      defineProps<{
        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 true. */
        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: emits `fetch-more` 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 `max-height` for a scroll container. */
        virtual?: boolean
        /** Sticky header — keeps `<thead>` visible when scrolling. Pair with `max-height`. */
        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 (search / filters / export / view) renders.
         *  - `'inside'` (default) is the canonical layout: toolbar lives inside
         *    the bordered container above the table, separated by a divider.
         *  - `'above'` floats the toolbar outside the bordered container, so
         *    the table's border only wraps the table (and pagination unless
         *    that is also moved out). Useful when you want the chrome to
         *    feel like a page action bar rather than card-internal. */
        toolbarPosition?: 'inside' | 'above'
        /** Where the pagination footer renders.
         *  - `'inside'` (default): footer sits inside the bordered container,
         *    separated by a top-divider.
         *  - `'below'` floats the footer outside the bordered container. */
        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
      }>(),
      {
        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,
        toolbarPosition: 'inside',
        paginationPosition: 'inside',
        maxHeight: '',
        totalRows: -1,
        loading: false,
      },
    )
    
    // User-mutable density -- initial value from prop, toggleable via toolbar
    // when `enableDensityToggle` is on. Watch the prop so parents can still
    // drive it externally (e.g. saving the user's last choice to a setting).
    const currentDensity = ref<'compact' | 'cozy' | 'comfortable'>(props.density)
    watch(() => props.density, (v) => { currentDensity.value = v })
    
    // `borderless` is an enum at the DataTable level (`'inner'` vs `'full'`), but
    // child components only need to know "should I drop my borders?" -- collapse
    // to a single boolean for the pass-through.
    const dropInnerBorders = computed(() => !!props.borderless)
    
    const densityClass = computed(() => {
      if (currentDensity.value === 'compact') return '[&_td]:py-1.5 [&_td]:px-3 [&_td]:text-xs [&_th]:h-8 [&_th]:px-3 [&_th]:text-xs'
      if (currentDensity.value === 'comfortable') return '[&_td]:py-3 [&_th]:h-12'
      // cozy -- 8px vertical cell padding, 40px header. Tightens the
      // TableCell/TableHead defaults (py-3 / h-12) so a cozy DataTable
      // doesn't feel airy compared with the surrounding canvas.
      return '[&_td]:py-2 [&_th]:h-10'
    })
    
    const emit = defineEmits<{
      /** Emitted when server-side state changes (pagination, sorting, filters) */
      (
        e: 'update:state',
        state: {
          page: number
          pageSize: number
          sortBy: string
          sortOrder: 'asc' | 'desc'
          filters: Record<string, any>
          search: string
        },
      ): void
      /** Infinite-scroll: last row entered viewport, time to load more */
      (e: 'fetch-more'): void
    }>()
    
    const isServerSide = computed(() => props.totalRows >= 0)
    const isFilterSheetOpen = ref(false)
    
    // ── Filter snapshot (restore on close without apply) ─────────────────────────
    // Shared between the modal Sheet and the inline Popover. Both surfaces stage
    // edits in the live `columnFilters` ref (so the in-popover "N selected"
    // badges update as the user clicks). Closing without hitting Apply rolls
    // back via the snapshot.
    let filterSnapshot: ColumnFiltersState | null = null
    let dateRangeSnapshot: Record<string, RekaDateRange> | null = null
    let filterApplied = false
    
    function snapshotFilters() {
      filterApplied = false
      filterSnapshot = JSON.parse(JSON.stringify(columnFilters.value))
      dateRangeSnapshot = JSON.parse(JSON.stringify(dateRangeModels.value))
    }
    
    function maybeRestoreFilters() {
      if (!filterApplied && filterSnapshot) {
        columnFilters.value = filterSnapshot
        dateRangeModels.value = dateRangeSnapshot ?? {}
      }
      filterSnapshot = null
      dateRangeSnapshot = null
    }
    
    function onFilterSheetOpen() {
      snapshotFilters()
      isFilterSheetOpen.value = true
    }
    
    function onFilterSheetClose(open: boolean) {
      if (!open) maybeRestoreFilters()
      isFilterSheetOpen.value = open
    }
    
    // ── Timers for debounce / batching ──────────────────────────────────────────
    let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
    let paginationEmitTimer: ReturnType<typeof setTimeout> | null = null
    
    function onSearchInput(val: string) {
      if (!props.filterColumn || !table.getColumn(props.filterColumn)) return
      table.getColumn(props.filterColumn)?.setFilterValue(val || undefined)
      if (isServerSide.value) {
        if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
        searchDebounceTimer = setTimeout(() => {
          table.setPageIndex(0)
          emitStateUpdate()
        }, 500)
      }
    }
    
    // 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: DateRange) => {
      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 = computed(() => {
      return props.columns.map((col) => {
        const colId = (col as any).accessorKey || (col as any).id
        const filter = props.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
      })
    })
    
    const sorting = ref<SortingState>([])
    const columnFilters = ref<ColumnFiltersState>([])
    const columnVisibility = ref<VisibilityState>({})
    const rowSelection = ref({})
    const expanded = ref<ExpandedState>({})
    const columnPinning = ref({
      left: props.defaultColumnPinning.left ?? [],
      right: props.defaultColumnPinning.right ?? [],
    })
    const columnOrder = ref<string[]>([])
    const grouping = ref<GroupingState>(props.defaultGrouping)
    const pagination = ref({ pageIndex: 0, pageSize: 10 })
    
    const table = useVueTable({
      get data() {
        return props.data
      },
      get columns() {
        return processedColumns.value
      },
      getCoreRowModel: getCoreRowModel(),
      getPaginationRowModel: isServerSide.value ? undefined : getPaginationRowModel(),
      getSortedRowModel: isServerSide.value ? undefined : getSortedRowModel(),
      getFilteredRowModel: isServerSide.value ? undefined : getFilteredRowModel(),
      getExpandedRowModel: getExpandedRowModel(),
      getGroupedRowModel: getGroupedRowModel(),
      get enableColumnResizing() {
        return props.enableResize
      },
      columnResizeMode: 'onChange',
      get manualPagination() {
        return isServerSide.value
      },
      get manualSorting() {
        return isServerSide.value
      },
      get manualFiltering() {
        return isServerSide.value
      },
      get rowCount() {
        return isServerSide.value ? props.totalRows : undefined
      },
      onSortingChange: (updaterOrValue) => {
        valueUpdater(updaterOrValue, sorting)
        if (isServerSide.value) emitStateUpdate()
      },
      onColumnFiltersChange: (updaterOrValue) => {
        valueUpdater(updaterOrValue, columnFilters)
        // Server-side: never auto-emit on filter change — handled by Apply button / search debounce
        // Client-side inline: filters apply locally via TanStack, no emit needed
      },
      onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility),
      onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection),
      onExpandedChange: updaterOrValue => valueUpdater(updaterOrValue, expanded),
      onPaginationChange: (updaterOrValue) => {
        valueUpdater(updaterOrValue, pagination)
        if (isServerSide.value) {
          // Debounce to batch rapid pagination changes (e.g. setPageSize + setPageIndex)
          if (paginationEmitTimer) clearTimeout(paginationEmitTimer)
          paginationEmitTimer = setTimeout(() => emitStateUpdate(), 0)
        }
      },
      state: {
        get sorting() {
          return sorting.value
        },
        get columnFilters() {
          return columnFilters.value
        },
        get columnVisibility() {
          return columnVisibility.value
        },
        get pagination() {
          return pagination.value
        },
        get rowSelection() {
          return rowSelection.value
        },
        get expanded() {
          return expanded.value
        },
        get columnPinning() {
          return columnPinning.value
        },
        get columnOrder() {
          return columnOrder.value
        },
        get grouping() {
          return grouping.value
        },
      },
      onColumnPinningChange: updaterOrValue => valueUpdater(updaterOrValue, columnPinning),
      onColumnOrderChange: updaterOrValue => valueUpdater(updaterOrValue, columnOrder),
      onGroupingChange: updaterOrValue => valueUpdater(updaterOrValue, grouping),
    })
    
    /** Build and emit current server-side state */
    function emitStateUpdate() {
      const s = sorting.value[0]
      const filterMap: Record<string, any> = {}
      for (const cf of columnFilters.value) {
        filterMap[cf.id] = cf.value
      }
      emit('update:state', {
        page: table.getState().pagination.pageIndex + 1,
        pageSize: table.getState().pagination.pageSize,
        sortBy: s?.id || '',
        sortOrder: s?.desc ? 'desc' : 'asc',
        filters: filterMap,
        search: (props.filterColumn && (table.getColumn(props.filterColumn)?.getFilterValue() as string)) || '',
      })
    }
    
    // ── Filter helpers ───────────────────────────────────────────────────────────
    
    function getMultiSelectValue(column: string): string[] {
      return (table.getColumn(column)?.getFilterValue() as string[]) ?? []
    }
    
    function getDateRangeValue(column: string): DateRange {
      return (table.getColumn(column)?.getFilterValue() as DateRange) ?? {}
    }
    
    // ── Date conversion helpers ──────────────────────────────────────────────────
    
    function stringToDateValue(str: string): DateValue | undefined {
      if (!str) return undefined
      const [y, m, d] = str.split('-').map(Number)
      if (y === undefined || m === undefined || d === undefined) return undefined
      return new CalendarDate(y, m, d)
    }
    
    function dateValueToString(dv: DateValue | undefined): string {
      if (!dv) return ''
      return `${dv.year}-${String(dv.month).padStart(2, '0')}-${String(dv.day).padStart(2, '0')}`
    }
    
    /** Per-filter reactive range calendar model, keyed by column name */
    const dateRangeModels = ref<Record<string, RekaDateRange>>({})
    
    function getCalendarModel(column: string): RekaDateRange {
      if (!dateRangeModels.value[column]) {
        const dr = getDateRangeValue(column)
        dateRangeModels.value[column] = {
          start: stringToDateValue(dr.from ?? '') as DateValue,
          end: stringToDateValue(dr.to ?? '') as DateValue,
        }
      }
      return dateRangeModels.value[column]
    }
    
    function onCalendarUpdate(column: string, val: RekaDateRange) {
      dateRangeModels.value[column] = val
      const from = val.start ? dateValueToString(val.start) : undefined
      const to = val.end ? dateValueToString(val.end) : undefined
      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 = true
      if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
      props.filters.forEach((f) => {
        table.getColumn(f.column)?.setFilterValue(undefined)
        if (f.type === 'date') {
          dateRangeModels.value[f.column] = { start: undefined as any, end: undefined as any }
        }
      })
      if (props.filterColumn && table.getColumn(props.filterColumn)) {
        table.getColumn(props.filterColumn)?.setFilterValue(undefined)
      }
      if (isServerSide.value) {
        table.setPageIndex(0)
        nextTick(() => 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 DateRange
        return !!(d.from || d.to)
      }
      return true
    }
    
    const isAnyFilterActive = computed(() => {
      const hasSearchValue = !!(props.filterColumn && (table.getColumn(props.filterColumn)?.getFilterValue() as string))
      return hasSearchValue || props.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 resolveOption(opt: string | FilterOption): FilterOption {
      if (typeof opt === 'string') return { value: opt, label: opt }
      return opt
    }
    
    function clearFilter(filter: FilterDefinition) {
      table.getColumn(filter.column)?.setFilterValue(undefined)
    }
    
    const activeFilterCount = computed(() => {
      return props.filters.filter(isFilterActive).length
    })
    
    function clearDateFilter(filter: FilterDefinition) {
      clearFilter(filter)
      dateRangeModels.value[filter.column] = { start: undefined as any, end: undefined as any }
    }
    
    function onApplyFilters() {
      filterApplied = true
      if (isServerSide.value) {
        if (table.getState().pagination.pageIndex === 0) {
          emitStateUpdate()
        }
        else {
          table.setPageIndex(0)
        }
      }
      isFilterSheetOpen.value = false
    }
    
    /**
     * Popover filter mode: handle the staged-edit commit. The popover never
     * mutates `columnFilters` during interaction — it builds a draft locally
     * and emits it here on Apply. 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 props.filters) {
        if (!(f.column in draft)) continue
        const val = draft[f.column]
        if (f.type === 'date') {
          const dr = (val ?? {}) as DateRange
          dateRangeModels.value[f.column] = {
            start: stringToDateValue(dr.from ?? '') as DateValue,
            end: stringToDateValue(dr.to ?? '') as DateValue,
          }
          table.getColumn(f.column)?.setFilterValue(val)
        }
        else {
          table.getColumn(f.column)?.setFilterValue(val)
        }
      }
      // Reuse the same apply-side bookkeeping (server-side re-fetch +
      // pagination reset). filterApplied flips so any stale Sheet snapshot
      // doesn't try to roll back.
      onApplyFilters()
    }
    
    // Column reorder — HTML5 drag/drop swaps the dragged column with the drop target.
    const dragColId = ref<string | null>(null)
    const dragOverColId = ref<string | null>(null)
    
    function onColDragStart(id: string, e: DragEvent) {
      dragColId.value = id
      document.body.style.cursor = 'grabbing'
      if (e.dataTransfer) {
        e.dataTransfer.effectAllowed = 'move'
        e.dataTransfer.setData('text/plain', id)
      }
    }
    
    function onColDragOver(targetId: string, e: DragEvent) {
      if (!props.enableReorder || !dragColId.value) return
      e.preventDefault()
      dragOverColId.value = targetId
      if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
    }
    
    function onColDragEnd() {
      document.body.style.cursor = ''
      dragColId.value = null
      dragOverColId.value = null
    }
    
    function onColDrop(targetId: string) {
      const src = dragColId.value
      onColDragEnd()
      if (!src || src === targetId) return
      const order = (columnOrder.value.length ? columnOrder.value : 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))
      columnOrder.value = order
    }
    
    // Pinning — return style for sticky pinned cells (header or body)
    function pinStyle(col: any): Record<string, string> | 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 = ref<HTMLElement | null>(null)
    let io: IntersectionObserver | null = null
    
    onMounted(() => {
      if (!props.infinite || !sentinelEl.value) return
      io = new IntersectionObserver(
        (entries) => {
          if (entries[0]?.isIntersecting) emit('fetch-more')
        },
        { rootMargin: '100px' },
      )
      io.observe(sentinelEl.value)
    })
    
    onBeforeUnmount(() => {
      io?.disconnect()
      io = null
    })
    
    watch(
      () => props.infinite,
      (on) => {
        if (on && sentinelEl.value && !io) {
          io = new IntersectionObserver(
            (entries) => {
              if (entries[0]?.isIntersecting) emit('fetch-more')
            },
            { rootMargin: '100px' },
          )
          io.observe(sentinelEl.value)
        }
        else if (!on) {
          io?.disconnect()
          io = null
        }
      },
    )
    
    // CSV export — uses currently filtered + visible data, skips select/actions 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')
      const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
      const url = URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = `export-${Date.now()}.csv`
      document.body.appendChild(a)
      a.click()
      document.body.removeChild(a)
      URL.revokeObjectURL(url)
    }
    
    // JSON export -- same row/column scope as CSV, but emits the underlying
    // row.original objects so downstream tools get typed data, not strings.
    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>
        // When the page projects via accessorKey, original keys match. When it
        // uses accessorFn (eg. a synthesized "name" column), surface that under
        // the column id so consumers see the same shape they exported.
        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)
      const blob = new Blob([json], { type: 'application/json;charset=utf-8;' })
      const url = URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = `export-${Date.now()}.json`
      document.body.appendChild(a)
      a.click()
      document.body.removeChild(a)
      URL.revokeObjectURL(url)
    }
    
    defineExpose({ table, exportCsv, exportJson })
    </script>
    
    <template>
      <!-- ClientOnly: the TanStack table renders identically on server and client
           in principle, but its interactive Reka-based controls resolve internal
           state in the browser, producing benign hydration mismatches. SSR of an
           interactive data grid adds little, so render it client-side. -->
      <ClientOnly>
        <div
          data-uipkge
          data-slot="data-table"
          class="w-full"
        >
          <!-- Toolbar (above-mode: floats outside the bordered card, no
               bottom-divider since nothing sits directly below it inside the
               same surface). -->
          <DataTableToolbar
            v-if="!hideToolbar && toolbarPosition === 'above'"
            :table="table"
            :filter-column="filterColumn"
            :filter-placeholder="filterPlaceholder"
            :filters="filters"
            :filter-mode="filterMode"
            :enable-search="enableSearch"
            :enable-column-visibility="enableColumnVisibility"
            :enable-export="enableExport"
            :enable-density-toggle="enableDensityToggle"
            :density="currentDensity"
            :borderless="true"
            :active-filter-count="activeFilterCount"
            :is-any-filter-active="isAnyFilterActive"
            :is-server-side="isServerSide"
            :get-multi-select-value="getMultiSelectValue"
            :get-date-range-value="getDateRangeValue"
            :get-filter-selected-labels="getFilterSelectedLabels"
            :format-date-range="formatDateRange"
            :get-calendar-model="getCalendarModel"
            @search="onSearchInput"
            @open-filter-sheet="onFilterSheetOpen"
            @apply-filters="onApplyFilters"
            @clear-all-filters="clearAllFilters"
            @toggle-multiselect="toggleMultiSelectValue"
            @clear-filter="clearFilter"
            @clear-date-filter="clearDateFilter"
            @calendar-update="onCalendarUpdate"
            @text-filter-update="(col, val) => table.getColumn(col)?.setFilterValue(val)"
            @commit-filters="onCommitDraft"
            @export-csv="exportCsv"
            @export-json="exportJson"
            @update:density="(d: 'compact' | 'cozy' | 'comfortable') => (currentDensity = d)"
          >
            <template
              v-if="$slots['toolbar-extra']"
              #toolbar-extra
            >
              <slot
                name="toolbar-extra"
                :table="table"
              />
            </template>
            <template
              v-if="$slots['custom-filters']"
              #custom-filters
            >
              <slot
                name="custom-filters"
                :table="table"
              />
            </template>
          </DataTableToolbar>
    
          <div
            :class="[
              'bg-card text-card-foreground overflow-hidden',
              borderless === 'full' ? '' : 'rounded-md border',
            ]"
          >
            <!-- Toolbar (inside-mode, default): canonical card layout, toolbar
                 sits at the top of the bordered card with its bottom-divider. -->
            <DataTableToolbar
              v-if="!hideToolbar && toolbarPosition === 'inside'"
              :table="table"
              :filter-column="filterColumn"
              :filter-placeholder="filterPlaceholder"
              :filters="filters"
              :filter-mode="filterMode"
              :enable-search="enableSearch"
              :enable-column-visibility="enableColumnVisibility"
              :enable-export="enableExport"
              :enable-density-toggle="enableDensityToggle"
              :density="currentDensity"
              :borderless="dropInnerBorders"
              :active-filter-count="activeFilterCount"
              :is-any-filter-active="isAnyFilterActive"
              :is-server-side="isServerSide"
              :get-multi-select-value="getMultiSelectValue"
              :get-date-range-value="getDateRangeValue"
              :get-filter-selected-labels="getFilterSelectedLabels"
              :format-date-range="formatDateRange"
              :get-calendar-model="getCalendarModel"
              @search="onSearchInput"
              @open-filter-sheet="onFilterSheetOpen"
              @apply-filters="onApplyFilters"
              @clear-all-filters="clearAllFilters"
              @toggle-multiselect="toggleMultiSelectValue"
              @clear-filter="clearFilter"
              @clear-date-filter="clearDateFilter"
              @calendar-update="onCalendarUpdate"
              @text-filter-update="(col, val) => table.getColumn(col)?.setFilterValue(val)"
              @commit-filters="onCommitDraft"
              @export-csv="exportCsv"
              @export-json="exportJson"
              @update:density="(d: 'compact' | 'cozy' | 'comfortable') => (currentDensity = d)"
            >
              <template
                v-if="$slots['toolbar-extra']"
                #toolbar-extra
              >
                <slot
                  name="toolbar-extra"
                  :table="table"
                />
              </template>
              <template
                v-if="$slots['custom-filters']"
                #custom-filters
              >
                <slot
                  name="custom-filters"
                  :table="table"
                />
              </template>
            </DataTableToolbar>
    
            <!-- Filter Sheet (modal mode) -->
            <DataTableFilterSheet
              :open="isFilterSheetOpen"
              :table="table"
              :filters="filters"
              :active-filter-count="activeFilterCount"
              :is-any-filter-active="isAnyFilterActive"
              :is-server-side="isServerSide"
              :borderless="dropInnerBorders"
              :get-multi-select-value="getMultiSelectValue"
              :get-date-range-value="getDateRangeValue"
              :format-date-range="formatDateRange"
              :get-calendar-model="getCalendarModel"
              @update:open="onFilterSheetClose"
              @apply="onApplyFilters"
              @clear-all="clearAllFilters"
              @toggle-multiselect="toggleMultiSelectValue"
              @clear-filter="clearFilter"
              @clear-date-filter="clearDateFilter"
              @calendar-update="onCalendarUpdate"
              @text-filter-update="(col, val) => table.getColumn(col)?.setFilterValue(val)"
            >
              <template
                v-if="$slots['custom-filters']"
                #custom-filters
              >
                <slot
                  name="custom-filters"
                  :table="table"
                />
              </template>
            </DataTableFilterSheet>
    
            <!-- Bulk action bar (slot above table when rows selected) -->
            <div
              v-if="$slots['bulk-actions'] && table.getSelectedRowModel().rows.length > 0"
              class="bg-muted/40 flex items-center gap-3 border-b px-4 py-2"
            >
              <span class="text-sm font-medium"> {{ table.getSelectedRowModel().rows.length }} selected </span>
              <slot
                name="bulk-actions"
                :rows="table.getSelectedRowModel().rows"
                :clear="() => table.toggleAllRowsSelected(false)"
              />
              <button
                type="button"
                class="text-muted-foreground hover:text-foreground ml-auto text-xs transition"
                @click="table.toggleAllRowsSelected(false)"
              >
                Clear selection
              </button>
            </div>
    
            <!-- Table.
               The Table primitive wraps the <table> in its own overflow-auto div
               which would normally trap sticky-positioned children + draw native
               scrollbars. Neutralize that inner overflow and let
               OverlayScrollbarsComponent own both axes so we get a single,
               themed, auto-hiding pair of scrollbars across the whole region. -->
            <div
              v-os-scroll
              :class="[densityClass, 'relative [&_[data-slot=table-container]]:overflow-visible']"
              :style="maxHeight ? `max-height: ${maxHeight}` : ''"
            >
              <!-- Loading overlay -->
              <div
                v-if="loading"
                class="bg-card/60 absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[1px]"
              >
                <div class="border-primary size-5 animate-spin rounded-full border-2 border-t-transparent" />
              </div>
              <Table>
                <TableHeader :class="stickyHeader ? 'bg-muted/95 sticky top-0 z-10 backdrop-blur-sm' : ''">
                  <TableRow
                    v-for="headerGroup in table.getHeaderGroups()"
                    :key="headerGroup.id"
                  >
                    <TableHead
                      v-for="header in headerGroup.headers"
                      :key="header.id"
                      class="relative transition-colors duration-150"
                      :class="[
                        enableReorder
                          && header.column.id !== 'select'
                          && header.column.id !== 'actions'
                          && header.column.id !== 'expander'
                          ? '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'
                          : '',
                      ]"
                      :style="{
                        ...(enableResize ? { width: `${header.getSize()}px` } : {}),
                        ...(pinStyle(header.column) ?? {}),
                      }"
                      :draggable="
                        enableReorder
                          && header.column.id !== 'select'
                          && header.column.id !== 'actions'
                          && header.column.id !== 'expander'
                      "
                      @dragstart="enableReorder ? onColDragStart(header.column.id, $event) : undefined"
                      @dragover="enableReorder ? onColDragOver(header.column.id, $event) : undefined"
                      @dragend="enableReorder ? onColDragEnd() : undefined"
                      @drop="enableReorder ? onColDrop(header.column.id) : undefined"
                    >
                      <FlexRender
                        v-if="!header.isPlaceholder"
                        :render="header.column.columnDef.header"
                        :props="header.getContext()"
                      />
                      <div
                        v-if="enableResize && header.column.getCanResize()"
                        class="hover:bg-foreground/30 absolute top-0 right-0 h-full w-1 cursor-col-resize touch-none transition-colors select-none"
                        :class="header.column.getIsResizing() ? 'bg-foreground/60' : ''"
                        @mousedown="header.getResizeHandler()?.($event)"
                        @touchstart="header.getResizeHandler()?.($event)"
                      />
                    </TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  <template v-if="table.getRowModel().rows?.length">
                    <template
                      v-for="row in table.getRowModel().rows"
                      :key="row.id"
                    >
                      <TableRow
                        :data-state="row.getIsSelected() ? 'selected' : undefined"
                        :class="onRowClick ? 'hover:bg-muted/40 cursor-pointer' : ''"
                        :style="virtual ? 'content-visibility: auto; contain-intrinsic-size: auto 48px;' : undefined"
                        @click="onRowClick?.(row.original)"
                      >
                        <TableCell
                          v-for="cell in row.getVisibleCells()"
                          :key="cell.id"
                          class="bg-card"
                          :style="pinStyle(cell.column)"
                        >
                          <FlexRender
                            :render="cell.column.columnDef.cell"
                            :props="cell.getContext()"
                          />
                        </TableCell>
                      </TableRow>
                      <TableRow
                        v-if="row.getIsExpanded() && $slots.expanded"
                        :key="`${row.id}-expanded`"
                      >
                        <TableCell
                          :colspan="row.getVisibleCells().length"
                          class="bg-muted/30 px-6 py-4"
                        >
                          <slot
                            name="expanded"
                            :row="row.original"
                            :tanstack-row="row"
                          />
                        </TableCell>
                      </TableRow>
                    </template>
                  </template>
                  <template v-else>
                    <TableRow>
                      <TableCell
                        :colspan="columns.length"
                        class="h-24 text-center"
                      >
                        <slot name="empty">
                          <div class="text-muted-foreground text-sm">
                            No results.
                          </div>
                        </slot>
                      </TableCell>
                    </TableRow>
                  </template>
                </TableBody>
                <tfoot
                  v-if="$slots.footer"
                  class="bg-muted/20 sticky bottom-0 border-t"
                >
                  <slot
                    name="footer"
                    :rows="table.getRowModel().rows"
                  />
                </tfoot>
              </Table>
              <!-- Infinite scroll sentinel -->
              <div
                v-if="infinite"
                ref="sentinelEl"
                class="h-1"
              />
              <div
                v-if="infinite && loading"
                class="border-border text-muted-foreground border-t px-4 py-3 text-center text-sm"
              >
                Loading more…
              </div>
            </div>
    
            <!-- Pagination (inside-mode, default) -->
            <DataTablePagination
              v-if="enablePagination && !infinite && paginationPosition === 'inside'"
              :table="table"
              :total-rows="totalRows"
              :is-server-side="isServerSide"
              :borderless="dropInnerBorders"
            />
          </div>
    
          <!-- Pagination (below-mode: floats outside the bordered card,
               dividerless since nothing sits directly above it on the same surface). -->
          <DataTablePagination
            v-if="enablePagination && !infinite && paginationPosition === 'below'"
            :table="table"
            :total-rows="totalRows"
            :is-server-side="isServerSide"
            :borderless="true"
          />
        </div>
      </ClientOnly>
    </template>
  • app/components/ui/data-table/DataTableColumnHeader.vue 10.5 kB
    <script setup lang="ts" generic="TData, TValue">
    /**
     * Sortable / hideable header cell for TanStack DataTable columns. Use it from
     * a column definition like:
     *   header: ({ column }) => h(DataTableColumnHeader, { column, label: 'Email' })
     *
     * Click on the label cycles sort asc → desc → none.
     *
     * Optional per-column filter:
     *   header: ({ column }) => h(DataTableColumnHeader, {
     *     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 { computed, ref, watch } from 'vue'
    import type { Column } from '@tanstack/vue-table'
    import { ArrowDown, ArrowUp, ArrowUpDown, Check, Filter, FilterX } from 'lucide-vue-next'
    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 { parseDate, CalendarDate, type DateValue } from '@internationalized/date'
    
    interface FilterOption {
      value: string
      label: string
    }
    interface FilterDefinition {
      column: string
      label: string
      type: 'text' | 'select' | 'multiselect' | 'date'
      options?: (string | FilterOption)[]
    }
    
    const props = defineProps<{
      column: Column<TData, TValue>
      label: string
      align?: 'left' | 'right' | 'center'
      filter?: FilterDefinition
      class?: string
    }>()
    
    const align = computed(() => props.align ?? 'left')
    
    function next() {
      const current = props.column.getIsSorted()
      if (!current) props.column.toggleSorting(false)
      else if (current === 'asc') props.column.toggleSorting(true)
      else props.column.clearSorting()
    }
    
    function resolveOption(opt: string | FilterOption): FilterOption {
      return typeof opt === 'string' ? { value: opt, label: opt } : opt
    }
    
    // Filter state read directly off the TanStack column so the popover stays
    // reactive to external clears (toolbar "Clear all", row-level edits, etc.).
    const open = ref(false)
    const filterValue = computed(() => props.column.getFilterValue())
    
    const isFilterActive = computed(() => {
      const v = filterValue.value
      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 = ref('')
    watch(open, (isOpen) => {
      if (isOpen && props.filter?.type === 'text') {
        textDraft.value = (filterValue.value as string) ?? ''
      }
    })
    
    function applyText() {
      props.column.setFilterValue(textDraft.value || undefined)
    }
    
    function clearText() {
      textDraft.value = ''
      props.column.setFilterValue(undefined)
    }
    
    function toggleMultiselect(value: string) {
      const current = (filterValue.value as string[]) ?? []
      const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value]
      props.column.setFilterValue(next.length > 0 ? next : undefined)
    }
    
    function selectOne(value: string) {
      props.column.setFilterValue(value || undefined)
      open.value = false
    }
    
    function clearFilter() {
      props.column.setFilterValue(undefined)
      textDraft.value = ''
    }
    
    // Date-range bridging. The column stores ISO strings; the calendar wants
    // CalendarDate instances. Reads/writes both shapes.
    const dateModel = computed({
      get() {
        const v = filterValue.value as { from?: string; to?: string } | undefined
        if (!v?.from && !v?.to) return undefined
        try {
          return {
            start: v.from ? parseDate(v.from) : undefined,
            end: v.to ? parseDate(v.to) : undefined,
          }
        } catch {
          return undefined
        }
      },
      set(next: { start?: DateValue; end?: DateValue } | undefined) {
        const isoFrom = next?.start ? (next.start as CalendarDate).toString() : undefined
        const isoTo = next?.end ? (next.end as CalendarDate).toString() : undefined
        if (!isoFrom && !isoTo) {
          props.column.setFilterValue(undefined)
        } else {
          props.column.setFilterValue({ from: isoFrom, to: isoTo })
        }
      },
    })
    
    function selectedLabels(): string[] {
      const f = props.filter
      if (!f?.options) return []
      const selected = (filterValue.value as string[]) ?? []
      return f.options
        .map(resolveOption)
        .filter((o) => selected.includes(o.value))
        .map((o) => o.label)
    }
    </script>
    
    <template>
      <div
        :class="
          cn(
            'flex items-center gap-0.5',
            align === 'right' && 'justify-end',
            align === 'center' && 'justify-center',
            props.class,
          )
        "
      >
        <button
          type="button"
          v-if="column.getCanSort()"
          class="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}`"
          @click="next"
        >
          <span>{{ label }}</span>
          <ArrowUp
            v-if="column.getIsSorted()"
            class="text-foreground size-3.5 transition-transform duration-200 ease-in-out"
            :class="column.getIsSorted() === 'desc' ? 'rotate-180' : 'rotate-0'"
          />
          <ArrowUpDown v-else class="size-3.5 opacity-40 transition-opacity duration-150 group-hover:opacity-70" />
        </button>
        <span v-else class="text-muted-foreground text-sm font-medium">{{ label }}</span>
    
        <!-- Optional per-column header filter -->
        <Popover v-if="filter" v-model:open="open">
          <PopoverTrigger as-child>
            <button
              type="button"
              :class="
                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 class="size-3.5" />
              <span
                v-if="isFilterActive"
                aria-hidden
                class="bg-primary absolute -top-0.5 -right-0.5 size-1.5 rounded-full"
              />
            </button>
          </PopoverTrigger>
          <PopoverContent class="w-72 p-0" align="start">
            <div class="border-border flex items-center justify-between border-b px-3 py-2 text-xs font-medium">
              <span>Filter · {{ filter.label }}</span>
              <button
                type="button"
                v-if="isFilterActive"
                class="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 transition-colors"
                @click="clearFilter"
              >
                <FilterX class="size-3" />
                Clear
              </button>
            </div>
    
            <!-- TEXT -->
            <div v-if="filter.type === 'text'" class="space-y-2 p-3">
              <Input
                v-model="textDraft"
                :placeholder="`Search ${filter.label.toLowerCase()}…`"
                class="h-9"
                @keydown.enter="
                  () => {
                    applyText()
                    open = false
                  }
                "
                @blur="applyText"
              />
              <div class="flex gap-2">
                <Button
                  size="sm"
                  class="h-8 flex-1"
                  @click="
                    () => {
                      applyText()
                      open = false
                    }
                  "
                  >Apply</Button
                >
                <Button size="sm" variant="outline" class="h-8" @click="clearText()">Clear</Button>
              </div>
            </div>
    
            <!-- SELECT / MULTISELECT -->
            <Command v-else-if="filter.type === 'select' || filter.type === 'multiselect'" class="max-h-[280px]">
              <CommandInput :placeholder="`Search ${filter.label.toLowerCase()}…`" class="h-9" />
              <CommandList>
                <CommandEmpty>No matches.</CommandEmpty>
                <CommandGroup>
                  <template v-for="opt in filter.options ?? []" :key="resolveOption(opt).value">
                    <CommandItem
                      :value="resolveOption(opt).value"
                      @select="
                        filter.type === 'multiselect'
                          ? toggleMultiselect(resolveOption(opt).value)
                          : selectOne(resolveOption(opt).value)
                      "
                    >
                      <div
                        v-if="filter.type === 'multiselect'"
                        :class="
                          cn(
                            'border-primary/50 mr-2 flex size-4 items-center justify-center rounded-sm border transition-colors',
                            ((filterValue as string[]) ?? []).includes(resolveOption(opt).value)
                              ? 'bg-primary text-primary-foreground'
                              : 'opacity-50',
                          )
                        "
                      >
                        <Check class="size-3" />
                      </div>
                      <Check
                        v-else
                        :class="cn('mr-2 size-4', filterValue === resolveOption(opt).value ? 'opacity-100' : 'opacity-0')"
                      />
                      <span>{{ resolveOption(opt).label }}</span>
                    </CommandItem>
                  </template>
                </CommandGroup>
              </CommandList>
            </Command>
    
            <!-- DATE RANGE -->
            <div v-else-if="filter.type === 'date'" class="p-2">
              <RangeCalendar v-model="dateModel as any" />
              <div class="flex gap-2 px-1 pt-2">
                <Button size="sm" class="h-8 flex-1" @click="open = false">Apply</Button>
                <Button size="sm" variant="outline" class="h-8" @click="clearFilter">Clear</Button>
              </div>
            </div>
    
            <!-- Active selection summary -->
            <div
              v-if="isFilterActive && (filter.type === 'multiselect' || filter.type === 'select')"
              class="border-border text-muted-foreground border-t px-3 py-2 text-[11px]"
            >
              <span v-if="filter.type === 'multiselect'">
                {{ selectedLabels().length }} selected:
                <span class="text-foreground">{{ selectedLabels().join(', ') }}</span>
              </span>
              <span v-else>
                <span class="text-foreground">{{ filterValue }}</span>
              </span>
            </div>
          </PopoverContent>
        </Popover>
      </div>
    </template>
  • app/components/ui/data-table/DataTableFilterPopover.vue 17.3 kB
    <script setup lang="ts">
    /**
     * 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 `commit-draft`
     * with the full draft and the parent walks the entries calling
     * `setFilterValue` per column. Closing the popover (Escape / click out)
     * simply discards the draft.
     *
     * Why: ticking a checkbox inside the popover used to write straight to
     * `columnFilters`, causing the underlying table to flash filtered before
     * the user committed. Truly staging the edits avoids that flash and
     * makes "Reset" / per-section "Clear" behave intuitively (they clear
     * the draft, not the live table).
     */
    import type { Table } from '@tanstack/vue-table'
    import type { FilterDefinition, FilterOption } from './DataTable.vue'
    import type { DateValue } from '@internationalized/date'
    import type { DateRange as RekaDateRange } from 'reka-ui'
    import { computed, ref, watch } from 'vue'
    import { CalendarDate } from '@internationalized/date'
    
    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 { Check, SlidersHorizontal, X } from 'lucide-vue-next'
    
    type DraftDateValue = { from?: string, to?: string }
    type DraftValue = string[] | string | DraftDateValue | undefined
    type Draft = Record<string, DraftValue>
    
    function resolveOption(opt: string | FilterOption): FilterOption {
      if (typeof opt === 'string') return { value: opt, label: opt }
      return opt
    }
    
    const props = defineProps<{
      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) => any
    }>()
    
    const open = ref(false)
    
    const emit = defineEmits<{
      (e: 'clear-all'): void
      // Committed draft on Apply -- a `Record<columnId, value>` where each
      // value is the final shape TanStack's `setFilterValue` expects
      // (multiselect: string[]|undefined, text: string|undefined,
      //  date: {from?,to?}|undefined).
      (e: 'commit-draft', draft: Draft): void
      (e: 'open'): void
    }>()
    
    // ── Draft state ──────────────────────────────────────────────────────
    // Seeded from real column filter values when the popover opens; cleared
    // when it closes. All in-popover UI binds to this map -- never to the
    // live TanStack filter values directly.
    const draft = ref<Draft>({})
    
    function seedDraft() {
      const next: Draft = {}
      for (const f of props.filters) {
        if (f.type === 'multiselect' || f.type === 'select') {
          next[f.column] = [...props.getMultiSelectValue(f.column)]
        }
        else if (f.type === 'date') {
          const dr = props.getDateRangeValue(f.column)
          next[f.column] = { from: dr.from, to: dr.to }
        }
        else if (f.type === 'text') {
          const v = props.table.getColumn(f.column)?.getFilterValue() as string | undefined
          next[f.column] = v ?? ''
        }
      }
      draft.value = next
    }
    
    function getDraftMulti(column: string): string[] {
      const v = draft.value[column]
      return Array.isArray(v) ? v : []
    }
    
    function getDraftText(column: string): string {
      const v = draft.value[column]
      return typeof v === 'string' ? v : ''
    }
    
    function getDraftDate(column: string): DraftDateValue {
      const v = draft.value[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]
      draft.value = { ...draft.value, [column]: next }
    }
    
    function setDraftText(column: string, value: string) {
      draft.value = { ...draft.value, [column]: value }
    }
    
    function clearDraftSection(filter: FilterDefinition) {
      if (filter.type === 'multiselect' || filter.type === 'select') {
        draft.value = { ...draft.value, [filter.column]: [] }
      }
      else if (filter.type === 'date') {
        draft.value = { ...draft.value, [filter.column]: {} }
      }
      else if (filter.type === 'text') {
        draft.value = { ...draft.value, [filter.column]: '' }
      }
    }
    
    function resetDraft() {
      const next: Draft = {}
      for (const f of props.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] = ''
      }
      draft.value = next
    }
    
    // ── Date helpers (local; popover is fully self-contained for draft) ──
    function stringToDateValue(str: string | undefined): DateValue | undefined {
      if (!str) return undefined
      const [y, m, d] = str.split('-').map(Number)
      if (y === undefined || m === undefined || d === undefined) return undefined
      return new CalendarDate(y, m, d)
    }
    
    function dateValueToString(dv: DateValue | undefined): string {
      if (!dv) return ''
      return `${dv.year}-${String(dv.month).padStart(2, '0')}-${String(dv.day).padStart(2, '0')}`
    }
    
    function getDraftCalendarModel(column: string): RekaDateRange {
      const dr = getDraftDate(column)
      return {
        start: stringToDateValue(dr.from) as DateValue,
        end: stringToDateValue(dr.to) as DateValue,
      }
    }
    
    function onDraftCalendarUpdate(column: string, val: RekaDateRange) {
      const from = val?.start ? dateValueToString(val.start) : undefined
      const to = val?.end ? dateValueToString(val.end) : undefined
      draft.value = { ...draft.value, [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 ''
    }
    
    // ── Active section detection (draft, not live) ───────────────────────
    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 = computed(() => props.filters.some(isDraftSectionActive))
    
    // ── Popover lifecycle ────────────────────────────────────────────────
    watch(open, (isOpen, wasOpen) => {
      if (isOpen && !wasOpen) {
        seedDraft()
        emit('open')
        return
      }
      if (!isOpen && wasOpen) {
        // Close-without-apply: nothing to do. Real column filters were never
        // mutated by the popover; the draft is local and can be left to
        // garbage-collect (next open re-seeds from live state).
        draft.value = {}
      }
    })
    
    function applyAndClose() {
      // Snapshot the draft so the parent receives a stable object even if
      // the close watcher fires before they finish consuming it.
      const snapshot: Draft = {}
      for (const f of props.filters) {
        const v = draft.value[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
        }
      }
      emit('commit-draft', snapshot)
      open.value = false
    }
    
    function onResetClick() {
      resetDraft()
    }
    
    const filterScrollRef = ref<HTMLElement | null>(null)
    
    watch(open, (isOpen) => {
      if (isOpen) {
        setTimeout(() => {
          filterScrollRef.value?.scrollTo({ top: 0 })
        }, 50)
      }
    })
    </script>
    
    <template>
      <Popover v-model:open="open">
        <PopoverTrigger as-child>
          <Button
            variant="outline"
            size="sm"
            class="h-9 gap-2"
          >
            <SlidersHorizontal class="size-3.5" />
            Filters
            <Badge
              v-if="activeFilterCount > 0"
              variant="secondary"
              class="ml-1 h-5 min-w-5 rounded-full px-1.5 text-xs font-semibold"
            >
              {{ activeFilterCount }}
            </Badge>
          </Button>
        </PopoverTrigger>
        <PopoverContent
          align="start"
          class="flex max-h-[min(560px,80vh)] w-[380px] flex-col overflow-hidden p-0"
        >
          <!-- Header -->
          <div class="border-b px-4 pt-3.5 pb-3">
            <div class="flex items-center gap-2.5">
              <div class="bg-muted flex size-7 items-center justify-center rounded-md">
                <SlidersHorizontal class="text-muted-foreground size-3.5" />
              </div>
              <div class="flex-1">
                <p class="text-sm leading-none font-semibold">
                  Filters
                </p>
                <p class="text-muted-foreground mt-1 text-xs">
                  <template v-if="activeFilterCount > 0">
                    {{ activeFilterCount }} active
                    <template v-if="!isServerSide">
                      &middot; {{ table.getFilteredRowModel().rows.length }} result{{
                        table.getFilteredRowModel().rows.length !== 1 ? 's' : ''
                      }}
                    </template>
                  </template>
                  <template v-else>
                    Narrow down results
                  </template>
                </p>
              </div>
            </div>
          </div>
    
          <!-- Scrollable filter sections -->
          <div
            ref="filterScrollRef"
            class="flex-1 overflow-y-auto"
          >
            <div class="space-y-2 p-3">
              <template
                v-for="filter in filters"
                :key="filter.column"
              >
                <!-- Text filter -->
                <div
                  v-if="filter.type === 'text'"
                  class="bg-muted/40 rounded-lg p-2.5"
                >
                  <div class="mb-2 flex items-center justify-between">
                    <Label class="text-muted-foreground text-xs font-medium tracking-wide uppercase">{{
                      filter.label
                    }}</Label>
                    <button
                      v-if="getDraftText(filter.column)"
                      type="button"
                      class="text-muted-foreground hover:text-foreground text-xs transition-colors"
                      @click="clearDraftSection(filter)"
                    >
                      Clear
                    </button>
                  </div>
                  <Input
                    :placeholder="`Filter by ${filter.label.toLowerCase()}...`"
                    :model-value="getDraftText(filter.column)"
                    class="h-8 text-sm"
                    @update:model-value="setDraftText(filter.column, ($event as string) ?? '')"
                  />
                </div>
    
                <!-- Multiselect / Select filter -->
                <div
                  v-else-if="filter.type === 'multiselect' || filter.type === 'select'"
                  class="rounded-lg p-2.5 transition-colors"
                  :class="[
                    getDraftMulti(filter.column).length > 0
                      ? 'bg-primary/[0.04] ring-primary/20 ring-1'
                      : 'bg-muted/40',
                  ]"
                >
                  <div class="mb-2 flex items-center justify-between">
                    <div class="flex items-center gap-2">
                      <Label class="text-muted-foreground text-xs font-medium tracking-wide uppercase">{{
                        filter.label
                      }}</Label>
                      <Badge
                        v-if="getDraftMulti(filter.column).length > 0"
                        variant="secondary"
                        class="bg-primary/15 text-primary h-4 rounded-full px-1.5 text-xs font-semibold"
                      >
                        {{ getDraftMulti(filter.column).length }}
                      </Badge>
                    </div>
                    <button
                      v-if="getDraftMulti(filter.column).length > 0"
                      type="button"
                      class="text-muted-foreground hover:text-foreground text-xs transition-colors"
                      @click="clearDraftSection(filter)"
                    >
                      Clear
                    </button>
                  </div>
                  <Command
                    class="[&_[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
                      class="h-7 text-sm"
                      :placeholder="`Search ${filter.label.toLowerCase()}...`"
                    />
                    <CommandList class="mt-1 max-h-[132px]">
                      <CommandEmpty>No results.</CommandEmpty>
                      <CommandGroup class="p-0">
                        <CommandItem
                          v-for="rawOpt in filter.options"
                          :key="resolveOption(rawOpt).value"
                          :value="resolveOption(rawOpt).label"
                          class="rounded-md px-2 py-1.5 text-sm"
                          @select="toggleDraftMulti(filter.column, resolveOption(rawOpt).value)"
                        >
                          <div
                            class="flex size-4 shrink-0 items-center justify-center rounded-sm border transition-colors"
                            :class="[
                              getDraftMulti(filter.column).includes(resolveOption(rawOpt).value)
                                ? 'border-primary bg-primary text-primary-foreground'
                                : 'border-muted-foreground/40 [&_svg]:invisible',
                            ]"
                          >
                            <Check class="size-3" />
                          </div>
                          <component
                            :is="resolveOption(rawOpt).icon"
                            v-if="resolveOption(rawOpt).icon"
                            class="text-muted-foreground size-4"
                          />
                          <span>{{ resolveOption(rawOpt).label }}</span>
                        </CommandItem>
                      </CommandGroup>
                    </CommandList>
                  </Command>
                </div>
    
                <!-- Date range filter -->
                <div
                  v-else-if="filter.type === 'date'"
                  class="rounded-lg p-2.5 transition-colors"
                  :class="[
                    getDraftDate(filter.column).from || getDraftDate(filter.column).to
                      ? 'bg-primary/[0.04] ring-primary/20 ring-1'
                      : 'bg-muted/40',
                  ]"
                >
                  <div class="mb-2 flex items-center justify-between">
                    <div class="flex items-center gap-2">
                      <Label class="text-muted-foreground text-xs font-medium tracking-wide uppercase">{{
                        filter.label
                      }}</Label>
                      <Badge
                        v-if="getDraftDate(filter.column).from || getDraftDate(filter.column).to"
                        variant="secondary"
                        class="bg-primary/15 text-primary h-auto rounded-full px-1.5 py-0 text-xs font-medium"
                      >
                        {{ formatDraftDateRange(filter.column) }}
                      </Badge>
                    </div>
                    <button
                      v-if="getDraftDate(filter.column).from || getDraftDate(filter.column).to"
                      type="button"
                      class="text-muted-foreground hover:text-foreground text-xs transition-colors"
                      @click="clearDraftSection(filter)"
                    >
                      Clear
                    </button>
                  </div>
                  <div class="flex justify-center overflow-hidden rounded-md border">
                    <RangeCalendar
                      :model-value="getDraftCalendarModel(filter.column)"
                      :number-of-months="1"
                      class="p-2"
                      @update:model-value="onDraftCalendarUpdate(filter.column, $event as RekaDateRange)"
                    />
                  </div>
                </div>
              </template>
    
              <!-- Consumer-supplied custom filter UI -->
              <slot name="custom-filters" />
            </div>
          </div>
    
          <!-- Footer -->
          <div class="flex gap-2 border-t px-3 py-2.5">
            <Button
              variant="outline"
              size="sm"
              class="h-8 flex-1"
              :disabled="!isDraftDirty"
              @click="onResetClick"
            >
              <X class="size-3.5" />
              Reset
            </Button>
            <Button
              size="sm"
              class="h-8 flex-1"
              @click="applyAndClose"
            >
              Apply
            </Button>
          </div>
        </PopoverContent>
      </Popover>
    </template>
  • app/components/ui/data-table/DataTableFilterSheet.vue 11.2 kB
    <script setup lang="ts">
    import type { Table } from '@tanstack/vue-table'
    import type { FilterDefinition, FilterOption } from './DataTable.vue'
    import { ref, watch } from 'vue'
    
    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 { Check, SlidersHorizontal, X } from 'lucide-vue-next'
    
    function resolveOption(opt: string | FilterOption): FilterOption {
      if (typeof opt === 'string') return { value: opt, label: opt }
      return opt
    }
    
    withDefaults(
      defineProps<{
        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) => any
      }>(),
      { borderless: false },
    )
    
    const open = defineModel<boolean>('open', { default: false })
    
    const emit = defineEmits<{
      (e: 'apply' | 'clear-all'): void
      (e: 'toggle-multiselect', column: string, value: string): void
      (e: 'clear-filter' | 'clear-date-filter', filter: FilterDefinition): void
      (e: 'calendar-update', column: string, value: any): void
      (e: 'text-filter-update', column: string, value: string | undefined): void
    }>()
    
    const filterScrollRef = ref<HTMLElement | null>(null)
    
    watch(open, (isOpen) => {
      if (isOpen) {
        setTimeout(() => {
          filterScrollRef.value?.scrollTo({ top: 0 })
        }, 50)
      }
    })
    </script>
    
    <template>
      <Sheet v-model:open="open">
        <SheetContent :class="['flex flex-col gap-0 p-0 sm:max-w-[400px]', borderless ? 'border-0' : '']">
          <!-- Header -->
          <div :class="[borderless ? 'px-5 pt-5 pb-2' : 'border-b px-5 pt-5 pb-4']">
            <div class="flex items-center gap-3">
              <div class="bg-muted flex size-9 items-center justify-center rounded-lg">
                <SlidersHorizontal class="text-muted-foreground size-4" />
              </div>
              <div class="flex-1">
                <SheetHeader class="space-y-0.5 p-0">
                  <SheetTitle class="text-sm font-semibold">
                    Filters
                  </SheetTitle>
                  <SheetDescription class="text-xs">
                    <template v-if="activeFilterCount > 0">
                      {{ activeFilterCount }} active filter{{ activeFilterCount > 1 ? 's' : '' }}
                      <template v-if="!isServerSide">
                        &middot;
                        {{ table.getFilteredRowModel().rows.length }} result{{
                          table.getFilteredRowModel().rows.length !== 1 ? 's' : ''
                        }}
                      </template>
                    </template>
                    <template v-else>
                      Narrow down results
                    </template>
                  </SheetDescription>
                </SheetHeader>
              </div>
            </div>
          </div>
    
          <!-- Scrollable filter sections -->
          <div
            ref="filterScrollRef"
            class="flex-1 overflow-y-auto"
          >
            <div class="space-y-2 p-4">
              <template
                v-for="filter in filters"
                :key="filter.column"
              >
                <!-- Text filter -->
                <div
                  v-if="filter.type === 'text'"
                  :class="[borderless ? 'py-2' : 'bg-muted/40 rounded-lg p-3']"
                >
                  <div class="mb-2 flex items-center justify-between">
                    <Label class="text-muted-foreground text-xs font-medium tracking-wide uppercase">{{
                      filter.label
                    }}</Label>
                    <button
                      v-if="table.getColumn(filter.column)?.getFilterValue() as string"
                      type="button"
                      class="text-muted-foreground hover:text-foreground text-xs transition-colors"
                      @click="emit('text-filter-update', filter.column, undefined)"
                    >
                      Clear
                    </button>
                  </div>
                  <Input
                    :placeholder="`Filter by ${filter.label.toLowerCase()}...`"
                    :model-value="(table.getColumn(filter.column)?.getFilterValue() as string) ?? ''"
                    class="h-8 text-sm"
                    @update:model-value="emit('text-filter-update', filter.column, ($event as string) || undefined)"
                  />
                </div>
    
                <!-- Multiselect / Select filter -->
                <div
                  v-else-if="filter.type === 'multiselect' || filter.type === 'select'"
                  :class="
                    borderless
                      ? 'py-2 transition-colors'
                      : [
                        'rounded-lg p-3 transition-colors',
                        getMultiSelectValue(filter.column).length > 0
                          ? 'bg-primary/[0.04] ring-primary/20 ring-1'
                          : 'bg-muted/40',
                      ]
                  "
                >
                  <div class="mb-2 flex items-center justify-between">
                    <div class="flex items-center gap-2">
                      <Label class="text-muted-foreground text-xs font-medium tracking-wide uppercase">{{
                        filter.label
                      }}</Label>
                      <Badge
                        v-if="getMultiSelectValue(filter.column).length > 0"
                        variant="secondary"
                        class="bg-primary/15 text-primary h-4 rounded-full px-1.5 text-xs font-semibold"
                      >
                        {{ getMultiSelectValue(filter.column).length }}
                      </Badge>
                    </div>
                    <button
                      v-if="getMultiSelectValue(filter.column).length > 0"
                      type="button"
                      class="text-muted-foreground hover:text-foreground text-xs transition-colors"
                      @click="emit('clear-filter', filter)"
                    >
                      Clear
                    </button>
                  </div>
                  <Command
                    class="[&_[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
                      class="h-7 text-sm"
                      :placeholder="`Search ${filter.label.toLowerCase()}...`"
                    />
                    <CommandList class="mt-1 max-h-[132px]">
                      <CommandEmpty>No results.</CommandEmpty>
                      <CommandGroup class="p-0">
                        <CommandItem
                          v-for="rawOpt in filter.options"
                          :key="resolveOption(rawOpt).value"
                          :value="resolveOption(rawOpt).label"
                          class="rounded-md px-2 py-1.5 text-sm"
                          @select="emit('toggle-multiselect', filter.column, resolveOption(rawOpt).value)"
                        >
                          <div
                            class="flex size-4 shrink-0 items-center justify-center rounded-sm border transition-colors"
                            :class="[
                              getMultiSelectValue(filter.column).includes(resolveOption(rawOpt).value)
                                ? 'border-primary bg-primary text-primary-foreground'
                                : 'border-muted-foreground/40 [&_svg]:invisible',
                            ]"
                          >
                            <Check class="size-3" />
                          </div>
                          <component
                            :is="resolveOption(rawOpt).icon"
                            v-if="resolveOption(rawOpt).icon"
                            class="text-muted-foreground size-4"
                          />
                          <span>{{ resolveOption(rawOpt).label }}</span>
                        </CommandItem>
                      </CommandGroup>
                    </CommandList>
                  </Command>
                </div>
    
                <!-- Date range filter -->
                <div
                  v-else-if="filter.type === 'date'"
                  :class="
                    borderless
                      ? 'py-2 transition-colors'
                      : [
                        'rounded-lg p-3 transition-colors',
                        getDateRangeValue(filter.column).from || getDateRangeValue(filter.column).to
                          ? 'bg-primary/[0.04] ring-primary/20 ring-1'
                          : 'bg-muted/40',
                      ]
                  "
                >
                  <div class="mb-2 flex items-center justify-between">
                    <div class="flex items-center gap-2">
                      <Label class="text-muted-foreground text-xs font-medium tracking-wide uppercase">{{
                        filter.label
                      }}</Label>
                      <Badge
                        v-if="getDateRangeValue(filter.column).from || getDateRangeValue(filter.column).to"
                        variant="secondary"
                        class="bg-primary/15 text-primary h-auto rounded-full px-1.5 py-0 text-xs font-medium"
                      >
                        {{ formatDateRange(filter.column) }}
                      </Badge>
                    </div>
                    <button
                      v-if="getDateRangeValue(filter.column).from || getDateRangeValue(filter.column).to"
                      type="button"
                      class="text-muted-foreground hover:text-foreground text-xs transition-colors"
                      @click="emit('clear-date-filter', filter)"
                    >
                      Clear
                    </button>
                  </div>
                  <div :class="['flex justify-center overflow-hidden', borderless ? '' : 'rounded-md border']">
                    <RangeCalendar
                      :model-value="getCalendarModel(filter.column)"
                      :number-of-months="1"
                      class="p-2"
                      @update:model-value="emit('calendar-update', filter.column, $event)"
                    />
                  </div>
                </div>
              </template>
    
              <!-- Consumer-supplied custom filter UI -->
              <slot name="custom-filters" />
            </div>
          </div>
    
          <!-- Footer -->
          <div :class="['flex gap-2 px-4 py-3', borderless ? '' : 'border-t']">
            <Button
              variant="outline"
              size="sm"
              class="flex-1"
              :disabled="!isAnyFilterActive"
              @click="emit('clear-all')"
            >
              <X class="size-3.5" />
              Reset All
            </Button>
            <Button
              size="sm"
              class="flex-1"
              @click="emit('apply')"
            >
              <template v-if="isServerSide">
                Apply Filters
              </template>
              <template v-else>
                Show {{ table.getFilteredRowModel().rows.length }} result{{
                  table.getFilteredRowModel().rows.length !== 1 ? 's' : ''
                }}
              </template>
            </Button>
          </div>
        </SheetContent>
      </Sheet>
    </template>
  • app/components/ui/data-table/DataTablePagination.vue 3.4 kB
    <script setup lang="ts">
    import type { Table } from '@tanstack/vue-table'
    
    import { Button } from '@/components/ui/button'
    import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
    import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next'
    
    withDefaults(
      defineProps<{
        table: Table<any>
        totalRows: number
        isServerSide: boolean
        borderless?: boolean
      }>(),
      { borderless: false },
    )
    </script>
    
    <template>
      <div :class="['flex items-center justify-between py-3', borderless ? '' : 'border-t px-4']">
        <div class="text-muted-foreground flex-1 text-sm">
          {{ table.getFilteredSelectedRowModel().rows.length }} of
          {{ isServerSide ? totalRows : table.getFilteredRowModel().rows.length }} row(s) selected.
        </div>
        <div class="flex items-center gap-6 lg:gap-8">
          <div class="flex items-center gap-2">
            <p class="text-sm font-medium whitespace-nowrap">
              Rows per page
            </p>
            <Select
              :model-value="`${table.getState().pagination.pageSize}`"
              @update:model-value="
                (value) => {
                  table.setPageSize(Number(value))
                  table.setPageIndex(0)
                }
              "
            >
              <SelectTrigger class="h-8 w-[70px]">
                <SelectValue :placeholder="`${table.getState().pagination.pageSize}`" />
              </SelectTrigger>
              <SelectContent side="top">
                <SelectItem
                  v-for="pageSize in [10, 20, 30, 40, 50]"
                  :key="pageSize"
                  :value="`${pageSize}`"
                >
                  {{ pageSize }}
                </SelectItem>
              </SelectContent>
            </Select>
          </div>
          <div class="flex items-center justify-center text-sm font-medium whitespace-nowrap">
            Page {{ table.getState().pagination.pageIndex + 1 }} of
            {{ table.getPageCount() }}
          </div>
          <div class="flex items-center gap-1">
            <Button
              variant="outline"
              size="icon"
              class="size-8"
              :disabled="!table.getCanPreviousPage()"
              @click="table.setPageIndex(0)"
            >
              <ChevronsLeft
                class="size-4"
                aria-hidden="true"
              />
              <span class="sr-only">First page</span>
            </Button>
            <Button
              variant="outline"
              size="icon"
              class="size-8"
              :disabled="!table.getCanPreviousPage()"
              @click="table.previousPage()"
            >
              <ChevronLeft
                class="size-4"
                aria-hidden="true"
              />
              <span class="sr-only">Previous page</span>
            </Button>
            <Button
              variant="outline"
              size="icon"
              class="size-8"
              :disabled="!table.getCanNextPage()"
              @click="table.nextPage()"
            >
              <ChevronRight
                class="size-4"
                aria-hidden="true"
              />
              <span class="sr-only">Next page</span>
            </Button>
            <Button
              variant="outline"
              size="icon"
              class="size-8"
              :disabled="!table.getCanNextPage()"
              @click="table.setPageIndex(table.getPageCount() - 1)"
            >
              <ChevronsRight
                class="size-4"
                aria-hidden="true"
              />
              <span class="sr-only">Last page</span>
            </Button>
          </div>
        </div>
      </div>
    </template>
  • app/components/ui/data-table/DataTableToolbar.vue 16.1 kB
    <script setup lang="ts">
    import type { Table } from '@tanstack/vue-table'
    import type { FilterDefinition, FilterOption } from './DataTable.vue'
    
    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 { CalendarIcon, Check, ChevronDown, Download, ListFilter, Plus, Rows3, SlidersHorizontal, X } from 'lucide-vue-next'
    import DataTableFilterPopover from './DataTableFilterPopover.vue'
    
    function resolveOption(opt: string | FilterOption): FilterOption {
      if (typeof opt === 'string') return { value: opt, label: opt }
      return opt
    }
    
    withDefaults(
      defineProps<{
        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) => any
      }>(),
      { enableSearch: true, enableColumnVisibility: true, enableExport: false, enableDensityToggle: false, density: 'cozy', borderless: false },
    )
    
    const emit = defineEmits<{
      (e: 'search', value: string): void
      (e: 'open-filter-sheet' | 'clear-all-filters' | 'apply-filters' | 'export-csv' | 'export-json'): void
      (e: 'toggle-multiselect', column: string, value: string): void
      (e: 'clear-filter' | 'clear-date-filter', filter: FilterDefinition): void
      (e: 'calendar-update', column: string, value: any): void
      (e: 'text-filter-update', column: string, value: string | undefined): void
      // Popover filter mode: commit-draft fires when the user clicks Apply
      // in the staged-edit popover. The payload is a Record<columnId, value>
      // ready to be fed into `setFilterValue` per column.
      (e: 'commit-filters', draft: Record<string, any>): void
      (e: 'update:density', value: 'compact' | 'cozy' | 'comfortable'): void
    }>()
    </script>
    
    <template>
      <div :class="['flex flex-col gap-2 py-3', borderless ? '' : 'border-b px-4']">
        <div class="flex items-center gap-2">
          <!-- Search only renders when filterColumn maps to an actual column
               in the table; otherwise TanStack's getColumn() logs a noisy
               "[Table] Column with id '<x>' does not exist" warning even
               though our optional-chaining keeps the call safe. -->
          <Input
            v-if="enableSearch && filterColumn && table.getColumn(filterColumn)"
            class="h-9 max-w-xs"
            :placeholder="filterPlaceholder"
            :model-value="table.getColumn(filterColumn)?.getFilterValue() as string"
            @update:model-value="emit('search', $event as string)"
          />
    
          <!-- ── INLINE filter mode ── -->
          <template v-if="filterMode === 'inline'">
            <template
              v-for="filter in filters"
              :key="filter.column"
            >
              <!-- Multiselect / Select -->
              <Popover v-if="filter.type === 'multiselect' || filter.type === 'select'">
                <PopoverTrigger as-child>
                  <Button
                    variant="outline"
                    size="sm"
                    class="h-9 border-dashed"
                  >
                    <Plus
                      class="size-4"
                      aria-hidden="true"
                    />
                    {{ filter.label }}
                    <template v-if="getMultiSelectValue(filter.column).length > 0">
                      <Separator
                        orientation="vertical"
                        class="mx-1 h-4"
                      />
                      <div class="flex gap-1">
                        <Badge
                          v-if="getMultiSelectValue(filter.column).length > 2"
                          variant="secondary"
                          class="rounded-sm px-1 font-normal"
                        >
                          {{ getMultiSelectValue(filter.column).length }} selected
                        </Badge>
                        <template v-else>
                          <Badge
                            v-for="label in getFilterSelectedLabels(filter)"
                            :key="label"
                            variant="secondary"
                            class="rounded-sm px-1 font-normal"
                          >
                            {{ label }}
                          </Badge>
                        </template>
                      </div>
                    </template>
                  </Button>
                </PopoverTrigger>
                <PopoverContent
                  class="w-52 p-0"
                  align="start"
                >
                  <Command>
                    <CommandInput :placeholder="`Search ${filter.label.toLowerCase()}...`" />
                    <CommandList>
                      <CommandEmpty>No results.</CommandEmpty>
                      <CommandGroup>
                        <CommandItem
                          v-for="rawOpt in filter.options"
                          :key="resolveOption(rawOpt).value"
                          :value="resolveOption(rawOpt).label"
                          @select="emit('toggle-multiselect', filter.column, resolveOption(rawOpt).value)"
                        >
                          <div
                            class="border-primary flex size-4 shrink-0 items-center justify-center rounded-sm border"
                            :class="[
                              getMultiSelectValue(filter.column).includes(resolveOption(rawOpt).value)
                                ? 'bg-primary text-primary-foreground'
                                : 'opacity-50 [&_svg]:invisible',
                            ]"
                          >
                            <Check class="size-3" />
                          </div>
                          <component
                            :is="resolveOption(rawOpt).icon"
                            v-if="resolveOption(rawOpt).icon"
                            class="text-muted-foreground size-4"
                          />
                          <span>{{ resolveOption(rawOpt).label }}</span>
                        </CommandItem>
                      </CommandGroup>
                      <template v-if="getMultiSelectValue(filter.column).length > 0">
                        <CommandSeparator />
                        <CommandGroup>
                          <CommandItem
                            value="__clear__"
                            class="justify-center text-center"
                            @select="emit('clear-filter', filter)"
                          >
                            Clear filter
                          </CommandItem>
                        </CommandGroup>
                      </template>
                    </CommandList>
                  </Command>
                </PopoverContent>
              </Popover>
    
              <!-- Date range filter -->
              <Popover v-else-if="filter.type === 'date'">
                <PopoverTrigger as-child>
                  <Button
                    variant="outline"
                    size="sm"
                    class="h-9 border-dashed"
                  >
                    <CalendarIcon
                      class="size-4"
                      aria-hidden="true"
                    />
                    {{ filter.label }}
                    <template v-if="getDateRangeValue(filter.column).from || getDateRangeValue(filter.column).to">
                      <Separator
                        orientation="vertical"
                        class="mx-1 h-4"
                      />
                      <Badge
                        variant="secondary"
                        class="rounded-sm px-1 font-normal"
                      >
                        {{ formatDateRange(filter.column) }}
                      </Badge>
                    </template>
                  </Button>
                </PopoverTrigger>
                <PopoverContent
                  class="w-auto p-0"
                  align="start"
                >
                  <RangeCalendar
                    :model-value="getCalendarModel(filter.column)"
                    initial-focus
                    :number-of-months="2"
                    @update:model-value="emit('calendar-update', filter.column, $event)"
                  />
                  <div
                    v-if="getDateRangeValue(filter.column).from || getDateRangeValue(filter.column).to"
                    class="border-t p-2"
                  >
                    <Button
                      variant="ghost"
                      size="sm"
                      class="h-7 w-full text-xs"
                      @click="emit('clear-date-filter', filter)"
                    >
                      Clear dates
                    </Button>
                  </div>
                </PopoverContent>
              </Popover>
    
              <!-- Text filter -->
              <Popover v-else-if="filter.type === 'text'">
                <PopoverTrigger as-child>
                  <Button
                    variant="outline"
                    size="sm"
                    class="h-9 border-dashed"
                  >
                    <Plus
                      class="size-4"
                      aria-hidden="true"
                    />
                    {{ filter.label }}
                    <template v-if="table.getColumn(filter.column)?.getFilterValue() as string">
                      <Separator
                        orientation="vertical"
                        class="mx-1 h-4"
                      />
                      <Badge
                        variant="secondary"
                        class="rounded-sm px-1 font-normal"
                      >
                        {{ table.getColumn(filter.column)?.getFilterValue() }}
                      </Badge>
                    </template>
                  </Button>
                </PopoverTrigger>
                <PopoverContent
                  class="w-60 p-3"
                  align="start"
                >
                  <div class="space-y-2">
                    <p class="text-sm font-medium">
                      {{ filter.label }}
                    </p>
                    <Input
                      :placeholder="`Filter by ${filter.label.toLowerCase()}...`"
                      :model-value="(table.getColumn(filter.column)?.getFilterValue() as string) ?? ''"
                      class="h-8 text-sm"
                      @update:model-value="table.getColumn(filter.column)?.setFilterValue($event || undefined)"
                    />
                  </div>
                </PopoverContent>
              </Popover>
            </template>
          </template>
    
          <!-- ── MODAL filter mode (Sheet from the right) ── -->
          <template v-if="filterMode === 'modal'">
            <Button
              variant="outline"
              size="sm"
              class="h-9"
              :class="[activeFilterCount > 0 ? 'border-primary/40 bg-primary/5 text-primary hover:bg-primary/10' : '']"
              @click="emit('open-filter-sheet')"
            >
              <SlidersHorizontal
                class="size-4"
                aria-hidden="true"
              />
              Filters
              <Badge
                v-if="activeFilterCount > 0"
                class="bg-primary text-primary-foreground ml-0.5 size-5 rounded-full p-0 text-xs font-semibold"
              >
                {{ activeFilterCount }}
              </Badge>
            </Button>
          </template>
    
          <!-- ── POPOVER filter mode ── -->
          <template v-if="filterMode === 'popover' && filters.length">
            <DataTableFilterPopover
              :table="table"
              :filters="filters"
              :active-filter-count="activeFilterCount"
              :is-any-filter-active="isAnyFilterActive"
              :is-server-side="isServerSide"
              :get-multi-select-value="getMultiSelectValue"
              :get-date-range-value="getDateRangeValue"
              :format-date-range="formatDateRange"
              :get-calendar-model="getCalendarModel"
              @commit-draft="(d) => emit('commit-filters', d)"
              @clear-all="emit('clear-all-filters')"
            >
              <template
                v-if="$slots['custom-filters']"
                #custom-filters
              >
                <slot name="custom-filters" />
              </template>
            </DataTableFilterPopover>
          </template>
    
          <!-- Inline custom filters (when filterMode === 'inline') -->
          <slot
            v-if="filterMode === 'inline'"
            name="custom-filters"
          />
    
          <!-- Toolbar extras (e.g. group-by selector, density toggle) -->
          <slot name="toolbar-extra" />
    
          <!-- Reset button -->
          <Button
            v-if="isAnyFilterActive"
            variant="ghost"
            size="sm"
            class="h-9"
            @click="emit('clear-all-filters')"
          >
            Reset
            <X
              class="size-4"
              aria-hidden="true"
            />
          </Button>
    
          <!-- Export (CSV / JSON) -->
          <div
            v-if="enableExport"
            class="ml-auto"
          >
            <DropdownMenu>
              <DropdownMenuTrigger as-child>
                <Button
                  variant="outline"
                  size="sm"
                  class="h-9"
                >
                  <Download class="size-3.5" />
                  Export
                  <ChevronDown class="size-3.5 opacity-60" />
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end">
                <DropdownMenuItem @select="emit('export-csv')">
                  Export as CSV
                </DropdownMenuItem>
                <DropdownMenuItem @select="emit('export-json')">
                  Export as JSON
                </DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          </div>
    
          <!-- Density toggle -->
          <div
            v-if="enableDensityToggle"
            :class="enableExport ? '' : 'ml-auto'"
          >
            <DropdownMenu>
              <DropdownMenuTrigger as-child>
                <Button
                  variant="outline"
                  size="sm"
                  class="h-9"
                  :aria-label="`Row density: ${density}`"
                >
                  <Rows3
                    class="size-4"
                    aria-hidden="true"
                  />
                  <span class="capitalize">{{ density }}</span>
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end">
                <DropdownMenuRadioGroup
                  :model-value="density"
                  @update:model-value="(v: any) => emit('update:density', 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 -->
          <div
            v-if="enableColumnVisibility"
            :class="enableExport || enableDensityToggle ? '' : 'ml-auto'"
          >
            <DropdownMenu>
              <DropdownMenuTrigger as-child>
                <Button
                  variant="outline"
                  size="sm"
                  class="h-9"
                >
                  <ListFilter
                    class="size-4"
                    aria-hidden="true"
                  />
                  View
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end">
                <DropdownMenuCheckboxItem
                  v-for="column in table.getAllColumns().filter((column) => column.getCanHide())"
                  :key="column.id"
                  class="capitalize"
                  :checked="column.getIsVisible()"
                  @update:checked="(value: boolean) => column.toggleVisibility(!!value)"
                >
                  {{ column.id }}
                </DropdownMenuCheckboxItem>
              </DropdownMenuContent>
            </DropdownMenu>
          </div>
        </div>
      </div>
    </template>
  • app/components/ui/data-table/index.ts 0.5 kB
    export { default as DataTable } from './DataTable.vue'
    export { default as DataTableColumnHeader } from './DataTableColumnHeader.vue'
    export { default as DataTableToolbar } from './DataTableToolbar.vue'
    export { default as DataTableFilterSheet } from './DataTableFilterSheet.vue'
    export { default as DataTableFilterPopover } from './DataTableFilterPopover.vue'
    export { default as DataTablePagination } from './DataTablePagination.vue'
    export type { FilterDefinition, FilterOption } from './DataTable.vue'

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