{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "popover",
  "type": "registry:ui",
  "title": "Popover",
  "description": "A floating glass panel for a small form, a share link or a detail card. It grows from its trigger's corner, slides in from the side it really opens on, and flips near an edge.",
  "dependencies": [
    "@radix-ui/react-dialog",
    "@radix-ui/react-popover",
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/dialog.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/popover.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/popover 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Popover: a floating panel for content that needs more than a row. A small\n * form, a share link, a filter set, a detail card. Rows that run a command\n * belong to Menu.\n *\n * - Radix Popover is the accessibility layer (role=\"dialog\", focus into the\n *   panel and back to the trigger, Esc, outside click, aria-expanded on the\n *   trigger). Everything visual is written here.\n * - Surface: floating glass (litt-glass, default float shadow), rounded-litt-surface,\n *   p-1 (4px) like Menu. The content sits in a 12px pad inside that inset, so a\n *   form field starts 16px from the edge, like a Menu row's label. Turn the pad\n *   off (padded={false}) for rows that bring their own 44px height and 12px side\n *   padding: they then sit concentric in the 4px inset, exactly as Menu rows do.\n * - Glass never sits on glass. When the trigger sits inside a glass surface (a\n *   dialog, the header, a toast) or inside another popover, the panel is solid\n *   (bg-litt-surface-2 + shadow-litt-float), as Menu's submenus are. Found from the DOM\n *   when the panel opens, so nothing has to be wired by hand. `solid` forces it.\n * - Motion, responsive tier. The panel grows from the trigger: Radix gives\n *   --radix-popover-content-transform-origin, and it travels a quarter of the\n *   travel token from the side it actually opens on (after collision flipping).\n *   We wait for Radix to place it, then read data-side. Exit uses useExit.\n *   AnimatePresence + forceMount + controlled open lets the exit play.\n * - Focus: Radix moves it into the panel on open. It lands on the control marked\n *   data-autofocus, else the first focusable control, else the panel itself.\n *   Closing returns it to the trigger. Opened with the pointer, a button gets no\n *   ring (:focus-visible), a text field always does.\n * - Sizes sit on the 8px grid: 256, 320, 384. The panel never grows past the room\n *   on screen, and scrolls inside when it is taller than the room.\n *\n * On a phone (foundations.md section 6; useDevice().compact, so a desktop can preview it with\n * <DeviceProvider mode=\"phone\">) the popover is a bottom sheet, because it holds fields and\n * pickers that need the width and a place above the keyboard:\n * - It is DialogSheet from dialog.tsx, the same sheet the Dialog uses, so a phone has one: it\n *   rises by its own height, follows a finger down to close, and leaves on the exit token. It is\n *   a modal there (focus trapped and returned to the trigger, Esc, a tap on the scrim, a 44px\n *   close button on touch), whatever `modal` says.\n * - The same children render in both. Only the shell changes, so a caller keeps one copy of the\n *   content. `label` is the sheet's heading (the Radix Dialog title), so it is what names the sheet.\n *   `side`, `align`, `sideOffset`, `size` and `collisionBoundary` describe an anchored panel and\n *   are ignored. `padded` and `solid` still apply.\n * - Radix Popover cannot be a sheet, so the root holds a Radix Dialog root beside the Popover one,\n *   both driven by the same open state, and PopoverTrigger is the Dialog's trigger on a phone: that\n *   is what returns focus to it and points its aria-controls at the sheet. PopoverClose works in both.\n * - Glass never sits on glass: a sheet opened from a trigger inside a dialog, another sheet or the\n *   header is solid, found from the trigger when it opens.\n */\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ComponentProps,\n  type ReactNode,\n} from \"react\";\nimport * as RadixDialog from \"@radix-ui/react-dialog\";\nimport * as RadixPopover from \"@radix-ui/react-popover\";\nimport { AnimatePresence, animate, motion, useMotionValue } from \"motion/react\";\nimport { useDevice, useExit, useTransition, useTravel } from \"../../lib/litt/motion\";\nimport { useOverlayMotion } from \"../../lib/litt/motion/deal\";\nimport { Button } from \"./button\";\nimport { DialogSheet } from \"./dialog\";\nimport { XIcon } from \"./icons\";\n\n/* Radix's asChild, spread in: shadcn's CLI rewrites a written asChild to Base UI's render prop in base-* hosts. */\nconst asChildProps = { asChild: true } as const;\n\ntype Side = \"top\" | \"right\" | \"bottom\" | \"left\";\n\n/* The open state, and the way to change it (a drag, the close button and Esc on a phone ask through this). */\nconst PopoverState = createContext<{ open: boolean; change: (open: boolean) => void }>({ open: false, change: () => {} });\n\nexport type PopoverProps = {\n  /** Whether the popover is open. Pair it with onOpenChange. Leave it out and the popover keeps its own state. */\n  open?: boolean;\n  /** Start open when you do not control it. */\n  defaultOpen?: boolean;\n  /** Called with the next state when Esc, a click outside, PopoverClose or PopoverTrigger asks to change it. Ignore a false to keep the popover open, for example while a save runs. */\n  onOpenChange?: (open: boolean) => void;\n  /** Make the rest of the page inert while it is open: focus is trapped, outside clicks only close it, and screen readers skip the page. */\n  modal?: boolean;\n  /** The trigger and the content: a PopoverTrigger and a PopoverContent. */\n  children: ReactNode;\n};\n\n/** The root. It holds the open state and shares it with PopoverTrigger and PopoverContent, which go inside it. */\nexport function Popover({ open: openProp, defaultOpen = false, onOpenChange, modal = false, children }: PopoverProps) {\n  const [inner, setInner] = useState(defaultOpen);\n  const open = openProp ?? inner;\n  const change = useCallback(\n    (next: boolean) => {\n      setInner(next);\n      onOpenChange?.(next);\n    },\n    [onOpenChange],\n  );\n  /* Both Radix roots are only context, so they cost nothing. The Popover one serves the anchored panel and\n     PopoverClose; the Dialog one serves the sheet a phone gets. Both follow the same open state. */\n  return (\n    <PopoverState value={{ open, change }}>\n      <RadixPopover.Root open={open} onOpenChange={change} modal={modal}>\n        <RadixDialog.Root open={open} onOpenChange={change}>\n          {children}\n        </RadixDialog.Root>\n      </RadixPopover.Root>\n    </PopoverState>\n  );\n}\n\n/** Opens and closes the popover. Wrap your own Button with asChild, so it keeps its look and gets aria-expanded and the toggle. */\nexport function PopoverTrigger(props: ComponentProps<typeof RadixPopover.Trigger>) {\n  /* On a phone the panel is a Radix Dialog, so its trigger is the Dialog's: that is what gives the sheet its\n     aria-controls and brings focus back to this button when the sheet closes. */\n  const { compact } = useDevice();\n  const Trigger = (compact ? RadixDialog.Trigger : RadixPopover.Trigger) as typeof RadixPopover.Trigger;\n  return <Trigger {...props} />;\n}\n/** Closes the popover. Wrap a Cancel or Done button with asChild. */\nexport const PopoverClose = RadixPopover.Close;\n\nexport type PopoverContentProps = {\n  /** The popover's accessible name. It is a dialog, and a dialog needs a name, so this is required. On a phone it is also the sheet's heading, so write it as a short title and do not repeat it at the top of your content. */\n  label: string;\n  /** The side of the trigger it opens on first. With no room there it flips to the opposite side, and the entrance follows. On a phone it is a sheet and this is ignored. */\n  side?: Side;\n  /** Which edge of the trigger it lines up with. On a phone this is ignored. */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Gap between the trigger and the popover, in px. On a phone this is ignored. */\n  sideOffset?: number;\n  /** Width on the 8px grid: sm is 256px, md is 320px, lg is 384px. It never grows past the room on screen. On a phone the sheet spans the screen and this is ignored. */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Pad the content 12px inside the 4px inset, so it sits 16px from the edge. Turn it off for rows that bring their own 44px height and 12px side padding. On a phone the same two choices line up with the sheet's heading. */\n  padded?: boolean;\n  /** Draw a solid surface instead of glass. Left out, it is solid only when the trigger sits inside a glass surface or another popover. */\n  solid?: boolean;\n  /** Flip against this element instead of the viewport (a stage, a card). On a phone this is ignored. */\n  collisionBoundary?: Element | null;\n  /** The content: a form, a link and a button, a detail card, a set of options. The same content shows in the anchored panel and in the phone sheet. */\n  children: ReactNode;\n};\n\nconst WIDTH = { sm: \"w-64\", md: \"w-80\", lg: \"w-96\" } as const;\n/** What a popover must not be glass over: any glass surface, and any popover. */\nconst FLOATING = \".litt-glass, [data-popover-surface]\";\n\n/** Flat: the glass becomes a solid surface-1 card with the float shadow and no hairline. */\nconst FLAT_PANEL =\n  \"in-[[data-material=flat]]:[--litt-glass-bg:var(--litt-surface-1)] in-[[data-material=flat]]:[--litt-glass-border:transparent] \" +\n  \"in-[[data-material=flat]]:[--litt-highlight:0_0_0_0_transparent] in-[[data-material=flat]]:[--litt-glass-blur:0px]\";\n\n\n/**\n * The panel. Render it inside a Popover, next to the PopoverTrigger.\n * Focus lands on the control marked data-autofocus, else the first focusable control, else the panel itself.\n * On a phone (narrower than 640px) it is a bottom sheet with `label` as its heading, and the same children.\n */\nexport function PopoverContent({\n  label,\n  side = \"bottom\",\n  align = \"start\",\n  sideOffset = 8,\n  size = \"md\",\n  padded = true,\n  solid,\n  collisionBoundary,\n  children,\n}: PopoverContentProps) {\n  const { open } = useContext(PopoverState);\n  const { compact } = useDevice();\n  if (compact) {\n    return (\n      <Sheet open={open} label={label} padded={padded} solid={solid}>\n        {children}\n      </Sheet>\n    );\n  }\n  return (\n    <RadixPopover.Portal forceMount>\n      <AnimatePresence>\n        {open && (\n          <Surface\n            key=\"popover\"\n            label={label}\n            side={side}\n            align={align}\n            sideOffset={sideOffset}\n            size={size}\n            padded={padded}\n            solid={solid}\n            collisionBoundary={collisionBoundary}\n          >\n            {children}\n          </Surface>\n        )}\n      </AnimatePresence>\n    </RadixPopover.Portal>\n  );\n}\n\n/* ------------------------------------------------------------ the phone sheet */\n\n/**\n * The popover on a phone: DialogSheet with a heading, the body and a close button. Same grid areas as the Dialog,\n * so the heading and the close button sit exactly where a Dialog's do.\n */\nfunction Sheet({\n  open,\n  label,\n  padded,\n  solid,\n  children,\n}: Pick<PopoverContentProps, \"label\" | \"padded\" | \"solid\" | \"children\"> & { open: boolean }) {\n  const { change } = useContext(PopoverState);\n  const panel = useRef<HTMLDivElement | null>(null);\n  const [overFloating, setOverFloating] = useState(false);\n  const flat = solid ?? overFloating;\n  return (\n    <DialogSheet\n      open={open}\n      onDismiss={() => change(false)}\n      onOpenAutoFocus={(e) => {\n        /* Fine pointers: the marked control, else Radix's first focusable. A coarse pointer is handled by the sheet. */\n        const target = panel.current?.querySelector<HTMLElement>(\"[data-autofocus]\");\n        if (!target) return;\n        e.preventDefault();\n        target.focus({ preventScroll: true });\n      }}\n      described={false}\n      contentRef={panel}\n      className={(flat ? \"[--litt-glass-bg:var(--litt-surface-2)] [--litt-glass-shadow:var(--litt-shadow-float)] \" : \"\") + FLAT_PANEL}\n    >\n      <SheetParts label={label} padded={padded ?? true} onFloating={setOverFloating}>\n        {children}\n      </SheetParts>\n    </DialogSheet>\n  );\n}\n\nfunction SheetParts({\n  label,\n  padded,\n  onFloating,\n  children,\n}: {\n  label: string;\n  padded: boolean;\n  onFloating: (floating: boolean) => void;\n  children: ReactNode;\n}) {\n  const marker = useRef<HTMLSpanElement | null>(null);\n  /* Is the trigger inside another floating surface? The Dialog's trigger has aria-controls = this sheet's id, so find\n     it from the panel this renders in, before the first paint. */\n  useLayoutEffect(() => {\n    const id = marker.current?.closest<HTMLElement>('[role=\"dialog\"]')?.id;\n    const trigger = id ? document.querySelector(`[aria-controls=\"${CSS.escape(id)}\"]`) : null;\n    onFloating(Boolean(trigger?.closest(FLOATING)));\n  }, [onFloating]);\n\n  return (\n    <>\n      <span ref={marker} hidden />\n      <div className=\"grid content-start pt-6 pr-4 pl-6\" style={{ gridArea: \"head\" }}>\n        <RadixDialog.Title className=\"text-litt-h4 text-litt-fg text-balance\">{label}</RadixDialog.Title>\n      </div>\n\n      <div\n        className={\"min-h-0 overflow-y-auto overscroll-contain pt-6 pb-(--sheet-foot) \" + (padded ? \"px-6\" : \"px-3\")}\n        style={{ gridArea: \"body\" }}\n      >\n        {children}\n      </div>\n\n      <div className=\"self-start pt-6 pr-4\" style={{ gridArea: \"close\" }}>\n        <RadixDialog.Close {...asChildProps}>\n          <Button\n            iconOnly\n            size=\"sm\"\n            variant=\"ghost\"\n            aria-label=\"Close\"\n            className=\"-mt-1 pointer-coarse:-mt-2.5 pointer-coarse:h-litt-control-md pointer-coarse:w-litt-control-md\"\n          >\n            <XIcon />\n          </Button>\n        </RadixDialog.Close>\n      </div>\n    </>\n  );\n}\n\n/** The animated surface. */\nfunction Surface({\n  label,\n  side,\n  align,\n  sideOffset,\n  size,\n  padded,\n  solid,\n  collisionBoundary,\n  children,\n}: Required<Pick<PopoverContentProps, \"label\" | \"side\" | \"align\" | \"sideOffset\" | \"size\" | \"padded\">> &\n  Pick<PopoverContentProps, \"solid\" | \"collisionBoundary\" | \"children\">) {\n  /* The material's motion language (@/motion/deal): it grows from om.grow, and under Device it switches on and off\n     in steps where it stands, with no slide. */\n  const om = useOverlayMotion();\n  const springIn = useTransition(\"responsive\");\n  const springOut = useExit(\"responsive\");\n  const enter = om.switches ? om.switchIn : springIn;\n  const exit = om.switches ? om.switchOut : springOut;\n  const fullTravel = useTravel();\n  const travel = om.slides ? fullTravel : 0;\n  const latest = useRef(enter);\n  latest.current = enter;\n\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const el = useRef<HTMLDivElement | null>(null);\n  /* null until Radix has placed the panel; then the side it really opened on. */\n  const [placed, setPlaced] = useState<Side | null>(null);\n  /* Is the trigger inside another floating surface? Radix gives the trigger aria-controls = this\n     panel's id while it is open, so find it from there, before the first paint. */\n  const [overFloating, setOverFloating] = useState(false);\n\n  useLayoutEffect(() => {\n    const id = el.current?.id;\n    const trigger = id ? document.querySelector(`[aria-controls=\"${CSS.escape(id)}\"]`) : null;\n    setOverFloating(Boolean(trigger?.closest(FLOATING)));\n  }, []);\n\n  /* A quarter of the travel, starting nearer the trigger than the resting place. */\n  const offset = (s: Side) => {\n    const d = travel / 4;\n    return { x: s === \"left\" ? d : s === \"right\" ? -d : 0, y: s === \"top\" ? d : s === \"bottom\" ? -d : 0 };\n  };\n\n  const onPlaced = () => {\n    const s = (el.current?.dataset.side as Side | undefined) ?? \"bottom\";\n    const o = offset(s);\n    x.jump(o.x);\n    y.jump(o.y);\n    setPlaced(s);\n  };\n\n  useEffect(() => {\n    if (!placed) return;\n    animate(x, 0, latest.current);\n    animate(y, 0, latest.current);\n  }, [placed, x, y]);\n\n  const out = offset(placed ?? side);\n\n  /* Radix forwards onPlaced to its popper, but the popover types omit it. */\n  const placedProp = { onPlaced } as Record<string, unknown>;\n\n  const flat = solid ?? overFloating;\n  const surface =\n    \"relative z-50 max-w-(--radix-popover-content-available-width) rounded-litt-surface p-1 outline-none \" +\n    \"origin-(--radix-popover-content-transform-origin) data-[state=closed]:pointer-events-none \" +\n    WIDTH[size] +\n    (flat ? \" bg-litt-surface-2 shadow-litt-float in-[[data-material=flat]]:bg-litt-surface-1\" : \" litt-glass \" + FLAT_PANEL);\n\n  return (\n    <RadixPopover.Content\n      forceMount\n      {...asChildProps}\n      aria-label={label}\n      side={side}\n      align={align}\n      sideOffset={sideOffset}\n      collisionPadding={8}\n      collisionBoundary={collisionBoundary ?? undefined}\n      onOpenAutoFocus={(e) => {\n        const target = el.current?.querySelector<HTMLElement>(\"[data-autofocus]\");\n        if (!target) return;\n        e.preventDefault();\n        target.focus({ preventScroll: true });\n      }}\n      {...placedProp}\n    >\n      <motion.div\n        ref={el}\n        data-popover-surface=\"\"\n        className={surface}\n        initial={{ opacity: 0, scale: om.grow }}\n        animate={{ opacity: placed ? 1 : 0, scale: placed ? 1 : om.grow, transition: enter }}\n        exit={{ opacity: 0, scale: om.grow, x: out.x, y: out.y, transition: exit }}\n        style={{ x, y }}\n      >\n        <div\n          className={\n            \"max-h-[calc(var(--radix-popover-content-available-height)-0.5rem)] overflow-y-auto overscroll-contain rounded-litt-control in-[[data-material=flat]]:rounded-[calc(var(--litt-radius-surface)-4px)]\" +\n            (padded ? \" p-3\" : \"\")\n          }\n        >\n          {children}\n        </div>\n      </motion.div>\n    </RadixPopover.Content>\n  );\n}\n",
      "target": "components/litt/popover.tsx"
    }
  ],
  "docs": "Import Popover, PopoverTrigger, PopoverContent and PopoverClose from components/litt/popover. It turns solid when its trigger sits inside glass.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
