{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "ledger-tables",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "Hierarchical with rule creation.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { type OptionRow, useOptionKeys } from \"@/hooks/ledger/use-option-keys\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport {\n  Popover,\n  PopoverPopup,\n  PopoverPortal,\n  PopoverPositioner,\n  PopoverTrigger,\n} from \"@/components/ledger/primitives/popover\";\nimport { Button } from \"@/components/ledger/button\";\nimport { ChevronDownIcon, MagnifyingGlassIcon, SparklesIcon } from \"@/components/ledger/icons\";\n\nexport type CategoryNode = {\n  id: string;\n  label: string;\n  children?: CategoryNode[];\n};\n\nexport interface CategoryPickerProps {\n  className?: string;\n  /** The preview count is shown before the rule is applied, never after. */\n  onCreateRule?: (categoryId: string) => { matchCount: number; description: string };\n  onValueChange: (id: string) => void;\n  recents?: string[];\n  tree: CategoryNode[];\n  value: string | null;\n}\n\n/**\n * Categorizing one transaction and offering to categorize the other 40 like\n * it is the highest-leverage interaction in bookkeeping software. The rule\n * preview shows the match count *before* applying.\n */\nexport function CategoryPicker({\n  tree,\n  value,\n  onValueChange,\n  recents = [],\n  onCreateRule,\n  className,\n}: CategoryPickerProps) {\n  const [open, setOpen] = React.useState(false);\n  const [query, setQuery] = React.useState(\"\");\n  const [rulePreview, setRulePreview] = React.useState<{\n    matchCount: number;\n    description: string;\n  } | null>(null);\n\n  const flat = React.useMemo(() => flatten(tree), [tree]);\n  const selected = flat.find((n) => n.id === value);\n  const filtered = React.useMemo(\n    () => (query ? flat.filter((n) => n.label.toLowerCase().includes(query.toLowerCase())) : flat),\n    [flat, query],\n  );\n\n  /* Recents and the tree as one flat list of fixed-height rows, so a long\n     chart of categories can be windowed. A recent keeps its own key, so the\n     same category can appear in both places with distinct option ids. */\n  const rows = React.useMemo<CategoryRow[]>(() => {\n    const out: CategoryRow[] = [];\n    if (!query) {\n      const recentNodes = recents\n        .map((id) => flat.find((n) => n.id === id))\n        .filter((n): n is FlatNode => Boolean(n));\n      if (recentNodes.length > 0) {\n        out.push({ heading: \"Recent\", key: \"heading-recent\", option: false });\n        for (const node of recentNodes) out.push({ key: `recent-${node.id}`, node, option: true });\n      }\n    }\n    for (const node of filtered) out.push({ key: node.id, node, option: true });\n    return out;\n  }, [flat, filtered, query, recents]);\n\n  const list = useOptionKeys({\n    onPick: (row) => {\n      if (!row.node) return;\n      onValueChange(row.node.id);\n      setRulePreview(onCreateRule?.(row.node.id) ?? null);\n    },\n    open,\n    resetKey: query,\n    rowHeight: ROW_HEIGHT,\n    rows,\n    selectedKey: value,\n    viewportHeight: 280,\n  });\n\n  return (\n    <Popover onOpenChange={setOpen} open={open}>\n      <PopoverTrigger\n        aria-label={selected ? `Category: ${selected.label}` : \"Choose a category\"}\n        className={cn(\n          \"inline-flex h-8 items-center gap-2 rounded-sm border border-border bg-surface px-3\",\n          \"text-label transition-colors duration-1 ease-standard hover:bg-surface-hover\",\n          className,\n        )}\n      >\n        {selected?.label ?? \"Uncategorized\"}\n        <ChevronDownIcon aria-hidden=\"true\" className=\"size-3.5 text-text-tertiary\" />\n      </PopoverTrigger>\n      <PopoverPortal>\n        <PopoverPositioner align=\"start\" className=\"z-dropdown\" sideOffset={8}>\n          <PopoverPopup\n            className=\"w-[320px] overflow-hidden rounded-md border border-border bg-surface-raised shadow-elevation-3\"\n            style={{ transformOrigin: \"var(--transform-origin)\" }}\n          >\n            <div className=\"flex items-center gap-2 border-b border-border-subtle px-3\">\n              <MagnifyingGlassIcon aria-hidden=\"true\" className=\"size-4 text-text-tertiary\" />\n              <input\n                aria-label=\"Search categories\"\n                autoFocus\n                {...list.inputProps}\n                className=\"h-10 flex-1 bg-transparent text-body outline-none\"\n                onChange={(e) => setQuery(e.target.value)}\n                placeholder=\"Search categories\"\n                value={query}\n              />\n            </div>\n            <div {...list.listProps} aria-label=\"Categories\" className=\"overflow-y-auto scrollbar-thin\">\n              <div role=\"presentation\" style={list.window.style}>\n                {rows.slice(list.window.start, list.window.end).map((row, offset) =>\n                  row.node ? (\n                    <Option\n                      key={row.key}\n                      node={row.node}\n                      optionProps={list.optionProps(list.window.start + offset)}\n                      selected={row.node.id === value}\n                    />\n                  ) : (\n                    <p\n                      className=\"flex items-end px-3 pb-1 text-caption text-text-tertiary\"\n                      key={row.key}\n                      role=\"presentation\"\n                      style={{ height: ROW_HEIGHT }}\n                    >\n                      {row.heading}\n                    </p>\n                  ),\n                )}\n              </div>\n            </div>\n\n            {rulePreview ? (\n              <div className=\"ledger-crossfade border-t border-border-subtle bg-accent-subtle p-3\">\n                <p className=\"flex items-start gap-2 text-caption text-text-primary\">\n                  <SparklesIcon aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0 text-accent\" />\n                  {rulePreview.description} — this would recategorize{\" \"}\n                  <span className=\"numeric font-medium\">{rulePreview.matchCount}</span> other transactions.\n                </p>\n                <div className=\"mt-2 flex gap-2\">\n                  <Button onClick={() => setRulePreview(null)} size=\"sm\" variant=\"primary\">\n                    Create the rule\n                  </Button>\n                  <Button onClick={() => setRulePreview(null)} size=\"sm\" variant=\"ghost\">\n                    Just this one\n                  </Button>\n                </div>\n              </div>\n            ) : null}\n          </PopoverPopup>\n        </PopoverPositioner>\n      </PopoverPortal>\n    </Popover>\n  );\n}\n\ntype FlatNode = CategoryNode & { depth: number };\n\nfunction flatten(nodes: CategoryNode[], depth = 0): FlatNode[] {\n  return nodes.flatMap((n) => [{ ...n, depth }, ...(n.children ? flatten(n.children, depth + 1) : [])]);\n}\n\n/** Every row, heading or category, is this tall — the window is computed from it. */\nconst ROW_HEIGHT = 36;\n\ntype CategoryRow = OptionRow & { heading?: string; node?: FlatNode };\n\nfunction Option({\n  node,\n  selected,\n  optionProps,\n}: {\n  node: FlatNode;\n  selected: boolean;\n  optionProps: React.ComponentPropsWithoutRef<\"button\">;\n}) {\n  return (\n    <button\n      {...optionProps}\n      className={cn(\n        \"flex w-full items-center truncate px-3 text-left text-body data-[active]:bg-surface-hover\",\n        selected && \"bg-surface-selected\",\n        node.children && node.depth === 0 && \"font-medium\",\n      )}\n      style={{ height: ROW_HEIGHT, paddingLeft: 12 + node.depth * 16 }}\n      type=\"button\"\n    >\n      {node.label}\n    </button>\n  );\n}\n",
      "path": "components/ledger/category-picker.tsx",
      "target": "components/ledger/category-picker.tsx",
      "type": "registry:component"
    }
  ],
  "meta": {
    "license": "MIT"
  },
  "name": "category-picker",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/button",
    "@lopifi/cn",
    "@lopifi/icons",
    "@lopifi/primitive-popover",
    "@lopifi/use-option-keys"
  ],
  "title": "CategoryPicker",
  "type": "registry:component"
}
