From 6e8190b00073f0a7578c106ff41f6818c4793f46 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:00:53 +0200 Subject: [PATCH 1/4] fix(gui): portal select dropdowns to avoid clipping --- gui/src/select-position.ts | 97 +++++++++++++++++++++++++++++++ gui/src/styles.css | 1 + gui/src/ui.tsx | 87 ++++++++++++++++++++++----- gui/tests/select-position.test.ts | 90 ++++++++++++++++++++++++++++ 4 files changed, 259 insertions(+), 16 deletions(-) create mode 100644 gui/src/select-position.ts create mode 100644 gui/tests/select-position.test.ts diff --git a/gui/src/select-position.ts b/gui/src/select-position.ts new file mode 100644 index 00000000000..270cbd9f792 --- /dev/null +++ b/gui/src/select-position.ts @@ -0,0 +1,97 @@ +import type { CSSProperties } from "react"; + +export interface SelectMenuTriggerRect { + top: number; + bottom: number; + left: number; + right: number; + width: number; + height: number; +} + +export interface SelectMenuStyleOptions { + align?: "left" | "right"; + placement?: "below" | "right"; + menuHeight?: number; +} + +const MENU_GAP_PX = 4; +const FLIP_GAP_PX = 8; +const VIEWPORT_PAD_PX = 8; +const MAX_MENU_HEIGHT_PX = 280; +const MIN_MENU_HEIGHT_PX = 120; +const BESIDE_MIN_WIDTH_PX = 160; + +function viewportHeight() { + return typeof window !== "undefined" ? window.innerHeight : 800; +} + +function viewportWidth() { + return typeof window !== "undefined" ? window.innerWidth : 1024; +} + +export function computeSelectMenuStyle( + trigger: SelectMenuTriggerRect, + { align = "left", placement = "below", menuHeight = MAX_MENU_HEIGHT_PX }: SelectMenuStyleOptions = {}, +): CSSProperties { + const measuredHeight = Math.min(Math.max(menuHeight, MIN_MENU_HEIGHT_PX), MAX_MENU_HEIGHT_PX); + const vh = viewportHeight(); + const vw = viewportWidth(); + + if (placement === "right") { + const spaceBelow = vh - trigger.bottom - VIEWPORT_PAD_PX; + const overflowBelow = trigger.top + measuredHeight - (vh - VIEWPORT_PAD_PX); + const openAbove = overflowBelow > 0 && overflowBelow > spaceBelow; + + if (openAbove) { + return { + position: "fixed", + left: trigger.right + 6, + 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: trigger.right + 6, + 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..bd826928f54 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -575,6 +575,7 @@ 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-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..b23d1ba00bb 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 ( @@ -35,21 +37,87 @@ export function Select({ value, options, onChange, disabled, label, style, align dropdownStyle?: CSSProperties; }) { 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) => { + const trigger = triggerRef.current; + if (!trigger) return; + setMenuStyle(computeSelectMenuStyle(trigger.getBoundingClientRect(), { + align, + placement, + menuHeight, + })); + }, [align, placement]); + 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) 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, reposition]); + + useLayoutEffect(() => { + if (!open || !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]); + + const dropdown = open ? ( +
+ {options.map(o => ( + + ))} +
+ ) : null; + return (
- {open && ( -
- {options.map(o => ( - - ))} -
- )} + {dropdown && createPortal(dropdown, document.body)}
); } diff --git a/gui/tests/select-position.test.ts b/gui/tests/select-position.test.ts new file mode 100644 index 00000000000..38bac51ee04 --- /dev/null +++ b/gui/tests/select-position.test.ts @@ -0,0 +1,90 @@ +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: 700, + bottom: 736, + left: 120, + right: 320, + width: 200, + height: 36, + }, { placement: "right", menuHeight: 120 }); + expect(style.left).toBe(326); + expect(style.top).toBe(700); + 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 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(); +}); From cc08705541951580ad6fa6bd08367cecc9addc62 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:04:01 +0200 Subject: [PATCH 2/4] fix(gui): scope select portals to clipped sidecar panels --- gui/src/pages/ClaudeCode.tsx | 3 +++ gui/src/styles.css | 1 + gui/src/ui.tsx | 20 +++++++++++--------- 3 files changed, 15 insertions(+), 9 deletions(-) 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 /> void; @@ -35,6 +35,7 @@ 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(); @@ -44,6 +45,7 @@ export function Select({ value, options, onChange, disabled, label, style, align 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(), { @@ -51,7 +53,7 @@ export function Select({ value, options, onChange, disabled, label, style, align placement, menuHeight, })); - }, [align, placement]); + }, [align, placement, portal]); useEffect(() => { if (!open) return; @@ -67,7 +69,7 @@ export function Select({ value, options, onChange, disabled, label, style, align }, [open]); useLayoutEffect(() => { - if (!open) return; + if (!open || !portal) return; reposition(); const onViewportChange = () => reposition(menuRef.current?.offsetHeight); window.addEventListener("resize", onViewportChange); @@ -76,10 +78,10 @@ export function Select({ value, options, onChange, disabled, label, style, align window.removeEventListener("resize", onViewportChange); window.removeEventListener("scroll", onViewportChange, true); }; - }, [open, options.length, reposition]); + }, [open, options.length, portal, reposition]); useLayoutEffect(() => { - if (!open || !menuRef.current || !triggerRef.current) return; + if (!open || !portal || !menuRef.current || !triggerRef.current) return; const nextHeight = menuRef.current.offsetHeight; if (!nextHeight) return; const nextStyle = computeSelectMenuStyle(triggerRef.current.getBoundingClientRect(), { @@ -91,15 +93,15 @@ export function Select({ value, options, onChange, disabled, label, style, align if (prev?.top === nextStyle.top && prev?.bottom === nextStyle.bottom && prev?.maxHeight === nextStyle.maxHeight) return prev; return nextStyle; }); - }, [align, open, options.length, placement]); + }, [align, open, options.length, placement, portal]); const dropdown = open ? (
{options.map(o => ( - {dropdown && createPortal(dropdown, document.body)} + {portal ? (dropdown && createPortal(dropdown, document.body)) : dropdown}
); } From e3d6a8697f87c08b9869ac266f14b7cef5c3e1a3 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:01:49 +0200 Subject: [PATCH 3/4] fix(gui): clamp and flip right-placed select menus --- gui/src/select-position.ts | 11 ++++++----- gui/tests/select-position.test.ts | 33 ++++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/gui/src/select-position.ts b/gui/src/select-position.ts index 270cbd9f792..e5294512acd 100644 --- a/gui/src/select-position.ts +++ b/gui/src/select-position.ts @@ -39,14 +39,15 @@ export function computeSelectMenuStyle( const vw = viewportWidth(); if (placement === "right") { - const spaceBelow = vh - trigger.bottom - VIEWPORT_PAD_PX; - const overflowBelow = trigger.top + measuredHeight - (vh - VIEWPORT_PAD_PX); - const openAbove = overflowBelow > 0 && overflowBelow > spaceBelow; + const spaceBelow = vh - trigger.top - VIEWPORT_PAD_PX; + const spaceAbove = trigger.top - VIEWPORT_PAD_PX; + const openAbove = measuredHeight + FLIP_GAP_PX > 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: trigger.right + 6, + 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)), @@ -56,7 +57,7 @@ export function computeSelectMenuStyle( return { position: "fixed", top: trigger.top, - left: trigger.right + 6, + 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)), }; diff --git a/gui/tests/select-position.test.ts b/gui/tests/select-position.test.ts index 38bac51ee04..fd3d7e9b770 100644 --- a/gui/tests/select-position.test.ts +++ b/gui/tests/select-position.test.ts @@ -50,15 +50,15 @@ test("flips upward near the bottom of the viewport", () => { test("right placement opens beside the trigger", () => { const style = computeSelectMenuStyle({ - top: 700, - bottom: 736, + 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(700); + expect(style.top).toBe(120); expect(style.minWidth).toBe(160); }); @@ -76,6 +76,33 @@ test("right placement flips above when the trigger is near the bottom edge", () 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, From 33aaa5bb4b05e68d3a82f027b9cccd335170c6ec Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 24 Jul 2026 21:32:30 +0900 Subject: [PATCH 4/4] test(gui): mounted portal/language-menu regressions for select dropdown (#340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on #393: add mounted React regressions (happy-dom) proving (1) a portal Select renders its dropdown under document.body, outside the clipping card, with the select-dropdown-portal marker, and (2) a non-portal Select keeps the language-menu contract — dropdown stays a descendant of .custom-select and retains .select-dropdown-beside so the glass fallback and mobile upward-placement CSS still apply. The pure coordinate helper (select-position.test.ts) already covers viewport flip/clamp. --- gui/tests/select-portal.test.tsx | 83 ++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 gui/tests/select-portal.test.tsx 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(); }); +});