Filter Builder

Field, operator and value filter chips with a matchesFilter helper for your rows.

@drift-ui/filter-builderRegistry JSON
  • MY-104Invoice totals round twice3 pts
  • MY-112Dark mode chart contrast2 pts
  • MY-118Invoice PDF missing logo1 pts
  • MY-121Retry webhook deliveries5 pts
On this page

Installation

The CLI also installs the npm packages and registry items it builds on. Register the namespace once first with https://drift-ui.dev/r/{style}/{name}.json. The CLI fills in {style} from your components.json and installs the Base UI or Radix build to match. See setup.

API reference

FilterBuilder

A row of filter chips with an Add filter menu. Each chip edits its operator and value in place.

Prop
fieldsRequiredFilterField[]Filterable fields: { id, label, type: "text" | "number" | "select", options?, icon? }.
valueFilter[]Controlled filters.
defaultValueFilter[]= []Initial value when uncontrolled.
onValueChange(filters: Filter[]) => voidCalled when a filter is added, changed or removed.
aria-labelstring= "Filters"Name of the filter group.

Other props go to the <div>.

matchesFilter

matchesFilter(fieldValue: string | number, filter: Filter): boolean

Import from @/lib/filter to apply a Filter ({ id, fieldId, operator, value }) to your rows. An empty filter value matches everything. Text fields use contains, not-contains, is and is-not, numbers use eq, gt and lt, and selects use is and is-not.

Source

components/ui/filter-builder.tsx
"use client"

import * as React from "react"
import { ListFilterIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  createFilter,
  type Filter,
  type FilterField,
} from "@/lib/filter"
import { useControllableState } from "@/hooks/use-controllable-state"
import { Button } from "@/components/ui/button"
import { FilterChip } from "@/components/ui/filter-chip"
import { FilterMenu, FilterMenuItem } from "@/components/ui/filter-menu"

function FilterBuilder({
  fields,
  value,
  defaultValue = [],
  onValueChange,
  "aria-label": ariaLabel = "Filters",
  className,
  ...props
}: Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
  fields: FilterField[]
  value?: Filter[]
  defaultValue?: Filter[]
  onValueChange?: (filters: Filter[]) => void
}) {
  const { value: filters, setValue: setFilters } = useControllableState({
    value,
    defaultValue,
    onChange: onValueChange,
  })
  const [newFilterId, setNewFilterId] = React.useState<string | null>(null)
  const didAddFilterRef = React.useRef(false)
  const fieldsById = new Map(fields.map((field) => [field.id, field]))

  function addFilter(field: FilterField) {
    const filter = createFilter(field)
    setFilters((current) => [...current, filter])
    setNewFilterId(filter.id)
    didAddFilterRef.current = true
  }

  function updateFilter(next: Filter) {
    setFilters((current) =>
      current.map((filter) => (filter.id === next.id ? next : filter))
    )
  }

  function removeFilter(id: string) {
    setFilters((current) => current.filter((filter) => filter.id !== id))
  }

  return (
    <div
      role="group"
      aria-label={ariaLabel}
      data-slot="filter-builder"
      className={cn("flex flex-wrap items-center gap-2", className)}
      {...props}
    >
      {filters.map((filter) => {
        const field = fieldsById.get(filter.fieldId)
        if (!field) return null
        return (
          <FilterChip
            key={filter.id}
            field={field}
            filter={filter}
            isNew={filter.id === newFilterId}
            onFilterChange={updateFilter}
            onRemove={() => removeFilter(filter.id)}
          />
        )
      })}
      <FilterMenu
        onCloseAutoFocus={(event) => {
          if (!didAddFilterRef.current) return
          didAddFilterRef.current = false
          event.preventDefault()
        }}
        trigger={
          <Button
            variant="outline"
            size="sm"
            data-slot="filter-builder-add"
            aria-label="Add filter"
            className="border-dashed"
          >
            <ListFilterIcon />
            {filters.length === 0 && "Filter"}
          </Button>
        }
      >
        {fields.map((field) => (
          <FilterMenuItem key={field.id} onSelect={() => addFilter(field)}>
            {field.icon}
            {field.label}
          </FilterMenuItem>
        ))}
      </FilterMenu>
      {filters.length > 0 && (
        <Button
          variant="ghost"
          size="sm"
          data-slot="filter-builder-clear"
          onClick={() => setFilters([])}
        >
          Clear
        </Button>
      )}
    </div>
  )
}

export { FilterBuilder }