{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"popover","type":"registry:component","title":"Popover","description":"A panel of arbitrary content anchored to a trigger, opening on click and flipping above it when there's no room below.","author":"Ryan","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/popover.tsx","type":"registry:component","target":"@components/motion/popover.tsx","content":"\"use client\";\n// easeui.dev/components/motion/popover\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ButtonHTMLAttributes,\n  createContext,\n  type KeyboardEvent,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\n// Matches DropdownMenu: opens in 150ms, closes a little faster.\nconst OPEN = { duration: 0.15, ease: EASE } as const;\nconst CLOSE = { duration: 0.1, ease: EASE } as const;\n\nconst FOCUSABLE =\n  'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\ninterface PopoverContextValue {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  triggerRef: RefObject<HTMLButtonElement | null>;\n  contentRef: RefObject<HTMLDivElement | null>;\n  triggerId: string;\n  contentId: string;\n}\n\nconst PopoverContext = createContext<PopoverContextValue | null>(null);\n\nfunction usePopover(part: string) {\n  const ctx = useContext(PopoverContext);\n  if (!ctx) throw new Error(`${part} must be used inside <Popover>`);\n  return ctx;\n}\n\nexport interface PopoverProps {\n  /** Controlled open state. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. Default false. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n  children: ReactNode;\n}\n\n/**\n * A panel of arbitrary content anchored to a trigger. Opens on click, not\n * hover, and flips above the trigger when there's no room below.\n */\nexport function Popover({\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n  children,\n}: PopoverProps) {\n  const id = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const [innerOpen, setInnerOpen] = useState(defaultOpen);\n  const open = openProp ?? innerOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (openProp === undefined) setInnerOpen(next);\n      onOpenChange?.(next);\n    },\n    [openProp, onOpenChange],\n  );\n\n  // A press anywhere outside the popover closes it.\n  useEffect(() => {\n    if (!open) return;\n    const onPointerDown = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    window.addEventListener(\"pointerdown\", onPointerDown);\n    return () => window.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [open, setOpen]);\n\n  // Moves focus into the content, onto its first focusable element.\n  useEffect(() => {\n    if (!open) return;\n    contentRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus();\n  }, [open]);\n\n  const ctx = useMemo<PopoverContextValue>(\n    () => ({\n      open,\n      setOpen,\n      triggerRef,\n      contentRef,\n      triggerId: `${id}-trigger`,\n      contentId: `${id}-content`,\n    }),\n    [open, setOpen, id],\n  );\n\n  return (\n    <PopoverContext.Provider value={ctx}>\n      <div ref={rootRef} className={cn(\"relative inline-block\", className)}>\n        {children}\n      </div>\n    </PopoverContext.Provider>\n  );\n}\n\nexport type PopoverTriggerProps = Omit<\n  ButtonHTMLAttributes<HTMLButtonElement>,\n  \"type\" | \"id\" | \"onClick\" | \"onKeyDown\"\n>;\n\nexport function PopoverTrigger({ className, children, ...props }: PopoverTriggerProps) {\n  const p = usePopover(\"PopoverTrigger\");\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key === \"ArrowDown\" && !p.open) {\n      event.preventDefault();\n      p.setOpen(true);\n    }\n  };\n\n  return (\n    <button\n      ref={p.triggerRef}\n      type=\"button\"\n      id={p.triggerId}\n      aria-haspopup=\"dialog\"\n      aria-expanded={p.open}\n      aria-controls={p.contentId}\n      onClick={() => p.setOpen(!p.open)}\n      onKeyDown={onKeyDown}\n      className={className}\n      {...props}\n    >\n      {children}\n    </button>\n  );\n}\n\nexport interface PopoverContentProps {\n  /** Which trigger edge the panel hangs from. Default \"bottom\". */\n  side?: \"top\" | \"bottom\";\n  /** Which trigger edge the panel aligns to. Default \"start\". */\n  align?: \"start\" | \"end\";\n  className?: string;\n  children: ReactNode;\n}\n\nexport function PopoverContent({ side = \"bottom\", align = \"start\", className, children }: PopoverContentProps) {\n  const p = usePopover(\"PopoverContent\");\n  const reduce = useReducedMotion();\n  const [placement, setPlacement] = useState<\"top\" | \"bottom\">(side);\n  const { open, triggerRef, contentRef } = p;\n\n  // Opens upward when there is not enough room below the trigger.\n  useLayoutEffect(() => {\n    if (!open) return;\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    if (!trigger || !content) return;\n    const rect = trigger.getBoundingClientRect();\n    const below = window.innerHeight - rect.bottom;\n    setPlacement(below < content.offsetHeight + 16 && rect.top > below ? \"top\" : side);\n  }, [open, side, triggerRef, contentRef]);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key !== \"Escape\") return;\n    event.preventDefault();\n    p.setOpen(false);\n    p.triggerRef.current?.focus();\n  };\n\n  const isTop = placement === \"top\";\n\n  // Stays mounted while closed so the exit transition can play.\n  return (\n    <motion.div\n      ref={contentRef}\n      id={p.contentId}\n      role=\"dialog\"\n      aria-labelledby={p.triggerId}\n      aria-hidden={!open}\n      inert={!open}\n      onKeyDown={onKeyDown}\n      initial={false}\n      animate={reduce ? { opacity: open ? 1 : 0 } : { opacity: open ? 1 : 0, scale: open ? 1 : 0.97 }}\n      transition={reduce ? { duration: 0 } : open ? OPEN : CLOSE}\n      style={{\n        transformOrigin: `${isTop ? \"bottom\" : \"top\"} ${align === \"end\" ? \"right\" : \"left\"}`,\n        pointerEvents: open ? \"auto\" : \"none\",\n      }}\n      className={cn(\n        \"absolute z-30 min-w-56 max-w-[calc(100vw-2rem)] rounded-2xl bg-background p-4\",\n        \"shadow-[0_0_0_1px_var(--border-strong),0_12px_24px_-12px_rgb(0_0_0/0.3)]\",\n        isTop ? \"bottom-full mb-1.5\" : \"top-full mt-1.5\",\n        align === \"end\" ? \"right-0\" : \"left-0\",\n        className,\n      )}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}