shadimage
niko-table
componentdata-table-column-faceted-filtersource: ok · refreshed

DataTable Column Faceted Filter

Column header faceted filter with multi-select options in a dropdown.

npx shadcn add "https://niko-table.com/r/data-table-column-faceted-filter.json"
free

Where it goes

navbardata-table

Interaction

dropdown

Use cases

dashboard

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

https://niko-table.com/r/data-table-core.jsonhttps://niko-table.com/r/data-table-faceted-filter.jsonbuttondropdown-menutooltippopovercommand

Files3 · source cached 10/8/2026, 5:37:02 AM

src/components/niko-table/components/data-table-column-faceted-filter.tsx
"use client"

/**
 * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
 *
 * Before reporting anything: please check the changelog first.
 *  - In-repo: ./CHANGELOG.md
 *  - Docs site: https://niko-table.com/changelog
 *
 * Found a bug or have a fix? Open an issue or PR on GitHub so other
 * users (and future LLMs reading this code) benefit:
 * https://github.com/Semkoo/niko-table-registry
 */
import React from "react"

import {
  TableColumnFacetedFilterOptions,
  TableColumnFacetedFilterMenu,
} from "../filters/table-column-faceted-filter"
import { useDataTable } from "../core/data-table-context"
import { useColumnHeaderContext } from "./data-table-column-header"

import type { DataTableColumn } from "../types"
import type { RowData } from "@tanstack/react-table"
/**
 * Faceted filter options for composing inside DataTableColumnActions using context.
 */
export function DataTableColumnFacetedFilterOptions<
  TData extends RowData,
  TValue,
>(
  props: Omit<
    React.ComponentProps<typeof TableColumnFacetedFilterOptions>,
    "column"
  > & {
    column?: DataTableColumn<TData, TValue>
  },
) {
  const context = useColumnHeaderContext<TData, TValue>(false)
  const column = props.column || context?.column

  if (!column) {
    console.warn(
      "DataTableColumnFacetedFilterOptions must be used within DataTableColumnHeaderRoot or provided with a column prop",
    )
    return null
  }

  return <TableColumnFacetedFilterOptions column={column} {...props} />
}

DataTableColumnFacetedFilterOptions.displayName =
  "DataTableColumnFacetedFilterOptions"

/**
 * Standalone faceted filter menu for column header using context.
 */
export function DataTableColumnFacetedFilterMenu<TData extends RowData, TValue>(
  props: Omit<
    React.ComponentProps<typeof TableColumnFacetedFilterMenu>,
    "column" | "table"
  > & {
    column?: DataTableColumn<TData, TValue>
  },
) {
  const context = useColumnHeaderContext<TData, TValue>(false)
  const column = props.column || context?.column
  const { table, generatedOptionsMap } = useDataTable<TData>()

  if (!column) {
    console.warn(
      "DataTableColumnFacetedFilterMenu must be used within DataTableColumnHeaderRoot or provided with a column prop",
    )
    return null
  }

  return (
    <TableColumnFacetedFilterMenu
      column={column}
      table={table}
      precomputedOptions={
        // Skip batch cache when caller supplies custom per-column config
        // (dynamicCounts / limitToFilteredRows) so those props are honoured.
        "dynamicCounts" in props || "limitToFilteredRows" in props
          ? undefined
          : generatedOptionsMap
      }
      {...props}
    />
  )
}

DataTableColumnFacetedFilterMenu.displayName =
  "DataTableColumnFacetedFilterMenu"
src/components/niko-table/filters/table-column-faceted-filter.tsx
"use client"

/**
 * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
 *
 * Before reporting anything: please check the changelog first.
 *  - In-repo: ./CHANGELOG.md
 *  - Docs site: https://niko-table.com/changelog
 *
 * Found a bug or have a fix? Open an issue or PR on GitHub so other
 * users (and future LLMs reading this code) benefit:
 * https://github.com/Semkoo/niko-table-registry
 */
import React from "react"
import type { RowData } from "@tanstack/react-table"
import { CircleHelp, Filter, FilterX } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  DropdownMenuSeparator,
  DropdownMenuLabel,
} from "@/components/ui/dropdown-menu"
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import {
  TableFacetedFilter,
  TableFacetedFilterContent,
  useTableFacetedFilter,
} from "./table-faceted-filter"
import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"
import { useGeneratedOptionsForColumn } from "../hooks/use-generated-options"
import { extractFilterSelectedValues } from "../lib/build-faceted-options"
import { formatLabel } from "../lib/format"
import type { DataTableColumn, DataTableInstance, Option } from "../types"

/**
 * A standard filter trigger button (Funnel icon).
 */
export function TableColumnFilterTrigger<TData extends RowData, TValue>({
  column,
  className,
  ...props
}: {
  column: DataTableColumn<TData, TValue>
} & React.ComponentProps<typeof Button>) {
  const isFiltered = column.getIsFiltered()

  const Icon = isFiltered ? FilterX : Filter

  return (
    <Button
      variant="ghost"
      size="icon"
      className={cn(
        "size-7 transition-opacity dark:text-muted-foreground",
        isFiltered && "text-primary",
        className,
      )}
      {...props}
    >
      <Icon className="size-3.5" />
      <span className="sr-only">Filter column</span>
    </Button>
  )
}

/**
 * Faceted filter options for composing inside TableColumnActions.
 * Renders as inline searchable menu with checkboxes.
 *
 * @example
 * ```tsx
 * // Inside TableColumnActions
 * <TableColumnActions column={column}>
 *   <TableColumnFacetedFilterOptions
 *     column={column}
 *     options={[{ label: "Active", value: "active" }]}
 *     multiple
 *   />
 * </TableColumnActions>
 * ```
 */
export function TableColumnFacetedFilterOptions<TData extends RowData, TValue>({
  column,
  title,
  options = [],
  onValueChange,
  multiple = true,
  withSeparator = true,
}: {
  column: DataTableColumn<TData, TValue>
  title?: string
  options?: Option[]
  onValueChange?: (value: string[] | undefined) => void
  /** Whether to allow multiple selections. Defaults to true. */
  multiple?: boolean
  /** Whether to render a separator before the options. Defaults to true. */
  withSeparator?: boolean
}) {
  const { selectedValues, onItemSelect, onReset } = useTableFacetedFilter({
    column: column as DataTableColumn<TData, unknown>,
    onValueChange,
    multiple,
  })

  const derivedTitle = useDerivedColumnTitle(column, column.id, title)
  const labelText = multiple ? "Column Multi Select" : "Column Select"
  const tooltipText = multiple
    ? "Select multiple options to filter"
    : "Select a single option to filter"

  return (
    <>
      {withSeparator && <DropdownMenuSeparator />}
      <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground">
        <span>{labelText}</span>
        <Tooltip>
          <TooltipTrigger asChild>
            <CircleHelp className="size-3.5 cursor-help" />
          </TooltipTrigger>
          <TooltipContent side="right">
            {tooltipText}
            {derivedTitle && ` - ${derivedTitle}`}
          </TooltipContent>
        </Tooltip>
      </DropdownMenuLabel>
      <TableFacetedFilterContent
        title={derivedTitle}
        options={options}
        selectedValues={selectedValues}
        onItemSelect={onItemSelect}
        onReset={onReset}
      />
    </>
  )
}

/**
 * Standalone faceted filter menu for column headers.
 * Shows a filter button that opens a popover with filter options.
 *
 * @example
 * ```tsx
 * // Standalone usage
 * <TableColumnFacetedFilterMenu
 *   column={column}
 *   options={[{ label: "Active", value: "active" }]}
 * />
 * ```
 */
export function TableColumnFacetedFilterMenu<TData extends RowData, TValue>({
  column,
  table,
  title,
  options,
  onValueChange,
  multiple,
  limitToFilteredRows,
  dynamicCounts = true,
  precomputedOptions,
  ...props
}: Omit<
  React.ComponentProps<typeof TableFacetedFilter>,
  "column" | "trigger" | "options"
> & {
  column: DataTableColumn<TData, TValue>
  table?: DataTableInstance<TData>
  title?: string
  options?: React.ComponentProps<typeof TableFacetedFilter>["options"]
  /**
   * If true, only show options that exist in the currently filtered rows.
   * If false, show all options from the entire dataset.
   * @default !multiple (true for single-select, false for multi-select)
   */
  limitToFilteredRows?: boolean
  /**
   * Whether to update counts based on other active filters.
   * @default true
   */
  dynamicCounts?: boolean
  /**
   * Precomputed options map from DataTableProvider context. When provided,
   * skips per-column option generation.
   */
  precomputedOptions?: Record<string, Option[]>
}) {
  // Default: multi-select shows all options, single-select filters to visible rows
  limitToFilteredRows ??= !multiple

  // A column's own selected values must never be narrowed or count-0 hidden
  // away — otherwise a selection excluded by another filter disappears from
  // its own facet and can't be un-checked. Recomputed on every render so it
  // tracks the current selection.
  const selectedValues = extractFilterSelectedValues(column.getFilterValue())

  const derivedTitle = useDerivedColumnTitle(column, column.id, title)

  // Auto-generate options from column meta (works for select/multiSelect variants).
  // Use precomputed batch when available to avoid per-column row scans.
  const needsPerColumnGeneration = !precomputedOptions
  const perColumnOptions = useGeneratedOptionsForColumn(
    table as DataTableInstance<TData>,
    needsPerColumnGeneration ? column.id : "__noop__",
    { limitToFilteredRows, dynamicCounts },
  )
  const generatedOptions = precomputedOptions?.[column.id] ?? perColumnOptions

  /**
   * REACTIVITY FIX: Extract row model references outside memos so that when
   * async data arrives, the new rows array reference triggers memo recomputation.
   * Without this, `table` reference is stable across data changes and memos
   * would return stale (empty) results after initial render with no data.
   */
  const coreRows = table?.getCoreRowModel().rows
  const filteredRows = table?.getFilteredRowModel().rows

  // Fallback: generate options from row data for any variant (text, boolean, etc.)
  const fallbackOptions = React.useMemo((): Option[] => {
    if (!table || !column) return []

    const meta = column.columnDef.meta
    const autoOptionsFormat =
      (meta as Record<string, unknown>)?.autoOptionsFormat ?? true
    const showCounts = (meta as Record<string, unknown>)?.showCounts ?? true

    // optionRows used for the list of options
    const optionRows = limitToFilteredRows ? filteredRows : coreRows

    // countRows used for the counts
    const countRows = dynamicCounts ? filteredRows : coreRows

    if (!optionRows || !countRows) return []

    const valueCounts = new Map<string, number>()

    // Determine the set of available options
    const availableOptions = new Set<string>()
    optionRows.forEach(row => {
      const raw = row.getValue(column.id) as unknown
      const values: unknown[] = Array.isArray(raw) ? raw : [raw]
      values.forEach(v => {
        if (v != null) {
          const s = String(v)
          if (s) availableOptions.add(s)
        }
      })
    })

    // Calculate counts for available options
    countRows.forEach(row => {
      const raw = row.getValue(column.id) as unknown
      const values: unknown[] = Array.isArray(raw) ? raw : [raw]
      values.forEach(v => {
        if (v != null) {
          const s = String(v)
          if (availableOptions.has(s)) {
            valueCounts.set(s, (valueCounts.get(s) || 0) + 1)
          }
        }
      })
    })

    // If static options exist in meta with augment strategy, use them with counts
    const metaOptions = (meta as Record<string, unknown>)?.options as
      Option[] | undefined
    const mergeStrategy = (meta as Record<string, unknown>)?.mergeStrategy as
      string | undefined

    if (metaOptions && metaOptions.length > 0 && mergeStrategy === "augment") {
      return metaOptions
        .filter(
          opt =>
            !limitToFilteredRows ||
            availableOptions.has(opt.value) ||
            selectedValues.has(opt.value),
        )
        .map(opt => ({
          ...opt,
          count: showCounts ? (valueCounts.get(opt.value) ?? 0) : undefined,
        }))
    }

    if (metaOptions && metaOptions.length > 0) {
      return limitToFilteredRows
        ? metaOptions.filter(
            opt =>
              availableOptions.has(opt.value) || selectedValues.has(opt.value),
          )
        : metaOptions
    }

    const fallbackValues = limitToFilteredRows
      ? new Set([...availableOptions, ...selectedValues])
      : availableOptions
    return Array.from(fallbackValues)
      .map(value => ({
        label: autoOptionsFormat ? formatLabel(value) : value,
        value,
        count: showCounts ? valueCounts.get(value) || 0 : undefined,
      }))
      .sort((a, b) => a.label.localeCompare(b.label))
  }, [
    table,
    column,
    limitToFilteredRows,
    dynamicCounts,
    coreRows,
    filteredRows,
    selectedValues,
  ])

  /**
   * Enrich caller-supplied `options` with live counts and (optionally) narrow
   * them to values that exist in the current row set. Mirrors the row-set
   * split used by `fallbackOptions` so explicit and generated paths stay
   * consistent — without this, `dynamicCounts` was silently ignored whenever
   * a caller passed their own options.
   */
  const enrichedCallerOptions = React.useMemo(() => {
    if (!options) return null
    // Preserve the original `options ?? ...` semantics: an explicit empty
    // array still wins over generated/fallback options.
    if (options.length === 0 || !table || !column) return options

    const showCounts =
      (column.columnDef.meta as Record<string, unknown>)?.showCounts ?? true

    // Caller-supplied options are never narrowed — the caller is the source
    // of truth for which values can ever appear (e.g. server-side tables
    // pass the full static option list every render). Narrowing here would
    // hide cross-filter pivots.
    if (!showCounts) {
      return options.map(opt => ({ ...opt, count: undefined }))
    }

    const countRows = dynamicCounts ? filteredRows : coreRows
    const valueCounts = new Map<string, number>()
    if (countRows) {
      countRows.forEach(row => {
        const raw = row.getValue(column.id) as unknown
        const values: unknown[] = Array.isArray(raw) ? raw : [raw]
        values.forEach(v => {
          if (v != null) {
            const s = String(v)
            valueCounts.set(s, (valueCounts.get(s) || 0) + 1)
          }
        })
      })
    }

    /**
     * Cross-filter narrowing default — caller-supplied `count` wins (server-
     * side tables compute true cross-filter counts on the server; client-
     * derived `valueCounts` only sees the current page). After merging,
     * options with explicit `count: 0` are hidden so server-side tables get
     * automatic cross-filter narrowing without each caller writing a helper.
     *
     * Opt-out: pass `count: undefined` (or omit it) on options you want
     * visible regardless. Pure label-only callers (no counts anywhere) are
     * unaffected — `valueCounts` falls back to client rows.
     */
    return options
      .map(opt => ({
        ...opt,
        count: opt.count ?? valueCounts.get(opt.value) ?? 0,
      }))
      .filter(opt => opt.count !== 0 || selectedValues.has(opt.value))
  }, [
    options,
    table,
    column,
    dynamicCounts,
    coreRows,
    filteredRows,
    selectedValues,
  ])

  const resolvedOptions =
    enrichedCallerOptions ??
    (generatedOptions.length > 0 ? generatedOptions : fallbackOptions)

  return (
    <TableFacetedFilter
      column={column}
      title={derivedTitle}
      options={resolvedOptions}
      multiple={multiple}
      onValueChange={onValueChange}
      trigger={<TableColumnFilterTrigger column={column} />}
      {...props}
    />
  )
}

TableColumnFacetedFilterOptions.displayName = "TableColumnFacetedFilterOptions"
TableColumnFacetedFilterMenu.displayName = "TableColumnFacetedFilterMenu"
src/components/niko-table/components/data-table-column-filter-trigger.tsx
"use client"

/**
 * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
 *
 * Before reporting anything: please check the changelog first.
 *  - In-repo: ./CHANGELOG.md
 *  - Docs site: https://niko-table.com/changelog
 *
 * Found a bug or have a fix? Open an issue or PR on GitHub so other
 * users (and future LLMs reading this code) benefit:
 * https://github.com/Semkoo/niko-table-registry
 */
import type { RowData } from "@tanstack/react-table"
import React from "react"

import { TableColumnFilterTrigger } from "../filters/table-column-faceted-filter"
import { useColumnHeaderContext } from "./data-table-column-header"

/**
 * A standard filter trigger button (Funnel icon) using context.
 */
export function DataTableColumnFilterTrigger<TData extends RowData, TValue>(
  props: Omit<React.ComponentProps<typeof TableColumnFilterTrigger>, "column">,
) {
  const { column } = useColumnHeaderContext<TData, TValue>(true)
  return <TableColumnFilterTrigger column={column} {...props} />
}

DataTableColumnFilterTrigger.displayName = "DataTableColumnFilterTrigger"

More from niko-table

Browse all components in this library →