Search Filter Bar
Install this component from the Scintillar registry.
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>
)
}