Fix wave chunk B: shared MenuShell for model menus, setup CTA on /chat/new
- components/menu-shell.tsx: one portaled, fixed-anchored, viewport-capped (60vh pattern) menu shell with site-preferred side + honest-fit auto-flip; computeMenuPlacement exported pure, placement-only (sites keep their own open/close/Esc contracts); re-places on capture-phase scroll/resize - migrated: settings slot menus (agent-section), keys Add-provider, composer model+thinking (open up, right-aligned), SetupDialog pickers (portaled out of the overflow-hidden dialog frame — no clipping by construction); graph popovers + stats-bar summary stay bespoke (owner) - /chat/new: the setup CTA above the draft blob, mirroring home's - tests: menu-shell-pure (geometry/cap/flip/alignment), DraftShell CTA
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { 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
|
||||
|
||||
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<string, string | number>;
|
||||
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<string, number> =
|
||||
align === "right" ? { right: viewport.width - rect.right } : { left: rect.left };
|
||||
const vertical: Record<string, number> =
|
||||
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<HTMLElement | null>;
|
||||
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<HTMLDivElement>(null);
|
||||
const [placed, setPlaced] = useState<MenuPlacement | null>(null);
|
||||
|
||||
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(
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-testid={testid}
|
||||
data-lh-menu=""
|
||||
style={{ position: "fixed", left: 0, top: 0, visibility: "hidden" }}
|
||||
className={menuClass}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-testid={testid}
|
||||
data-lh-menu=""
|
||||
style={{ position: "fixed", zIndex: 60, ...placed.style, maxHeight: placed.maxHeight, overflowY: "auto" }}
|
||||
className={menuClass}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user