{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-sheet",
  "title": "Filter Sheet",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "The filter panel in a slide-over for small screens. Changes are staged and applied once — on Show or on close — with a live result count on the button and focus returned to the trigger. Accepts the same FilterPanel parts as children, so a custom layout stages too.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "sheet",
    "@ecomcn/filter-panel"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/filter-sheet/filter-sheet.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { SlidersHorizontal } from \"lucide-react\"\n\nimport { Button, buttonVariants } from \"@/components/ui/button\"\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n  SheetTrigger,\n} from \"@/components/ui/sheet\"\nimport { FilterPanel, type FilterPanelProps } from \"@/components/ecomcn/filter-panel\"\nimport {\n  countActiveFilters,\n  isSameFilters,\n  type FilterState,\n} from \"@/lib/filter-params\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface FilterSheetProps\n  extends Pick<FilterPanelProps, \"facets\" | \"currency\" | \"locale\" | \"loading\"> {\n  /** The applied filters — usually straight from `useFilterParams`. */\n  value: FilterState\n  /** Called once with the staged filters when the sheet applies — never per tap. */\n  onValueChange: (next: FilterState) => void\n  /** Result count for the *staged* filters, shown on the apply button. */\n  getResultCount?: (staged: FilterState) => number | undefined\n  /**\n   * Fires on every staged change. When counting needs a request, fetch here\n   * and pass the answer back as `resultCount`.\n   */\n  onStagedChange?: (staged: FilterState) => void\n  /** Wins over `getResultCount`. `undefined` renders a plain \"Show results\". */\n  resultCount?: number\n  /** What Escape, the close button and the overlay do with staged changes. */\n  dismissBehavior?: \"apply\" | \"discard\"\n  side?: \"left\" | \"right\" | \"bottom\"\n  title?: string\n  /** Trigger label. */\n  label?: string\n  /** Applied to the trigger button. */\n  className?: string\n  contentClassName?: string\n  noun?: { one: string; other: string }\n  /**\n   * A custom panel layout built from FilterPanel parts. They render inside a\n   * FilterPanel that holds the *staged* value, so the same layout you use on\n   * desktop stages here without any changes.\n   */\n  children?: React.ReactNode\n}\n\nexport function FilterSheet({\n  facets,\n  value,\n  onValueChange,\n  getResultCount,\n  onStagedChange,\n  resultCount,\n  dismissBehavior = \"apply\",\n  side = \"left\",\n  title = \"Filters\",\n  label = \"Filters\",\n  currency,\n  locale,\n  loading,\n  className,\n  contentClassName,\n  noun = { one: \"result\", other: \"results\" },\n  children,\n}: FilterSheetProps) {\n  const [open, setOpen] = React.useState(false)\n  // Changes made inside the sheet are staged here, not pushed to the URL:\n  // applying live would re-fetch the listing behind the sheet on every tap.\n  const [staged, setStaged] = React.useState<FilterState>(value)\n\n  const applied = countActiveFilters(value, facets)\n  const stagedCount = countActiveFilters(staged, facets)\n  const dirty = !isSameFilters(staged, value, facets)\n  const count = resultCount ?? (open ? getResultCount?.(staged) : undefined)\n\n  const stage = (next: FilterState) => {\n    setStaged(next)\n    onStagedChange?.(next)\n  }\n\n  const plural = new Intl.PluralRules(locale)\n  const results =\n    count === undefined\n      ? null\n      : `${new Intl.NumberFormat(locale).format(count)} ${\n          plural.select(count) === \"one\" ? noun.one : noun.other\n        }`\n\n  // Only a real change reaches onValueChange, so closing an untouched sheet\n  // never adds a history entry or fires a request.\n  const commit = () => {\n    if (dirty) onValueChange(staged)\n  }\n\n  const handleOpenChange = (next: boolean) => {\n    if (next) stage(value)\n    else if (dismissBehavior === \"apply\") commit()\n    setOpen(next)\n  }\n\n  return (\n    <Sheet open={open} onOpenChange={handleOpenChange}>\n      {/* A real SheetTrigger, not a button that sets `open`: the dialog\n          primitive only knows where to return focus if it owns the trigger.\n          Styled with buttonVariants rather than asChild so it works on both\n          the Radix and Base UI flavours of shadcn/ui. */}\n      <SheetTrigger\n        className={cn(buttonVariants({ variant: \"outline\" }), \"gap-2\", className)}\n      >\n        <SlidersHorizontal className=\"size-4\" aria-hidden />\n        {label}\n        {applied > 0 && (\n          <span className=\"grid h-5 min-w-5 place-items-center rounded-sm bg-foreground px-1 text-[11px] leading-none text-background tabular-nums\">\n            {applied}\n            <span className=\"sr-only\"> applied</span>\n          </span>\n        )}\n      </SheetTrigger>\n\n      <SheetContent\n        side={side}\n        className={cn(\n          \"flex flex-col gap-0 p-0\",\n          side === \"bottom\" ? \"max-h-[88dvh]\" : \"w-full sm:max-w-sm\",\n          contentClassName\n        )}\n      >\n        <SheetHeader className=\"border-b px-5 py-4 text-left\">\n          <SheetTitle>{title}</SheetTitle>\n          <SheetDescription>\n            {dismissBehavior === \"apply\"\n              ? \"Changes apply when you close this panel.\"\n              : \"Changes apply when you press Show.\"}\n          </SheetDescription>\n        </SheetHeader>\n\n        <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-3\">\n          <FilterPanel\n            hideHeader\n            title={title}\n            facets={facets}\n            value={staged}\n            onValueChange={stage}\n            currency={currency}\n            locale={locale}\n            loading={loading}\n          >\n            {children}\n          </FilterPanel>\n        </div>\n\n        {/* Outside the scroller, so the footer rule runs edge to edge and the\n            primary action never scrolls away. */}\n        <SheetFooter className=\"mt-0 flex-row gap-2 border-t px-5 py-4\">\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            className=\"flex-1\"\n            disabled={stagedCount === 0}\n            onClick={() => stage({})}\n          >\n            Clear\n          </Button>\n          <Button\n            type=\"button\"\n            className=\"flex-[2] tabular-nums\"\n            onClick={() => {\n              commit()\n              setOpen(false)\n            }}\n          >\n            {results ? `Show ${results}` : \"Show results\"}\n          </Button>\n          <span className=\"sr-only\" aria-live=\"polite\">\n            {dirty && results ? results : \"\"}\n          </span>\n        </SheetFooter>\n      </SheetContent>\n    </Sheet>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/filter-sheet.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "catalog",
    "listing",
    "filters",
    "mobile"
  ],
  "type": "registry:block"
}