{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stack-panel",
  "title": "Stack Panel",
  "description": "A panel that drills into nested pages the way a phone's settings do. The row you pressed becomes the title of the page it opens.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/stack-panel/stack-panel.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { ChevronLeft, ChevronRight } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface StackPanelProps extends Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"title\"\n> {\n  /** The heading of the first page. */\n  title: React.ReactNode\n  /** Read before the previous page's title on the back button. */\n  backLabel?: string\n  children: React.ReactNode\n}\n\nexport interface StackPanelItemProps {\n  /** The row's text, and the title of the page it opens. */\n  label: React.ReactNode\n  /** A title for the opened page when it should differ from the label. */\n  title?: React.ReactNode\n  /** A value shown at the end of the row, such as On or English. */\n  detail?: React.ReactNode\n  icon?: React.ReactNode\n  /** A stable id for the page. Defaults to one React generates. */\n  value?: string\n  /** Runs for a row that opens no page. */\n  onSelect?: () => void\n  disabled?: boolean\n  className?: string\n  /** The page this row opens. A row without children opens nothing. */\n  children?: React.ReactNode\n}\n\ntype PanelContextValue = {\n  path: readonly string[]\n  push: (depth: number, id: string) => void\n  pop: () => void\n  exiting: { depth: number; id: string } | null\n  finishExit: () => void\n  /** True once, for the row whose page was just closed. */\n  claimFocus: (id: string) => boolean\n  layer: HTMLElement | null\n  animate: boolean\n  backLabel: string\n}\n\ntype PageContextValue = { depth: number; title: React.ReactNode }\n\nconst PanelContext = React.createContext<PanelContextValue | null>(null)\nconst PageContext = React.createContext<PageContextValue>({\n  depth: 0,\n  title: null,\n})\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\"\n\nfunction subscribeReduced(onChange: () => void) {\n  const query = window.matchMedia(reducedQuery)\n  query.addEventListener(\"change\", onChange)\n  return () => query.removeEventListener(\"change\", onChange)\n}\n\nfunction canAnimate() {\n  return (\n    typeof Element.prototype.animate === \"function\" &&\n    !window.matchMedia(reducedQuery).matches\n  )\n}\n\nconst EASE = \"cubic-bezier(0.2, 0.8, 0.2, 1)\"\nconst DURATION = 260\n\n/** The transform that puts the heading where the row's label is. */\nfunction flight(heading: HTMLElement | null, from: HTMLElement | null) {\n  if (!heading || !from) return null\n  const a = from.getBoundingClientRect()\n  const b = heading.getBoundingClientRect()\n  if (b.height === 0) return null\n  const scale = Math.min(Math.max(a.height / b.height, 0.6), 1.4)\n  return `translate(${a.left - b.left}px, ${a.top - b.top}px) scale(${scale})`\n}\n\nfunction isEditable(target: EventTarget | null) {\n  if (!(target instanceof HTMLElement)) return false\n  return (\n    target.isContentEditable ||\n    target instanceof HTMLInputElement ||\n    target instanceof HTMLTextAreaElement ||\n    target instanceof HTMLSelectElement\n  )\n}\n\n/**\n * A panel that drills into nested pages the way a phone's settings do. Each\n * row that has children opens them as the next page, under the row's own\n * label, and Back retraces the way.\n */\nexport function StackPanel({\n  title,\n  backLabel = \"Back to\",\n  className,\n  children,\n  onKeyDown,\n  ...rootProps\n}: StackPanelProps) {\n  const [path, setPath] = React.useState<readonly string[]>([])\n  const [exiting, setExiting] =\n    React.useState<PanelContextValue[\"exiting\"]>(null)\n  const [layer, setLayer] = React.useState<HTMLElement | null>(null)\n  const returnFocus = React.useRef<string | null>(null)\n  const animate = React.useSyncExternalStore(\n    subscribeReduced,\n    canAnimate,\n    () => false\n  )\n\n  const push = React.useCallback((depth: number, id: string) => {\n    setExiting(null)\n    setPath((current) => [...current.slice(0, depth), id])\n  }, [])\n\n  const pop = React.useCallback(() => {\n    const id = path[path.length - 1]\n    if (id === undefined) return\n    returnFocus.current = id\n    if (animate) setExiting({ depth: path.length - 1, id })\n    setPath(path.slice(0, -1))\n  }, [animate, path])\n\n  const finishExit = React.useCallback(() => setExiting(null), [])\n\n  const claimFocus = React.useCallback((id: string) => {\n    if (returnFocus.current !== id) return false\n    returnFocus.current = null\n    return true\n  }, [])\n\n  const value = React.useMemo<PanelContextValue>(\n    () => ({\n      path,\n      push,\n      pop,\n      exiting,\n      finishExit,\n      claimFocus,\n      layer,\n      animate,\n      backLabel,\n    }),\n    [\n      animate,\n      backLabel,\n      claimFocus,\n      exiting,\n      finishExit,\n      layer,\n      path,\n      pop,\n      push,\n    ]\n  )\n\n  return (\n    <PanelContext.Provider value={value}>\n      <div\n        data-slot=\"stack-panel\"\n        data-depth={path.length}\n        className={cn(\n          \"bg-background text-foreground border-border relative isolate grid grid-cols-1 grid-rows-[minmax(0,1fr)] overflow-hidden rounded-[var(--radius)] border\",\n          className\n        )}\n        onKeyDown={(event) => {\n          onKeyDown?.(event)\n          if (event.defaultPrevented || path.length === 0) return\n          if (\n            event.key === \"Escape\" ||\n            (event.key === \"Backspace\" && !isEditable(event.target))\n          ) {\n            event.preventDefault()\n            event.stopPropagation()\n            pop()\n          }\n        }}\n        {...rootProps}\n      >\n        <StackPanelPage depth={0} title={title} parentTitle={null}>\n          {children}\n        </StackPanelPage>\n        <div ref={setLayer} className=\"contents\" />\n      </div>\n    </PanelContext.Provider>\n  )\n}\n\nfunction StackPanelPage({\n  depth,\n  title,\n  parentTitle,\n  origin,\n  leaving = false,\n  children,\n}: {\n  depth: number\n  title: React.ReactNode\n  parentTitle: React.ReactNode\n  origin?: React.RefObject<HTMLElement | null>\n  leaving?: boolean\n  children: React.ReactNode\n}) {\n  const panel = React.useContext(PanelContext)\n  const id = React.useId()\n  const sectionRef = React.useRef<HTMLElement>(null)\n  const headingRef = React.useRef<HTMLHeadingElement>(null)\n  const bodyRef = React.useRef<HTMLDivElement>(null)\n  const entered = React.useRef(false)\n  const pathLength = panel?.path.length ?? 0\n  const state = leaving ? \"exiting\" : pathLength > depth ? \"covered\" : \"open\"\n  const animate = panel?.animate ?? false\n  const finishExit = panel?.finishExit\n\n  React.useLayoutEffect(() => {\n    if (depth === 0 || entered.current) return\n    entered.current = true\n    if (animate) {\n      const section = sectionRef.current\n      const from = flight(headingRef.current, origin?.current ?? null)\n      section?.animate([{ opacity: 0 }, { opacity: 1 }], {\n        duration: 180,\n        easing: \"ease-out\",\n      })\n      bodyRef.current?.animate(\n        [{ transform: \"translateX(1.5rem)\" }, { transform: \"none\" }],\n        { duration: DURATION, easing: EASE }\n      )\n      if (from) {\n        headingRef.current?.animate(\n          [{ transform: from }, { transform: \"none\" }],\n          { duration: DURATION, easing: EASE }\n        )\n      }\n    }\n    headingRef.current?.focus({ preventScroll: true })\n  }, [animate, depth, origin])\n\n  React.useLayoutEffect(() => {\n    if (!leaving) return\n    const section = sectionRef.current\n    if (!section) return\n    const to = flight(headingRef.current, origin?.current ?? null)\n    const fade = section.animate([{ opacity: 1 }, { opacity: 0 }], {\n      duration: 220,\n      easing: \"ease-in\",\n      fill: \"forwards\",\n    })\n    bodyRef.current?.animate(\n      [{ transform: \"none\" }, { transform: \"translateX(1.5rem)\" }],\n      { duration: 220, easing: \"ease-in\", fill: \"forwards\" }\n    )\n    if (to) {\n      headingRef.current?.animate([{ transform: \"none\" }, { transform: to }], {\n        duration: 220,\n        easing: EASE,\n        fill: \"forwards\",\n      })\n    }\n    let done = false\n    void fade.finished\n      .catch(() => undefined)\n      .then(() => {\n        if (!done) finishExit?.()\n      })\n    return () => {\n      done = true\n    }\n  }, [finishExit, leaving, origin])\n\n  return (\n    <section\n      ref={sectionRef}\n      aria-labelledby={`${id}-title`}\n      data-slot=\"stack-panel-page\"\n      data-state={state}\n      data-depth={depth}\n      inert={state !== \"open\"}\n      className=\"bg-background flex min-h-0 flex-col [grid-area:1/1]\"\n      style={{ zIndex: depth }}\n    >\n      <header\n        data-slot=\"stack-panel-header\"\n        className=\"border-border flex min-h-12 items-center gap-1 border-b px-2\"\n      >\n        {depth > 0 ? (\n          <button\n            type=\"button\"\n            data-slot=\"stack-panel-back\"\n            className=\"text-primary hover:bg-muted focus-visible:ring-ring inline-flex min-h-9 max-w-[45%] shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1 text-sm transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none\"\n            onClick={() => panel?.pop()}\n          >\n            <ChevronLeft aria-hidden=\"true\" className=\"size-5 shrink-0\" />\n            <span className=\"sr-only\">{panel?.backLabel}</span>{\" \"}\n            <span className=\"truncate\">{parentTitle}</span>\n          </button>\n        ) : null}\n        <h2\n          ref={headingRef}\n          id={`${id}-title`}\n          tabIndex={-1}\n          data-slot=\"stack-panel-title\"\n          className={cn(\n            \"min-w-0 flex-1 origin-top-left truncate text-base font-semibold outline-none\",\n            depth === 0 && \"px-2\"\n          )}\n        >\n          {title}\n        </h2>\n      </header>\n      <div\n        ref={bodyRef}\n        data-slot=\"stack-panel-body\"\n        className=\"min-h-0 flex-1 overflow-y-auto\"\n      >\n        <PageContext.Provider value={{ depth, title }}>\n          {children}\n        </PageContext.Provider>\n      </div>\n    </section>\n  )\n}\n\nexport function StackPanelItem({\n  label,\n  title,\n  detail,\n  icon,\n  value,\n  onSelect,\n  disabled,\n  className,\n  children,\n}: StackPanelItemProps) {\n  const panel = React.useContext(PanelContext)\n  const page = React.useContext(PageContext)\n  const generated = React.useId()\n  const id = value ?? generated\n  const rowRef = React.useRef<HTMLButtonElement>(null)\n  const labelRef = React.useRef<HTMLSpanElement>(null)\n  const opens =\n    children !== undefined && children !== null && children !== false\n  const open = opens && panel?.path[page.depth] === id\n  const leaving =\n    opens &&\n    panel?.exiting?.id === id &&\n    panel.exiting.depth === page.depth &&\n    !open\n\n  React.useEffect(() => {\n    if (!open && panel?.claimFocus(id)) {\n      rowRef.current?.focus({ preventScroll: true })\n    }\n  }, [id, open, panel])\n\n  const content = (\n    <>\n      {icon ? (\n        <span\n          aria-hidden=\"true\"\n          className=\"text-muted-foreground flex size-5 shrink-0 items-center justify-center [&_svg]:size-5\"\n        >\n          {icon}\n        </span>\n      ) : null}\n      <span\n        ref={labelRef}\n        className=\"min-w-0 flex-1 origin-top-left truncate font-medium\"\n      >\n        {label}\n      </span>\n      {detail !== undefined ? (\n        <span className=\"text-muted-foreground max-w-[45%] shrink-0 truncate\">\n          {detail}\n        </span>\n      ) : null}\n      {opens ? (\n        <ChevronRight\n          aria-hidden=\"true\"\n          className=\"text-muted-foreground size-4 shrink-0\"\n        />\n      ) : null}\n    </>\n  )\n\n  const rowClass = cn(\n    \"border-border flex min-h-11 w-full items-center gap-3 border-b px-4 py-2.5 text-left text-sm\",\n    className\n  )\n\n  if (!opens && !onSelect) {\n    return (\n      <div data-slot=\"stack-panel-item\" className={rowClass}>\n        {content}\n      </div>\n    )\n  }\n\n  return (\n    <>\n      <button\n        ref={rowRef}\n        type=\"button\"\n        disabled={disabled}\n        data-slot=\"stack-panel-item\"\n        data-state={open ? \"open\" : undefined}\n        className={cn(\n          rowClass,\n          \"hover:bg-muted/60 focus-visible:bg-muted focus-visible:ring-ring transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none\"\n        )}\n        onClick={() => {\n          if (opens) panel?.push(page.depth, id)\n          else onSelect?.()\n        }}\n      >\n        {content}\n      </button>\n      {(open || leaving) && panel?.layer\n        ? createPortal(\n            <StackPanelPage\n              depth={page.depth + 1}\n              title={title ?? label}\n              parentTitle={page.title}\n              origin={labelRef}\n              leaving={leaving}\n            >\n              {children}\n            </StackPanelPage>,\n            panel.layer\n          )\n        : null}\n    </>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation",
    "layout",
    "settings",
    "pages"
  ],
  "type": "registry:ui"
}