Inputs

Multi-select filter

Popover checklist for filtering a column.

Preview
Live demo coming - the component is in the registry below.
Install
npx shadcn@latest add https://ui.thjsecurity.tech/r/multi-select-filter.json

Pulls the component into components/ui/multi-select-filter.tsx and its token dependencies. The app owns the copy from then on.

Dependencies
@tabler/icons-react(registry) badge(registry) button(registry) checkbox(registry) popover
Source
"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>
  )
}