"use client"; import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react"; import { createPortal } from "react-dom"; /** * THE shared menu shell (menu standardization, fix wave chunk B): one * placement body for the app's dropdown menus — portaled to document.body * (never clipped by an ancestor's overflow), fixed-anchored to the trigger's * rect, viewport-capped. PLACEMENT-ONLY by design: every site keeps its own * open/close/Esc contract (the layered-dismissal machinery differs * legitimately by surface — settings sections, the keys editor, the setup * dialog each own their Escape). * * - Side: each site declares its preferred side — the composer opens UP * (its control sits at screen bottom), the settings rows open DOWN; * the shell flips when the preferred side cannot show the menu's * content and the other side is roomier (the graph popovers' * honest-fitting behavior, generalized). * - Cap: max-height = min(60vh, the chosen side's available room); the * menu scrolls internally instead of stretching the page or escaping * the viewport (graph-view's max-h-[60vh] pattern). * - Re-placement: capture-phase scroll + window resize — the settings * menus' discipline (the settings dialog's inner content scroll * included). * * Sites (model menus first, owner 2026-09-22; the graph popovers and the * stats-bar summary popover stay bespoke — seams stay open for later * adoption): the settings Agent section's slot + search-engine menus, the * Keys section's Add-provider menu, the composer's model/thinking menus * (model-picker.tsx), and the SetupDialog's slot pickers — portaling THOSE * out of the dialog's overflow-hidden frame so they extend beyond the * dialog exactly like the settings menus (owner, chunk B item 7). * * SSR: menus only mount client-side while open; the hidden measuring pass * below is the only pre-placement render, so a server render never emits * a positioned menu. */ const GAP = 4; // anchor edge → menu (the bespoke shells' settled 4px) const EDGE = 8; // viewport floor the menu never enters const CAP_VH = 0.6; // 60vh — the graph pattern's cap const MIN_USABLE = 96; // floor: a shorter cap is an unusable sliver; the // cap then overflows the edge instead of crushing the menu /** A MenuShell mounted = a dropdown open (fix wave, owner ruling: * the settings dialog's content column locks while any menu is down — * no scrolling under a dropdown). The shell stays placement-only: it * just ANNOUNCES, surfaces decide what to do with the fact (the * settings dialog locks; anything un-lockable keeps the re-place-on- * scroll fallback below). Count-based: overlapping menus open/close in * any order and the accounting stays balanced. */ export const MENU_OPEN_EVENT = "lh:menu-open"; export const MENU_CLOSE_EVENT = "lh:menu-close"; export type MenuSide = "up" | "down"; export type MenuAlign = "left" | "right"; /** One placement decision. `style` is the fixed-position payload; the side * rides back for the flip assertion in tests. */ export interface MenuPlacement { side: MenuSide; style: Record; maxHeight: number; } /** Pure: the placement decision — anchored rect + viewport + the menu's * UNCAPPED content height (scrollHeight, so re-placements after a search * filter re-measure honestly) → side, fixed style, cap. Flip rule: stay * on the preferred side unless it cannot show the full content AND the * other side is roomier. The cap always keeps the menu inside its side's * room when there is room; the floor keeps it usable when there isn't. */ export function computeMenuPlacement( rect: { top: number; bottom: number; left: number; right: number }, viewport: { width: number; height: number }, contentHeight: number, preferredSide: MenuSide, align: MenuAlign, ): MenuPlacement { const availDown = viewport.height - EDGE - (rect.bottom + GAP); const availUp = rect.top - GAP - EDGE; // contentHeight ≤ 0 (nothing measured yet) keeps the preferred side — // the hidden first pass re-measures and decides with real numbers. let side = preferredSide; if (contentHeight > 0) { const preferredAvail = preferredSide === "down" ? availDown : availUp; const otherAvail = preferredSide === "down" ? availUp : availDown; if (contentHeight > preferredAvail && otherAvail > preferredAvail) { side = preferredSide === "down" ? "up" : "down"; } } const avail = side === "down" ? availDown : availUp; const maxHeight = Math.max(MIN_USABLE, Math.min(CAP_VH * viewport.height, avail)); const horizontal: Record = align === "right" ? { right: viewport.width - rect.right } : { left: rect.left }; const vertical: Record = side === "down" ? { top: rect.bottom + GAP } : { bottom: viewport.height - rect.top + GAP }; return { side, style: { ...horizontal, ...vertical }, maxHeight }; } /** The shared shell. Exactly one anchor seam: pass `anchorRef` (a live * element ref) or `anchorTestid` (the settings section's pattern — the * trigger is queried by testid at place time). */ export function MenuShell({ anchorRef, anchorTestid, testid, preferredSide, align = "right", menuClass, children, }: { anchorRef?: RefObject; anchorTestid?: string; /** The menu body's own testid (sites assert it). */ testid: string; /** Which way the menu opens from the anchor when both sides fit. */ preferredSide: MenuSide; /** Which anchor edge the menu's horizontal position keys off. */ align?: MenuAlign; /** The menu body classes: width, border, bg, padding, text — the * shell adds position, z-index, the height cap, and scrolling. */ menuClass: string; children: ReactNode; }) { const menuRef = useRef(null); const [placed, setPlaced] = useState(null); // Announce open↔close for lockable surfaces (see MENU_OPEN_EVENT). useEffect(() => { window.dispatchEvent(new CustomEvent(MENU_OPEN_EVENT)); return () => { window.dispatchEvent(new CustomEvent(MENU_CLOSE_EVENT)); }; }, []); const anchor = () => { if (anchorRef) return anchorRef.current; if (anchorTestid !== undefined) return document.querySelector(`[data-testid="${anchorTestid}"]`); return null; }; // Measure + (re)place: on mount, then on every capture-phase scroll and // resize (rAF-throttled). scrollHeight keeps the flip decision honest // after the content changes shape (a search filtering the list). useLayoutEffect(() => { let raf = 0; const place = () => { const el = anchor(); if (!el || !menuRef.current) return; const p = computeMenuPlacement( el.getBoundingClientRect(), { width: window.innerWidth, height: window.innerHeight }, menuRef.current.scrollHeight, preferredSide, align, ); setPlaced(p); }; place(); const rePlace = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(place); }; window.addEventListener("scroll", rePlace, true); window.addEventListener("resize", rePlace); return () => { cancelAnimationFrame(raf); window.removeEventListener("scroll", rePlace, true); window.removeEventListener("resize", rePlace); }; }, [anchorRef, anchorTestid, preferredSide, align]); // First pass: mounted hidden at the viewport's top-left so the layout // effect can measure the real content height before anything is seen. // The menu is positioned ONCE and then only re-anchored — no flash. if (placed === null) { return createPortal(
{children}
, document.body, ); } return createPortal(
{children}
, document.body, ); }