{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-bar",
  "title": "Filter Bar",
  "author": "ilinxa",
  "description": "Composite filter bar — search, category pills, date-range picker, and results count, each independently controlled or hidden.",
  "dependencies": [
    "lucide-react",
    "react-day-picker",
    "date-fns"
  ],
  "registryDependencies": [
    "input",
    "button",
    "popover",
    "calendar"
  ],
  "files": [
    {
      "path": "src/registry/components/forms/filter-bar/filter-bar.tsx",
      "content": "\"use client\";\n\nimport { memo, useCallback, useEffect, useMemo, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  defaultFormatDate,\n  defaultFormatDateRange,\n} from \"./lib/format-default\";\nimport { ChipRow } from \"./parts/chip-row\";\nimport { DateRangePicker } from \"./parts/date-range-picker\";\nimport { SearchInput } from \"./parts/search-input\";\nimport {\n  DEFAULT_LABELS,\n  type DateRange,\n  type FilterBarCategoryItem,\n  type FilterBarProps,\n  type FilterBarValue,\n} from \"./types\";\n\nconst ALIGN_CLASSES = {\n  left: \"items-start text-left\",\n  center: \"items-center text-center\",\n  right: \"items-end text-right\",\n} as const;\n\nconst normalizeCategories = (\n  input: FilterBarCategoryItem[] | string[] | undefined,\n): FilterBarCategoryItem[] => {\n  if (!input) return [];\n  return input.map((entry) =>\n    typeof entry === \"string\" ? { value: entry, label: entry } : entry,\n  );\n};\n\n/**\n * FilterBar — composite filter bar with search + category chips + date-range\n * + optional results count. Each sub-control is independently controlled-or-\n * uncontrolled; a combined `onChange` fires on any change.\n */\nfunction FilterBarImpl(props: FilterBarProps) {\n  const {\n    categories,\n    search: controlledSearch,\n    defaultSearch = \"\",\n    onSearchChange,\n    searchDebounceMs = 250,\n    category: controlledCategory,\n    defaultCategory = null,\n    onCategoryChange,\n    dateRange: controlledDateRange,\n    defaultDateRange = { from: undefined, to: undefined },\n    onDateRangeChange,\n    onChange,\n    resultsCount,\n    align = \"center\",\n    hideSearch = false,\n    hideCategories = false,\n    hideDateRange = false,\n    labels: labelsProp,\n    formatDateRange = defaultFormatDateRange,\n    formatDate = defaultFormatDate,\n    className,\n  } = props;\n\n  const labels = useMemo(\n    () => ({ ...DEFAULT_LABELS, ...labelsProp }),\n    [labelsProp],\n  );\n\n  const normalizedCategories = useMemo(\n    () => normalizeCategories(categories),\n    [categories],\n  );\n\n  // ─── Search state ────────────────────────────────────────\n  const [internalSearch, setInternalSearch] = useState(defaultSearch);\n  const isSearchControlled = controlledSearch !== undefined;\n  const search = isSearchControlled ? controlledSearch : internalSearch;\n\n  const handleSearchChange = useCallback(\n    (next: string) => {\n      if (!isSearchControlled) setInternalSearch(next);\n      onSearchChange?.(next);\n    },\n    [isSearchControlled, onSearchChange],\n  );\n\n  // ─── Category state ──────────────────────────────────────\n  const [internalCategory, setInternalCategory] = useState<string | null>(\n    defaultCategory,\n  );\n  const isCategoryControlled = controlledCategory !== undefined;\n  const category = isCategoryControlled ? controlledCategory : internalCategory;\n\n  const handleCategoryChange = useCallback(\n    (next: string | null) => {\n      if (!isCategoryControlled) setInternalCategory(next);\n      onCategoryChange?.(next);\n    },\n    [isCategoryControlled, onCategoryChange],\n  );\n\n  // ─── Date range state ────────────────────────────────────\n  const [internalDateRange, setInternalDateRange] = useState<DateRange>(defaultDateRange);\n  const isDateRangeControlled = controlledDateRange !== undefined;\n  const dateRange = isDateRangeControlled ? controlledDateRange : internalDateRange;\n\n  const handleDateRangeChange = useCallback(\n    (next: DateRange) => {\n      if (!isDateRangeControlled) setInternalDateRange(next);\n      onDateRangeChange?.(next);\n    },\n    [isDateRangeControlled, onDateRangeChange],\n  );\n\n  // ─── Combined onChange ───────────────────────────────────\n  useEffect(() => {\n    if (!onChange) return;\n    onChange({ search, category, dateRange });\n    // We intentionally fire on every change (including initial mount).\n    // Consumers wanting initial-render skip should track it themselves.\n  }, [search, category, dateRange, onChange]);\n\n  const filtersRowVisible = !hideCategories || !hideDateRange;\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col gap-6\",\n        ALIGN_CLASSES[align],\n        className,\n      )}\n    >\n      {!hideSearch ? (\n        <SearchInput\n          value={search}\n          onChange={handleSearchChange}\n          isControlled={isSearchControlled}\n          debounceMs={searchDebounceMs}\n          placeholder={labels.searchPlaceholder}\n          ariaLabel={labels.searchAriaLabel}\n          className=\"w-full max-w-xl\"\n        />\n      ) : null}\n\n      {filtersRowVisible ? (\n        <div className=\"flex flex-wrap items-center justify-center gap-4\">\n          {!hideCategories && normalizedCategories.length > 0 ? (\n            <ChipRow\n              items={normalizedCategories}\n              value={category}\n              onChange={handleCategoryChange}\n              allLabel={labels.allLabel}\n              ariaLabel={labels.categoriesAriaLabel}\n            />\n          ) : null}\n          {!hideDateRange ? (\n            <DateRangePicker\n              value={dateRange}\n              onChange={handleDateRangeChange}\n              buttonText={labels.dateButtonText}\n              clearLabel={labels.clearDateLabel}\n              clearText={labels.clearDateText}\n              formatRange={formatDateRange}\n              formatDate={formatDate}\n            />\n          ) : null}\n        </div>\n      ) : null}\n\n      {resultsCount !== undefined ? (\n        <p\n          aria-live=\"polite\"\n          className=\"text-sm text-muted-foreground\"\n        >\n          {labels.resultsCountText(resultsCount)}\n        </p>\n      ) : null}\n    </div>\n  );\n}\n\nexport const FilterBar = memo(FilterBarImpl);\nFilterBar.displayName = \"FilterBar\";\n\nexport default FilterBar;\n\n// Re-export the value type for consumers importing from the root file.\nexport type { FilterBarValue };\n",
      "type": "registry:component",
      "target": "components/filter-bar/filter-bar.tsx"
    },
    {
      "path": "src/registry/components/forms/filter-bar/index.ts",
      "content": "export { FilterBar, default } from \"./filter-bar\";\nexport type {\n  DateRange,\n  FilterBarAlign,\n  FilterBarCategoryItem,\n  FilterBarLabels,\n  FilterBarProps,\n  FilterBarValue,\n} from \"./types\";\n",
      "type": "registry:component",
      "target": "components/filter-bar/index.ts"
    },
    {
      "path": "src/registry/components/forms/filter-bar/types.ts",
      "content": "export interface DateRange {\n  from?: Date;\n  to?: Date;\n}\n\nexport interface FilterBarValue {\n  search: string;\n  category: string | null;\n  dateRange: DateRange;\n}\n\nexport interface FilterBarCategoryItem {\n  value: string;\n  label?: string;\n}\n\nexport interface FilterBarLabels {\n  /** Search placeholder. Default: 'Search…'. */\n  searchPlaceholder?: string;\n  /** Search input aria-label. Default: 'Search'. */\n  searchAriaLabel?: string;\n  /** \"All\" chip label. Default: 'All'. */\n  allLabel?: string;\n  /** Date trigger label when no range set. Default: 'Filter by date'. */\n  dateButtonText?: string;\n  /** Aria-label on the clear-date button. Default: 'Clear date filter'. */\n  clearDateLabel?: string;\n  /** Visible text on the clear-date button. Default: 'Clear date'. */\n  clearDateText?: string;\n  /** Results count formatter. Default: (n) => `${n} items found`. */\n  resultsCountText?: (count: number) => string;\n  /** ARIA group label for the chip row. Default: 'Filter by category'. */\n  categoriesAriaLabel?: string;\n}\n\nexport type FilterBarAlign = \"left\" | \"center\" | \"right\";\n\nexport interface FilterBarProps {\n  /** Categories to render in the chip row. Pass `string[]` as shorthand. */\n  categories?: FilterBarCategoryItem[] | string[];\n\n  // ─── Search (controlled-or-uncontrolled) ──────────────\n  search?: string;\n  defaultSearch?: string;\n  onSearchChange?: (search: string) => void;\n  /** Debounce in ms for uncontrolled mode. Default: 250. */\n  searchDebounceMs?: number;\n\n  // ─── Category (controlled-or-uncontrolled) ────────────\n  category?: string | null;\n  defaultCategory?: string | null;\n  onCategoryChange?: (category: string | null) => void;\n\n  // ─── Date range (controlled-or-uncontrolled) ──────────\n  dateRange?: DateRange;\n  defaultDateRange?: DateRange;\n  onDateRangeChange?: (range: DateRange) => void;\n\n  // ─── Combined emitter ─────────────────────────────────\n  onChange?: (value: FilterBarValue) => void;\n\n  // ─── Display ──────────────────────────────────────────\n  resultsCount?: number;\n  align?: FilterBarAlign;\n  hideSearch?: boolean;\n  hideCategories?: boolean;\n  hideDateRange?: boolean;\n\n  // ─── i18n + theming ───────────────────────────────────\n  labels?: FilterBarLabels;\n  formatDateRange?: (range: { from: Date; to: Date }) => string;\n  formatDate?: (date: Date) => string;\n\n  // ─── Overrides ────────────────────────────────────────\n  className?: string;\n}\n\nexport const DEFAULT_LABELS: Required<FilterBarLabels> = {\n  searchPlaceholder: \"Search…\",\n  searchAriaLabel: \"Search\",\n  allLabel: \"All\",\n  dateButtonText: \"Filter by date\",\n  clearDateLabel: \"Clear date filter\",\n  clearDateText: \"Clear date\",\n  resultsCountText: (count) => `${count} items found`,\n  categoriesAriaLabel: \"Filter by category\",\n};\n",
      "type": "registry:component",
      "target": "components/filter-bar/types.ts"
    },
    {
      "path": "src/registry/components/forms/filter-bar/lib/format-default.ts",
      "content": "/**\n * Default date formatters using native `Intl.DateTimeFormat`. Browser-locale\n * aware by default; consumers override via `formatDate` / `formatDateRange`\n * props for full control.\n */\n\nconst SHORT = new Intl.DateTimeFormat(undefined, {\n  day: \"numeric\",\n  month: \"short\",\n});\n\nconst LONG = new Intl.DateTimeFormat(undefined, {\n  day: \"numeric\",\n  month: \"long\",\n  year: \"numeric\",\n});\n\nexport const defaultFormatDate = (date: Date): string => LONG.format(date);\n\nexport const defaultFormatDateRange = ({\n  from,\n  to,\n}: {\n  from: Date;\n  to: Date;\n}): string => `${SHORT.format(from)} - ${SHORT.format(to)}`;\n",
      "type": "registry:component",
      "target": "components/filter-bar/lib/format-default.ts"
    },
    {
      "path": "src/registry/components/forms/filter-bar/parts/search-input.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Search } from \"lucide-react\";\nimport { Input } from \"@/components/ui/input\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Search input with magnifier icon. Internal debounce when running in\n * uncontrolled mode (`isControlled = false`); controlled mode passes\n * value through immediately.\n */\nexport function SearchInput({\n  value,\n  onChange,\n  isControlled,\n  debounceMs,\n  placeholder,\n  ariaLabel,\n  className,\n}: {\n  value: string;\n  onChange: (next: string) => void;\n  isControlled: boolean;\n  debounceMs: number;\n  placeholder: string;\n  ariaLabel: string;\n  className?: string;\n}) {\n  // Local state is the source of truth for keystrokes in uncontrolled mode.\n  // In controlled mode the prop is the source of truth and `localValue`\n  // is unused (so its initial seed doesn't matter).\n  const [localValue, setLocalValue] = useState(value);\n\n  // Debounce localValue → upstream onChange in uncontrolled mode only.\n  useEffect(() => {\n    if (isControlled) return;\n    if (localValue === value) return;\n    const id = setTimeout(() => onChange(localValue), debounceMs);\n    return () => clearTimeout(id);\n  }, [localValue, value, isControlled, debounceMs, onChange]);\n\n  return (\n    <div role=\"search\" className={cn(\"relative\", className)}>\n      <Search\n        aria-hidden=\"true\"\n        className=\"absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground\"\n      />\n      <Input\n        type=\"search\"\n        value={isControlled ? value : localValue}\n        onChange={(event) => {\n          const next = event.target.value;\n          if (isControlled) onChange(next);\n          else setLocalValue(next);\n        }}\n        placeholder={placeholder}\n        aria-label={ariaLabel}\n        className=\"h-14 rounded-xl border-border/50 pl-12 text-lg focus-visible:border-primary\"\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/filter-bar/parts/search-input.tsx"
    },
    {
      "path": "src/registry/components/forms/filter-bar/parts/chip-row.tsx",
      "content": "import { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport type { FilterBarCategoryItem } from \"../types\";\n\n/**\n * Pill-row of category chips. The first chip is the \"All\" sentinel —\n * clicking it sets value to null (clears the category filter).\n *\n * Active chip uses Button `variant=\"default\"` (filled lime), inactive uses\n * `variant=\"outline\"`. Each chip is a real button with `aria-pressed`.\n */\nexport function ChipRow({\n  items,\n  value,\n  onChange,\n  allLabel,\n  ariaLabel,\n  className,\n}: {\n  items: FilterBarCategoryItem[];\n  value: string | null;\n  onChange: (value: string | null) => void;\n  allLabel: string;\n  ariaLabel: string;\n  className?: string;\n}) {\n  return (\n    <div\n      role=\"group\"\n      aria-label={ariaLabel}\n      className={cn(\"flex flex-wrap justify-center gap-2\", className)}\n    >\n      <Button\n        variant={value === null ? \"default\" : \"outline\"}\n        size=\"sm\"\n        aria-pressed={value === null}\n        onClick={() => onChange(null)}\n        className=\"rounded-full\"\n      >\n        {allLabel}\n      </Button>\n      {items.map((item) => (\n        <Button\n          key={item.value}\n          variant={value === item.value ? \"default\" : \"outline\"}\n          size=\"sm\"\n          aria-pressed={value === item.value}\n          onClick={() => onChange(item.value)}\n          className=\"rounded-full\"\n        >\n          {item.label ?? item.value}\n        </Button>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/filter-bar/parts/chip-row.tsx"
    },
    {
      "path": "src/registry/components/forms/filter-bar/parts/date-range-picker.tsx",
      "content": "\"use client\";\n\nimport { Calendar as CalendarIcon, ChevronDown, X } from \"lucide-react\";\nimport { Button, buttonVariants } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { cn } from \"@/lib/utils\";\nimport type { DateRange } from \"../types\";\n\n/**\n * Date-range Popover. Renders a 2-month calendar in range mode.\n * Conditional clear-X button appears next to the trigger when a range is set.\n *\n * Composes shadcn Popover + Calendar primitives. Calendar wraps\n * react-day-picker; date-fns is a transitive peer.\n */\nexport function DateRangePicker({\n  value,\n  onChange,\n  buttonText,\n  clearLabel,\n  clearText,\n  formatRange,\n  formatDate,\n}: {\n  value: DateRange;\n  onChange: (next: DateRange) => void;\n  buttonText: string;\n  clearLabel: string;\n  clearText: string;\n  formatRange: (range: { from: Date; to: Date }) => string;\n  formatDate: (date: Date) => string;\n}) {\n  const hasRange = value.from !== undefined || value.to !== undefined;\n\n  let triggerText = buttonText;\n  if (value.from && value.to) {\n    triggerText = formatRange({ from: value.from, to: value.to });\n  } else if (value.from) {\n    triggerText = formatDate(value.from);\n  }\n\n  return (\n    <div className=\"flex items-center gap-2\">\n      <Popover>\n        {/* F-cross-13: no `asChild` — render the trigger as the button directly\n            (both Radix + Base UI pass native props through to a <button>). */}\n        <PopoverTrigger\n          type=\"button\"\n          className={cn(buttonVariants({ variant: \"outline\" }), \"gap-2 rounded-full\")}\n        >\n          <CalendarIcon aria-hidden=\"true\" className=\"h-4 w-4\" />\n          {triggerText}\n          <ChevronDown aria-hidden=\"true\" className=\"h-4 w-4\" />\n        </PopoverTrigger>\n        <PopoverContent className=\"w-auto p-0\" align=\"center\">\n          <Calendar\n            mode=\"range\"\n            defaultMonth={value.from}\n            selected={value.from || value.to ? { from: value.from, to: value.to } : undefined}\n            onSelect={(range) =>\n              onChange({ from: range?.from, to: range?.to })\n            }\n            numberOfMonths={2}\n          />\n        </PopoverContent>\n      </Popover>\n\n      {hasRange ? (\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={() => onChange({ from: undefined, to: undefined })}\n          aria-label={clearLabel}\n          className=\"gap-1\"\n        >\n          <X aria-hidden=\"true\" className=\"h-3 w-3\" />\n          {clearText}\n        </Button>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/filter-bar/parts/date-range-picker.tsx"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:block"
}