"use client"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "framer-motion"; import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; /** * Desktop surface for the context/model pickers. * * The chips live wherever the screen puts them: at the bottom of the chat * column, or inside a scrolling card (Teach it back's start). An absolutely * positioned panel is then clipped by the scrolling ancestor — the teach model * list opened upward and lost its first rows off the top of the viewport — so * this renders the panel in a portal on , `position: fixed`, anchored to * the trigger that opened it. It opens on the side with room, is capped to the * viewport (the panel scrolls, not the page), and closes on resize, on an * outside press or when the page scrolls away from its chip — never floating * loose. Phones keep their bottom sheet. */ /** Gap between the panel and the chip, and the minimum distance to the edge. */ const GAP = 8; /** Below this there is not enough room to open on that side. */ const MIN_ROOM = 240; export function PickerPopover({ open, label, align = "left", width = "w-80", onClose, children, }: { open: boolean; /** Accessible name of the panel (role="dialog"). */ label: string; /** Which chip edge the panel lines up with. */ align?: "left" | "right"; /** Panel width utility class. */ width?: string; onClose: () => void; children: React.ReactNode; }) { const panel = useRef(null); const reduceMotion = useReducedMotion(); const [style, setStyle] = useState(null); /* Anchor to the open chip and fit inside the viewport. */ useLayoutEffect(() => { if (!open) { setStyle(null); return; } const place = () => { const node = panel.current; if (!node) return; const anchor = document.querySelector('[data-picker-trigger][aria-expanded="true"]'); const rect = anchor?.getBoundingClientRect(); const panelWidth = node.offsetWidth || 320; /* The full content height, before any cap is applied. */ const natural = node.scrollHeight || node.offsetHeight || 320; const below = rect ? window.innerHeight - rect.bottom - GAP * 2 : MIN_ROOM; const above = rect ? rect.top - GAP * 2 : MIN_ROOM; /* Prefer opening downwards when there is room; otherwise up, and split the difference when neither side is comfortable. */ const placement = below >= MIN_ROOM || below >= above ? "bottom" : "top"; const room = Math.max(MIN_ROOM, placement === "bottom" ? below : above); const height = Math.min(natural, room); const rawLeft = rect ? (align === "right" ? rect.right - panelWidth : rect.left) : GAP; const left = Math.max(GAP, Math.min(rawLeft, window.innerWidth - panelWidth - GAP)); /* Anchored to the chip on whichever side it opens: the panel's near edge sits a GAP away from it, never drifting to the viewport edge. */ const top = placement === "bottom" ? (rect?.bottom ?? GAP) + GAP : Math.max(GAP, (rect?.top ?? height + GAP * 2) - GAP - height); setStyle({ top, left, maxHeight: room }); }; place(); const close = () => onClose(); /* A scroll inside the panel (the model list) is not the page moving. */ const onScroll = (event: Event) => { if (panel.current && event.target instanceof Node && panel.current.contains(event.target)) return; onClose(); }; window.addEventListener("resize", close); window.addEventListener("scroll", onScroll, true); return () => { window.removeEventListener("resize", close); window.removeEventListener("scroll", onScroll, true); }; }, [open, align, onClose]); /* Desktop dropdown: a press anywhere outside closes it, except on the trigger chips, which toggle their own popover. */ useEffect(() => { if (!open) return; const onDown = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Element)) return; if (panel.current?.contains(target)) return; if (target.closest("[data-picker-trigger]")) return; onClose(); }; document.addEventListener("pointerdown", onDown); return () => document.removeEventListener("pointerdown", onDown); }, [open, onClose]); if (typeof document === "undefined") return null; return createPortal( {open ? ( {children} ) : null} , document.body, ); }