{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-builder",
  "title": "Filter Builder",
  "description": "Field, operator and value filter chips with a matchesFilter helper for your rows.",
  "dependencies": [
    "radix-ui",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "button",
    "input",
    "popover",
    "@drift-ui/use-controllable-state"
  ],
  "files": [
    {
      "path": "registry/default/ui/filter-builder.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ListFilterIcon } from \"lucide-react\"\n\nimport { cn } from \"@/registry/default/lib/utils\"\nimport {\n  createFilter,\n  type Filter,\n  type FilterField,\n} from \"@/registry/default/lib/filter\"\nimport { useControllableState } from \"@/registry/default/hooks/use-controllable-state\"\nimport { Button } from \"@/registry/default/ui/button\"\nimport { FilterChip } from \"@/registry/default/ui/filter-chip\"\nimport { FilterMenu, FilterMenuItem } from \"@/registry/default/ui/filter-menu\"\n\nfunction FilterBuilder({\n  fields,\n  value,\n  defaultValue = [],\n  onValueChange,\n  \"aria-label\": ariaLabel = \"Filters\",\n  className,\n  ...props\n}: Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  fields: FilterField[]\n  value?: Filter[]\n  defaultValue?: Filter[]\n  onValueChange?: (filters: Filter[]) => void\n}) {\n  const { value: filters, setValue: setFilters } = useControllableState({\n    value,\n    defaultValue,\n    onChange: onValueChange,\n  })\n  const [newFilterId, setNewFilterId] = React.useState<string | null>(null)\n  const didAddFilterRef = React.useRef(false)\n  const fieldsById = new Map(fields.map((field) => [field.id, field]))\n\n  function addFilter(field: FilterField) {\n    const filter = createFilter(field)\n    setFilters((current) => [...current, filter])\n    setNewFilterId(filter.id)\n    didAddFilterRef.current = true\n  }\n\n  function updateFilter(next: Filter) {\n    setFilters((current) =>\n      current.map((filter) => (filter.id === next.id ? next : filter))\n    )\n  }\n\n  function removeFilter(id: string) {\n    setFilters((current) => current.filter((filter) => filter.id !== id))\n  }\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={ariaLabel}\n      data-slot=\"filter-builder\"\n      className={cn(\"flex flex-wrap items-center gap-2\", className)}\n      {...props}\n    >\n      {filters.map((filter) => {\n        const field = fieldsById.get(filter.fieldId)\n        if (!field) return null\n        return (\n          <FilterChip\n            key={filter.id}\n            field={field}\n            filter={filter}\n            isNew={filter.id === newFilterId}\n            onFilterChange={updateFilter}\n            onRemove={() => removeFilter(filter.id)}\n          />\n        )\n      })}\n      <FilterMenu\n        onCloseAutoFocus={(event) => {\n          if (!didAddFilterRef.current) return\n          didAddFilterRef.current = false\n          event.preventDefault()\n        }}\n        trigger={\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            data-slot=\"filter-builder-add\"\n            aria-label=\"Add filter\"\n            className=\"border-dashed\"\n          >\n            <ListFilterIcon />\n            {filters.length === 0 && \"Filter\"}\n          </Button>\n        }\n      >\n        {fields.map((field) => (\n          <FilterMenuItem key={field.id} onSelect={() => addFilter(field)}>\n            {field.icon}\n            {field.label}\n          </FilterMenuItem>\n        ))}\n      </FilterMenu>\n      {filters.length > 0 && (\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          data-slot=\"filter-builder-clear\"\n          onClick={() => setFilters([])}\n        >\n          Clear\n        </Button>\n      )}\n    </div>\n  )\n}\n\nexport { FilterBuilder }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/ui/filter-chip.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { XIcon } from \"lucide-react\"\n\nimport { cn } from \"@/registry/default/lib/utils\"\nimport {\n  operatorLabel,\n  operatorsFor,\n  type Filter,\n  type FilterField,\n} from \"@/registry/default/lib/filter\"\nimport { FilterMenu, FilterMenuItem } from \"@/registry/default/ui/filter-menu\"\nimport { Input } from \"@/registry/default/ui/input\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/registry/default/ui/popover\"\n\nconst segmentClassName =\n  \"flex items-center gap-1.5 px-2 outline-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset data-popup-open:bg-accent data-[state=open]:bg-accent [&_svg]:size-3.5\"\n\nfunction ValueLabel({ text }: { text: string }) {\n  if (!text) return <span className=\"text-muted-foreground\">Any</span>\n  return <span className=\"max-w-40 truncate font-medium\">{text}</span>\n}\n\nfunction SelectValueEditor({\n  field,\n  filter,\n  defaultOpen,\n  onCommit,\n  onDismissEmpty,\n}: FilterValueEditorProps) {\n  const hasPickedRef = React.useRef(false)\n  const selected = field.options?.find(\n    (option) => option.value === filter.value\n  )\n\n  return (\n    <FilterMenu\n      defaultOpen={defaultOpen}\n      onOpenChange={(open) => {\n        if (open || filter.value || hasPickedRef.current) return\n        onDismissEmpty()\n      }}\n      trigger={\n        <button\n          type=\"button\"\n          aria-label={`${field.label} value`}\n          className={segmentClassName}\n        >\n          <ValueLabel text={selected?.label ?? \"\"} />\n        </button>\n      }\n    >\n      {field.options?.map((option) => (\n        <FilterMenuItem\n          key={option.value}\n          isSelected={option.value === filter.value}\n          onSelect={() => {\n            hasPickedRef.current = true\n            onCommit(option.value)\n          }}\n        >\n          {option.label}\n        </FilterMenuItem>\n      ))}\n    </FilterMenu>\n  )\n}\n\nfunction TypedValueEditor({\n  field,\n  filter,\n  defaultOpen,\n  onCommit,\n  onDismissEmpty,\n}: FilterValueEditorProps) {\n  const [isOpen, setIsOpen] = React.useState(defaultOpen)\n  const [draft, setDraft] = React.useState(filter.value)\n\n  function handleOpenChange(open: boolean) {\n    setIsOpen(open)\n    if (open) return setDraft(filter.value)\n    if (draft.trim()) return onCommit(draft.trim())\n    onDismissEmpty()\n  }\n\n  return (\n    <Popover open={isOpen} onOpenChange={handleOpenChange}>\n      <PopoverTrigger\n        aria-label={`${field.label} value`}\n        className={segmentClassName}\n      >\n        <ValueLabel text={filter.value} />\n      </PopoverTrigger>\n      <PopoverContent align=\"start\" className=\"w-56 p-2\">\n        <Input\n          autoFocus\n          aria-label={`${field.label} value`}\n          type={field.type === \"number\" ? \"number\" : \"text\"}\n          value={draft}\n          placeholder={`Filter by ${field.label.toLowerCase()}`}\n          onChange={(event) => setDraft(event.target.value)}\n          onKeyDown={(event) => {\n            if (event.key === \"Enter\") handleOpenChange(false)\n          }}\n          className=\"h-8\"\n        />\n      </PopoverContent>\n    </Popover>\n  )\n}\n\ntype FilterValueEditorProps = {\n  field: FilterField\n  filter: Filter\n  defaultOpen: boolean\n  onCommit: (value: string) => void\n  onDismissEmpty: () => void\n}\n\nfunction FilterChip({\n  field,\n  filter,\n  isNew = false,\n  onFilterChange,\n  onRemove,\n  className,\n  ...props\n}: Omit<React.ComponentProps<\"div\">, \"onChange\"> & {\n  field: FilterField\n  filter: Filter\n  isNew?: boolean\n  onFilterChange: (filter: Filter) => void\n  onRemove: () => void\n}) {\n  const ValueEditor =\n    field.type === \"select\" ? SelectValueEditor : TypedValueEditor\n\n  return (\n    <div\n      data-slot=\"filter-chip\"\n      className={cn(\n        \"inline-flex h-8 items-stretch divide-x overflow-hidden rounded-md border bg-background text-sm shadow-xs dark:bg-input/30\",\n        className\n      )}\n      {...props}\n    >\n      <span className=\"flex items-center gap-1.5 px-2 text-muted-foreground [&_svg]:size-3.5\">\n        {field.icon}\n        {field.label}\n      </span>\n      <FilterMenu\n        trigger={\n          <button\n            type=\"button\"\n            aria-label={`${field.label} operator`}\n            className={cn(segmentClassName, \"text-muted-foreground\")}\n          >\n            {operatorLabel(filter.operator)}\n          </button>\n        }\n      >\n        {operatorsFor(field.type).map((operator) => (\n          <FilterMenuItem\n            key={operator}\n            isSelected={operator === filter.operator}\n            onSelect={() => onFilterChange({ ...filter, operator })}\n          >\n            {operatorLabel(operator)}\n          </FilterMenuItem>\n        ))}\n      </FilterMenu>\n      <ValueEditor\n        field={field}\n        filter={filter}\n        defaultOpen={isNew}\n        onCommit={(value) => onFilterChange({ ...filter, value })}\n        onDismissEmpty={onRemove}\n      />\n      <button\n        type=\"button\"\n        aria-label={`Remove ${field.label} filter`}\n        onClick={onRemove}\n        className={segmentClassName}\n      >\n        <XIcon />\n      </button>\n    </div>\n  )\n}\n\nexport { FilterChip }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/ui/filter-menu.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { CheckIcon } from \"lucide-react\"\nimport { DropdownMenu as DropdownMenuPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/registry/default/lib/utils\"\n\nfunction FilterMenu({\n  trigger,\n  defaultOpen,\n  onOpenChange,\n  onCloseAutoFocus,\n  align = \"start\",\n  children,\n}: {\n  trigger: React.ReactNode\n  defaultOpen?: boolean\n  onOpenChange?: (open: boolean) => void\n  onCloseAutoFocus?: React.ComponentProps<\n    typeof DropdownMenuPrimitive.Content\n  >[\"onCloseAutoFocus\"]\n  align?: React.ComponentProps<typeof DropdownMenuPrimitive.Content>[\"align\"]\n  children: React.ReactNode\n}) {\n  return (\n    <DropdownMenuPrimitive.Root\n      modal={false}\n      defaultOpen={defaultOpen}\n      onOpenChange={onOpenChange}\n    >\n      <DropdownMenuPrimitive.Trigger asChild>\n        {trigger}\n      </DropdownMenuPrimitive.Trigger>\n      <DropdownMenuPrimitive.Portal>\n        <DropdownMenuPrimitive.Content\n          data-slot=\"filter-menu\"\n          align={align}\n          sideOffset={4}\n          onCloseAutoFocus={onCloseAutoFocus}\n          className=\"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95\"\n        >\n          {children}\n        </DropdownMenuPrimitive.Content>\n      </DropdownMenuPrimitive.Portal>\n    </DropdownMenuPrimitive.Root>\n  )\n}\n\nfunction FilterMenuItem({\n  isSelected,\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {\n  isSelected?: boolean\n}) {\n  return (\n    <DropdownMenuPrimitive.Item\n      data-slot=\"filter-menu-item\"\n      className={cn(\n        \"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      {isSelected && <CheckIcon className=\"absolute right-2\" />}\n    </DropdownMenuPrimitive.Item>\n  )\n}\n\nexport { FilterMenu, FilterMenuItem }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/filter.ts",
      "content": "import type { ReactNode } from \"react\"\n\nexport type FilterFieldType = \"text\" | \"number\" | \"select\"\n\nexport type FilterOperator =\n  \"contains\" | \"not-contains\" | \"is\" | \"is-not\" | \"eq\" | \"gt\" | \"lt\"\n\nexport type FilterField = {\n  id: string\n  label: string\n  type: FilterFieldType\n  options?: { value: string; label: string }[]\n  icon?: ReactNode\n}\n\nexport type Filter = {\n  id: string\n  fieldId: string\n  operator: FilterOperator\n  value: string\n}\n\nconst OPERATORS_BY_TYPE: Record<FilterFieldType, FilterOperator[]> = {\n  text: [\"contains\", \"not-contains\", \"is\", \"is-not\"],\n  number: [\"eq\", \"gt\", \"lt\"],\n  select: [\"is\", \"is-not\"],\n}\n\nconst OPERATOR_LABEL: Record<FilterOperator, string> = {\n  contains: \"contains\",\n  \"not-contains\": \"does not contain\",\n  is: \"is\",\n  \"is-not\": \"is not\",\n  eq: \"=\",\n  gt: \">\",\n  lt: \"<\",\n}\n\nexport function operatorsFor(type: FilterFieldType) {\n  return OPERATORS_BY_TYPE[type]\n}\n\nexport function operatorLabel(operator: FilterOperator) {\n  return OPERATOR_LABEL[operator]\n}\n\nexport function createFilter(field: FilterField): Filter {\n  return {\n    id: Math.random().toString(36).slice(2),\n    fieldId: field.id,\n    operator: OPERATORS_BY_TYPE[field.type][0],\n    value: \"\",\n  }\n}\n\nexport function matchesFilter(fieldValue: string | number, filter: Filter) {\n  const text = String(fieldValue).toLowerCase()\n  const needle = filter.value.toLowerCase()\n  if (needle === \"\") return true\n\n  switch (filter.operator) {\n    case \"contains\":\n      return text.includes(needle)\n    case \"not-contains\":\n      return !text.includes(needle)\n    case \"is\":\n      return text === needle\n    case \"is-not\":\n      return text !== needle\n    case \"eq\":\n      return Number(fieldValue) === Number(filter.value)\n    case \"gt\":\n      return Number(fieldValue) > Number(filter.value)\n    case \"lt\":\n      return Number(fieldValue) < Number(filter.value)\n  }\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}