shadimage
niko-table
componentdata-table-faceted-filtersource: ok · hit

DataTable Faceted Filter

Faceted filter for single/multiple selection with option counts and search.

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

Where it goes

data-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.jsonbuttonbadgecommandpopoverseparator

Files3 · source cached 10/8/2026, 7:44:19 PM

src/components/niko-table/components/data-table-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 * as React from "react"
import {
  TableFacetedFilter,
  TableFacetedFilterContent,
  useTableFacetedFilter,
  type TableFacetedFilterProps,
} from "../filters/table-faceted-filter"
import { useDataTable } from "../core/data-table-context"
import type { DataTableInstance, Option } from "../types"
import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"
import { useGeneratedOptionsForColumn } from "../hooks/use-generated-options"
import { buildFacetedOptions } from "../lib/build-faceted-options"

import type { RowData } from "@tanstack/react-table"
type DataTableFacetedFilterProps<TData extends RowData, TValue> = Omit<
  TableFacetedFilterProps<TData, TValue>,
  "column" | "options"
> & {
  /**
   * The accessor key of the column to filter (matches column definition)
   */
  accessorKey: keyof TData & string
  /**
   * Optional title override (if not provided, will use column.meta.label)
   */
  title?: string
  /**
   * Static options (if provided, will be used instead of dynamic generation)
   */
  options?: Option[]
  /**
   * Whether to show counts for each option
   * @default true
   */
  showCounts?: boolean
  /**
   * Whether to update counts based on other active filters
   * @default true
   */
  dynamicCounts?: boolean
  /**
   * If true, only show options that exist in the currently filtered table rows.
   * If false, show all options from the entire dataset (useful for multi-select filters
   * where you want to see all possible options even if they're not in the current filtered results).
   * @default !multiple (true for single-select, false for multi-select)
   */
  limitToFilteredRows?: boolean
}

/**
 * A faceted filter component that automatically connects to the DataTable context
 * and dynamically generates options with counts based on the filtered data.
 *
 * @example - Auto-detect options from data with dynamic counts
 * const columns: DataTableColumnDef[] = [{ accessorKey: "category", ..., meta: { label: "Category" } }, ...]
 * <DataTableFacetedFilter accessorKey="category" />
 *
 * @example - With static options
 * const categoryOptions: Option[] = [
 *   { label: "Electronics", value: "electronics" },
 *   { label: "Clothing", value: "clothing" },
 * ]
 * <DataTableFacetedFilter
 *   accessorKey="category"
 *   title="Category"
 *   options={categoryOptions}
 * />
 *
 * @example - With dynamic option generation and multiple selection
 * <DataTableFacetedFilter
 *   accessorKey="brand"
 *   title="Brand"
 *   multiple
 *   dynamicCounts
 * />
 *
 * @example - Without counts
 * <DataTableFacetedFilter
 *   accessorKey="status"
 *   showCounts={false}
 * />
 */

interface UseFacetedOptionsArgs<TData extends RowData> {
  table: DataTableInstance<TData>
  accessorKey: string
  options?: Option[]
  showCounts: boolean
  dynamicCounts: boolean
  limitToFilteredRows: boolean
  precomputedOptions?: Record<string, Option[]>
}

// Resolves option list in priority order: 1) caller `options`, 2) meta-aware
// generator (select/multiSelect), 3) data-derived fallback. Memo gates it so
// we don't walk rows twice.
function useFacetedOptions<TData extends RowData>({
  table,
  accessorKey,
  options,
  showCounts,
  dynamicCounts,
  limitToFilteredRows,
  precomputedOptions,
}: UseFacetedOptionsArgs<TData>): Option[] {
  const column = table.getColumn(accessorKey)

  // The meta-aware generator is authoritative for declared select variants —
  // it handles augment/preserve strategies and per-column meta overrides.
  // It returns `[]` for columns that don't match a select variant, which is
  // how we detect "fall back to data-derived options."
  const metaGenerated = precomputedOptions?.[accessorKey] ?? []
  const needsFallbackGeneration = !precomputedOptions
  const perColumnGenerated = useGeneratedOptionsForColumn(
    table,
    needsFallbackGeneration ? accessorKey : "__noop__",
    {
      showCounts,
      dynamicCounts,
      limitToFilteredRows,
    },
  )
  const resolvedMetaGenerated = needsFallbackGeneration
    ? perColumnGenerated
    : metaGenerated

  // Pull state slices for memo reactivity.
  const state = table.state
  const columnFilters = state.columnFilters
  const globalFilter = state.globalFilter

  // Extract `coreRows` so async-data row-array identity drives recompute;
  // `table` ref is stable and would hold stale (empty) results.
  const coreRows = table.getCoreRowModel().rows

  return React.useMemo((): Option[] => {
    if (!column) return []

    const meta = column.columnDef.meta
    const autoOptionsFormat = meta?.autoOptionsFormat ?? true
    const formatOptionLabel = meta?.formatOptionLabel

    // Priority 1: caller-supplied options — always wins over meta/data.
    if (options && options.length > 0) {
      return buildFacetedOptions(
        table,
        coreRows,
        accessorKey,
        columnFilters,
        globalFilter,
        {
          staticOptions: options,
          limitToFilteredRows,
          dynamicCounts,
          showCounts,
          autoOptionsFormat,
          formatOptionLabel,
        },
      )
    }

    // Priority 2: trust the meta-aware generator when it produced anything.
    // (Preserved original "non-empty result wins" behavior so auto-generated
    // columns with valid data don't get clobbered by the fallback.)
    if (resolvedMetaGenerated.length > 0) return resolvedMetaGenerated

    // Priority 3: data-derived fallback for non-select variants (or select
    // variants that had no rows to work with — empty output either way).
    return buildFacetedOptions(
      table,
      coreRows,
      accessorKey,
      columnFilters,
      globalFilter,
      {
        limitToFilteredRows,
        dynamicCounts,
        showCounts,
        autoOptionsFormat,
        formatOptionLabel,
      },
    )
  }, [
    column,
    options,
    resolvedMetaGenerated,
    table,
    coreRows,
    accessorKey,
    columnFilters,
    globalFilter,
    limitToFilteredRows,
    dynamicCounts,
    showCounts,
  ])
}

/**
 * Shared setup for the two exported wrapper components. Keeps column lookup,
 * title derivation, and options resolution in one place so the wrappers stay
 * thin.
 */
function useFacetedFilterSetup<TData extends RowData>({
  accessorKey,
  options,
  showCounts,
  dynamicCounts,
  limitToFilteredRows,
  title,
}: {
  accessorKey: string
  options?: Option[]
  showCounts: boolean
  dynamicCounts: boolean
  limitToFilteredRows: boolean
  title?: string
}) {
  const { table, generatedOptionsMap } = useDataTable<TData>()
  const column = table.getColumn(accessorKey)

  const derivedTitle = useDerivedColumnTitle(column, accessorKey, title)

  const dynamicOptions = useFacetedOptions({
    table,
    accessorKey,
    options,
    showCounts,
    dynamicCounts,
    limitToFilteredRows,
    precomputedOptions: generatedOptionsMap,
  })

  return { table, column, derivedTitle, dynamicOptions }
}

export function DataTableFacetedFilter<
  TData extends RowData,
  TValue = unknown,
>({
  accessorKey,
  options,
  showCounts = true,
  dynamicCounts = true,
  limitToFilteredRows,
  title,
  multiple,
  trigger,
  ...props
}: DataTableFacetedFilterProps<TData, TValue>) {
  // Default: multi-select shows all options, single-select filters to visible rows
  const resolvedLimitToFilteredRows = limitToFilteredRows ?? !multiple

  const { column, derivedTitle, dynamicOptions } = useFacetedFilterSetup<TData>(
    {
      accessorKey: accessorKey as string,
      options,
      showCounts,
      dynamicCounts,
      limitToFilteredRows: resolvedLimitToFilteredRows,
      title,
    },
  )

  // Early return if column not found
  if (!column) {
    console.warn(
      `Column with accessorKey "${accessorKey}" not found in table columns`,
    )
    return null
  }

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

/**
 * @required displayName is required for auto feature detection
 * @see "feature-detection.ts"
 */

DataTableFacetedFilter.displayName = "DataTableFacetedFilter"

export function DataTableFacetedFilterContent<
  TData extends RowData,
  TValue = unknown,
>({
  accessorKey,
  options,
  showCounts = true,
  dynamicCounts = true,
  limitToFilteredRows,
  title,
  multiple,
  onValueChange,
}: DataTableFacetedFilterProps<TData, TValue>) {
  // Default: multi-select shows all options, single-select filters to visible rows
  const resolvedLimitToFilteredRows = limitToFilteredRows ?? !multiple

  const { column, derivedTitle, dynamicOptions } = useFacetedFilterSetup<TData>(
    {
      accessorKey: accessorKey as string,
      options,
      showCounts,
      dynamicCounts,
      limitToFilteredRows: resolvedLimitToFilteredRows,
      title,
    },
  )

  // Use the shared hook for filter logic
  const { selectedValues, onItemSelect, onReset } = useTableFacetedFilter({
    column,
    onValueChange,
    multiple,
  })

  if (!column) return null

  return (
    <TableFacetedFilterContent
      title={derivedTitle}
      options={dynamicOptions}
      selectedValues={selectedValues}
      onItemSelect={onItemSelect}
      onReset={onReset}
    />
  )
}

DataTableFacetedFilterContent.displayName = "DataTableFacetedFilterContent"
src/components/niko-table/filters/table-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
 */
/**
 * Table faceted filter component
 * @description A faceted filter component for DataTable that allows users to filter data based on multiple selectable options. It supports both single and multiple selection modes.
 */

import { Check, PlusCircle, XCircle } from "lucide-react"
import * as React from "react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
} from "@/components/ui/command"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
import type { DataTableColumn, ExtendedColumnFilter, Option } from "../types"
import {
  FILTER_OPERATORS,
  FILTER_VARIANTS,
  JOIN_OPERATORS,
} from "../lib/constants"

import type { RowData } from "@tanstack/react-table"
export interface TableFacetedFilterProps<TData extends RowData, TValue> {
  column?: DataTableColumn<TData, TValue>
  title?: string
  options: Option[]
  multiple?: boolean
  /**
   * Callback fired when filter value changes
   * Useful for server-side filtering or external state management
   */
  onValueChange?: (value: string[] | undefined) => void
  /**
   * Optional custom trigger element
   */
  trigger?: React.ReactNode
}

export function useTableFacetedFilter<TData extends RowData, TValue = unknown>({
  column,
  onValueChange,
  multiple,
}: {
  column?: DataTableColumn<TData, TValue>
  onValueChange?: (value: string[] | undefined) => void
  multiple?: boolean
}) {
  const columnFilterValue = column?.getFilterValue()

  // Handle both ExtendedColumnFilter format (new) and legacy array format
  const selectedValues = React.useMemo(() => {
    // Handle ExtendedColumnFilter format (from filter menu or new faceted filter)
    if (
      columnFilterValue &&
      typeof columnFilterValue === "object" &&
      !Array.isArray(columnFilterValue) &&
      "value" in columnFilterValue
    ) {
      const filterValue = (columnFilterValue as ExtendedColumnFilter<TData>)
        .value
      return new Set(
        Array.isArray(filterValue) ? filterValue : [String(filterValue)],
      )
    }
    // Handle legacy array format (backward compatibility)
    return new Set(Array.isArray(columnFilterValue) ? columnFilterValue : [])
  }, [columnFilterValue])

  const onItemSelect = React.useCallback(
    (option: Option, isSelected: boolean) => {
      if (!column) return

      if (multiple) {
        const newSelectedValues = new Set(selectedValues)
        if (isSelected) {
          newSelectedValues.delete(option.value)
        } else {
          newSelectedValues.add(option.value)
        }
        const filterValues = Array.from(newSelectedValues)

        if (filterValues.length === 0) {
          column.setFilterValue(undefined)
          onValueChange?.(undefined)
        } else {
          // Create ExtendedColumnFilter format for interoperability with filter menu
          // FORCE variant to multiSelect when using IN operator to ensure it shows up in the menu
          const extendedFilter: ExtendedColumnFilter<TData> = {
            id: column.id as Extract<keyof TData, string>,
            value: filterValues,
            variant: FILTER_VARIANTS.MULTI_SELECT,
            operator: FILTER_OPERATORS.IN,
            filterId: `faceted-${column.id}`,
            joinOperator: JOIN_OPERATORS.AND,
          }
          column.setFilterValue(extendedFilter)
          onValueChange?.(filterValues)
        }
      } else {
        // Single selection
        if (isSelected) {
          column.setFilterValue(undefined)
          onValueChange?.(undefined)
        } else {
          // Create ExtendedColumnFilter format for single selection
          // Use EQUAL operator for single select
          const extendedFilter: ExtendedColumnFilter<TData> = {
            id: column.id as Extract<keyof TData, string>,
            value: option.value, // Single value, not array
            variant: FILTER_VARIANTS.SELECT,
            operator: FILTER_OPERATORS.EQ,
            filterId: `faceted-${column.id}`,
            joinOperator: JOIN_OPERATORS.AND,
          }
          column.setFilterValue(extendedFilter)
          onValueChange?.([option.value])
        }
      }
    },
    [column, multiple, selectedValues, onValueChange],
  )

  const onReset = React.useCallback(
    (event?: React.MouseEvent) => {
      event?.stopPropagation()
      column?.setFilterValue(undefined)
      onValueChange?.(undefined)
    },
    [column, onValueChange],
  )

  return {
    selectedValues,
    onItemSelect,
    onReset,
  }
}

export function TableFacetedFilter<TData extends RowData, TValue>({
  column,
  title,
  options = [],
  multiple,
  onValueChange,
  trigger,
}: TableFacetedFilterProps<TData, TValue>) {
  const [open, setOpen] = React.useState(false)

  const { selectedValues, onItemSelect, onReset } = useTableFacetedFilter({
    column,
    onValueChange,
    multiple,
  })

  // Wrap onItemSelect to close multiple=false popover
  const handleItemSelect = React.useCallback(
    (option: Option, isSelected: boolean) => {
      onItemSelect(option, isSelected)
      if (!multiple) {
        setOpen(false)
      }
    },
    [onItemSelect, multiple, setOpen],
  )

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        {trigger || (
          <Button variant="outline" size="sm" className="h-8 border-dashed">
            {selectedValues?.size > 0 ? (
              <div
                role="button"
                aria-label={`Clear ${title} filter`}
                tabIndex={0}
                onClick={onReset}
                onKeyDown={e => {
                  if (e.key === "Enter" || e.key === " ") {
                    e.preventDefault()
                    onReset(e as unknown as React.MouseEvent)
                  }
                }}
                className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
              >
                <XCircle className="size-4" />
              </div>
            ) : (
              <PlusCircle className="size-4" />
            )}
            {title}
            {selectedValues?.size > 0 && (
              <>
                <Separator orientation="vertical" className="mx-2 h-4" />
                <Badge
                  variant="secondary"
                  className="rounded-sm px-1 font-normal lg:hidden"
                >
                  {selectedValues.size}
                </Badge>
                <div className="hidden items-center gap-1 lg:flex">
                  {selectedValues.size > 2 ? (
                    <Badge
                      variant="secondary"
                      className="rounded-sm px-1 font-normal"
                    >
                      {selectedValues.size} selected
                    </Badge>
                  ) : (
                    options
                      .filter(option => selectedValues.has(option.value))
                      .map(option => (
                        <Badge
                          variant="secondary"
                          key={option.value}
                          className="rounded-sm px-1 font-normal"
                        >
                          {option.label}
                        </Badge>
                      ))
                  )}
                </div>
              </>
            )}
          </Button>
        )}
      </PopoverTrigger>
      <PopoverContent className="w-52 p-0" align="start">
        <TableFacetedFilterContent
          title={title}
          options={options}
          selectedValues={selectedValues}
          onItemSelect={handleItemSelect}
          onReset={onReset}
        />
      </PopoverContent>
    </Popover>
  )
}

export function TableFacetedFilterContent({
  title,
  options,
  selectedValues,
  onItemSelect,
  onReset,
}: {
  title?: string
  options: Option[]
  selectedValues: Set<string>
  onItemSelect: (option: Option, isSelected: boolean) => void
  onReset: (event?: React.MouseEvent) => void
}) {
  return (
    <Command>
      <CommandInput placeholder={title} className="pl-2" />
      <CommandList className="max-h-full">
        <CommandEmpty>No results found.</CommandEmpty>
        <CommandGroup className="max-h-75 overflow-x-hidden overflow-y-auto">
          {options.map(option => {
            const isSelected = selectedValues.has(option.value)

            return (
              <CommandItem
                key={option.value}
                onSelect={() => onItemSelect(option, isSelected)}
              >
                <div
                  className={cn(
                    "mr-2 flex size-4 items-center justify-center rounded-sm border border-primary",
                    isSelected
                      ? "bg-primary text-primary-foreground"
                      : "opacity-50 [&_svg]:invisible",
                  )}
                >
                  <Check className="size-4" />
                </div>
                {option.icon && <option.icon className="mr-2 size-4" />}
                <span className="truncate">{option.label}</span>
                {option.count !== undefined && (
                  <span className="ml-auto font-mono text-xs">
                    {option.count}
                  </span>
                )}
              </CommandItem>
            )
          })}
        </CommandGroup>
        {selectedValues.size > 0 && (
          <>
            <CommandSeparator />
            <CommandGroup>
              <CommandItem
                onSelect={() => onReset()}
                className="justify-center text-center"
              >
                Clear filters
              </CommandItem>
            </CommandGroup>
          </>
        )}
      </CommandList>
    </Command>
  )
}

/**
 * @required displayName is required for auto feature detection
 * @see "feature-detection.ts"
 */

TableFacetedFilter.displayName = "TableFacetedFilter"
src/components/niko-table/lib/build-faceted-options.ts
/**
 * 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 type { DataTableInstance, DataTableRow } from "../types"

import { getFilteredRowsExcludingColumn } from "./filter-rows"
import { formatLabel } from "./format"
import type { Option } from "../types"

export interface BuildFacetedOptionsConfig {
  /**
   * If provided, these options are the source of truth and the returned list
   * is a subset of them (optionally narrowed by `limitToFilteredRows` and
   * enriched with live counts).
   *
   * If omitted, options are derived from the rows themselves — useful for
   * columns that are not declared as `select`/`multiSelect` variants but are
   * still being used with a faceted filter (boolean, text, etc.).
   */
  staticOptions?: Option[]
  /**
   * Narrow the returned options to values that exist in rows passing every
   * *other* active filter (the current column's own filter is excluded).
   */
  limitToFilteredRows: boolean
  /**
   * Compute counts against filtered rows (again, excluding the current
   * column's filter) rather than against the full core row model.
   */
  dynamicCounts: boolean
  /**
   * When false, `count` is stripped from the output for a consistent shape.
   */
  showCounts: boolean
  /**
   * When deriving options from rows, run labels through `formatLabel` (title
   * case etc.). Ignored when `staticOptions` is provided — callers' labels
   * are always preserved as-is.
   */
  autoOptionsFormat: boolean
  /**
   * Optional per-column label formatter. Wins over `autoOptionsFormat` when
   * present. Receives the stringified row value, returns the display label.
   * Ignored when `staticOptions` is provided.
   */
  formatOptionLabel?: (value: string) => string
}

/**
 * Pure builder for the option list surfaced by a faceted filter. Handles
 * both explicit `staticOptions` (narrow + enrich with counts) and
 * row-derived options. Plain function (not a hook) so the wrapper can
 * gate it behind a single `useMemo`.
 */
export function buildFacetedOptions<TData extends RowData>(
  table: DataTableInstance<TData>,
  coreRows: DataTableRow<TData>[],
  accessorKey: string,
  columnFilters: Array<{ id: string; value: unknown }>,
  globalFilter: unknown,
  config: BuildFacetedOptionsConfig,
): Option[] {
  const {
    staticOptions,
    limitToFilteredRows,
    dynamicCounts,
    showCounts,
    autoOptionsFormat,
    formatOptionLabel,
  } = config

  // Fast path: explicit options, no narrowing, no counts — just normalize the
  // shape so every return path of this function looks the same to callers.
  if (staticOptions && !limitToFilteredRows && !showCounts) {
    return staticOptions.map(opt => ({ ...opt, count: undefined }))
  }

  // Only compute the filtered row subset if at least one flag actually needs
  // it. This avoids the cost of `getFilteredRowsExcludingColumn` when the
  // caller has opted out of both narrowing and dynamic counts.
  const needsFilteredRows = limitToFilteredRows || dynamicCounts
  const filteredRows = needsFilteredRows
    ? getFilteredRowsExcludingColumn(
        table,
        coreRows,
        accessorKey,
        columnFilters,
        globalFilter,
      )
    : coreRows

  const optionRows = limitToFilteredRows ? filteredRows : coreRows
  const countRows = dynamicCounts ? filteredRows : coreRows

  // Collect the set of values present in `optionRows`. Used for narrowing
  // static options and for producing the auto-derived option list.
  const availableValues = collectRowValues(optionRows, accessorKey)

  // The column's own currently-selected values must never be narrowed away:
  // otherwise a selection that another filter excludes (e.g. Brand=Apple while
  // Category=Clothing) vanishes from its own facet — the pill disappears and
  // the value can't be un-checked, even though the filter is still applied.
  const selectedValues = getSelectedValues(columnFilters, accessorKey)
  const keepValue = (value: string) =>
    availableValues.has(value) || selectedValues.has(value)

  // Build the base option list (no counts yet).
  let baseOptions: Option[]
  if (staticOptions) {
    baseOptions = limitToFilteredRows
      ? staticOptions.filter(opt => keepValue(opt.value))
      : staticOptions
  } else {
    const optionValues = limitToFilteredRows
      ? new Set([...availableValues, ...selectedValues])
      : availableValues
    baseOptions = Array.from(optionValues)
      .map(value => ({
        value,
        label: formatOptionLabel
          ? formatOptionLabel(value)
          : autoOptionsFormat
            ? formatLabel(value)
            : value,
      }))
      .sort((a, b) => a.label.localeCompare(b.label))
  }

  if (!showCounts) {
    return baseOptions.map(opt => ({ ...opt, count: undefined }))
  }

  // Scope counts to baseOptions values up-front so every returned option
  // has a count and every count maps to a returned option.
  const targetValues = new Set(baseOptions.map(opt => opt.value))
  const valueCounts = new Map<string, number>()
  for (const row of countRows) {
    const raw = row.getValue(accessorKey) as unknown
    const values: unknown[] = Array.isArray(raw) ? raw : [raw]
    for (const v of values) {
      if (v == null) continue
      const str = String(v)
      if (!str) continue
      if (targetValues.has(str)) {
        valueCounts.set(str, (valueCounts.get(str) ?? 0) + 1)
      }
    }
  }

  return baseOptions.map(opt => ({
    ...opt,
    // Prefer a caller-supplied count over the row-derived one. On a server-side
    // table the rows are only the current page, so the caller passes true
    // server-computed facet counts; keep them. Options without a count fall
    // back to the row-derived value. Matches the header-funnel path.
    count: opt.count ?? valueCounts.get(opt.value) ?? 0,
  }))
}

/**
 * Extract the set of selected values from a single column's filter value,
 * tolerating every shape it can take: the `ExtendedColumnFilter` object written
 * by the faceted control and the advanced menu (`{ value: string | string[] }`),
 * and the legacy bare array / scalar.
 */
export function extractFilterSelectedValues(rawValue: unknown): Set<string> {
  let raw = rawValue
  if (raw && typeof raw === "object" && !Array.isArray(raw) && "value" in raw) {
    raw = (raw as { value: unknown }).value
  }
  if (raw == null || raw === "") return new Set()

  const values = Array.isArray(raw) ? raw : [raw]
  return new Set(values.filter(v => v != null && v !== "").map(String))
}

function getSelectedValues(
  columnFilters: Array<{ id: string; value: unknown }>,
  accessorKey: string,
): Set<string> {
  const entry = columnFilters.find(filter => filter.id === accessorKey)
  return entry ? extractFilterSelectedValues(entry.value) : new Set()
}

function collectRowValues<TData extends RowData>(
  rows: DataTableRow<TData>[],
  accessorKey: string,
): Set<string> {
  const set = new Set<string>()
  for (const row of rows) {
    const raw = row.getValue(accessorKey) as unknown
    const values: unknown[] = Array.isArray(raw) ? raw : [raw]
    for (const v of values) {
      if (v == null) continue
      const str = String(v)
      if (str) set.add(str)
    }
  }
  return set
}

More from niko-table

Browse all components in this library →