{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command-pages",
  "type": "registry:ui",
  "title": "Command Pages",
  "description": "Palette pages that slide in, land on the current choice and remember the way back.",
  "categories": [
    "command"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json",
    "https://www.stealth.pm/r/command-palette.json",
    "https://www.stealth.pm/r/kbd.json"
  ],
  "files": [
    {
      "path": "components/ui/command-pages.tsx",
      "content": "\"use client\";\nimport { Autocomplete } from \"@base-ui/react/autocomplete\";\nimport { Dialog } from \"@base-ui/react/dialog\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronRight, Loader, Search } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\nimport { Kbd, matchShortcut, usePlatform } from \"@/components/ui/kbd\";\nimport {\n  CommandRow,\n  PaletteFooter,\n  messageOf,\n  paletteClasses,\n  useCommandSearch,\n  useDelayedFlag,\n  useListGlide,\n  useMeasuredHeight,\n  usePaletteHotkey,\n  type CommandEntry,\n  type CommandGroup,\n  type CommandItem,\n  type CommandSection,\n} from \"@/components/ui/command-palette\";\n\nexport type CommandPagesItem = CommandItem & {\n  /** Opens this page instead of running. Write the label with an ellipsis (\"Change theme…\"). */\n  to?: string;\n  /** Marks the current choice among options, drawn as a check. */\n  checked?: boolean;\n};\n\nexport type CommandPage = {\n  /** Shown in the breadcrumb chip and as the heading when its items turn up in a search from the root. */\n  title: string;\n  placeholder?: string;\n  groups: { heading: string; items: CommandPagesItem[] }[];\n};\n\nexport type CommandPagesProps = {\n  /** Every page by id. The first page shown is `root`. */\n  pages: Record<string, CommandPage>;\n  root?: string;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Called with the page stack, root first, whenever it changes. */\n  onPageChange?: (stack: string[]) => void;\n  hotkey?: string | false;\n  hotkeyTarget?: React.RefObject<HTMLElement | null>;\n  /** Called with every item that runs. */\n  onSelect?: (item: CommandPagesItem) => void;\n  container?: Dialog.Portal.Props[\"container\"];\n  label?: string;\n  footer?: React.ReactNode;\n  className?: string;\n  children?: React.ReactNode;\n};\n\ntype Level = { id: string; query: string; scroll: number };\ntype Failure = { key: string; message: string };\n\nexport function CommandPages({\n  pages,\n  root = \"root\",\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  onPageChange,\n  hotkey = \"mod+k\",\n  hotkeyTarget,\n  onSelect,\n  container,\n  label = \"Command palette\",\n  footer,\n  className,\n  children,\n}: CommandPagesProps) {\n  const reduce = !!useReducedMotion();\n  const platform = usePlatform();\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  // Each level remembers what was typed and how far it was scrolled, so going back lands where you left.\n  const [stack, setStack] = useState<Level[]>([{ id: root, query: \"\", scroll: 0 }]);\n  const [instant, setInstant] = useState(false);\n  const [busy, setBusy] = useState<string | null>(null);\n  const [failure, setFailure] = useState<Failure | null>(null);\n  const [direction, setDirection] = useState<1 | -1>(1);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const pageRef = useRef<HTMLDivElement>(null);\n  const ghostRef = useRef<HTMLDivElement>(null);\n  const contained = container != null;\n\n  const level = stack[stack.length - 1];\n  const page = pages[level.id] ?? pages[root];\n  const query = level.query;\n  const atRoot = stack.length === 1;\n\n  // From the root, a search also reaches into every page (\"dark\" finds Theme › Dark).\n  const groups = useMemo<CommandGroup[]>(() => {\n    const own = page.groups;\n    if (!atRoot || !query.trim()) return own;\n    const deep = Object.entries(pages)\n      .filter(([id]) => id !== root)\n      .map(([id, p]) => ({\n        heading: p.title,\n        items: p.groups.flatMap((g) => g.items).map((i) => ({ ...i, id: `${id}/${i.id}` })),\n      }));\n    return [...own, ...deep];\n  }, [page, pages, root, atRoot, query]);\n\n  const sections = useCommandSearch(groups, query);\n  const count = sections.reduce((n, s) => n + s.items.length, 0);\n  const spinning = useDelayedFlag(busy !== null);\n  const { setList: setGlideList, y: glideY, height: glideH, opacity: glideO } = useListGlide(reduce);\n  const [measure, contentHeight] = useMeasuredHeight();\n\n  const commit = (next: Level[]) => {\n    setStack(next);\n    onPageChange?.(next.map((l) => l.id));\n  };\n\n  // The page you leave is photographed and slid out; the live list slides in from the other side.\n  const ghost = (dir: 1 | -1) => {\n    const live = pageRef.current;\n    const host = ghostRef.current;\n    if (reduce || !live || !host || typeof live.animate !== \"function\") return;\n    const clone = live.cloneNode(true) as HTMLElement;\n    clone.removeAttribute(\"id\");\n    clone.querySelectorAll(\"[id]\").forEach((n) => n.removeAttribute(\"id\"));\n    clone.setAttribute(\"aria-hidden\", \"true\");\n    clone.setAttribute(\"inert\", \"\");\n    clone.style.cssText = `position:absolute;left:0;right:0;top:${-(scrollRef.current?.scrollTop ?? 0)}px;pointer-events:none`;\n    host.replaceChildren(clone);\n    clone\n      .animate([{ transform: \"translateX(0)\", opacity: 1 }, { transform: `translateX(${-dir * 16}px)`, opacity: 0 }], {\n        duration: 120,\n        easing: `cubic-bezier(${ease.in.join(\",\")})`,\n        fill: \"forwards\",\n      })\n      .finished.then(() => clone.remove(), () => clone.remove());\n  };\n\n  // Which row to land on after a page change: the current choice going in, the row you came from going back.\n  const landing = useRef<((e: CommandEntry) => boolean) | null>(null);\n\n  const push = (to: string) => {\n    if (!pages[to]) return;\n    landing.current = (e) => (e.item as CommandPagesItem).checked === true;\n    ghost(1);\n    setDirection(1);\n    setFailure(null);\n    const saved = { ...level, scroll: scrollRef.current?.scrollTop ?? 0 };\n    commit([...stack.slice(0, -1), saved, { id: to, query: \"\", scroll: 0 }]);\n  };\n\n  const popTo = (depth: number) => {\n    if (depth >= stack.length - 1 || depth < 0) return;\n    const from = stack[depth + 1].id;\n    landing.current = (e) => (e.item as CommandPagesItem).to === from;\n    ghost(-1);\n    setDirection(-1);\n    setFailure(null);\n    commit(stack.slice(0, depth + 1));\n  };\n\n  // After a page change: restore its scroll and slide the new list in.\n  const depth = stack.length;\n  const first = useRef(true);\n  useLayoutEffect(() => {\n    if (first.current) {\n      first.current = false;\n      return;\n    }\n    const el = scrollRef.current;\n    if (el) el.scrollTop = stack[stack.length - 1].scroll;\n    const input = inputRef.current;\n    input?.focus();\n    // Step the highlight to the landing row (or the first) as if by arrow key, so it is announced too.\n    // The list keeps its previous index across pages, so walk from wherever it is now.\n    const found = landing.current ? sections.flatMap((sec) => sec.items).findIndex(landing.current) : -1;\n    const target = Math.max(0, found);\n    landing.current = null;\n    const frame = requestAnimationFrame(() => {\n      const options = [...(pageRef.current?.querySelectorAll<HTMLElement>(\"[role=option]\") ?? [])];\n      const current = options.findIndex((o) => o.hasAttribute(\"data-highlighted\"));\n      if (!input || current < 0 || current === target) return;\n      const key = target > current ? \"ArrowDown\" : \"ArrowUp\";\n      for (let i = 0; i < Math.abs(target - current); i++) input.dispatchEvent(new KeyboardEvent(\"keydown\", { key, code: key, bubbles: true, cancelable: true }));\n    });\n    const live = pageRef.current;\n    if (reduce || !live || typeof live.animate !== \"function\") return () => cancelAnimationFrame(frame);\n    live.animate([{ transform: `translateX(${direction * 16}px)`, opacity: 0 }, { transform: \"translateX(0)\", opacity: 1 }], {\n      duration: 200,\n      easing: `cubic-bezier(${ease.out.join(\",\")})`,\n    });\n    return () => cancelAnimationFrame(frame);\n    // Only a change of page should slide; typing never does.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [depth, level.id]);\n\n  const setQuery = (q: string) => setStack((s) => [...s.slice(0, -1), { ...s[s.length - 1], query: q }]);\n\n  const close = (fromKeyboard: boolean) => {\n    setInstant(fromKeyboard);\n    setOpen(false);\n  };\n\n  usePaletteHotkey({\n    hotkey,\n    target: hotkeyTarget,\n    open,\n    onToggle: () => {\n      setInstant(true);\n      setOpen(!open);\n    },\n  });\n\n  const run = async (entry: CommandEntry, fromKeyboard: boolean) => {\n    const item = entry.item as CommandPagesItem;\n    if (item.disabled || busy) return;\n    if (item.to) return push(item.to);\n    setFailure(null);\n    let result: void | Promise<unknown>;\n    try {\n      result = item.onSelect?.();\n    } catch (error) {\n      return setFailure({ key: entry.key, message: messageOf(error) });\n    }\n    if (result && typeof (result as Promise<unknown>).then === \"function\") {\n      setBusy(entry.key);\n      try {\n        await result;\n      } catch (error) {\n        setBusy(null);\n        return setFailure({ key: entry.key, message: messageOf(error) });\n      }\n      setBusy(null);\n    }\n    onSelect?.(item);\n    if (item.keepOpen) return;\n    // A choice made with the pointer holds for a beat so the check can land on it; the keyboard never waits.\n    if (!fromKeyboard && item.checked !== undefined && !reduce) window.setTimeout(() => close(false), 260);\n    else close(fromKeyboard);\n  };\n\n  const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === \"Backspace\" && !e.currentTarget.value && !atRoot && e.currentTarget.selectionStart === 0) {\n      e.preventDefault();\n      popTo(stack.length - 2);\n    }\n  };\n\n  const onPopupKeyDown = (e: React.KeyboardEvent) => {\n    const native = e.nativeEvent;\n    if (!native.metaKey && !native.ctrlKey && !native.altKey) return;\n    for (const g of page.groups)\n      for (const item of g.items)\n        if (item.shortcut && !item.disabled && matchShortcut(native, item.shortcut, platform)) {\n          e.preventDefault();\n          e.stopPropagation();\n          void run({ key: item.id, item, ranges: [], score: 0 }, true);\n          return;\n        }\n  };\n\n  const trail = stack.slice(1);\n\n  return (\n    <Dialog.Root\n      open={open}\n      onOpenChange={(next, details) => {\n        const ev = details.event as Event | undefined;\n        // Escape on a nested page goes back one page instead of closing.\n        if (!next && details.reason === \"escape-key\" && !atRoot) {\n          details.cancel();\n          popTo(stack.length - 2);\n          return;\n        }\n        setInstant(ev instanceof KeyboardEvent || (ev instanceof MouseEvent && ev.detail === 0));\n        setOpen(next);\n      }}\n      onOpenChangeComplete={(isOpen) => {\n        if (isOpen) return;\n        first.current = true;\n        setStack([{ id: root, query: \"\", scroll: 0 }]);\n        setFailure(null);\n        setBusy(null);\n      }}\n    >\n      {children}\n      <Dialog.Portal container={container}>\n        <Dialog.Backdrop data-instant={instant ? \"\" : undefined} className={cn(contained ? \"absolute\" : \"fixed\", paletteClasses.backdrop)} />\n        <Dialog.Viewport className={cn(contained ? \"absolute pt-14\" : \"fixed pt-[12vh]\", paletteClasses.viewport)}>\n          <Dialog.Popup\n            initialFocus={inputRef}\n            data-instant={instant ? \"\" : undefined}\n            data-depth={stack.length}\n            onKeyDown={onPopupKeyDown}\n            className={cn(paletteClasses.popup, className)}\n          >\n            <Dialog.Title className=\"sr-only\">{label}</Dialog.Title>\n            <Autocomplete.Root\n              inline\n              open\n              items={sections}\n              filteredItems={sections}\n              value={query}\n              onValueChange={(next, details) => {\n                if (details.reason === \"item-press\") return;\n                setQuery(next);\n                setFailure(null);\n              }}\n              onOpenChange={(next, details) => {\n                if (next || details.reason !== \"escape-key\") return;\n                if (atRoot) close(true);\n                else popTo(stack.length - 2);\n              }}\n              itemToStringValue={(entry: CommandEntry) => entry.item.label}\n              autoHighlight=\"always\"\n              keepHighlight\n            >\n              <div className=\"flex h-12 shrink-0 items-center gap-2 border-b border-line pr-2.5 pl-4 pointer-coarse:h-14\">\n                <span className=\"relative mr-0.5 grid size-4 shrink-0 place-items-center text-fg-3\">\n                  <Search size={16} className={cn(\"transition-[opacity,scale] duration-150\", spinning && \"scale-75 opacity-0\")} />\n                  <Loader size={16} className={cn(\"absolute animate-spin transition-[opacity,scale] duration-150\", !spinning && \"scale-75 opacity-0\")} />\n                </span>\n                <nav aria-label=\"Pages\" className=\"flex min-w-0 shrink items-center gap-1 empty:hidden\">\n                  <AnimatePresence initial={false} mode=\"popLayout\">\n                    {trail.map((l, i) => (\n                      <motion.button\n                        key={`${i}-${l.id}`}\n                        type=\"button\"\n                        layout={reduce ? false : \"position\"}\n                        initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: \"blur(2px)\" }}\n                        animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                        exit={reduce ? { opacity: 0, transition: { duration: 0.08 } } : { opacity: 0, scale: 0.9, transition: { duration: 0.1 } }}\n                        transition={reduce ? { duration: 0.12 } : spring.pop}\n                        onClick={() => popTo(i)}\n                        aria-current={i === trail.length - 1 ? \"page\" : undefined}\n                        aria-label={i === trail.length - 1 ? `${pages[l.id]?.title}, current page` : `Back to ${pages[l.id]?.title}`}\n                        className={cn(\n                          \"relative inline-flex h-6 max-w-[9rem] shrink-0 items-center rounded-md border border-line-2 bg-fg/[0.05] px-2 text-[12px] font-medium text-fg-2 outline-none select-none\",\n                          \"transition-[background-color,border-color,color] duration-150 hover:border-fg-4 hover:text-fg active:scale-[0.96]\",\n                          \"aria-[current=page]:text-fg\",\n                          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n                        )}\n                      >\n                        <span className=\"truncate\">{pages[l.id]?.title}</span>\n                      </motion.button>\n                    ))}\n                  </AnimatePresence>\n                </nav>\n                <motion.div layout={reduce ? false : \"position\"} transition={spring.snappy} className=\"flex h-full min-w-0 flex-1\">\n                  <Autocomplete.Input\n                    ref={inputRef}\n                    placeholder={page.placeholder ?? \"Search or jump to…\"}\n                    aria-label={page.placeholder?.replace(/…$/, \"\") ?? \"Search commands\"}\n                    autoComplete=\"off\"\n                    spellCheck={false}\n                    enterKeyHint=\"go\"\n                    onKeyDown={onInputKeyDown}\n                    className={cn(paletteClasses.input, trail.length > 0 && \"pl-1\")}\n                  />\n                </motion.div>\n                <Dialog.Close\n                  aria-label=\"Close\"\n                  className={cn(\n                    \"relative grid shrink-0 place-items-center rounded-md outline-none\",\n                    \"transition-[scale,opacity] duration-150 hover:opacity-80 active:scale-[0.94]\",\n                    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                    \"after:absolute after:-inset-3 after:content-[''] pointer-fine:after:hidden\",\n                  )}\n                >\n                  <Kbd keys=\"esc\" size=\"sm\" />\n                </Dialog.Close>\n              </div>\n\n              <div\n                ref={scrollRef}\n                style={{ height: contentHeight ?? undefined }}\n                className=\"relative max-h-[min(22rem,55dvh)] min-h-0 overflow-x-hidden overflow-y-auto overscroll-contain transition-[height] duration-[180ms] ease-out-quart motion-reduce:transition-none\"\n              >\n                <div ref={ghostRef} aria-hidden className=\"pointer-events-none absolute inset-x-0 top-0\" />\n                <div ref={measure} className=\"p-1.5\">\n                  <div ref={pageRef}>\n                    <Autocomplete.Empty>\n                      {count === 0 && (\n                        <div className=\"flex flex-col items-center gap-1 px-6 py-9 text-center\">\n                          <p className=\"max-w-full truncate text-[13px] text-fg-2\">\n                            {query.trim() ? (\n                              <>\n                                No results for <span className=\"text-fg\">“{query.trim()}”</span>\n                              </>\n                            ) : (\n                              \"Nothing here yet\"\n                            )}\n                          </p>\n                          {!atRoot && (\n                            <p className=\"text-[12px] text-fg-3\">\n                              Press <Kbd keys=\"backspace\" size=\"sm\" className=\"mx-0.5\" /> to go back\n                            </p>\n                          )}\n                        </div>\n                      )}\n                    </Autocomplete.Empty>\n                    <div ref={setGlideList} className=\"group/list relative\">\n                      <motion.div aria-hidden style={{ y: glideY, height: glideH, opacity: glideO }} className={paletteClasses.glide} />\n                      <Autocomplete.List className=\"outline-none empty:hidden\">\n                        {(section: CommandSection) => (\n                          <Autocomplete.Group key={section.value} items={section.items}>\n                            <Autocomplete.GroupLabel className={cn(paletteClasses.groupLabel, \"first:pt-1.5\")}>{section.value}</Autocomplete.GroupLabel>\n                            <Autocomplete.Collection>\n                              {(entry: CommandEntry) => {\n                                const item = entry.item as CommandPagesItem;\n                                return (\n                                  <CommandRow\n                                    key={entry.key}\n                                    entry={entry}\n                                    busy={busy === entry.key}\n                                    failure={failure?.key === entry.key ? failure.message : null}\n                                    onRun={(fromKeyboard) => void run(entry, fromKeyboard)}\n                                    trailing={\n                                      item.to ? (\n                                        <ChevronRight size={14} className=\"text-fg-4 transition-[color,translate] duration-150 group-data-highlighted/item:translate-x-0.5 group-data-highlighted/item:text-fg-2\" />\n                                      ) : item.checked !== undefined ? (\n                                        <Checkmark on={item.checked} reduce={reduce} />\n                                      ) : undefined\n                                    }\n                                  />\n                                );\n                              }}\n                            </Autocomplete.Collection>\n                          </Autocomplete.Group>\n                        )}\n                      </Autocomplete.List>\n                    </div>\n                  </div>\n                </div>\n              </div>\n\n              <PaletteFooter\n                hints={\n                  atRoot\n                    ? [\n                        [[\"up\", \"down\"], \"Navigate\"],\n                        [\"enter\", \"Open\"],\n                      ]\n                    : [\n                        [\"enter\", \"Choose\"],\n                        [\"backspace\", \"Back\"],\n                      ]\n                }\n              >\n                {footer}\n              </PaletteFooter>\n              <Autocomplete.Status className=\"sr-only\">\n                {busy ? \"Working…\" : failure ? failure.message : query.trim() ? (count === 0 ? \"No results\" : `${count} ${count === 1 ? \"result\" : \"results\"}`) : atRoot ? \"\" : `${page.title}, ${count} options`}\n              </Autocomplete.Status>\n            </Autocomplete.Root>\n          </Dialog.Popup>\n        </Dialog.Viewport>\n      </Dialog.Portal>\n    </Dialog.Root>\n  );\n}\n\n// The current choice. The tick draws itself when it arrives on a row.\nfunction Checkmark({ on, reduce }: { on: boolean; reduce: boolean }) {\n  return (\n    <span className=\"grid size-4 place-items-center text-fg\">\n      {on && (\n        <svg width={14} height={14} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n          <motion.path\n            d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n            initial={reduce ? false : { pathLength: 0 }}\n            animate={{ pathLength: 1 }}\n            transition={{ duration: 0.24, ease: ease.out }}\n          />\n        </svg>\n      )}\n      {on && <span className=\"sr-only\">Selected</span>}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/command-pages"
}
