{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-filter-menu",
  "type": "registry:ui",
  "title": "Filter Menu",
  "description": "Filter Menu shared by module faces and application hosts.",
  "dependencies": [
    "@base-ui/react",
    "class-variance-authority",
    "lucide-react",
    "maru-icons@^0.0.1",
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [
    "https://pluginui.com/r/core-button.json",
    "https://pluginui.com/r/core-popover.json"
  ],
  "files": [
    {
      "path": "components/ui/filter-menu.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/filter-menu\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/** Shared catalog facets. Hosts own selections, parsing and filtering. */\nimport { useRef, type ReactNode } from \"react\"\nimport { Radio } from \"@base-ui/react/radio\"\nimport { RadioGroup } from \"@base-ui/react/radio-group\"\nimport {\n  CheckIcon as Check,\n  ChevronDownIcon as ChevronDown,\n  XIcon as X,\n} from \"maru-icons/react\"\nimport { ListFilter } from \"lucide-react\"\nimport { cn } from \"maruaudio/ui/shared/shadcn-utils\"\nimport { overlayPopupStyle } from \"maruaudio/ui/shared/overlay-container\"\n\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Popover,\n  PopoverClose,\n  PopoverContent,\n  PopoverDescription,\n  PopoverTitle,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport {\n  ToggleGroup,\n  ToggleGroupItem,\n} from \"maruaudio/ui/shared/toggle-group\"\n\n/**\n * The values of a comma-joined URL param, keeping only ones the list offers.\n *\n * Deduplicated and order-preserving. An empty or absent param is an empty\n * selection, which every caller reads as \"no narrowing\" rather than as \"none of\n * them\".\n */\nexport function parseSelection<T extends string>(\n  value: string | undefined,\n  options: readonly { value: T }[]\n): T[] {\n  if (!value) return []\n  const allowed = new Map<string, T>(\n    options.map((option) => [option.value, option.value])\n  )\n  return [...new Set(value.split(\",\"))].flatMap((item) => {\n    const option = allowed.get(item)\n    return option === undefined ? [] : [option]\n  })\n}\n\n/**\n * A selection as a URL param: the joined values, or `undefined` to drop the key.\n *\n * `undefined` rather than `\"\"` so an empty selection leaves no `?kind=` behind.\n * A URL carrying empty params is one that looks filtered and is not.\n */\nexport function selectionParam<T extends string>(\n  value: readonly T[]\n): string | undefined {\n  return value.length > 0 ? value.join(\",\") : undefined\n}\n\n/** One choice in a `FilterGroup`. */\nexport interface FilterOption<T extends string = string> {\n  readonly value: T\n  readonly label: string\n  readonly hint?: string\n  /**\n   * How many results this option would show, counted by the host under its\n   * other active filters. Omitted counts render nothing. A zero-count option is\n   * muted and disabled unless it is the current selection, which always stays\n   * pickable so it can be changed or cleared.\n   */\n  readonly count?: number\n}\n\nconst countFormat = new Intl.NumberFormat(\"en\", {\n  notation: \"compact\",\n  maximumFractionDigits: 1,\n})\n\n/** A facet count as menus show it: `12`, `1.2K`, `34K`. */\nexport function formatFilterCount(count: number): string {\n  return countFormat.format(count)\n}\n\nfunction OptionCount({ count }: { count: number | undefined }) {\n  if (count === undefined) return null\n  return (\n    <span\n      data-filter-option-count=\"\"\n      className=\"ml-auto shrink-0 pl-2 text-xs font-normal text-muted-foreground tabular-nums\"\n    >\n      {formatFilterCount(count)}\n    </span>\n  )\n}\n\n/** An option no result would match, unless it is the one already chosen. */\nfunction emptyOption(option: FilterOption, value: readonly string[]): boolean {\n  return option.count === 0 && !value.includes(option.value)\n}\n\n/** One labelled row of multi-select chips. */\nexport function FilterGroup<T extends string>({\n  label,\n  options,\n  value,\n  onValueChange,\n  layout = \"chips\",\n  single = false,\n}: {\n  label: string\n  options: readonly FilterOption<T>[]\n  value: T[]\n  onValueChange: (value: T[]) => void\n  /** A compact option list suits facets whose choices need short explanations. */\n  layout?: \"chips\" | \"list\"\n  /** A conventional radio list: one choice, with clearing owned by the facet. */\n  single?: boolean\n}) {\n  if (single)\n    return (\n      <RadioGroup\n        value={value[0] ?? \"\"}\n        onValueChange={(next) => onValueChange(next ? [next] : [])}\n        aria-label={label}\n        className=\"flex flex-col gap-0.5\"\n        data-filter-radio-group=\"\"\n      >\n        {options.map((option) => (\n          <Radio.Root\n            key={option.value}\n            value={option.value}\n            nativeButton\n            disabled={emptyOption(option, value)}\n            data-filter-option-empty={emptyOption(option, value) || undefined}\n            render={<Button variant=\"ghost\" size=\"sm\" />}\n            className=\"h-auto min-h-8 w-full justify-start gap-2 rounded-none px-2 py-1.5 text-left whitespace-normal aria-checked:bg-primary/10 aria-checked:text-primary data-filter-option-empty:text-muted-foreground\"\n            onClick={() => {\n              // Picking the current radio keeps it selected and lets an\n              // immediate-choice host close the menu without toggling it off.\n              if (value[0] === option.value) onValueChange([option.value])\n            }}\n          >\n            <span\n              aria-hidden\n              data-filter-radio-mark=\"\"\n              className=\"flex size-3.5 shrink-0 items-center justify-center rounded-full border border-current/40\"\n            >\n              <Radio.Indicator\n                data-filter-radio-dot=\"\"\n                className=\"size-1.5 rounded-full bg-current\"\n              />\n            </span>\n            <span>\n              <span className=\"block\">{option.label}</span>\n              {option.hint ? (\n                <span className=\"mt-0.5 block text-xs font-normal text-muted-foreground\">\n                  {option.hint}\n                </span>\n              ) : null}\n            </span>\n            <OptionCount count={option.count} />\n          </Radio.Root>\n        ))}\n      </RadioGroup>\n    )\n  return (\n    <fieldset>\n      <legend\n        className={\n          layout === \"list\"\n            ? \"sr-only\"\n            : \"text-xs font-medium text-muted-foreground\"\n        }\n      >\n        {label}\n      </legend>\n      <ToggleGroup\n        orientation={layout === \"list\" ? \"vertical\" : \"horizontal\"}\n        value={value}\n        onValueChange={(next) =>\n          onValueChange(\n            next.flatMap((id) => {\n              const option = options.find((option) => option.value === id)\n              return option ? [option.value] : []\n            })\n          )\n        }\n        className={\n          layout === \"list\"\n            ? \"flex w-full flex-col items-stretch gap-0\"\n            : \"mt-2 flex w-full flex-wrap justify-start gap-1.5\"\n        }\n        aria-label={label}\n      >\n        {options.map((option) => (\n          <ToggleGroupItem\n            key={option.value}\n            value={option.value}\n            variant={layout === \"list\" ? \"default\" : \"outline\"}\n            size=\"sm\"\n            disabled={emptyOption(option, value)}\n            data-filter-option-empty={emptyOption(option, value) || undefined}\n            className={cn(\n              \"data-filter-option-empty:text-muted-foreground\",\n              layout === \"list\"\n                ? \"h-auto w-full justify-start gap-2 rounded-none px-2 py-2 text-left aria-pressed:bg-muted [&[aria-pressed=true]_svg]:opacity-100\"\n                : \"justify-start aria-pressed:border-foreground/30 aria-pressed:bg-foreground aria-pressed:text-background [&[aria-pressed=true]_[data-filter-option-count]]:text-background/70 [&[aria-pressed=true]_svg]:inline\"\n            )}\n          >\n            <Check\n              aria-hidden\n              className={\n                layout === \"list\"\n                  ? \"size-3.5 shrink-0 opacity-0\"\n                  : \"hidden size-3 aria-pressed:inline\"\n              }\n            />\n            <span>\n              <span className=\"block\">{option.label}</span>\n              {layout === \"list\" && option.hint ? (\n                <span className=\"mt-0.5 block text-xs font-normal text-muted-foreground\">\n                  {option.hint}\n                </span>\n              ) : null}\n            </span>\n            <OptionCount count={option.count} />\n          </ToggleGroupItem>\n        ))}\n      </ToggleGroup>\n    </fieldset>\n  )\n}\n\nexport interface FilterMenuProps {\n  /** Controlled disclosure for hosts that close after a single choice. */\n  readonly open?: boolean\n  readonly onOpenChange?: (open: boolean) => void\n  /** The trigger's text. \"Filters\" unless a list has a better noun. */\n  readonly label?: string\n  /** One short sentence under the title. */\n  readonly description?: ReactNode\n  /**\n   * How many values are currently selected across every group.\n   *\n   * Passed in rather than counted from `children`, because only the caller\n   * knows which of its URL params this menu owns.\n   */\n  readonly activeCount: number\n  /** Reset every param this menu owns. Rendered only when something is set. */\n  readonly onClear: () => void\n  /** The `FilterGroup` rows. */\n  readonly children: ReactNode\n  readonly testId?: string\n  /**\n   * The trigger's leading icon; `null` for none. A row of one menu per facet\n   * (the sound browser's Instruments, Genres, Key…) reads as plain dropdowns.\n   */\n  readonly icon?: ReactNode | null\n  /**\n   * What the trigger shows while something is selected, instead of the count:\n   * \"Hats\" rather than \"Instruments 1\" for a one-value facet menu.\n   */\n  readonly valueLabel?: string\n  /** Popover alignment; a left-to-right facet row opens from the start. */\n  readonly align?: \"start\" | \"end\"\n  readonly triggerClassName?: string\n  readonly contentClassName?: string\n  /** A small facet button whose selected value and clear action form one chip. */\n  readonly compact?: boolean\n  readonly clearLabel?: string\n}\n\nexport function FilterMenu({\n  open,\n  onOpenChange,\n  label = \"Filters\",\n  description,\n  activeCount,\n  onClear,\n  children,\n  testId = \"filter-menu\",\n  icon,\n  valueLabel,\n  align = \"end\",\n  contentClassName = \"\",\n  triggerClassName,\n  compact = false,\n  clearLabel,\n}: FilterMenuProps) {\n  const trigger = useRef<HTMLButtonElement>(null)\n  const clearable = compact && activeCount > 0\n  return (\n    <Popover open={open} onOpenChange={onOpenChange}>\n      <div\n        role={compact ? \"group\" : undefined}\n        aria-label={compact ? label : undefined}\n        data-filter-menu={testId}\n        data-active={activeCount > 0 || undefined}\n        className={cn(\n          \"inline-flex max-w-full items-center\",\n          compact && \"rounded-lg\"\n        )}\n      >\n        <PopoverTrigger\n          render={\n            <Button\n              ref={trigger}\n              className={cn(\n                compact && \"max-w-full\",\n                clearable &&\n                  \"rounded-r-none border-r-0 border-primary/25 bg-primary/10 pr-1.5 text-primary hover:bg-primary/15 aria-expanded:bg-primary/15 dark:border-primary/25 dark:bg-primary/10 dark:hover:bg-primary/15\",\n                triggerClassName\n              )}\n              variant={\n                compact ? \"outline\" : valueLabel ? \"secondary\" : \"outline\"\n              }\n              size={compact ? \"sm\" : \"default\"}\n              aria-label={valueLabel ? `${label}: ${valueLabel}` : label}\n              data-testid={testId}\n            >\n              {icon === undefined ? <ListFilter className=\"size-4\" /> : icon}\n              <span className=\"truncate\">{valueLabel ?? label}</span>\n              {icon === null && !clearable ? (\n                <ChevronDown className=\"size-3.5 text-muted-foreground\" />\n              ) : null}\n              {activeCount > 0 && !valueLabel ? (\n                <span\n                  data-testid={`${testId}-count`}\n                  className=\"ml-0.5 rounded-md bg-foreground px-1.5 py-0.5 text-[10px] leading-none text-background\"\n                >\n                  {activeCount}\n                </span>\n              ) : null}\n            </Button>\n          }\n        />\n        {clearable ? (\n          <PopoverClose\n            render={\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                size=\"icon-sm\"\n                className={cn(\n                  \"rounded-l-none border-l-0 border-primary/25 bg-primary/10 text-primary hover:bg-primary/15 dark:border-primary/25 dark:bg-primary/10 dark:hover:bg-primary/15\",\n                  triggerClassName\n                )}\n                aria-label={clearLabel ?? `Clear ${label} filter`}\n                data-filter-clear=\"\"\n                onClick={() => {\n                  onClear()\n                  trigger.current?.focus()\n                }}\n              >\n                <X aria-hidden className=\"size-3.5\" />\n              </Button>\n            }\n          />\n        ) : null}\n      </div>\n      <PopoverContent\n        align={align}\n        data-testid={`${testId}-content`}\n        style={compact ? overlayPopupStyle({ maxHeight: \"20rem\" }) : undefined}\n        className={cn(\n          \"max-h-[min(36rem,calc(100dvh-2rem))] w-[min(22rem,calc(100vw-1rem))] overflow-y-auto p-4\",\n          compact &&\n            \"max-h-[min(20rem,calc(100dvh-2rem))] w-[min(18rem,calc(100vw-1rem))] rounded-none p-2\",\n          contentClassName\n        )}\n      >\n        <div\n          className={\n            compact ? \"sr-only\" : \"flex items-start justify-between gap-4\"\n          }\n        >\n          <div>\n            <PopoverTitle>{label}</PopoverTitle>\n            {description ? (\n              <PopoverDescription className=\"mt-1\">\n                {description}\n              </PopoverDescription>\n            ) : null}\n          </div>\n          {activeCount > 0 && !compact ? (\n            <Button\n              variant=\"ghost\"\n              size=\"xs\"\n              onClick={onClear}\n              data-testid={`${testId}-clear`}\n            >\n              Clear\n            </Button>\n          ) : null}\n        </div>\n\n        <div className={compact ? \"space-y-3\" : \"mt-4 space-y-5\"}>\n          {children}\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n\n/** One selected facet toggles off; a different option replaces it. */\nexport function singleFilterValue<T extends string>(\n  current: T | undefined,\n  next: readonly T[]\n): T | undefined {\n  return next.find((value) => value !== current)\n}\n",
      "type": "registry:component",
      "target": "@ui/filter-menu.tsx"
    }
  ]
}
