{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "listings-filters",
  "title": "Listings Filters",
  "description": "The left-hand search & filter panel: injected place search with recents, free-text listing search, toned type chips, a category tree with indeterminate group states, region checklist, operational-only switch and gate-fee range — every control with live faceted counts, plus an active-filter chip row.",
  "dependencies": [
    "@untitledui/icons@^0.0.22"
  ],
  "registryDependencies": [
    "@nx-ui/badge",
    "@nx-ui/button",
    "@nx-ui/checkbox",
    "@nx-ui/collapsible",
    "@nx-ui/input-group",
    "@nx-ui/label",
    "@nx-ui/marketplace-types",
    "@nx-ui/slider",
    "@nx-ui/spinner",
    "@nx-ui/switch",
    "@nx-ui/tag",
    "@nx-ui/use-marketplace",
    "@nx-ui/use-mobile",
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/blocks/marketplace/listings-filters.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ChevronDown,\n  ChevronLeft,\n  Clock,\n  FilterLines,\n  MarkerPin01,\n  SearchLg,\n  XClose,\n} from \"@untitledui/icons\"\n\nimport { useIsMobile } from \"@/hooks/use-mobile\"\nimport { cn } from \"@/lib/utils\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport { Checkbox } from \"@/components/ui/checkbox\"\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\"\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupInput,\n} from \"@/components/ui/input-group\"\nimport { Label } from \"@/components/ui/label\"\nimport { Slider } from \"@/components/ui/slider\"\nimport { Spinner } from \"@/components/ui/spinner\"\nimport { Switch } from \"@/components/ui/switch\"\nimport { Tag } from \"@/components/ui/tag\"\nimport {\n  categoryLabel,\n  DEFAULT_CATEGORY_TREE,\n  listingTone,\n  type CategoryTreeGroup,\n  type GeoSearchFn,\n  type GeoSearchResult,\n  type ListingsFacets,\n  type MarketplaceFilters,\n} from \"@/components/blocks/marketplace/marketplace-types\"\nimport type { Marketplace } from \"@/components/blocks/marketplace/use-marketplace\"\n\n/**\n * listings-filters — the left-hand search & filter panel for the marketplace\n * map.\n *\n * Every control shows live result counts (the endpoint's self-excluding\n * facets), so a filter always tells you what it would do before you commit:\n *\n *   • Find a place — an injected geocoder (GeoSearchFn) with keyboard\n *     navigation and recent searches; picking a result flies the camera.\n *   • Search listings — the endpoint's free-text `q` over name / operator /\n *     postcode.\n *   • Facility-type chips with tone dots and counts (top 8 + show all).\n *   • Material category tree — group checkboxes with indeterminate states\n *     over the leaf categories.\n *   • Region checklist, operational-only switch, gate-fee range slider.\n *   • An active-filter chip row with per-chip remove and clear-all.\n *\n * Desktop: a floating overlay that collapses to an edge tab. Mobile: a\n * full-height sheet behind a \"Filters\" pill. Slides away in transfer mode.\n */\n\nexport interface ListingsFiltersProps {\n  marketplace: Marketplace\n  /** Forward geocoder for \"Find a place\". Omit to hide the location search. */\n  geoSearch?: GeoSearchFn\n  /** Fired when the user picks a place (the hook's flyToCoords by default). */\n  onPlaceSelect?: (result: GeoSearchResult) => void\n  /** The category grouping to render. Defaults to the built-in tree. */\n  categoryTree?: CategoryTreeGroup[]\n  /** Gate-fee slider bounds, £/t. */\n  feeBounds?: [number, number]\n  title?: string\n  className?: string\n  defaultCollapsed?: boolean\n}\n\n// ---------------------------------------------------------------------------\n// Location search (geocode + recents)\n// ---------------------------------------------------------------------------\n\nconst RECENTS_KEY = \"nx-ui-marketplace-recent-places\"\nconst MAX_RECENTS = 4\n\nfunction loadRecents(): GeoSearchResult[] {\n  try {\n    const raw = window.localStorage.getItem(RECENTS_KEY)\n    return raw ? (JSON.parse(raw) as GeoSearchResult[]) : []\n  } catch {\n    return []\n  }\n}\n\nfunction PlaceSearch({\n  geoSearch,\n  onSelect,\n}: {\n  geoSearch: GeoSearchFn\n  onSelect: (result: GeoSearchResult) => void\n}) {\n  const [query, setQuery] = React.useState(\"\")\n  const [results, setResults] = React.useState<GeoSearchResult[]>([])\n  const [open, setOpen] = React.useState(false)\n  const [loading, setLoading] = React.useState(false)\n  const [activeIndex, setActiveIndex] = React.useState(-1)\n  const [recents, setRecents] = React.useState<GeoSearchResult[]>([])\n  const containerRef = React.useRef<HTMLDivElement>(null)\n  const requestSeq = React.useRef(0)\n  const listboxId = React.useId()\n\n  React.useEffect(() => setRecents(loadRecents()), [])\n\n  React.useEffect(() => {\n    const trimmed = query.trim()\n    if (trimmed.length < 3) {\n      setResults([])\n      setLoading(false)\n      return\n    }\n    setLoading(true)\n    const seq = ++requestSeq.current\n    const timer = window.setTimeout(async () => {\n      try {\n        const next = await geoSearch(trimmed)\n        if (seq === requestSeq.current) {\n          setResults(next)\n          setActiveIndex(next.length > 0 ? 0 : -1)\n        }\n      } catch {\n        if (seq === requestSeq.current) setResults([])\n      } finally {\n        if (seq === requestSeq.current) setLoading(false)\n      }\n    }, 300)\n    return () => window.clearTimeout(timer)\n  }, [query, geoSearch])\n\n  React.useEffect(() => {\n    const handler = (event: MouseEvent) => {\n      if (!containerRef.current?.contains(event.target as Node)) setOpen(false)\n    }\n    document.addEventListener(\"mousedown\", handler)\n    return () => document.removeEventListener(\"mousedown\", handler)\n  }, [])\n\n  const choose = (result: GeoSearchResult) => {\n    onSelect(result)\n    setQuery(result.label)\n    setOpen(false)\n    setActiveIndex(-1)\n    setRecents((previous) => {\n      const next = [result, ...previous.filter((item) => item.id !== result.id)].slice(\n        0,\n        MAX_RECENTS\n      )\n      try {\n        window.localStorage.setItem(RECENTS_KEY, JSON.stringify(next))\n      } catch {\n        // Storage unavailable (private mode) — recents stay session-only.\n      }\n      return next\n    })\n  }\n\n  const showResults = open && query.trim().length >= 3\n  const showRecents = open && !showResults && recents.length > 0\n  const options = showResults ? results : showRecents ? recents : []\n\n  return (\n    <div ref={containerRef} className=\"relative\">\n      <InputGroup className=\"h-9 w-full\">\n        <InputGroupAddon>\n          {loading ? <Spinner className=\"size-4\" /> : <MarkerPin01 />}\n        </InputGroupAddon>\n        <InputGroupInput\n          role=\"combobox\"\n          aria-expanded={showResults || showRecents}\n          aria-controls={listboxId}\n          aria-autocomplete=\"list\"\n          aria-label=\"Find a place\"\n          autoComplete=\"off\"\n          placeholder=\"Find a place…\"\n          value={query}\n          data-testid=\"place-search\"\n          onChange={(event) => {\n            setQuery(event.target.value)\n            setOpen(true)\n          }}\n          onFocus={() => setOpen(true)}\n          onKeyDown={(event) => {\n            if (event.key === \"ArrowDown\") {\n              event.preventDefault()\n              setOpen(true)\n              setActiveIndex((index) => Math.min(index + 1, options.length - 1))\n            } else if (event.key === \"ArrowUp\") {\n              event.preventDefault()\n              setActiveIndex((index) => Math.max(index - 1, 0))\n            } else if (event.key === \"Enter\") {\n              if (activeIndex >= 0 && options[activeIndex]) {\n                event.preventDefault()\n                choose(options[activeIndex])\n              }\n            } else if (event.key === \"Escape\") {\n              setOpen(false)\n            }\n          }}\n        />\n      </InputGroup>\n\n      {(showResults || showRecents) && (\n        <div\n          id={listboxId}\n          role=\"listbox\"\n          aria-label={showRecents ? \"Recent places\" : \"Places\"}\n          className=\"absolute inset-x-0 top-full z-50 mt-1 overflow-hidden rounded-lg border border-border bg-popover shadow-lg\"\n        >\n          {showRecents ? (\n            <p className=\"px-3 pt-2 pb-1 text-[11px] font-semibold text-muted-foreground uppercase\">\n              Recent\n            </p>\n          ) : null}\n          {showResults && loading && results.length === 0 ? (\n            <p className=\"px-3 py-2.5 text-sm text-muted-foreground\">Searching…</p>\n          ) : showResults && !loading && results.length === 0 ? (\n            <p className=\"px-3 py-2.5 text-sm text-muted-foreground\">\n              No places found — try a town or postcode.\n            </p>\n          ) : (\n            <ul className=\"max-h-64 overflow-y-auto py-1\">\n              {options.map((result, index) => (\n                <li key={result.id}>\n                  <button\n                    type=\"button\"\n                    role=\"option\"\n                    aria-selected={index === activeIndex}\n                    onMouseEnter={() => setActiveIndex(index)}\n                    onClick={() => choose(result)}\n                    className={cn(\n                      \"flex w-full cursor-pointer items-start gap-2 px-3 py-1.5 text-left text-sm\",\n                      index === activeIndex ? \"bg-muted\" : \"hover:bg-muted\"\n                    )}\n                  >\n                    {showRecents ? (\n                      <Clock className=\"mt-0.5 size-3.5 shrink-0 text-fg-quaternary\" />\n                    ) : (\n                      <MarkerPin01 className=\"mt-0.5 size-3.5 shrink-0 text-fg-quaternary\" />\n                    )}\n                    <span className=\"min-w-0 flex-col\">\n                      <span className=\"block truncate font-medium text-foreground\">\n                        {result.label}\n                      </span>\n                      {result.sublabel ? (\n                        <span className=\"block truncate text-xs text-muted-foreground\">\n                          {result.sublabel}\n                        </span>\n                      ) : null}\n                    </span>\n                  </button>\n                </li>\n              ))}\n            </ul>\n          )}\n        </div>\n      )}\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Sections\n// ---------------------------------------------------------------------------\n\nfunction SectionHeading({ children }: { children: React.ReactNode }) {\n  return (\n    <p className=\"text-[11px] font-semibold tracking-wide text-muted-foreground uppercase\">\n      {children}\n    </p>\n  )\n}\n\nfunction toggleValue(list: string[], value: string): string[] {\n  return list.includes(value)\n    ? list.filter((item) => item !== value)\n    : [...list, value]\n}\n\nconst SHOWN_TYPE_CHIPS = 8\n\nfunction TypeChips({\n  facets,\n  selected,\n  onToggle,\n}: {\n  facets: ListingsFacets | null\n  selected: string[]\n  onToggle: (type: string) => void\n}) {\n  const [showAll, setShowAll] = React.useState(false)\n  const types = React.useMemo(() => {\n    const entries = Object.entries(facets?.types ?? {}).sort(\n      (a, b) => b[1] - a[1] || a[0].localeCompare(b[0])\n    )\n    // Selected chips always stay visible, wherever they rank.\n    const shown = showAll ? entries : entries.slice(0, SHOWN_TYPE_CHIPS)\n    const shownKeys = new Set(shown.map(([type]) => type))\n    for (const type of selected) {\n      if (!shownKeys.has(type)) {\n        shown.push([type, facets?.types[type] ?? 0])\n      }\n    }\n    return { shown, hiddenCount: entries.length - Math.min(entries.length, SHOWN_TYPE_CHIPS) }\n  }, [facets, selected, showAll])\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <SectionHeading>Facility type</SectionHeading>\n      <div className=\"flex flex-wrap gap-1.5\" data-testid=\"type-chips\">\n        {types.shown.map(([type, count]) => {\n          const tone = listingTone(type)\n          const active = selected.includes(type)\n          return (\n            <button\n              key={type}\n              type=\"button\"\n              aria-pressed={active}\n              data-type-chip={type}\n              onClick={() => onToggle(type)}\n              className={cn(\n                \"inline-flex max-w-full cursor-pointer items-center gap-1.5 rounded-full border px-2 py-1 text-xs font-medium transition-colors\",\n                active\n                  ? \"border-transparent bg-primary text-primary-foreground shadow-xs-skeuomorphic\"\n                  : \"border-border bg-background text-secondary-foreground hover:bg-muted\"\n              )}\n            >\n              <span\n                aria-hidden\n                className=\"size-2 shrink-0 rounded-full\"\n                style={{ backgroundColor: tone.color }}\n              />\n              <span className=\"truncate\">{type}</span>\n              <span\n                className={cn(\n                  \"tabular-nums\",\n                  active ? \"text-primary-foreground/70\" : \"text-muted-foreground\"\n                )}\n              >\n                {count.toLocaleString()}\n              </span>\n            </button>\n          )\n        })}\n      </div>\n      {types.hiddenCount > 0 ? (\n        <Button\n          type=\"button\"\n          variant=\"link\"\n          size=\"sm\"\n          onClick={() => setShowAll((value) => !value)}\n          className=\"h-auto self-start px-0 py-0 text-xs\"\n        >\n          {showAll ? \"Show fewer types\" : `Show all types (${types.hiddenCount} more)`}\n        </Button>\n      ) : null}\n    </div>\n  )\n}\n\nfunction CategoryTree({\n  tree,\n  facets,\n  selected,\n  onChange,\n}: {\n  tree: CategoryTreeGroup[]\n  facets: ListingsFacets | null\n  selected: string[]\n  onChange: (next: string[]) => void\n}) {\n  const selectedSet = new Set(selected)\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <SectionHeading>Materials</SectionHeading>\n      <div className=\"flex flex-col gap-0.5\">\n        {tree.map((group) => {\n          const groupCount = group.categories.reduce(\n            (sum, category) => sum + (facets?.categories[category] ?? 0),\n            0\n          )\n          const checkedCount = group.categories.filter((category) =>\n            selectedSet.has(category)\n          ).length\n          const allChecked = checkedCount === group.categories.length\n          const someChecked = checkedCount > 0 && !allChecked\n          return (\n            <Collapsible key={group.id} defaultOpen={checkedCount > 0}>\n              <div className=\"flex items-center gap-2 rounded-md px-1 py-1 hover:bg-muted/60\">\n                <Checkbox\n                  aria-label={`All ${group.label}`}\n                  checked={allChecked}\n                  indeterminate={someChecked}\n                  onCheckedChange={(next) => {\n                    const rest = selected.filter(\n                      (category) => !group.categories.includes(category)\n                    )\n                    onChange(next ? [...rest, ...group.categories] : rest)\n                  }}\n                />\n                <CollapsibleTrigger\n                  render={\n                    <button\n                      type=\"button\"\n                      className=\"group/tree flex min-w-0 flex-1 cursor-pointer items-center justify-between gap-1.5 text-left\"\n                    >\n                      <span className=\"truncate text-xs font-medium text-foreground\">\n                        {group.label}\n                      </span>\n                      <span className=\"inline-flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground tabular-nums\">\n                        {groupCount.toLocaleString()}\n                        <ChevronDown className=\"size-3.5 transition-transform group-data-[panel-open]/tree:rotate-180\" />\n                      </span>\n                    </button>\n                  }\n                />\n              </div>\n              <CollapsibleContent>\n                <div className=\"flex flex-col gap-0.5 py-0.5 pl-7\">\n                  {group.categories.map((category) => {\n                    const count = facets?.categories[category] ?? 0\n                    const checked = selectedSet.has(category)\n                    return (\n                      <label\n                        key={category}\n                        className=\"flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 hover:bg-muted/60\"\n                      >\n                        <Checkbox\n                          checked={checked}\n                          onCheckedChange={() => onChange(toggleValue(selected, category))}\n                          aria-label={categoryLabel(category)}\n                        />\n                        <span className=\"min-w-0 flex-1 truncate text-xs text-secondary-foreground\">\n                          {categoryLabel(category)}\n                        </span>\n                        <span className=\"text-[11px] text-muted-foreground tabular-nums\">\n                          {count.toLocaleString()}\n                        </span>\n                      </label>\n                    )\n                  })}\n                </div>\n              </CollapsibleContent>\n            </Collapsible>\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Active-filter chips\n// ---------------------------------------------------------------------------\n\nfunction ActiveFilterChips({\n  filters,\n  onPatch,\n  onClearAll,\n  feeBounds,\n}: {\n  filters: MarketplaceFilters\n  onPatch: (partial: Partial<MarketplaceFilters>) => void\n  onClearAll: () => void\n  feeBounds: [number, number]\n}) {\n  const chips: { key: string; label: string; remove: () => void }[] = []\n  for (const type of filters.types) {\n    chips.push({\n      key: `type:${type}`,\n      label: type,\n      remove: () => onPatch({ types: filters.types.filter((item) => item !== type) }),\n    })\n  }\n  for (const category of filters.categories) {\n    chips.push({\n      key: `category:${category}`,\n      label: categoryLabel(category),\n      remove: () =>\n        onPatch({\n          categories: filters.categories.filter((item) => item !== category),\n        }),\n    })\n  }\n  for (const region of filters.regions) {\n    chips.push({\n      key: `region:${region}`,\n      label: region,\n      remove: () => onPatch({ regions: filters.regions.filter((item) => item !== region) }),\n    })\n  }\n  if (filters.activeOnly) {\n    chips.push({\n      key: \"active\",\n      label: \"Operational only\",\n      remove: () => onPatch({ activeOnly: false }),\n    })\n  }\n  if (filters.q.trim()) {\n    chips.push({\n      key: \"q\",\n      label: `“${filters.q.trim()}”`,\n      remove: () => onPatch({ q: \"\" }),\n    })\n  }\n  if (filters.feeRange) {\n    const [low, high] = filters.feeRange\n    chips.push({\n      key: \"fee\",\n      label:\n        high >= feeBounds[1]\n          ? `Gate fee £${low}+/t`\n          : `Gate fee £${low}–£${high}/t`,\n      remove: () => onPatch({ feeRange: null }),\n    })\n  }\n\n  if (chips.length === 0) return null\n  return (\n    <div className=\"flex flex-wrap items-center gap-1.5\" data-testid=\"active-filters\">\n      {chips.map((chip) => (\n        <Tag key={chip.key} size=\"sm\" onRemove={chip.remove}>\n          {chip.label}\n        </Tag>\n      ))}\n      <Button\n        type=\"button\"\n        variant=\"link\"\n        size=\"sm\"\n        onClick={onClearAll}\n        className=\"h-auto px-1 py-0 text-xs\"\n      >\n        Clear all\n      </Button>\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// The panel\n// ---------------------------------------------------------------------------\n\nexport function ListingsFilters({\n  marketplace,\n  geoSearch,\n  onPlaceSelect,\n  categoryTree = DEFAULT_CATEGORY_TREE,\n  feeBounds = [0, 350],\n  title = \"Search & filters\",\n  className,\n  defaultCollapsed = false,\n}: ListingsFiltersProps) {\n  const isMobile = useIsMobile()\n  const [collapsed, setCollapsed] = React.useState(defaultCollapsed)\n  const [mobileOpen, setMobileOpen] = React.useState(false)\n\n  const {\n    filters,\n    patchFilters,\n    clearFilters,\n    activeFilterCount,\n    facets,\n    transferActive,\n    flyToCoords,\n  } = marketplace\n\n  // Local echo of the text input so typing feels instant; the hook debounces\n  // the fetch itself.\n  const [qDraft, setQDraft] = React.useState(filters.q)\n  React.useEffect(() => setQDraft(filters.q), [filters.q])\n\n  const [feeDraft, setFeeDraft] = React.useState<[number, number] | null>(\n    filters.feeRange\n  )\n  React.useEffect(() => setFeeDraft(filters.feeRange), [filters.feeRange])\n\n  const handlePlace = React.useCallback(\n    (result: GeoSearchResult) => {\n      if (onPlaceSelect) onPlaceSelect(result)\n      else flyToCoords(result.longitude, result.latitude, 11)\n      if (isMobile) setMobileOpen(false)\n    },\n    [onPlaceSelect, flyToCoords, isMobile]\n  )\n\n  const regions = React.useMemo(\n    () =>\n      Object.entries(facets?.regions ?? {}).sort(\n        (a, b) => b[1] - a[1] || a[0].localeCompare(b[0])\n      ),\n    [facets]\n  )\n\n  const feeValue = feeDraft ?? feeBounds\n\n  const body = (\n    <div className=\"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain px-3 pb-3\">\n      {geoSearch ? <PlaceSearch geoSearch={geoSearch} onSelect={handlePlace} /> : null}\n\n      <InputGroup className=\"h-9 w-full\">\n        <InputGroupAddon>\n          <SearchLg />\n        </InputGroupAddon>\n        <InputGroupInput\n          aria-label=\"Search listings\"\n          placeholder=\"Search name, operator, postcode…\"\n          value={qDraft}\n          data-testid=\"listing-search\"\n          onChange={(event) => {\n            setQDraft(event.target.value)\n            patchFilters({ q: event.target.value })\n          }}\n        />\n      </InputGroup>\n\n      <ActiveFilterChips\n        filters={filters}\n        onPatch={patchFilters}\n        onClearAll={clearFilters}\n        feeBounds={feeBounds}\n      />\n\n      <TypeChips\n        facets={facets}\n        selected={filters.types}\n        onToggle={(type) => patchFilters({ types: toggleValue(filters.types, type) })}\n      />\n\n      <CategoryTree\n        tree={categoryTree}\n        facets={facets}\n        selected={filters.categories}\n        onChange={(categories) => patchFilters({ categories })}\n      />\n\n      <div className=\"flex flex-col gap-2\">\n        <SectionHeading>Region</SectionHeading>\n        <div className=\"flex flex-col gap-0.5\">\n          {regions.map(([region, count]) => (\n            <label\n              key={region}\n              className=\"flex cursor-pointer items-center gap-2 rounded-md px-1 py-1 hover:bg-muted/60\"\n            >\n              <Checkbox\n                checked={filters.regions.includes(region)}\n                onCheckedChange={() =>\n                  patchFilters({ regions: toggleValue(filters.regions, region) })\n                }\n                aria-label={region}\n              />\n              <span className=\"min-w-0 flex-1 truncate text-xs text-secondary-foreground\">\n                {region}\n              </span>\n              <span className=\"text-[11px] text-muted-foreground tabular-nums\">\n                {count.toLocaleString()}\n              </span>\n            </label>\n          ))}\n        </div>\n      </div>\n\n      <label className=\"flex cursor-pointer items-center justify-between gap-2\">\n        <span className=\"text-xs font-medium text-foreground\">Operational only</span>\n        <Switch\n          size=\"sm\"\n          checked={filters.activeOnly}\n          onCheckedChange={(checked) => patchFilters({ activeOnly: checked })}\n        />\n      </label>\n\n      <div className=\"flex flex-col gap-2.5\">\n        <div className=\"flex items-center justify-between\">\n          <SectionHeading>Gate fee (mid, £/t)</SectionHeading>\n          <span className=\"text-[11px] text-muted-foreground tabular-nums\">\n            {filters.feeRange\n              ? `£${feeValue[0]} – £${feeValue[1]}`\n              : \"Any\"}\n          </span>\n        </div>\n        <Slider\n          aria-label=\"Gate-fee range\"\n          min={feeBounds[0]}\n          max={feeBounds[1]}\n          step={5}\n          value={[feeValue[0], feeValue[1]]}\n          onValueChange={(value) => {\n            if (Array.isArray(value) && value.length === 2) {\n              const range: [number, number] = [value[0], value[1]]\n              setFeeDraft(range)\n              patchFilters({\n                feeRange:\n                  range[0] <= feeBounds[0] && range[1] >= feeBounds[1] ? null : range,\n              })\n            }\n          }}\n        />\n        <p className=\"text-[11px] text-muted-foreground\">\n          Narrowing this hides listings without published fees.\n        </p>\n      </div>\n    </div>\n  )\n\n  const header = (\n    <div className=\"flex items-center justify-between gap-2 px-3 pt-2.5 pb-2\">\n      <h2 className=\"inline-flex items-center gap-1.5 text-sm font-semibold text-foreground\">\n        <FilterLines className=\"size-4 text-fg-quaternary\" />\n        {title}\n        {activeFilterCount > 0 ? (\n          <Badge variant=\"brand\" className=\"h-4.5 px-1.5 text-[10px] tabular-nums\">\n            {activeFilterCount}\n          </Badge>\n        ) : null}\n      </h2>\n      <Button\n        type=\"button\"\n        variant=\"ghost\"\n        size=\"icon-sm\"\n        aria-label={isMobile ? \"Close filters\" : \"Collapse filters panel\"}\n        onClick={() => (isMobile ? setMobileOpen(false) : setCollapsed(true))}\n        className=\"text-fg-quaternary hover:text-foreground\"\n      >\n        {isMobile ? <XClose /> : <ChevronLeft />}\n      </Button>\n    </div>\n  )\n\n  // --- Mobile: left sheet -----------------------------------------------------\n  if (isMobile) {\n    return (\n      <>\n        {!mobileOpen && !transferActive ? (\n          <div className=\"absolute top-3 left-3 z-20\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              size=\"sm\"\n              onClick={() => setMobileOpen(true)}\n              className=\"bg-background/90 shadow-lg backdrop-blur-md\"\n            >\n              <FilterLines />\n              Filters\n              {activeFilterCount > 0 ? (\n                <Badge variant=\"brand\" className=\"h-4.5 px-1.5 text-[10px] tabular-nums\">\n                  {activeFilterCount}\n                </Badge>\n              ) : null}\n            </Button>\n          </div>\n        ) : null}\n        <div\n          data-slot=\"listings-filters\"\n          inert={!mobileOpen || transferActive || undefined}\n          className={cn(\n            \"absolute inset-y-0 left-0 z-30 flex w-[85vw] max-w-80 flex-col border-r border-border/70 bg-background/95 shadow-2xl backdrop-blur-md transition-transform duration-300 motion-reduce:transition-none\",\n            (!mobileOpen || transferActive) && \"-translate-x-[calc(100%+1rem)]\",\n            className\n          )}\n        >\n          {header}\n          {body}\n        </div>\n      </>\n    )\n  }\n\n  // --- Desktop: left overlay ----------------------------------------------------\n  return (\n    <>\n      {collapsed && !transferActive ? (\n        <div className=\"absolute top-3 left-3 z-20\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            size=\"sm\"\n            aria-label=\"Expand filters panel\"\n            onClick={() => setCollapsed(false)}\n            className=\"bg-background/90 shadow-lg backdrop-blur-md\"\n          >\n            <FilterLines />\n            {activeFilterCount > 0 ? (\n              <Badge variant=\"brand\" className=\"h-4.5 px-1.5 text-[10px] tabular-nums\">\n                {activeFilterCount}\n              </Badge>\n            ) : null}\n          </Button>\n        </div>\n      ) : null}\n      <div\n        data-slot=\"listings-filters\"\n        inert={collapsed || transferActive || undefined}\n        className={cn(\n          \"absolute top-3 bottom-3 left-3 z-20 flex w-76 flex-col overflow-hidden rounded-xl border border-border/70 bg-background/95 shadow-lg backdrop-blur-md transition-transform duration-300 motion-reduce:transition-none\",\n          (collapsed || transferActive) && \"-translate-x-[calc(100%+1rem)]\",\n          className\n        )}\n      >\n        {header}\n        {body}\n      </div>\n    </>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/marketplace/listings-filters.tsx"
    }
  ],
  "type": "registry:block"
}