{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-menu",
  "title": "Filter menu",
  "author": "Tori Bryan",
  "description": "A filter menu of fields and values that turns into a search as you type.",
  "dependencies": [
    "@base-ui/react",
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://fibo.toribryan.com/r/button.json",
    "https://fibo.toribryan.com/r/checkbox.json",
    "https://fibo.toribryan.com/r/count.json",
    "https://fibo.toribryan.com/r/empty-state.json",
    "https://fibo.toribryan.com/r/input-group.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/filter-menu.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Popover as PopoverPrimitive } from \"@base-ui/react/popover\"\nimport {\n  AnimatePresence,\n  motion,\n  animate,\n  useIsPresent,\n  useMotionValue,\n  useReducedMotion,\n  type HTMLMotionProps,\n  type Transition,\n} from \"motion/react\"\nimport {\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  ListFilterIcon,\n  SearchIcon,\n} from \"lucide-react\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { CheckboxMark } from \"@/components/ui/checkbox\"\nimport { Count } from \"@/components/ui/count\"\nimport {\n  EmptyState,\n  EmptyStateTitle,\n} from \"@/components/ui/empty-state\"\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupInput,\n} from \"@/components/ui/input-group\"\nimport { cn } from \"@/lib/utils\"\n\ntype FilterOption = {\n  value: string\n  label: string\n  icon?: React.ReactNode\n}\n\ntype FilterField = {\n  id: string\n  label: string\n  icon?: React.ReactNode\n  options: FilterOption[]\n}\n\n/** The chosen values for each field, keyed by field id. */\ntype FilterValue = Record<string, string[]>\n\ntype Row =\n  | { kind: \"field\"; key: string; field: FilterField }\n  | { kind: \"option\"; key: string; field: FilterField; option: FilterOption }\n\ntype Group = { field: FilterField | null; rows: Row[] }\n\n/*\n * Which of three views the popup shows. With no query it's a menu of\n * fields, or one field's values once a field is open. Any text turns it\n * into a search: across every field from the menu, or within the open\n * field.\n */\ntype View = \"fields\" | \"values\" | \"search\"\n\nconst MOVE: Transition = { duration: 0.22, ease: [0.22, 1, 0.36, 1] }\nconst INSTANT: Transition = { duration: 0 }\n\nfunction matches(text: string, query: string) {\n  return text.toLowerCase().includes(query.toLowerCase())\n}\n\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  const at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1\n  if (at < 0) return <>{text}</>\n  return (\n    <>\n      {text.slice(0, at)}\n      <mark className=\"bg-transparent font-semibold text-foreground\">\n        {text.slice(at, at + query.length)}\n      </mark>\n      {text.slice(at + query.length)}\n    </>\n  )\n}\n\nfunction useControllable<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange: ((value: T) => void) | undefined\n) {\n  const [own, setOwn] = React.useState(defaultValue)\n  const current = value ?? own\n  const set = (next: T) => {\n    if (value === undefined) setOwn(next)\n    onChange?.(next)\n  }\n  return [current, set] as const\n}\n\n/*\n * The popup's height follows its content, so views of different lengths\n * grow and shrink into each other instead of jumping. The content only\n * changes size when what it shows changes, which `shape` sums up, so it's\n * measured then rather than watched with a ResizeObserver, which would\n * fight the popover's own observer as the height animates. The height\n * lives in a motion value, so it animates without re-rendering.\n */\nfunction useHeight(shape: string, transition: Transition) {\n  // A callback ref, so the content mounting (a render after the popup\n  // opens) also triggers a measurement.\n  const [node, setNode] = React.useState<HTMLDivElement | null>(null)\n  const height = useMotionValue<number | \"auto\">(\"auto\")\n  React.useLayoutEffect(() => {\n    if (!node) return height.set(\"auto\")\n    const next = node.offsetHeight\n    // The first measurement after opening sets the height without\n    // animating; later ones grow or shrink to it.\n    if (height.get() === \"auto\") return height.set(next)\n    const controls = animate(height, next, transition)\n    return () => controls.stop()\n  }, [node, shape, height, transition])\n  return [setNode, height] as const\n}\n\n// A view on its way out stays in the DOM while it fades, so it's made inert\n// and hidden: its rows can't be clicked or read, or answer to an id.\nfunction View(props: HTMLMotionProps<\"div\">) {\n  const present = useIsPresent()\n  return (\n    <motion.div\n      {...props}\n      inert={!present || undefined}\n      aria-hidden={!present || undefined}\n    />\n  )\n}\n\ntype FilterMenuLabels = {\n  /** The back button in a field's values. */\n  backToFields: string\n  /** The back button that leaves a search started from the field menu. */\n  backToFilters: string\n  /** The back button that leaves a search started inside a field. */\n  backToField: (field: string) => string\n  /** Announces how many rows a search found. */\n  results: (count: number) => string\n}\n\nconst DEFAULT_LABELS: FilterMenuLabels = {\n  backToFields: \"Back to fields\",\n  backToFilters: \"Back to filters\",\n  backToField: (field) => `Back to ${field}`,\n  results: (count) => `${count} ${count === 1 ? \"result\" : \"results\"}`,\n}\n\ntype FilterMenuProps = {\n  /** The fields people can filter by, each with the values it offers. */\n  fields: FilterField[]\n  /** The chosen values, keyed by field id, when you control the state. */\n  value?: FilterValue\n  /** The chosen values to start with, when the menu keeps its own state. */\n  defaultValue?: FilterValue\n  /** Called with every field's chosen values each time one is toggled. */\n  onValueChange?: (value: FilterValue) => void\n  /** What the trigger button says. */\n  triggerLabel?: React.ReactNode\n  /** Hint in the inline search box while the field menu is showing. */\n  placeholder?: string\n  /** Shown when a search matches nothing. */\n  emptyText?: React.ReactNode\n  /** Names the popup for assistive technology. */\n  label?: string\n  /** Names the search box for assistive technology. */\n  searchLabel?: string\n  /** Wording the menu writes for itself, for translation. */\n  labels?: Partial<FilterMenuLabels>\n  /** Whether the menu is open, when you control it. */\n  open?: boolean\n  /** Whether the menu starts open, when it keeps its own state. */\n  defaultOpen?: boolean\n  /** Called when the menu opens or closes. */\n  onOpenChange?: (open: boolean) => void\n  /** Which side of the trigger the popup opens on. Flips when there's no room. */\n  side?: PopoverPrimitive.Positioner.Props[\"side\"]\n  /** Which edge of the trigger the popup lines up with. */\n  align?: \"start\" | \"center\" | \"end\"\n  /** Where the popup portals to. Defaults to the body. */\n  container?: PopoverPrimitive.Portal.Props[\"container\"]\n  /**\n   * An element to use as the trigger instead of the default button. It must\n   * forward its ref and props, as fibo's Button does.\n   */\n  trigger?: React.ReactElement\n  /**\n   * How search starts. `button`, the default, shows a Search filters button\n   * at the top, and the menu slides over to its search state when it's\n   * used. `inline` keeps a search box there instead, and typing turns the\n   * menu into results.\n   */\n  search?: \"inline\" | \"button\"\n  /** Classes for the trigger button. */\n  className?: string\n  /** Classes for the popup. */\n  popupClassName?: string\n}\n\n/**\n * A filter button whose menu lists fields, then each field's values, and\n * turns into a search across all of them as soon as you type. It only picks\n * filters; showing the applied ones is up to the app.\n */\nfunction FilterMenu({\n  fields,\n  value,\n  defaultValue = {},\n  onValueChange,\n  triggerLabel = \"Filter\",\n  placeholder = \"Filter by…\",\n  emptyText = \"No matching filters\",\n  label = \"Filters\",\n  searchLabel = \"Search filters\",\n  labels,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  side = \"bottom\",\n  align = \"start\",\n  container,\n  trigger,\n  search = \"button\",\n  className,\n  popupClassName,\n}: FilterMenuProps) {\n  const [selected, setSelected] = useControllable(\n    value,\n    defaultValue,\n    onValueChange\n  )\n  const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange)\n  const text = { ...DEFAULT_LABELS, ...labels }\n  const [query, setQuery] = React.useState(\"\")\n  const [fieldId, setFieldId] = React.useState<string | null>(null)\n  const [highlight, setHighlight] = React.useState(0)\n  // Only the button style has a search state of its own; inline, any text\n  // is a search.\n  const [searching, setSearching] = React.useState(false)\n  // Which way the next view slides: in from the right going deeper, from\n  // the left coming back, and not at all into or out of a search.\n  const [direction, setDirection] = React.useState<1 | -1 | 0>(1)\n  const reduceMotion = useReducedMotion()\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const listRef = React.useRef<HTMLDivElement>(null)\n  const id = React.useId()\n\n  const field = fields.find((f) => f.id === fieldId) ?? null\n  const term = query.trim()\n  const inSearch = search === \"button\" ? searching : Boolean(term)\n  const view: View = inSearch ? \"search\" : field ? \"values\" : \"fields\"\n  // Where focus lives: the search box whenever there is one, otherwise the\n  // list itself, which then carries aria-activedescendant.\n  const usesInput = search === \"inline\" || inSearch\n  const home = usesInput ? inputRef : listRef\n  // Row ids come from positions, not from app data that may hold spaces,\n  // and carry the view so an outgoing view never shares an id with the\n  // incoming one.\n  const viewKey =\n    view === \"search\"\n      ? \"search\"\n      : field\n        ? `field-${fields.indexOf(field)}`\n        : \"fields\"\n\n  // The open field can vanish if `fields` changes while the menu is open;\n  // the menu then falls back to the field list for good.\n  if (fieldId && !field) setFieldId(null)\n\n  const groups: Group[] = React.useMemo(() => {\n    const option = (f: FilterField, o: FilterOption): Row => ({\n      kind: \"option\",\n      key: `${f.id}:${o.value}`,\n      field: f,\n      option: o,\n    })\n    if (view === \"fields\")\n      return [\n        {\n          field: null,\n          rows: fields.map((f) => ({ kind: \"field\", key: f.id, field: f })),\n        },\n      ]\n    if (view === \"values\" && field)\n      return [{ field: null, rows: field.options.map((o) => option(field, o)) }]\n    // A search inside an open field stays within it; from the menu it\n    // covers every field, and a field whose name matches brings all of\n    // its values.\n    return (field ? [field] : fields)\n      .map((f) => ({\n        field: field ? null : f,\n        rows: f.options\n          .filter((o) => matches(o.label, term) || matches(f.label, term))\n          .map((o) => option(f, o)),\n      }))\n      .filter((group) => group.rows.length > 0)\n  }, [view, fields, field, term])\n\n  const rows = groups.flatMap((group) => group.rows)\n  const active = rows[Math.min(highlight, rows.length - 1)]\n  const ids = new Map(\n    groups.flatMap((group, g) =>\n      group.rows.map((row, r) => [row, `${id}-${viewKey}-${g}-${r}`] as const)\n    )\n  )\n  const rowId = (row: Row) => ids.get(row) ?? \"\"\n  const transition = reduceMotion ? INSTANT : MOVE\n  const [contentRef, height] = useHeight(\n    `${open}:${viewKey}:${groups.length}:${rows.length}`,\n    transition\n  )\n\n  // Views mount and unmount the search box, so focus follows each change.\n  React.useEffect(() => {\n    if (open) home.current?.focus({ preventScroll: true })\n  }, [open, view, home])\n\n  // Scrolls only the list, where scrollIntoView would also scroll the page\n  // to reach a menu that is partly off screen.\n  const activeId = active ? rowId(active) : \"\"\n  React.useEffect(() => {\n    const list = listRef.current\n    if (!activeId || !list) return\n    const row = list.querySelector(`[id=\"${CSS.escape(activeId)}\"]`)\n    if (!row) return\n    const bounds = list.getBoundingClientRect()\n    const target = row.getBoundingClientRect()\n    // A menu drawn inside a scaled element measures scaled on screen.\n    const scale = bounds.height / list.offsetHeight || 1\n    if (target.top < bounds.top) {\n      list.scrollTop -= (bounds.top - target.top) / scale\n    } else if (target.bottom > bounds.bottom) {\n      list.scrollTop += (target.bottom - bounds.bottom) / scale\n    }\n  }, [activeId])\n\n  const reset = () => {\n    setQuery(\"\")\n    setFieldId(null)\n    setSearching(false)\n    setHighlight(0)\n  }\n\n  // A search started inside a field stays within it, as it does inline,\n  // and leaving the search goes back to that field.\n  const enterSearch = (initial = \"\") => {\n    setDirection(1)\n    setSearching(true)\n    setQuery(initial)\n    setHighlight(0)\n  }\n\n  const exitSearch = () => {\n    setDirection(-1)\n    setSearching(false)\n    setQuery(\"\")\n    setHighlight(0)\n  }\n\n  const openField = (next: FilterField) => {\n    setDirection(1)\n    setFieldId(next.id)\n    setQuery(\"\")\n    setHighlight(0)\n  }\n\n  const back = () => {\n    setDirection(-1)\n    const index = fields.findIndex((f) => f.id === fieldId)\n    setFieldId(null)\n    setHighlight(Math.max(0, index))\n  }\n\n  const toggle = (f: FilterField, option: FilterOption) => {\n    const current = selected[f.id] ?? []\n    const next = current.includes(option.value)\n      ? current.filter((v) => v !== option.value)\n      : [...current, option.value]\n    const rest = { ...selected }\n    if (next.length) rest[f.id] = next\n    else delete rest[f.id]\n    setSelected(rest)\n  }\n\n  const activate = (row: Row) => {\n    if (row.kind === \"field\") openField(row.field)\n    else toggle(row.field, row.option)\n    home.current?.focus({ preventScroll: true })\n  }\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {\n    const count = rows.length\n    // With the button style, typing on the list starts a search with that\n    // letter, so the keyboard path doesn't need the button.\n    if (\n      !usesInput &&\n      event.key.length === 1 &&\n      !event.metaKey &&\n      !event.ctrlKey &&\n      !event.altKey &&\n      event.key !== \" \"\n    ) {\n      event.preventDefault()\n      enterSearch(event.key)\n      return\n    }\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault()\n        if (count) setHighlight((h) => (Math.min(h, count - 1) + 1) % count)\n        break\n      case \"ArrowUp\":\n        event.preventDefault()\n        if (count)\n          setHighlight((h) => (Math.min(h, count - 1) - 1 + count) % count)\n        break\n      case \"Home\":\n        if (query) break\n        event.preventDefault()\n        setHighlight(0)\n        break\n      case \"End\":\n        if (query) break\n        event.preventDefault()\n        setHighlight(Math.max(0, count - 1))\n        break\n      case \"Enter\":\n        event.preventDefault()\n        if (active) activate(active)\n        break\n      case \"ArrowRight\":\n        if (!query && active?.kind === \"field\") {\n          event.preventDefault()\n          openField(active.field)\n        }\n        break\n      case \"ArrowLeft\":\n      case \"Backspace\":\n        if (query) break\n        if (field) {\n          event.preventDefault()\n          back()\n        } else if (search === \"button\" && searching) {\n          event.preventDefault()\n          exitSearch()\n        }\n        break\n    }\n  }\n\n  const slide = reduceMotion ? 0 : 16\n  const count = rows.length\n\n  const slides = {\n    custom: direction,\n    variants: {\n      enter: (d: number) => ({ opacity: 0, x: d * slide }),\n      center: { opacity: 1, x: 0 },\n      // The old view leaves faster than the new one arrives, so two lists\n      // never blur into each other.\n      exit: (d: number) => ({\n        opacity: 0,\n        x: d * -slide,\n        transition: { ...transition, duration: reduceMotion ? 0 : 0.1 },\n      }),\n    },\n    initial: \"enter\",\n    animate: \"center\",\n    exit: \"exit\",\n    transition,\n  }\n\n  const backButton = (onBack: () => void, name: string) => (\n    <Button\n      variant=\"ghost\"\n      size=\"icon-xs\"\n      aria-label={name}\n      onClick={() => {\n        onBack()\n        home.current?.focus({ preventScroll: true })\n      }}\n      className=\"text-muted-foreground\"\n    >\n      <ChevronLeftIcon className=\"size-4\" />\n    </Button>\n  )\n\n  const searchInput = (\n    <InputGroupInput\n      ref={inputRef}\n      role=\"combobox\"\n      aria-label={searchLabel}\n      aria-expanded=\"true\"\n      aria-controls={`${id}-list`}\n      aria-autocomplete=\"list\"\n      aria-activedescendant={active ? rowId(active) : undefined}\n      autoComplete=\"off\"\n      spellCheck={false}\n      value={query}\n      placeholder={\n        field\n          ? field.label\n          : search === \"button\"\n            ? `${searchLabel}…`\n            : placeholder\n      }\n      onChange={(event) => {\n        // Inline, text is what makes a search, so the views crossfade;\n        // the button style is already in its search state.\n        if (search === \"inline\") setDirection(0)\n        setQuery(event.target.value)\n        setHighlight(0)\n      }}\n      onKeyDown={onKeyDown}\n      className=\"pr-0 text-sm\"\n    />\n  )\n\n  return (\n    <PopoverPrimitive.Root\n      open={open}\n      onOpenChange={(next, details) => {\n        // Escape steps back before it closes: first it clears the search,\n        // then it leaves the open field.\n        const buttonSearch = search === \"button\" && searching\n        if (\n          !next &&\n          details.reason === \"escape-key\" &&\n          (query || field || buttonSearch)\n        ) {\n          details.cancel()\n          if (query) {\n            if (search === \"inline\") setDirection(0)\n            setQuery(\"\")\n            setHighlight(0)\n          } else if (buttonSearch) exitSearch()\n          else back()\n          home.current?.focus({ preventScroll: true })\n          return\n        }\n        setOpen(next)\n      }}\n      onOpenChangeComplete={(isOpen) => {\n        if (!isOpen) reset()\n      }}\n    >\n      {trigger ? (\n        <PopoverPrimitive.Trigger\n          data-slot=\"filter-menu-trigger\"\n          render={trigger}\n          className={className}\n        />\n      ) : (\n        <PopoverPrimitive.Trigger\n          data-slot=\"filter-menu-trigger\"\n          render={<Button variant=\"outline\" size=\"sm\" />}\n          className={className}\n        >\n          <ListFilterIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n          {triggerLabel}\n        </PopoverPrimitive.Trigger>\n      )}\n      <PopoverPrimitive.Portal container={container}>\n        <PopoverPrimitive.Positioner\n          side={side}\n          align={align}\n          sideOffset={6}\n          className=\"isolate z-50\"\n        >\n          <PopoverPrimitive.Popup\n            data-slot=\"filter-menu\"\n            data-view={view}\n            aria-label={label}\n            data-search={search}\n            // Focuses once the popup has mounted, without the scroll the\n            // popup's own focus would cause.\n            initialFocus={() => {\n              home.current?.focus({ preventScroll: true })\n              return false\n            }}\n            className={cn(\n              \"w-64 origin-(--transform-origin) overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-lg outline-hidden motion-reduce:animate-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95\",\n              popupClassName\n            )}\n          >\n            <motion.div style={{ height }}>\n              <div ref={contentRef}>\n                <div\n                  data-slot=\"filter-menu-search\"\n                  className=\"relative flex h-10 items-center overflow-hidden border-b border-border\"\n                >\n                  {search === \"inline\" ? (\n                    <InputGroup variant=\"ghost\" className=\"h-full px-2\">\n                      <InputGroupAddon className=\"pl-0\">\n                        <AnimatePresence initial={false} mode=\"popLayout\">\n                          {view === \"values\" ? (\n                            <motion.span\n                              key=\"back\"\n                              initial={{ opacity: 0, x: slide }}\n                              animate={{ opacity: 1, x: 0 }}\n                              exit={{ opacity: 0, x: slide }}\n                              transition={transition}\n                            >\n                              {backButton(back, text.backToFields)}\n                            </motion.span>\n                          ) : (\n                            <motion.span\n                              key={view === \"search\" ? \"search\" : \"filter\"}\n                              aria-hidden=\"true\"\n                              initial={{ opacity: 0, scale: 0.6 }}\n                              animate={{ opacity: 1, scale: 1 }}\n                              exit={{ opacity: 0, scale: 0.6 }}\n                              transition={transition}\n                              className=\"flex size-6 shrink-0 items-center justify-center text-muted-foreground\"\n                            >\n                              {view === \"search\" ? (\n                                <SearchIcon className=\"size-4\" />\n                              ) : (\n                                <ListFilterIcon className=\"size-4\" />\n                              )}\n                            </motion.span>\n                          )}\n                        </AnimatePresence>\n                      </InputGroupAddon>\n                      {searchInput}\n                    </InputGroup>\n                  ) : (\n                    // The header slides with the body: the Search filters\n                    // button, a field's name, or the search box.\n                    <AnimatePresence\n                      initial={false}\n                      mode=\"popLayout\"\n                      custom={direction}\n                    >\n                      <View\n                        key={view}\n                        {...slides}\n                        className=\"flex w-full items-center gap-2 px-2\"\n                      >\n                        {view === \"fields\" ? (\n                          <Button\n                            variant=\"ghost\"\n                            size=\"sm\"\n                            data-slot=\"filter-menu-search-button\"\n                            onClick={() => enterSearch()}\n                            className=\"-mx-1 flex-1 justify-start gap-2 px-1 font-normal text-muted-foreground\"\n                          >\n                            <span className=\"flex size-6 shrink-0 items-center justify-center\">\n                              <SearchIcon aria-hidden=\"true\" />\n                            </span>\n                            {searchLabel}\n                          </Button>\n                        ) : view === \"values\" ? (\n                          <>\n                            {backButton(back, text.backToFields)}\n                            <span className=\"truncate text-sm font-medium\">\n                              {field?.label}\n                            </span>\n                          </>\n                        ) : (\n                          <InputGroup variant=\"ghost\" className=\"h-full\">\n                            <InputGroupAddon className=\"pl-0\">\n                              {backButton(\n                                exitSearch,\n                                field\n                                  ? text.backToField(field.label)\n                                  : text.backToFilters\n                              )}\n                            </InputGroupAddon>\n                            {searchInput}\n                          </InputGroup>\n                        )}\n                      </View>\n                    </AnimatePresence>\n                  )}\n                </div>\n                <div\n                  ref={listRef}\n                  id={`${id}-list`}\n                  // Without a search box, the list itself takes focus and\n                  // points at the highlighted row.\n                  tabIndex={usesInput ? undefined : 0}\n                  aria-activedescendant={\n                    !usesInput && active ? rowId(active) : undefined\n                  }\n                  onKeyDown={usesInput ? undefined : onKeyDown}\n                  // An empty search leaves no options, and a listbox without\n                  // any is invalid, so the empty message stands on its own.\n                  role={count ? \"listbox\" : undefined}\n                  aria-label={\n                    count ? (field ? field.label : searchLabel) : undefined\n                  }\n                  aria-multiselectable={\n                    (count && view !== \"fields\") || undefined\n                  }\n                  className=\"relative max-h-72 overflow-x-hidden overflow-y-auto p-1 outline-hidden\"\n                >\n                  <AnimatePresence\n                    initial={false}\n                    mode=\"popLayout\"\n                    custom={direction}\n                  >\n                    <View key={viewKey} {...slides}>\n                      {count === 0 ? (\n                        <EmptyState size=\"sm\">\n                          <EmptyStateTitle>{emptyText}</EmptyStateTitle>\n                        </EmptyState>\n                      ) : (\n                        groups.map((group, index) => (\n                          <div\n                            key={group.field?.id ?? index}\n                            role={group.field ? \"group\" : undefined}\n                            aria-labelledby={\n                              group.field\n                                ? `${id}-${viewKey}-group-${index}`\n                                : undefined\n                            }\n                          >\n                            {group.field ? (\n                              <div\n                                id={`${id}-${viewKey}-group-${index}`}\n                                data-slot=\"filter-menu-group-label\"\n                                className=\"px-2 pt-2 pb-1 text-xs text-muted-foreground\"\n                              >\n                                {group.field.label}\n                              </div>\n                            ) : null}\n                            {group.rows.map((row) => (\n                              <FilterMenuRow\n                                key={row.key}\n                                id={rowId(row)}\n                                row={row}\n                                query={term}\n                                highlighted={row === active}\n                                selected={selected}\n                                onHover={() => setHighlight(rows.indexOf(row))}\n                                onActivate={() => activate(row)}\n                              />\n                            ))}\n                          </div>\n                        ))\n                      )}\n                    </View>\n                  </AnimatePresence>\n                </div>\n              </div>\n            </motion.div>\n            <span className=\"sr-only\" aria-live=\"polite\">\n              {view === \"search\" ? text.results(count) : \"\"}\n            </span>\n          </PopoverPrimitive.Popup>\n        </PopoverPrimitive.Positioner>\n      </PopoverPrimitive.Portal>\n    </PopoverPrimitive.Root>\n  )\n}\n\nfunction FilterMenuRow({\n  id,\n  row,\n  query,\n  highlighted,\n  selected,\n  onHover,\n  onActivate,\n}: {\n  id: string\n  row: Row\n  query: string\n  highlighted: boolean\n  selected: FilterValue\n  onHover: () => void\n  onActivate: () => void\n}) {\n  const chosen = selected[row.field.id] ?? []\n  const checked = row.kind === \"option\" && chosen.includes(row.option.value)\n  const icon = row.kind === \"field\" ? row.field.icon : row.option.icon\n  return (\n    <div\n      id={id}\n      role=\"option\"\n      data-slot={\n        row.kind === \"field\" ? \"filter-menu-field\" : \"filter-menu-option\"\n      }\n      data-highlighted={highlighted || undefined}\n      data-checked={checked || undefined}\n      aria-selected={row.kind === \"option\" ? checked : false}\n      // Focus stays in the search box; rows are picked with the pointer or\n      // through aria-activedescendant.\n      onMouseDown={(event) => event.preventDefault()}\n      onPointerMove={onHover}\n      onClick={onActivate}\n      className=\"flex h-8 cursor-default items-center gap-2 rounded-md px-2 text-sm select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n    >\n      {row.kind === \"option\" ? <CheckboxMark checked={checked} /> : null}\n      {icon ? (\n        <span aria-hidden=\"true\" className=\"text-muted-foreground\">\n          {icon}\n        </span>\n      ) : null}\n      <span className=\"flex-1 truncate\">\n        {row.kind === \"field\" ? (\n          row.field.label\n        ) : (\n          <Highlight text={row.option.label} query={query} />\n        )}\n      </span>\n      {row.kind === \"field\" ? (\n        <>\n          {chosen.length ? (\n            <Count\n              value={chosen.length}\n              label={(n) => `${n} selected`}\n              className=\"font-mono text-xs text-muted-foreground\"\n            />\n          ) : null}\n          <span className=\"sr-only\">, opens values</span>\n          <ChevronRightIcon\n            aria-hidden=\"true\"\n            className=\"text-muted-foreground\"\n          />\n        </>\n      ) : null}\n    </div>\n  )\n}\n\nexport { FilterMenu }\nexport type {\n  FilterField,\n  FilterMenuLabels,\n  FilterMenuProps,\n  FilterOption,\n  FilterValue,\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "tier": "special-components",
    "group": "Navigation",
    "docs": "https://fibo.toribryan.com/?path=/docs/special-components-filter-menu--docs"
  },
  "docs": "Docs and live examples: https://fibo.toribryan.com/?path=/docs/special-components-filter-menu--docs",
  "categories": [
    "special-components",
    "navigation"
  ],
  "type": "registry:ui"
}