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 · lighticonslucide-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"