Skip to content

Search Filter Bar

Install this component from the Scintillar registry.

Installation

npx shadcn@latest add @scintillar/search-filter-bar

Source

"use client"

import { useState, useRef, useCallback } from "react"
import { Search, Filter, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

/** A filter definition. */
export interface FilterDef {
  /** Unique filter ID. */
  id: string
  /** Display label. */
  label: string
  /** Available options for this filter. */
  options: { value: string; label: string }[]
}

/** An active filter with a selected value. */
export interface ActiveFilter {
  /** Filter definition ID. */
  filterId: string
  /** Selected option value. */
  value: string
}

const defaultLabels = {
  addFilter: "Add filter",
  allFiltersActive: "All filters are active.",
  clearAll: "Clear all",
}

export type SearchFilterBarLabels = typeof defaultLabels

export interface SearchFilterBarProps {
  /** Placeholder text for the search input. */
  placeholder?: string
  /** Current search query. */
  query: string
  /** Called when the search query changes. */
  onQueryChange: (query: string) => void
  /** Available filter definitions. */
  filters: FilterDef[]
  /** Currently active filters. */
  activeFilters: ActiveFilter[]
  /** Called when filters change. */
  onFiltersChange: (filters: ActiveFilter[]) => void
  /** Additional CSS classes. */
  className?: string
  /** Override internal labels for localization. */
  labels?: Partial<SearchFilterBarLabels>
}

/**
 * A search bar with a filter button that opens a popover for adding filters.
 * Active filters are shown as dismissible badges below the search input.
 * Each badge can be edited inline via a select dropdown.
 */
export function SearchFilterBar({
  placeholder = "Search...",
  query,
  onQueryChange,
  filters,
  activeFilters,
  onFiltersChange,
  className,
  labels,
}: SearchFilterBarProps) {
  const l = { ...defaultLabels, ...labels }
  const [filterOpen, setFilterOpen] = useState(false)
  const inputRef = useRef<HTMLInputElement>(null)

  const addFilter = useCallback(
    (filterId: string, value: string) => {
      const existing = activeFilters.findIndex((f) => f.filterId === filterId)
      if (existing >= 0) {
        const next = [...activeFilters]
        next[existing] = { filterId, value }
        onFiltersChange(next)
      } else {
        onFiltersChange([...activeFilters, { filterId, value }])
      }
    },
    [activeFilters, onFiltersChange]
  )

  const removeFilter = useCallback(
    (filterId: string) => {
      onFiltersChange(activeFilters.filter((f) => f.filterId !== filterId))
    },
    [activeFilters, onFiltersChange]
  )

  const updateFilter = useCallback(
    (filterId: string, value: string) => {
      onFiltersChange(
        activeFilters.map((f) =>
          f.filterId === filterId ? { ...f, value } : f
        )
      )
    },
    [activeFilters, onFiltersChange]
  )

  // Filters not yet active
  const availableFilters = filters.filter(
    (f) => !activeFilters.some((af) => af.filterId === f.id)
  )

  return (
    <div className={cn("space-y-2", className)}>
      <div className="flex items-center gap-2">
        {/* Search input */}
        <div className="relative flex-1">
          <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
          <Input
            ref={inputRef}
            value={query}
            onChange={(e) => onQueryChange(e.target.value)}
            placeholder={placeholder}
            className="pl-9"
          />
          {query && (
            <Button
              variant="ghost"
              size="icon-xs"
              className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground"
              onClick={() => {
                onQueryChange("")
                inputRef.current?.focus()
              }}
              aria-label="Clear search"
            >
              <X />
            </Button>
          )}
        </div>

        {/* Filter button */}
        <Popover open={filterOpen} onOpenChange={setFilterOpen}>
          <PopoverTrigger asChild>
            <Button
              variant={activeFilters.length > 0 ? "default" : "outline"}
              size="icon"
              aria-label="Filters"
            >
              <Filter className="size-4" />
            </Button>
          </PopoverTrigger>
          <PopoverContent className="w-64 p-3" align="end">
            <p className="text-sm font-semibold mb-2">{l.addFilter}</p>
            {availableFilters.length === 0 ? (
              <p className="text-xs text-muted-foreground">
                {l.allFiltersActive}
              </p>
            ) : (
              <div className="space-y-1.5">
                {availableFilters.map((filter) => (
                  <div key={filter.id} className="space-y-1">
                    <label className="text-xs text-muted-foreground">
                      {filter.label}
                    </label>
                    <Select
                      onValueChange={(value) => {
                        addFilter(filter.id, value)
                        setFilterOpen(false)
                      }}
                    >
                      <SelectTrigger className="h-8 text-xs">
                        <SelectValue placeholder={`Select ${filter.label.toLowerCase()}...`} />
                      </SelectTrigger>
                      <SelectContent>
                        {filter.options.map((opt) => (
                          <SelectItem key={opt.value} value={opt.value} className="text-xs">
                            {opt.label}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  </div>
                ))}
              </div>
            )}
          </PopoverContent>
        </Popover>
      </div>

      {/* Active filter badges */}
      {activeFilters.length > 0 && (
        <div className="flex flex-wrap items-center gap-1.5">
          {activeFilters.map((af) => {
            const def = filters.find((f) => f.id === af.filterId)
            if (!def) return null
            const opt = def.options.find((o) => o.value === af.value)

            return (
              <Badge
                key={af.filterId}
                variant="secondary"
                className="gap-1 pl-2 pr-1 py-1 h-auto"
              >
                <span className="text-muted-foreground text-[10px] mr-0.5">
                  {def.label}:
                </span>
                {/* Inline editable select */}
                <Select
                  value={af.value}
                  onValueChange={(v) => updateFilter(af.filterId, v)}
                >
                  <SelectTrigger className="h-auto border-0 bg-transparent shadow-none p-0 text-xs font-medium min-w-0 w-auto gap-1">
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    {def.options.map((o) => (
                      <SelectItem key={o.value} value={o.value} className="text-xs">
                        {o.label}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
                <button
                  onClick={() => removeFilter(af.filterId)}
                  className="rounded-sm p-0.5 hover:bg-muted transition-colors cursor-pointer"
                  aria-label={`Remove ${def.label} filter`}
                >
                  <X className="size-3" />
                </button>
              </Badge>
            )
          })}
          <button
            onClick={() => onFiltersChange([])}
            className="text-[10px] text-muted-foreground hover:text-foreground cursor-pointer transition-colors px-1"
          >
            {l.clearAll}
          </button>
        </div>
      )}
    </div>
  )
}