{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "panel-stack",
  "title": "Panel Stack",
  "description": "A story told one panel at a time with a page count and a progress line: in the page, with the next panel peeking, or sliding over the whole screen.",
  "dependencies": [
    "lucide-react",
    "motion",
    "radix-ui"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/panel-stack.tsx",
      "content": "import type * as React from \"react\"\r\nimport { useState } from \"react\"\r\nimport { ArrowLeft } from \"lucide-react\"\r\nimport { Dialog as DialogPrimitive } from \"radix-ui\"\r\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\r\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\r\nimport { PanelDeck, type PanelStackEffect, type PanelStackPanel, type PanelStackVariant } from \"@/registry/miseui/ui/panel-stack-deck\"\r\nimport { nearestPanel, wheelStep } from \"@/registry/miseui/ui/panel-stack-state\"\r\n\r\n/** A whole screen moving needs longer than a popover, or it reads as a jump. */\r\nconst SLIDE_TRANSITION = { ...SPRING_DEFAULT, duration: 0.5 }\r\nconst INSTANT = { duration: 0 } as const\r\n\r\nconst BACK_CLASS =\r\n  \"flex h-8 shrink-0 items-center gap-1 rounded-md border px-2 text-[13px] outline-none transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 pointer-coarse:h-10\"\r\n\r\ntype PanelStackProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\r\n  panels: readonly PanelStackPanel[]\r\n  /** `deck` one panel at a time in the page, `peek` the next panel shows at the edge, `overlay` the whole screen slides over the page. */\r\n  variant?: PanelStackVariant\r\n  /** `wheel` makes one wheel gesture turn one panel. It lets go at the first and last, so the page can move on. */\r\n  effect?: PanelStackEffect\r\n  /** Names the group of panels, and the dialog in `overlay`. */\r\n  label?: string\r\n  previousLabel?: string\r\n  nextLabel?: string\r\n  /** `overlay` only: the element that opens it. */\r\n  trigger?: React.ReactNode\r\n  /** `overlay` only. */\r\n  open?: boolean\r\n  defaultOpen?: boolean\r\n  onOpenChange?: (open: boolean) => void\r\n  /** `overlay` only: the way back. */\r\n  closeLabel?: string\r\n}\r\n\r\ninterface OverlayProps extends PanelStackProps {\r\n  words: { label: string; previousLabel: string; nextLabel: string }\r\n}\r\n\r\nfunction PanelOverlay({ panels, effect, words, trigger, open, defaultOpen = false, onOpenChange, closeLabel = \"Back\", className, ...rootProps }: OverlayProps) {\r\n  const reduceMotion = useReducedMotion() ?? false\r\n  const [uncontrolled, setUncontrolled] = useState(defaultOpen)\r\n  const isOpen = open ?? uncontrolled\r\n  const setOpen = (next: boolean) => {\r\n    setUncontrolled(next)\r\n    onOpenChange?.(next)\r\n  }\r\n  const hidden = reduceMotion ? { opacity: 0 } : { x: \"100%\" }\r\n  const shown = reduceMotion ? { opacity: 1 } : { x: \"0%\" }\r\n  return (\r\n    <DialogPrimitive.Root open={isOpen} onOpenChange={setOpen}>\r\n      {trigger ? <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger> : null}\r\n      {/* AnimatePresence holds the panel for its exit, so forceMount takes that job away from Radix. */}\r\n      <AnimatePresence>\r\n        {isOpen ? (\r\n          <DialogPrimitive.Portal forceMount>\r\n            {/* Invisible, and still needed: it carries the scroll lock for the page behind. */}\r\n            <DialogPrimitive.Overlay forceMount className=\"fixed inset-0 z-50\" />\r\n            <DialogPrimitive.Content forceMount asChild aria-describedby={undefined}>\r\n              <motion.div className=\"fixed inset-0 z-50 bg-background text-foreground outline-none\" initial={hidden} animate={shown} exit={hidden} transition={reduceMotion ? INSTANT : SLIDE_TRANSITION}>\r\n                <DialogPrimitive.Title className=\"sr-only\">{words.label}</DialogPrimitive.Title>\r\n                <PanelDeck\r\n                  {...words}\r\n                  panels={panels}\r\n                  variant=\"overlay\"\r\n                  effect={effect}\r\n                  className={className}\r\n                  rootProps={rootProps}\r\n                  leading={\r\n                    <DialogPrimitive.Close className={BACK_CLASS}>\r\n                      {/* The screen came in from the right, so the way out points back the other way. */}\r\n                      <ArrowLeft aria-hidden=\"true\" className=\"size-4 rtl:rotate-180\" />\r\n                      {closeLabel}\r\n                    </DialogPrimitive.Close>\r\n                  }\r\n                />\r\n              </motion.div>\r\n            </DialogPrimitive.Content>\r\n          </DialogPrimitive.Portal>\r\n        ) : null}\r\n      </AnimatePresence>\r\n    </DialogPrimitive.Root>\r\n  )\r\n}\r\n\r\n/** Meaningless on a stack in the page, and a DOM element would complain about them. */\r\nconst OVERLAY_ONLY: ReadonlySet<string> = new Set([\"trigger\", \"open\", \"defaultOpen\", \"onOpenChange\", \"closeLabel\"])\r\n\r\nfunction PanelStack({ variant = \"deck\", label = \"Panels\", previousLabel = \"Previous panel\", nextLabel = \"Next panel\", ...props }: PanelStackProps) {\r\n  const words = { label, previousLabel, nextLabel }\r\n  if (variant === \"overlay\") return <PanelOverlay {...props} words={words} />\r\n  const { panels, effect, className, ...rest } = props\r\n  const rootProps = Object.fromEntries(Object.entries(rest).filter(([key]) => !OVERLAY_ONLY.has(key)))\r\n  return <PanelDeck {...words} panels={panels} variant={variant} effect={effect} className={className} rootProps={rootProps} />\r\n}\r\n\r\nexport { PanelStack, nearestPanel, wheelStep, type PanelStackProps, type PanelStackPanel, type PanelStackVariant, type PanelStackEffect }\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/panel-stack-deck.tsx",
      "content": "import type * as React from \"react\"\r\nimport { ChevronLeft, ChevronRight } from \"lucide-react\"\r\nimport { motion, useReducedMotion } from \"motion/react\"\r\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\r\nimport { isEditable, usePanelTrack } from \"@/registry/miseui/ui/panel-stack-state\"\r\nimport { cn } from \"@/registry/miseui/lib/utils\"\r\n\r\ntype PanelStackVariant = \"deck\" | \"peek\" | \"overlay\"\r\ntype PanelStackEffect = \"wheel\"\r\n\r\ninterface PanelStackPanel {\r\n  /** Needed only when two panels share a title. */\r\n  id?: string\r\n  title: string\r\n  /** A few words above the title. */\r\n  kicker?: React.ReactNode\r\n  content: React.ReactNode\r\n}\r\n\r\nconst ROOT_CLASS: Record<PanelStackVariant, string> = {\r\n  deck: \"grid min-w-0 grid-cols-[minmax(0,1fr)] gap-3\",\r\n  peek: \"grid min-w-0 grid-cols-[minmax(0,1fr)] gap-3\",\r\n  overlay: \"grid h-dvh min-w-0 grid-cols-[minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)]\",\r\n}\r\n\r\nconst HEADER_CLASS: Record<PanelStackVariant, string> = {\r\n  deck: \"flex min-w-0 items-center gap-2\",\r\n  peek: \"flex min-w-0 items-center gap-2\",\r\n  overlay: \"flex min-w-0 items-center gap-2 px-4 pt-4 sm:px-8 sm:pt-6\",\r\n}\r\n\r\n/** Not rounded: a rounded clip on a sideways scroller cuts the hairlines of whatever runs off its edge. */\r\nconst TRACK_CLASS: Record<PanelStackVariant, string> = {\r\n  deck: \"\",\r\n  peek: \"gap-2\",\r\n  overlay: \"min-h-0\",\r\n}\r\n\r\nconst PANEL_CLASS: Record<PanelStackVariant, string> = {\r\n  deck: \"w-full border-y py-6\",\r\n  peek: \"w-[85%] rounded-lg border bg-card p-4 text-card-foreground\",\r\n  overlay: \"flex w-full overflow-y-auto px-4 py-8 sm:px-8\",\r\n}\r\n\r\nconst TITLE_CLASS: Record<PanelStackVariant, string> = {\r\n  deck: \"text-xl leading-7 font-semibold tracking-tight text-pretty\",\r\n  peek: \"text-[15px] leading-6 font-medium text-pretty\",\r\n  overlay: \"text-title text-[clamp(32px,7vw,64px)] text-pretty\",\r\n}\r\n\r\nconst BODY_CLASS: Record<PanelStackVariant, string> = {\r\n  deck: \"mt-2 text-[15px] leading-7\",\r\n  peek: \"mt-1 text-[13px] leading-5 text-muted-foreground\",\r\n  overlay: \"mt-6 max-w-[60ch] text-[15px] leading-7 sm:text-lg sm:leading-8\",\r\n}\r\n\r\nconst STEP_CLASS =\r\n  \"flex size-8 shrink-0 items-center justify-center rounded-md border text-foreground outline-none transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 pointer-coarse:size-10\"\r\n\r\nconst INSTANT = { duration: 0 } as const\r\n\r\ninterface PanelDeckProps {\r\n  panels: readonly PanelStackPanel[]\r\n  variant: PanelStackVariant\r\n  effect?: PanelStackEffect\r\n  label: string\r\n  previousLabel: string\r\n  nextLabel: string\r\n  /** Room in the header for a way out, used by the overlay. */\r\n  leading?: React.ReactNode\r\n  className?: string\r\n  rootProps?: React.ComponentProps<\"div\">\r\n}\r\n\r\nfunction PanelProgress({ index, count }: { index: number; count: number }) {\r\n  const reduceMotion = useReducedMotion() ?? false\r\n  return (\r\n    <span aria-hidden=\"true\" className=\"relative block h-0.5 min-w-4 flex-1 overflow-hidden rounded-full bg-border\">\r\n      <motion.span\r\n        data-slot=\"panel-stack-bar\"\r\n        className=\"absolute inset-0 origin-left bg-mise rtl:origin-right\"\r\n        initial={false}\r\n        animate={{ scaleX: (index + 1) / count }}\r\n        transition={reduceMotion ? INSTANT : SPRING_DEFAULT}\r\n      />\r\n    </span>\r\n  )\r\n}\r\n\r\nfunction PanelContent({ panel, variant }: { panel: PanelStackPanel; variant: PanelStackVariant }) {\r\n  return (\r\n    // Auto margins and not items-center: centring a panel taller than the screen would cut off its top for good.\r\n    <div className={cn(\"min-w-0\", variant === \"overlay\" && \"m-auto w-full max-w-3xl\")}>\r\n      {panel.kicker ? <p className=\"text-[13px] leading-5 text-muted-foreground\">{panel.kicker}</p> : null}\r\n      <h3 className={TITLE_CLASS[variant]}>{panel.title}</h3>\r\n      <div className={BODY_CLASS[variant]}>{panel.content}</div>\r\n    </div>\r\n  )\r\n}\r\n\r\nfunction PanelDeck({ panels, variant, effect, label, previousLabel, nextLabel, leading, className, rootProps }: PanelDeckProps) {\r\n  const reduceMotion = useReducedMotion() ?? false\r\n  const { setTrack, index, goTo } = usePanelTrack(panels.length, reduceMotion, effect === \"wheel\")\r\n  const current = panels[index]\r\n  const pad = (value: number) => String(value).padStart(2, \"0\")\r\n  const describe = (position: number, title: string) => `${position + 1} of ${panels.length}: ${title}`\r\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\r\n    rootProps?.onKeyDown?.(event)\r\n    if (isEditable(event.target) || (event.key !== \"ArrowRight\" && event.key !== \"ArrowLeft\")) return\r\n    const forward = (event.key === \"ArrowRight\") === (getComputedStyle(event.currentTarget).direction !== \"rtl\")\r\n    const next = index + (forward ? 1 : -1)\r\n    if (next < 0 || next >= panels.length) return\r\n    event.preventDefault()\r\n    goTo(next)\r\n  }\r\n  return (\r\n    <div {...rootProps} data-slot=\"panel-stack\" data-variant={variant} data-effect={effect} className={cn(ROOT_CLASS[variant], className)} onKeyDown={onKeyDown}>\r\n      <div data-slot=\"panel-stack-header\" className={HEADER_CLASS[variant]}>\r\n        {leading}\r\n        <p data-slot=\"panel-stack-count\" aria-hidden=\"true\" className=\"shrink-0 font-mono text-[13px] leading-5 whitespace-nowrap tabular-nums\">\r\n          {pad(index + 1)} <span className=\"text-muted-foreground\">/ {pad(panels.length)}</span>\r\n        </p>\r\n        <PanelProgress index={index} count={panels.length} />\r\n        <button type=\"button\" aria-label={previousLabel} disabled={index <= 0} className={STEP_CLASS} onClick={() => goTo(index - 1)}>\r\n          <ChevronLeft aria-hidden=\"true\" className=\"size-4 rtl:rotate-180\" />\r\n        </button>\r\n        <button type=\"button\" aria-label={nextLabel} disabled={index >= panels.length - 1} className={STEP_CLASS} onClick={() => goTo(index + 1)}>\r\n          <ChevronRight aria-hidden=\"true\" className=\"size-4 rtl:rotate-180\" />\r\n        </button>\r\n      </div>\r\n      <p aria-live=\"polite\" className=\"sr-only\">\r\n        {current ? describe(index, current.title) : null}\r\n      </p>\r\n      <div\r\n        ref={setTrack}\r\n        role=\"group\"\r\n        aria-label={label}\r\n        tabIndex={0}\r\n        data-slot=\"panel-stack-track\"\r\n        className={cn(\r\n          \"relative flex min-w-0 snap-x snap-mandatory overflow-x-auto overflow-y-hidden overscroll-x-contain outline-none [scrollbar-width:none] focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset [&::-webkit-scrollbar]:hidden\",\r\n          TRACK_CLASS[variant],\r\n        )}\r\n      >\r\n        {panels.map((panel, position) => (\r\n          <section key={panel.id ?? panel.title} aria-label={describe(position, panel.title)} data-slot=\"panel-stack-panel\" className={cn(\"min-w-0 shrink-0 snap-start\", PANEL_CLASS[variant])}>\r\n            <PanelContent panel={panel} variant={variant} />\r\n          </section>\r\n        ))}\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n\r\nexport { PanelDeck, type PanelDeckProps, type PanelStackEffect, type PanelStackPanel, type PanelStackVariant }\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/panel-stack-state.ts",
      "content": "import { useCallback, useEffect, useRef, useState } from \"react\"\n\nconst PANEL_SELECTOR = \"[data-slot='panel-stack-panel']\"\n/** Trackpads send a stream of tiny deltas after a swipe; this ignores the tail of it. */\nconst MIN_WHEEL_DELTA = 8\n/** One gesture, one panel. Longer than a smooth scroll, or momentum from the same swipe would turn a second page. */\nconst WHEEL_LOCK_MS = 700\n/** Releases the count if something interrupts a smooth scroll before it reaches its target. */\nconst TARGET_RELEASE_MS = 800\n\ninterface WheelInput {\n  deltaX: number\n  deltaY: number\n  index: number\n  count: number\n  /** The panel under the pointer has its own vertical scroll, which goes first. */\n  innerScroll: boolean\n}\n\n/**\n * What a wheel event should do: null leaves it alone, 0 swallows it, 1 or -1 turns a page. At the first and last panel\n * it is left alone, or the page around the stack could never be scrolled past it.\n */\nfunction wheelStep({ deltaX, deltaY, index, count, innerScroll }: WheelInput) {\n  if (innerScroll || Math.abs(deltaX) >= Math.abs(deltaY)) return null\n  const direction = Math.sign(deltaY)\n  const next = index + direction\n  if (next < 0 || next >= count) return null\n  return Math.abs(deltaY) < MIN_WHEEL_DELTA ? 0 : direction\n}\n\nfunction nearestPanel(scrollLeft: number, offsets: readonly number[]) {\n  return offsets.reduce((best, offset, index) => (Math.abs(offset - scrollLeft) < Math.abs(offsets[best] - scrollLeft) ? index : best), 0)\n}\n\nfunction isEditable(target: EventTarget | null) {\n  if (!(target instanceof HTMLElement)) return false\n  return target.isContentEditable || [\"INPUT\", \"TEXTAREA\", \"SELECT\"].includes(target.tagName)\n}\n\nfunction panelsIn(track: HTMLElement) {\n  return Array.from(track.querySelectorAll<HTMLElement>(PANEL_SELECTOR))\n}\n\n/** The track arrives through a callback ref held in state: inside a Radix portal it does not exist yet during the first layout effect. */\nfunction usePanelTrack(count: number, reduceMotion: boolean, wheel: boolean) {\n  const [track, setTrack] = useState<HTMLDivElement | null>(null)\n  const [index, setIndex] = useState(0)\n  const targetRef = useRef<number | null>(null)\n  const timerRef = useRef<number | undefined>(undefined)\n  const wheelLockRef = useRef(0)\n\n  const goTo = useCallback(\n    (next: number) => {\n      const panel = track ? panelsIn(track)[next] : undefined\n      if (!track || !panel) return\n      // Pressing next twice quickly should land two panels on, so the count moves now and the scroll catches up.\n      targetRef.current = next\n      window.clearTimeout(timerRef.current)\n      timerRef.current = window.setTimeout(() => {\n        targetRef.current = null\n      }, TARGET_RELEASE_MS)\n      setIndex(next)\n      track.scrollTo?.({ left: panel.offsetLeft, behavior: reduceMotion ? \"auto\" : \"smooth\" })\n    },\n    [track, reduceMotion],\n  )\n\n  useEffect(() => {\n    if (!track) return\n    const onScroll = () => {\n      const next = nearestPanel(track.scrollLeft, panelsIn(track).map((panel) => panel.offsetLeft))\n      if (targetRef.current !== null && targetRef.current !== next) return\n      targetRef.current = null\n      setIndex(next)\n    }\n    track.addEventListener(\"scroll\", onScroll, { passive: true })\n    return () => track.removeEventListener(\"scroll\", onScroll)\n  }, [track])\n\n  useEffect(() => {\n    if (!track || !wheel) return\n    const onWheel = (event: WheelEvent) => {\n      const panel = (event.target as Element).closest<HTMLElement>(PANEL_SELECTOR)\n      const innerScroll = panel !== null && panel.scrollHeight > panel.clientHeight + 1\n      const step = wheelStep({ deltaX: event.deltaX, deltaY: event.deltaY, index, count, innerScroll })\n      if (step === null) return\n      event.preventDefault()\n      if (step === 0 || performance.now() < wheelLockRef.current) return\n      wheelLockRef.current = performance.now() + WHEEL_LOCK_MS\n      goTo(index + step)\n    }\n    // Passive is the default for wheel on some elements, and a passive listener cannot stop the page from scrolling.\n    track.addEventListener(\"wheel\", onWheel, { passive: false })\n    return () => track.removeEventListener(\"wheel\", onWheel)\n  }, [track, wheel, index, count, goTo])\n\n  useEffect(() => () => window.clearTimeout(timerRef.current), [])\n\n  return { setTrack, index, goTo }\n}\n\nexport { isEditable, nearestPanel, usePanelTrack, wheelStep }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "display"
  ],
  "type": "registry:ui"
}