{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-toolbar",
  "title": "Filter Toolbar",
  "author": "@fma-ds/ds",
  "description": "The controls above a list or `DataGrid`, composed from parts that are each controlled (`value`/`onChange`), so every filter maps to a search param.\n\n- **`FilterToolbar`**: lays the parts out, wrapping on narrow screens; its `i18n.labels` reaches every part.\n- **`FilterSearch`**: a react-aria `SearchField` that commits the term on Enter and when cleared — and, with `debounceMs`, after the user stops typing.\n- **`FilterQuickToggles`**: an outlined `ToggleGroup` for one-press filters.\n- **`FilterToolbarActions`**: holds the trailing actions, `FilterSheet` and `FilterRefresh`.\n- **`FilterSheet`**: a \"Filters\" button with the active count, opening a side `Sheet`. Its children are the `Field`s of a draft the app keeps: `onApply` commits it (return `false` to stay open), `onClear` empties it, and Enter applies.\n- **`FilterRefresh`**: spins while `isPending`.\n- **`FilterChips`**: the applied filters as a react-aria `TagGroup`, each removable with its × or Backspace/Delete, plus \"Clear filters\". Renders nothing when empty.",
  "dependencies": [
    "cn@^0.5.0",
    "lucide-react@^1.61.0",
    "react-aria-components@^1.22.1"
  ],
  "registryDependencies": [
    "@fma-ds/badge",
    "@fma-ds/button",
    "@fma-ds/input",
    "@fma-ds/input-group",
    "@fma-ds/sheet",
    "@fma-ds/toggle-group",
    "@fma-ds/tooltip"
  ],
  "files": [
    {
      "path": "src/blocks/filter-toolbar/filter-toolbar.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"cn\";\nimport { FilterIcon, RefreshCwIcon, SearchIcon, XIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport {\n  Form,\n  SearchField,\n  Tag,\n  TagGroup,\n  TagList,\n  type ToggleButtonGroupProps,\n} from \"react-aria-components\";\nimport { Badge } from \"@/core/badge/badge\";\nimport { Button } from \"@/core/button/button\";\nimport { Input } from \"@/core/input/input\";\nimport { InputGroup, InputGroupAddon, InputGroupButton } from \"@/core/input-group/input-group\";\nimport {\n  Sheet,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n  SheetTrigger,\n} from \"@/core/sheet/sheet\";\nimport { ToggleGroup } from \"@/core/toggle-group/toggle-group\";\nimport { Tooltip, TooltipTrigger } from \"@/core/tooltip/tooltip\";\n\ntype FilterToolbarLabels = {\n  search: string;\n  searchPlaceholder: string;\n  clearSearch: string;\n  quickFilters: string;\n  filters: string;\n  /** The filters button's accessible name, with the active count. */\n  filtersTrigger: (active: number) => string;\n  filtersTitle: string;\n  apply: string;\n  clear: string;\n  activeFilters: string;\n  clearAll: string;\n  refresh: string;\n};\n\ntype FilterToolbarI18n = { labels?: Partial<FilterToolbarLabels> };\n\nconst defaultFilterToolbarLabels: FilterToolbarLabels = {\n  search: \"Search\",\n  searchPlaceholder: \"Search…\",\n  clearSearch: \"Clear search\",\n  quickFilters: \"Quick filters\",\n  filters: \"Filters\",\n  filtersTrigger: (active) => (active > 0 ? `Filters, ${active} active` : \"Filters\"),\n  filtersTitle: \"Filter results\",\n  apply: \"Apply\",\n  clear: \"Clear\",\n  activeFilters: \"Active filters\",\n  clearAll: \"Clear filters\",\n  refresh: \"Refresh\",\n};\n\nconst FilterToolbarContext = React.createContext<FilterToolbarLabels>(defaultFilterToolbarLabels);\n\nconst useFilterToolbarLabels = () => React.useContext(FilterToolbarContext);\n\n/**\n * Lays out the list controls: search, quick filters and actions on one row\n * (wrapping on narrow screens), active filter chips on a row of their own.\n * Its `i18n` reaches every part inside.\n */\nfunction FilterToolbar({\n  i18n,\n  className,\n  ...props\n}: React.ComponentProps<\"div\"> & { i18n?: FilterToolbarI18n }) {\n  const overrides = i18n?.labels;\n  const labels = React.useMemo(\n    () => ({ ...defaultFilterToolbarLabels, ...overrides }),\n    [overrides],\n  );\n  return (\n    <FilterToolbarContext.Provider value={labels}>\n      <div\n        data-slot=\"filter-toolbar\"\n        className={cn(\"flex min-w-0 flex-wrap items-center gap-2\", className)}\n        {...props}\n      />\n    </FilterToolbarContext.Provider>\n  );\n}\n\n/** Groups the trailing actions (filters, refresh, export…) at the end of the row. */\nfunction FilterToolbarActions({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"filter-toolbar-actions\"\n      className={cn(\"ml-auto flex shrink-0 items-center gap-2\", className)}\n      {...props}\n    />\n  );\n}\n\n/**\n * The search box. `onChange` gets the committed term: on Enter, when cleared,\n * and — with `debounceMs` — after the user stops typing. Typing alone doesn't\n * fire a request per keystroke.\n */\nfunction FilterSearch({\n  value,\n  onChange,\n  debounceMs,\n  placeholder,\n  \"aria-label\": ariaLabel,\n  isDisabled,\n  maxLength,\n  className,\n}: {\n  value: string;\n  onChange: (value: string) => void;\n  debounceMs?: number;\n  placeholder?: string;\n  \"aria-label\"?: string;\n  isDisabled?: boolean;\n  maxLength?: number;\n  className?: string;\n}) {\n  const labels = useFilterToolbarLabels();\n  const [draft, setDraft] = React.useState(value);\n  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined);\n  const lastValue = React.useRef(value);\n\n  // Follow outside changes (e.g. \"Clear filters\" or the back button).\n  if (lastValue.current !== value) {\n    lastValue.current = value;\n    if (draft !== value) setDraft(value);\n  }\n\n  React.useEffect(() => () => clearTimeout(timer.current), []);\n\n  function commit(next: string) {\n    clearTimeout(timer.current);\n    const trimmed = next.trim();\n    if (trimmed !== value) onChange(trimmed);\n  }\n\n  function handleChange(next: string) {\n    setDraft(next);\n    if (debounceMs === undefined) return;\n    clearTimeout(timer.current);\n    timer.current = setTimeout(() => commit(next), debounceMs);\n  }\n\n  return (\n    <SearchField\n      data-slot=\"filter-search\"\n      aria-label={ariaLabel ?? labels.search}\n      value={draft}\n      onChange={handleChange}\n      onSubmit={commit}\n      onClear={() => commit(\"\")}\n      isDisabled={isDisabled}\n      maxLength={maxLength}\n      className={cn(\"min-w-48 flex-1 sm:max-w-sm\", className)}\n    >\n      {({ isEmpty }) => (\n        <InputGroup>\n          <InputGroupAddon>\n            <SearchIcon aria-hidden=\"true\" />\n          </InputGroupAddon>\n          <Input\n            data-slot=\"input-group-control\"\n            placeholder={placeholder ?? labels.searchPlaceholder}\n            className=\"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 dark:bg-transparent [&::-webkit-search-cancel-button]:hidden\"\n          />\n          {!isEmpty && (\n            <InputGroupAddon align=\"inline-end\">\n              <InputGroupButton size=\"icon-xs\" aria-label={labels.clearSearch}>\n                <XIcon />\n              </InputGroupButton>\n            </InputGroupAddon>\n          )}\n        </InputGroup>\n      )}\n    </SearchField>\n  );\n}\n\n/**\n * Filters applied with one press: a `ToggleGroup` of `ToggleGroupItem`s\n * (single or multiple selection), outlined so the pressed state reads at a glance.\n */\nfunction FilterQuickToggles({\n  className,\n  \"aria-label\": ariaLabel,\n  ...props\n}: React.ComponentProps<typeof ToggleGroup> & Pick<ToggleButtonGroupProps, \"aria-label\">) {\n  const labels = useFilterToolbarLabels();\n  return (\n    <ToggleGroup\n      data-slot=\"filter-quick-toggles\"\n      variant=\"outline\"\n      aria-label={ariaLabel ?? labels.quickFilters}\n      className={cn(\n        \"flex-wrap [&>[data-selected]]:border-ring/40 [&>[data-selected]]:bg-accent [&>[data-selected]]:text-accent-foreground\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/**\n * The \"Filters\" button and the side sheet with the less frequent filters.\n * Put `Field`s in `children`, controlled by a draft the app keeps: `onApply`\n * commits it (return `false` to keep the sheet open, e.g. on a validation\n * error), `onClear` empties the draft, and `onOpenChange(false)` is the\n * moment to throw an unapplied draft away. The fields are a form, so Enter\n * applies.\n */\nfunction FilterSheet({\n  activeCount = 0,\n  onApply,\n  onClear,\n  isOpen: isOpenProp,\n  onOpenChange,\n  title,\n  description,\n  isDisabled,\n  children,\n  className,\n}: {\n  /** How many advanced filters are applied; shown on the button. */\n  activeCount?: number;\n  onApply: () => boolean | undefined | Promise<boolean | undefined>;\n  onClear?: () => void;\n  isOpen?: boolean;\n  onOpenChange?: (isOpen: boolean) => void;\n  title?: React.ReactNode;\n  description?: React.ReactNode;\n  isDisabled?: boolean;\n  children: React.ReactNode;\n  className?: string;\n}) {\n  const labels = useFilterToolbarLabels();\n  const [isOpenState, setIsOpenState] = React.useState(false);\n  const isOpen = isOpenProp ?? isOpenState;\n  const [isApplying, setIsApplying] = React.useState(false);\n\n  function setOpen(next: boolean) {\n    setIsOpenState(next);\n    onOpenChange?.(next);\n  }\n\n  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {\n    event.preventDefault();\n    setIsApplying(true);\n    try {\n      if ((await onApply()) !== false) setOpen(false);\n    } finally {\n      setIsApplying(false);\n    }\n  }\n\n  return (\n    <SheetTrigger isOpen={isOpen} onOpenChange={setOpen}>\n      <Button\n        variant=\"outline\"\n        data-slot=\"filter-sheet-trigger\"\n        aria-label={labels.filtersTrigger(activeCount)}\n        isDisabled={isDisabled}\n      >\n        <FilterIcon data-icon=\"inline-start\" />\n        {labels.filters}\n        {activeCount > 0 && (\n          <Badge size=\"sm\" radius=\"full\" aria-hidden=\"true\">\n            {activeCount}\n          </Badge>\n        )}\n      </Button>\n      <Sheet side=\"right\" size=\"md\" className={className}>\n        <SheetHeader>\n          <SheetTitle>{title ?? labels.filtersTitle}</SheetTitle>\n          {description && <SheetDescription>{description}</SheetDescription>}\n        </SheetHeader>\n        <Form\n          data-slot=\"filter-sheet-form\"\n          onSubmit={handleSubmit}\n          className=\"flex min-h-0 flex-1 flex-col\"\n        >\n          <div className=\"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4\">{children}</div>\n          <SheetFooter className=\"flex-row border-t *:flex-1\">\n            {onClear && (\n              <Button type=\"button\" variant=\"outline\" onPress={onClear}>\n                {labels.clear}\n              </Button>\n            )}\n            <Button type=\"submit\" isPending={isApplying}>\n              {labels.apply}\n            </Button>\n          </SheetFooter>\n        </Form>\n      </Sheet>\n    </SheetTrigger>\n  );\n}\n\ntype FilterChip = {\n  id: string;\n  /** What is filtered, e.g. \"Status\". */\n  label: React.ReactNode;\n  /** The applied value, already formatted, e.g. \"Active\". */\n  value: React.ReactNode;\n  /** Plain text for assistive tech when `label`/`value` aren't strings. */\n  textValue?: string;\n};\n\n/**\n * The applied filters as chips, each removable with its × (or Backspace and\n * Delete from the keyboard), plus \"Clear filters\". Renders nothing when empty.\n */\nfunction FilterChips({\n  items,\n  onRemove,\n  onClearAll,\n  className,\n}: {\n  items: FilterChip[];\n  onRemove: (id: string) => void;\n  onClearAll?: () => void;\n  className?: string;\n}) {\n  const labels = useFilterToolbarLabels();\n  if (items.length === 0) return null;\n  return (\n    <div\n      data-slot=\"filter-chips\"\n      className={cn(\"flex w-full basis-full flex-wrap items-center gap-2\", className)}\n    >\n      <TagGroup\n        aria-label={labels.activeFilters}\n        onRemove={(keys) => {\n          for (const key of keys) onRemove(String(key));\n        }}\n      >\n        <TagList items={items} className=\"flex flex-wrap items-center gap-1.5\">\n          {(item) => (\n            <Tag\n              id={item.id}\n              textValue={\n                item.textValue ??\n                (typeof item.label === \"string\" && typeof item.value === \"string\"\n                  ? `${item.label}: ${item.value}`\n                  : undefined)\n              }\n              data-slot=\"filter-chip\"\n              className=\"flex h-7 items-center gap-1 rounded-md border bg-background pr-0.5 pl-2 text-xs outline-none data-focus-visible:border-ring data-focus-visible:ring-3 data-focus-visible:ring-ring/50 dark:bg-input/30\"\n            >\n              <span className=\"text-muted-foreground\">{item.label}:</span>\n              <span className=\"font-medium\">{item.value}</span>\n              <Button\n                slot=\"remove\"\n                variant=\"ghost\"\n                size=\"icon-xs\"\n                data-slot=\"filter-chip-remove\"\n                className=\"text-muted-foreground\"\n              >\n                <XIcon />\n              </Button>\n            </Tag>\n          )}\n        </TagList>\n      </TagGroup>\n      {onClearAll && (\n        <Button variant=\"ghost\" size=\"sm\" onPress={onClearAll}>\n          {labels.clearAll}\n        </Button>\n      )}\n    </div>\n  );\n}\n\n/** Reloads the list; spins while `isPending`. */\nfunction FilterRefresh({\n  onRefresh,\n  isPending = false,\n  \"aria-label\": ariaLabel,\n  className,\n}: {\n  onRefresh: () => void;\n  isPending?: boolean;\n  \"aria-label\"?: string;\n  className?: string;\n}) {\n  const labels = useFilterToolbarLabels();\n  const label = ariaLabel ?? labels.refresh;\n  return (\n    <TooltipTrigger>\n      <Button\n        variant=\"outline\"\n        size=\"icon\"\n        data-slot=\"filter-refresh\"\n        aria-label={label}\n        isPending={isPending}\n        onPress={onRefresh}\n        className={className}\n      >\n        <RefreshCwIcon className={cn(isPending && \"animate-spin\")} />\n      </Button>\n      <Tooltip>{label}</Tooltip>\n    </TooltipTrigger>\n  );\n}\n\nexport type { FilterChip, FilterToolbarI18n, FilterToolbarLabels };\nexport {\n  defaultFilterToolbarLabels,\n  FilterChips,\n  FilterQuickToggles,\n  FilterRefresh,\n  FilterSearch,\n  FilterSheet,\n  FilterToolbar,\n  FilterToolbarActions,\n};\n",
      "type": "registry:component",
      "target": "components/fma-ds/filter-toolbar/filter-toolbar.tsx"
    }
  ],
  "meta": {
    "status": "experimental",
    "source": "original",
    "license": "MIT",
    "tags": [
      "block",
      "filters",
      "search",
      "toolbar",
      "list"
    ],
    "contentHash": "7b36ec2c4eb6"
  },
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}