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:
amosblomqvist
2026-09-22 16:56:44 +03:00
parent f6626f1703
commit 8675b19bef
8 changed files with 370 additions and 89 deletions
+185
View File
@@ -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,
);
}