Multi-select filter
Popover checklist for filtering a column.
npx shadcn@latest add https://ui.thjsecurity.tech/r/multi-select-filter.jsonPulls the component into components/ui/multi-select-filter.tsx and its token dependencies. The app owns the copy from then on.
@tabler/icons-react(registry) badge(registry) button(registry) checkbox(registry) popover
"use client"
import * as React from "react"
import { IconChevronDown } from "@tabler/icons-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
export interface MultiSelectOption {
value: string
label: string
/** Optional dot colour (e.g. severity colour). */
color?: string
}
interface MultiSelectFilterProps {
label: string
options: MultiSelectOption[]
selected: string[]
onChange: (next: string[]) => void
className?: string
}
/**
* Pill-style multi-select filter modelled on ProjectDiscovery's ASM:
* a trigger showing the label + an active-count badge, opening a checkbox
* list with Clear / Apply. Selection is staged and committed on Apply.
*/
export function MultiSelectFilter({
label,
options,
selected,
onChange,
className,
}: MultiSelectFilterProps) {
const [open, setOpen] = React.useState(false)
const [draft, setDraft] = React.useState<string[]>(selected)
// Re-sync the draft whenever the popover opens or the committed value
// changes externally (e.g. global clear).
React.useEffect(() => {
if (open) setDraft(selected)
}, [open, selected])
const count = selected.length
function toggle(value: string) {
setDraft((d) =>
d.includes(value) ? d.filter((v) => v !== value) : [...d, value],
)
}
function apply() {
onChange(draft)
setOpen(false)
}
function clear() {
setDraft([])
onChange([])
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn("gap-2", className)}
>
{label}
{count > 0 && (
<Badge
variant="secondary"
className="ml-0.5 h-5 min-w-5 justify-center rounded-full px-1.5 text-xs tabular-nums"
>
{count}
</Badge>
)}
<IconChevronDown className="size-[17px] opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-0">
<div className="max-h-72 overflow-y-auto p-1.5">
{options.map((opt) => {
const checked = draft.includes(opt.value)
return (
// A div (not button) because it contains the Checkbox button -
// nesting <button> in <button> is invalid HTML.
<div
key={opt.value}
role="button"
tabIndex={0}
onClick={() => toggle(opt.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
toggle(opt.value)
}
}}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Checkbox
checked={checked}
className="pointer-events-none"
tabIndex={-1}
/>
{opt.color && (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ background: opt.color }}
/>
)}
<span className="flex-1 truncate">{opt.label}</span>
</div>
)
})}
</div>
<div className="flex items-center justify-between gap-2 border-t p-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 flex-1"
onClick={clear}
>
Clear
</Button>
<Button
type="button"
size="sm"
className="h-8 flex-1"
onClick={apply}
>
Apply
</Button>
</div>
</PopoverContent>
</Popover>
)
}