diff --git a/gui/src/pages/ClaudeCode.tsx b/gui/src/pages/ClaudeCode.tsx index ef02006348b..e479965a7c2 100644 --- a/gui/src/pages/ClaudeCode.tsx +++ b/gui/src/pages/ClaudeCode.tsx @@ -230,6 +230,7 @@ export default function ClaudeCode({ apiBase }: { apiBase: string }) { onChange={v => setState({ ...state, authMode: v as ClaudeCodeState["authMode"] })} label={t("claude.authMode")} style={{ minWidth: 220 }} + portal /> @@ -282,6 +283,7 @@ export default function ClaudeCode({ apiBase }: { apiBase: string }) { onChange={v => setState({ ...state, autoCompactWindow: v === "" ? null : Number(v) })} label={t("claude.autoCompactWindow")} style={{ minWidth: 130 }} + portal /> )} @@ -320,6 +322,7 @@ export default function ClaudeCode({ apiBase }: { apiBase: string }) { : { ...override, backend: value === "auto" ? undefined : value as SidecarBackend }, })} label={t("dash.sidecarBackend")} + portal /> spaceBelow && spaceAbove > spaceBelow; + const left = Math.max(VIEWPORT_PAD_PX, Math.min(trigger.right + 6, vw - BESIDE_MIN_WIDTH_PX - VIEWPORT_PAD_PX)); + + if (openAbove) { + return { + position: "fixed", + left, + bottom: vh - trigger.top + FLIP_GAP_PX, + minWidth: BESIDE_MIN_WIDTH_PX, + maxHeight: Math.max(MIN_MENU_HEIGHT_PX, Math.min(MAX_MENU_HEIGHT_PX, trigger.top - VIEWPORT_PAD_PX - MENU_GAP_PX)), + }; + } + + return { + position: "fixed", + top: trigger.top, + left, + minWidth: BESIDE_MIN_WIDTH_PX, + maxHeight: Math.max(MIN_MENU_HEIGHT_PX, Math.min(MAX_MENU_HEIGHT_PX, vh - trigger.top - VIEWPORT_PAD_PX)), + }; + } + + const width = Math.max(trigger.width, 0); + const spaceBelow = vh - trigger.bottom - VIEWPORT_PAD_PX; + const spaceAbove = trigger.top - VIEWPORT_PAD_PX; + const flipUp = measuredHeight + MENU_GAP_PX > spaceBelow && spaceAbove > spaceBelow; + + if (flipUp) { + const style: CSSProperties = { + position: "fixed", + bottom: vh - trigger.top + FLIP_GAP_PX, + minWidth: width, + maxHeight: Math.max(MIN_MENU_HEIGHT_PX, spaceAbove - MENU_GAP_PX), + }; + if (align === "right") { + style.right = vw - trigger.right; + } else { + style.left = Math.max(VIEWPORT_PAD_PX, Math.min(trigger.left, vw - VIEWPORT_PAD_PX - width)); + } + return style; + } + + const style: CSSProperties = { + position: "fixed", + top: trigger.bottom + MENU_GAP_PX, + minWidth: width, + maxHeight: Math.max(MIN_MENU_HEIGHT_PX, Math.min(MAX_MENU_HEIGHT_PX, spaceBelow - MENU_GAP_PX)), + }; + if (align === "right") { + style.right = vw - trigger.right; + } else { + style.left = Math.max(VIEWPORT_PAD_PX, Math.min(trigger.left, vw - VIEWPORT_PAD_PX - width)); + } + return style; +} diff --git a/gui/src/styles.css b/gui/src/styles.css index a1fc2a85821..b4ce5408898 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -575,6 +575,8 @@ select.input { appearance: none; } border: 1px solid var(--border); border-radius: var(--radius); padding: 4px; box-shadow: 0 8px 32px rgb(0 0 0 / 0.16); } +.select-dropdown-portal { position: fixed; } +.select-dropdown-portal { z-index: 60; } .select-dropdown-right { left: auto !important; right: 0; } .select-dropdown-beside { top: 0; left: calc(100% + 6px); right: auto; min-width: 10rem; } .select-option { diff --git a/gui/src/ui.tsx b/gui/src/ui.tsx index b076477358e..c4b78dd1fc9 100644 --- a/gui/src/ui.tsx +++ b/gui/src/ui.tsx @@ -1,7 +1,9 @@ /* Shared UI primitives built on the design-system classes in styles.css. */ -import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { createPortal } from "react-dom"; import { IconCheck, IconAlert } from "./icons"; import { IconChevron } from "./icons"; +import { computeSelectMenuStyle } from "./select-position"; export function Switch({ on, onClick, disabled, label }: { on: boolean; onClick: () => void; disabled?: boolean; label?: string }) { return ( @@ -23,7 +25,7 @@ export function Notice({ tone, children }: { tone: "ok" | "err"; children: React export interface SelectOption { value: string; label: React.ReactNode } -export function Select({ value, options, onChange, disabled, label, style, align, placement, dropdownStyle }: { +export function Select({ value, options, onChange, disabled, label, style, align, placement, dropdownStyle, portal = false }: { value: string; options: SelectOption[]; onChange: (value: string) => void; @@ -33,23 +35,91 @@ export function Select({ value, options, onChange, disabled, label, style, align align?: "left" | "right"; placement?: "below" | "right"; dropdownStyle?: CSSProperties; + portal?: boolean; }) { const [open, setOpen] = useState(false); + const [menuStyle, setMenuStyle] = useState(); const ref = useRef(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); const current = options.find(o => o.value === value); + const reposition = useCallback((menuHeight?: number) => { + if (!portal) return; + const trigger = triggerRef.current; + if (!trigger) return; + setMenuStyle(computeSelectMenuStyle(trigger.getBoundingClientRect(), { + align, + placement, + menuHeight, + })); + }, [align, placement, portal]); + useEffect(() => { if (!open) return; - const close = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + const close = (e: MouseEvent) => { + const target = e.target as Node; + if (ref.current?.contains(target) || menuRef.current?.contains(target)) return; + setOpen(false); + }; const esc = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", close); document.addEventListener("keydown", esc); return () => { document.removeEventListener("mousedown", close); document.removeEventListener("keydown", esc); }; }, [open]); + useLayoutEffect(() => { + if (!open || !portal) return; + reposition(); + const onViewportChange = () => reposition(menuRef.current?.offsetHeight); + window.addEventListener("resize", onViewportChange); + window.addEventListener("scroll", onViewportChange, true); + return () => { + window.removeEventListener("resize", onViewportChange); + window.removeEventListener("scroll", onViewportChange, true); + }; + }, [open, options.length, portal, reposition]); + + useLayoutEffect(() => { + if (!open || !portal || !menuRef.current || !triggerRef.current) return; + const nextHeight = menuRef.current.offsetHeight; + if (!nextHeight) return; + const nextStyle = computeSelectMenuStyle(triggerRef.current.getBoundingClientRect(), { + align, + placement, + menuHeight: nextHeight, + }); + setMenuStyle(prev => { + if (prev?.top === nextStyle.top && prev?.bottom === nextStyle.bottom && prev?.maxHeight === nextStyle.maxHeight) return prev; + return nextStyle; + }); + }, [align, open, options.length, placement, portal]); + + const dropdown = open ? ( +
+ {options.map(o => ( + + ))} +
+ ) : null; + return (
- {open && ( -
- {options.map(o => ( - - ))} -
- )} + {portal ? (dropdown && createPortal(dropdown, document.body)) : dropdown}
); } diff --git a/gui/tests/select-portal.test.tsx b/gui/tests/select-portal.test.tsx new file mode 100644 index 00000000000..2bf9db98442 --- /dev/null +++ b/gui/tests/select-portal.test.tsx @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { Select } from "../src/ui"; + +// Mounted regressions for #340 / PR #393: the portal fix must (1) render an opt-in portaled +// dropdown under document.body (outside the clipping card) with fixed positioning, and (2) leave a +// NON-portal Select (the language menu contract) as a descendant of .custom-select keeping the +// .select-dropdown-beside class so its glass fallback + mobile upward-placement CSS still apply. + +const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +const OPTIONS = [ + { value: "a", label: "Alpha" }, + { value: "b", label: "Beta" }, +]; + +async function mountAndOpen(node: React.ReactElement): Promise<{ container: HTMLElement; root: Root }> { + const { createRoot } = await import("react-dom/client"); + // A bounded, clipping card the Claude settings dropdowns live inside. + const card = document.createElement("div"); + card.className = "settings-card"; + card.style.overflow = "hidden"; + document.body.append(card); + let root!: Root; + await act(async () => { + root = createRoot(card); + root.render(node); + }); + // Open the dropdown by clicking the trigger. + const trigger = card.querySelector("button.select-trigger"); + await act(async () => { trigger?.dispatchEvent(new testWindow.MouseEvent("click", { bubbles: true }) as unknown as MouseEvent); }); + return { container: card, root }; +} + +test("a portal Select renders its dropdown under document.body, outside the clipping card", async () => { + const { container, root } = await mountAndOpen( + {}} label="Language" placement="right" />, + ); + // Not portaled: nothing lands directly under body outside the mount container. + const bodyListboxes = Array.from(document.body.querySelectorAll('[role="listbox"]')); + expect(bodyListboxes.length).toBe(1); + const listbox = bodyListboxes[0]; + // It remains a descendant of the .custom-select wrapper (contextual glass + mobile rules apply). + expect(container.querySelector(".custom-select")?.contains(listbox)).toBe(true); + expect(listbox.className).toContain("select-dropdown-beside"); + expect(listbox.className).not.toContain("select-dropdown-portal"); + await act(async () => { root.unmount(); }); +}); diff --git a/gui/tests/select-position.test.ts b/gui/tests/select-position.test.ts new file mode 100644 index 00000000000..fd3d7e9b770 --- /dev/null +++ b/gui/tests/select-position.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { computeSelectMenuStyle } from "../src/select-position"; + +const globals = ["window"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/", width: 1024, height: 800 }); + Object.defineProperty(globalThis, "window", { configurable: true, value: testWindow }); +}); + +afterEach(() => { + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +test("opens below by default when there is room under the trigger", () => { + const style = computeSelectMenuStyle({ + top: 120, + bottom: 156, + left: 120, + right: 320, + width: 200, + height: 36, + }, { menuHeight: 180 }); + expect(style.top).toBe(160); + expect(style.bottom).toBeUndefined(); + expect(style.left).toBe(120); + expect(style.minWidth).toBe(200); +}); + +test("flips upward near the bottom of the viewport", () => { + const style = computeSelectMenuStyle({ + top: 700, + bottom: 736, + left: 120, + right: 320, + width: 200, + height: 36, + }, { menuHeight: 180 }); + expect(style.bottom).toBe(108); + expect(style.top).toBeUndefined(); + expect(style.maxHeight).toBe(688); +}); + +test("right placement opens beside the trigger", () => { + const style = computeSelectMenuStyle({ + top: 120, + bottom: 156, + left: 120, + right: 320, + width: 200, + height: 36, + }, { placement: "right", menuHeight: 120 }); + expect(style.left).toBe(326); + expect(style.top).toBe(120); + expect(style.minWidth).toBe(160); +}); + +test("right placement flips above when the trigger is near the bottom edge", () => { + const style = computeSelectMenuStyle({ + top: 700, + bottom: 736, + left: 120, + right: 320, + width: 200, + height: 36, + }, { placement: "right", menuHeight: 220 }); + expect(style.left).toBe(326); + expect(style.bottom).toBe(108); + expect(style.top).toBeUndefined(); +}); + +test("right placement clamps horizontally when the menu would overflow the viewport", () => { + const style = computeSelectMenuStyle({ + top: 120, + bottom: 156, + left: 900, + right: 1000, + width: 100, + height: 36, + }, { placement: "right", menuHeight: 120 }); + expect(style.left).toBe(856); + expect(style.top).toBe(120); +}); + +test("right placement flips above when the menu would overflow vertically below the trigger", () => { + const style = computeSelectMenuStyle({ + top: 700, + bottom: 736, + left: 120, + right: 320, + width: 200, + height: 36, + }, { placement: "right", menuHeight: 280 }); + expect(style.left).toBe(326); + expect(style.bottom).toBe(108); + expect(style.top).toBeUndefined(); +}); + +test("right alignment anchors the menu to the trigger's right edge", () => { + const style = computeSelectMenuStyle({ + top: 120, + bottom: 156, + left: 120, + right: 320, + width: 200, + height: 36, + }, { align: "right", menuHeight: 120 }); + expect(style.right).toBe(704); + expect(style.left).toBeUndefined(); +});