{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "palette",
  "type": "registry:component",
  "title": "Palette",
  "description": "Cmd-K fuzzy command palette with subsequence matching, character highlighting, focus trap, and on-page section jumps.",
  "dependencies": [],
  "registryDependencies": [
    "https://mocoui.site/r/tokens.json",
    "https://mocoui.site/r/hooks.json"
  ],
  "files": [
    {
      "path": "registry/palette/palette.tsx",
      "type": "registry:component",
      "target": "components/moco/palette.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport \"./palette.css\";\nimport { smoothScrollTo } from \"@/components/moco/hooks\";\n\nexport interface IndexEntry {\n  kind: \"post\" | \"page\" | \"section\";\n  label: string;\n  href?: string;\n  id?: string;\n}\n\nexport interface PaletteProps {\n  index: IndexEntry[];\n  /** Headings scraped into the index each time the palette opens. */\n  sectionSelector?: string;\n  /** Called for entries with an href. Defaults to a full navigation. */\n  onNavigate?: (href: string) => void;\n}\n\n/** Subsequence match. Returns matched character indices, or null. */\nfunction fuzzy(needle: string, hay: string): number[] | null {\n  if (!needle) return [];\n  const h = hay.toLowerCase();\n  const n = needle.toLowerCase();\n  const hits: number[] = [];\n  let i = 0;\n  for (const ch of n) {\n    const at = h.indexOf(ch, i);\n    if (at === -1) return null;\n    hits.push(at);\n    i = at + 1;\n  }\n  return hits;\n}\n\nfunction Highlighted({ text, hits }: { text: string; hits: number[] }) {\n  const set = new Set(hits);\n  return (\n    <span>\n      {text.split(\"\").map((c, i) => (set.has(i) ? <mark key={i}>{c}</mark> : <span key={i}>{c}</span>))}\n    </span>\n  );\n}\n\n/** ⌘K / Ctrl-K. Traps focus while open, restores it on close. */\nexport function Palette({\n  index,\n  sectionSelector = \".prose h2[id]\",\n  onNavigate,\n}: PaletteProps) {\n  const [open, setOpen] = React.useState(false);\n  const [q, setQ] = React.useState(\"\");\n  const [sel, setSel] = React.useState(0);\n  const [sections, setSections] = React.useState<IndexEntry[]>([]);\n  const restore = React.useRef<HTMLElement | null>(null);\n  const input = React.useRef<HTMLInputElement>(null);\n  const box = React.useRef<HTMLDivElement>(null);\n\n  React.useEffect(() => {\n    const onKey = (e: KeyboardEvent) => {\n      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"k\") {\n        e.preventDefault();\n        setOpen((v) => !v);\n      }\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, []);\n\n  React.useEffect(() => {\n    if (!open) {\n      restore.current?.focus();\n      return;\n    }\n    restore.current = document.activeElement as HTMLElement;\n    setQ(\"\");\n    setSel(0);\n    setSections(\n      Array.from(document.querySelectorAll<HTMLElement>(sectionSelector)).map((h) => ({\n        kind: \"section\" as const,\n        label: h.textContent ?? \"\",\n        id: h.id,\n      })),\n    );\n    const t = setTimeout(() => input.current?.focus(), 0);\n    return () => clearTimeout(t);\n  }, [open, sectionSelector]);\n\n  const all = React.useMemo(() => [...sections, ...index], [sections, index]);\n\n  const results = React.useMemo(() => {\n    return all\n      .map((e) => ({ e, hits: fuzzy(q, e.label) }))\n      .filter((r): r is { e: IndexEntry; hits: number[] } => r.hits !== null)\n      .slice(0, 40);\n  }, [all, q]);\n\n  const go = (e: IndexEntry) => {\n    setOpen(false);\n    if (e.kind === \"section\" && e.id) smoothScrollTo(e.id);\n    else if (e.href) (onNavigate ?? ((href: string) => location.assign(href)))(e.href);\n  };\n\n  if (!open) return null;\n\n  return (\n    <div\n      className=\"moco-cmdk-scrim\"\n      onMouseDown={(e) => e.target === e.currentTarget && setOpen(false)}\n    >\n      <div\n        ref={box}\n        className=\"moco-cmdk\"\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-label=\"Search\"\n        onKeyDown={(e) => {\n          if (e.key === \"Escape\") setOpen(false);\n          if (e.key === \"ArrowDown\") {\n            e.preventDefault();\n            setSel((v) => Math.min(results.length - 1, v + 1));\n          }\n          if (e.key === \"ArrowUp\") {\n            e.preventDefault();\n            setSel((v) => Math.max(0, v - 1));\n          }\n          if (e.key === \"Enter\" && results[sel]) {\n            e.preventDefault();\n            go(results[sel].e);\n          }\n          if (e.key === \"Tab\") {\n            // Focus stays in the input; there is nowhere else to go.\n            e.preventDefault();\n          }\n        }}\n      >\n        <input\n          ref={input}\n          value={q}\n          placeholder=\"Jump to a post, a section, a page…\"\n          aria-label=\"Search posts and sections\"\n          onChange={(e) => {\n            setQ(e.target.value);\n            setSel(0);\n          }}\n        />\n        {results.length ? (\n          <ul role=\"listbox\" aria-label=\"Results\">\n            {results.map((r, i) => (\n              <li key={`${r.e.kind}-${r.e.label}-${i}`} role=\"option\" aria-selected={i === sel}>\n                <button type=\"button\" onMouseEnter={() => setSel(i)} onClick={() => go(r.e)}>\n                  <Highlighted text={r.e.label} hits={r.hits} />\n                  <span className=\"moco-kind\">{r.e.kind}</span>\n                </button>\n              </li>\n            ))}\n          </ul>\n        ) : (\n          <div className=\"moco-empty\">Nothing matches “{q}”.</div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
    },
    {
      "path": "registry/palette/palette.css",
      "type": "registry:file",
      "target": "components/moco/palette.css",
      "content": ".moco-cmdk-scrim {\n  position: fixed;\n  inset: 0;\n  z-index: 100;\n  background: rgba(0, 0, 0, 0.28);\n  backdrop-filter: blur(3px);\n  display: flex;\n  justify-content: center;\n  padding: 12vh 1rem 2rem;\n}\n\n.moco-cmdk {\n  width: min(560px, 100%);\n  height: max-content;\n  max-height: 70vh;\n  background: var(--moco-bg);\n  border: 1px solid var(--moco-line);\n  border-radius: 4px;\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n}\n\n.moco-cmdk input {\n  width: 100%;\n  background: transparent;\n  border: none;\n  border-bottom: 1px solid var(--moco-line);\n  padding: 0.9rem 1rem;\n  font: inherit;\n  font-size: 14px;\n  color: var(--moco-ink);\n  outline: none;\n}\n\n.moco-cmdk input::placeholder {\n  color: var(--moco-faint);\n}\n\n.moco-cmdk ul {\n  list-style: none;\n  margin: 0;\n  padding: 0.35rem;\n  overflow-y: auto;\n}\n\n.moco-cmdk li button {\n  display: flex;\n  justify-content: space-between;\n  align-items: baseline;\n  gap: 1rem;\n  width: 100%;\n  text-align: left;\n  padding: 0.45rem 0.65rem;\n  font-size: 13px;\n  color: var(--moco-muted);\n  border-radius: 2px;\n  /* Button reset the host app may not provide. */\n  font-family: inherit;\n  background: none;\n  border: 0;\n  cursor: pointer;\n}\n\n.moco-cmdk li[aria-selected=\"true\"] button {\n  background: var(--moco-hover-wash);\n  color: var(--moco-ink);\n}\n\n.moco-cmdk .moco-kind {\n  color: var(--moco-faint);\n  font-size: 11px;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n  flex: none;\n}\n\n.moco-cmdk mark {\n  background: none;\n  color: var(--moco-accent-dk);\n  font-weight: 700;\n}\n\n.moco-cmdk .moco-empty {\n  padding: 1rem;\n  color: var(--moco-faint);\n  font-size: 13px;\n}\n"
    }
  ],
  "docs": "\"use client\";\n\nimport { Palette, type IndexEntry } from \"./palette\";\n\nconst INDEX: IndexEntry[] = [\n  { kind: \"post\", label: \"Prompt caching by the numbers\", href: \"#\" },\n  { kind: \"post\", label: \"The island pattern\", href: \"#\" },\n  { kind: \"page\", label: \"About\", href: \"#\" },\n];\n\nexport default function PaletteDemo() {\n  return (\n    <div className=\"prose\" style={{ maxWidth: \"42rem\", margin: \"0 auto\", padding: \"2rem 1rem\" }}>\n      <p>\n        Press <kbd>⌘K</kbd> (or <kbd>Ctrl-K</kbd>) and type a few letters — matching is by\n        subsequence, so “pcn” finds “Prompt caching by the numbers”.\n      </p>\n      <h2 id=\"demo-section-one\">A section on this page</h2>\n      <p>Sections with ids are scraped into the index each time the palette opens.</p>\n      <h2 id=\"demo-section-two\">Another section</h2>\n      <p>Selecting a section scrolls to it; selecting a post or page navigates.</p>\n      <Palette index={INDEX} onNavigate={(href) => console.log(\"navigate:\", href)} />\n    </div>\n  );\n}\n",
  "meta": {
    "tags": [
      "command-palette",
      "search",
      "keyboard",
      "dialog"
    ]
  }
}