From 201901d35ecd67c433866ae1b0c64d857aa37920 Mon Sep 17 00:00:00 2001 From: kristinhe Date: Wed, 28 Oct 2026 19:04:11 +0800 Subject: [PATCH] fix(memhub): keep option menus inside viewport Co-authored-by: Cursor --- memhub/apps/web/src/LanguageSelector.test.tsx | 147 +++++++++++++- memhub/apps/web/src/LanguageSelector.tsx | 5 + memhub/apps/web/src/OptionMenu.tsx | 181 +++++++++++++++--- memhub/apps/web/src/core-visual.css | 13 +- memhub/apps/web/src/r6-settings.css | 20 +- 5 files changed, 316 insertions(+), 50 deletions(-) diff --git a/memhub/apps/web/src/LanguageSelector.test.tsx b/memhub/apps/web/src/LanguageSelector.test.tsx index 4fdb033..b9ad9b6 100644 --- a/memhub/apps/web/src/LanguageSelector.test.tsx +++ b/memhub/apps/web/src/LanguageSelector.test.tsx @@ -1,11 +1,18 @@ // @vitest-environment jsdom import { translate } from "@one-memory/i18n"; -import { cleanup, render, screen } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language"; import { LanguageSelector } from "./LanguageSelector"; +import { optionMenuPosition } from "./OptionMenu"; afterEach(() => { cleanup(); @@ -159,3 +166,141 @@ it("announces storage errors without replacing the confirmed choice", async () = "zh-CN", ); }); + +it("keeps a right-edge menu inside the viewport", () => { + expect( + optionMenuPosition( + { top: 8, right: 1016, bottom: 40, left: 872, width: 144 }, + 160, + 1024, + 600, + ), + ).toMatchObject({ + top: 44, + left: 868, + width: 144, + maxHeight: 280, + placement: "below", + }); +}); + +it("flips the menu above when the lower edge has no room", () => { + expect( + optionMenuPosition( + { top: 540, right: 500, bottom: 572, left: 356, width: 144 }, + 180, + 1024, + 600, + ), + ).toMatchObject({ + top: 356, + left: 356, + width: 144, + maxHeight: 280, + placement: "above", + }); +}); + +it("limits the panel to the actual space when neither side has 280px", () => { + expect( + optionMenuPosition( + { top: 40, right: 160, bottom: 60, left: 16, width: 144 }, + 180, + 320, + 100, + ), + ).toMatchObject({ + top: 64, + left: 16, + width: 144, + maxHeight: 24, + placement: "below", + }); +}); + +it("portals the open menu outside clipping layout containers", async () => { + render( + +
+ +
+
, + ); + await userEvent.click(screen.getByRole("combobox")); + const listbox = screen.getByRole("listbox"); + expect(listbox.parentElement).toBe(document.body); + expect( + listbox.classList.contains("r6-language-option-menu-panel--compact"), + ).toBe(true); +}); + +it("keeps the menu open while its portal scrollbar or padding is pressed", async () => { + render( + + + , + ); + const selector = screen.getByRole("combobox"); + await userEvent.click(selector); + const listbox = screen.getByRole("listbox"); + fireEvent.mouseDown(listbox); + fireEvent.blur(selector, { relatedTarget: null }); + expect(selector.getAttribute("aria-expanded")).toBe("true"); + fireEvent.mouseUp(document); +}); + +it("clears the portal pointer guard when the menu closes mid-press", async () => { + render( + + + , + ); + const selector = screen.getByRole("combobox"); + await userEvent.click(selector); + fireEvent.mouseDown(screen.getByRole("listbox")); + fireEvent.keyDown(document, { key: "Escape" }); + expect(selector.getAttribute("aria-expanded")).toBe("false"); + + await userEvent.click(selector); + fireEvent.blur(selector, { relatedTarget: null }); + expect(selector.getAttribute("aria-expanded")).toBe("false"); +}); + +it("clears the portal pointer guard when the window loses focus", async () => { + render( + + + , + ); + const selector = screen.getByRole("combobox"); + await userEvent.click(selector); + fireEvent.mouseDown(screen.getByRole("listbox")); + fireEvent.blur(window); + fireEvent.blur(selector, { relatedTarget: null }); + expect(selector.getAttribute("aria-expanded")).toBe("false"); +}); + +it("closes the portal when its anchor scrolls completely out of view", async () => { + render( + + + , + ); + const selector = screen.getByRole("combobox"); + vi.spyOn(selector, "getBoundingClientRect").mockReturnValue({ + top: 20, + right: 1168, + bottom: 52, + left: 1024, + width: 144, + height: 32, + x: 1024, + y: 20, + toJSON: () => ({}), + }); + await userEvent.click(selector); + await waitFor(() => + expect(selector.getAttribute("aria-expanded")).toBe("false"), + ); + expect(screen.queryByRole("listbox")).toBeNull(); +}); diff --git a/memhub/apps/web/src/LanguageSelector.tsx b/memhub/apps/web/src/LanguageSelector.tsx index 04b871a..5227451 100644 --- a/memhub/apps/web/src/LanguageSelector.tsx +++ b/memhub/apps/web/src/LanguageSelector.tsx @@ -54,6 +54,11 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) { disabled={pending} options={options} className="r6-language-option-menu" + panelClassName={ + compact + ? "r6-language-option-menu-panel--compact" + : "r6-language-option-menu-panel--settings" + } onChange={async (value) => { if (!isLanguagePreference(value)) return; setPending(true); diff --git a/memhub/apps/web/src/OptionMenu.tsx b/memhub/apps/web/src/OptionMenu.tsx index e78901d..d13c447 100644 --- a/memhub/apps/web/src/OptionMenu.tsx +++ b/memhub/apps/web/src/OptionMenu.tsx @@ -1,4 +1,13 @@ -import { useEffect, useId, useRef, useState } from "react"; +import { + type CSSProperties, + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; import { useGuiLanguage } from "./gui-language"; export interface OptionMenuItem { @@ -8,6 +17,43 @@ export interface OptionMenuItem { lang?: string; } +const VIEWPORT_MARGIN = 12; +const PANEL_GAP = 4; +const MAX_PANEL_HEIGHT = 280; + +export function optionMenuPosition( + anchor: Pick, + panelHeight: number, + viewportWidth: number, + viewportHeight: number, +) { + const width = Math.min( + anchor.width, + Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2), + ); + const left = Math.min( + Math.max(VIEWPORT_MARGIN, anchor.left), + Math.max(VIEWPORT_MARGIN, viewportWidth - VIEWPORT_MARGIN - width), + ); + const below = Math.max( + 0, + viewportHeight - VIEWPORT_MARGIN - anchor.bottom - PANEL_GAP, + ); + const above = Math.max(0, anchor.top - VIEWPORT_MARGIN - PANEL_GAP); + const placement: "above" | "below" = + below >= Math.min(panelHeight, 120) || below >= above ? "below" : "above"; + const maxHeight = Math.min( + MAX_PANEL_HEIGHT, + placement === "below" ? below : above, + ); + const height = Math.min(panelHeight, maxHeight); + const top = + placement === "below" + ? anchor.bottom + PANEL_GAP + : anchor.top - PANEL_GAP - height; + return { top, left, width, maxHeight, placement }; +} + /** Replaces a native select so the open list follows the product field, not the OS menu. */ export function OptionMenu(props: { id?: string; @@ -17,6 +63,7 @@ export function OptionMenu(props: { value: string; disabled?: boolean; className?: string; + panelClassName?: string; options: readonly OptionMenuItem[]; onChange: (value: string) => void; }) { @@ -32,8 +79,14 @@ export function OptionMenu(props: { ? selectedIndex : firstEnabledIndex; const [activeIndex, setActiveIndex] = useState(preferredIndex); + const [panelStyle, setPanelStyle] = useState({ + visibility: "hidden", + }); + const [placement, setPlacement] = useState<"above" | "below">("below"); const root = useRef(null); const button = useRef(null); + const panel = useRef(null); + const panelMouseDown = useRef(false); const selected = props.options.find((item) => item.value === props.value); const visibleLabel = selected?.label ?? t("wb.select"); const controlId = props.id ?? `${generatedId}-control`; @@ -62,20 +115,77 @@ export function OptionMenu(props: { button.current?.focus(); } + const placePanel = useCallback(() => { + const anchor = button.current?.getBoundingClientRect(); + const menu = panel.current; + if (!anchor || !menu) return; + if ( + anchor.width > 0 && + (anchor.bottom <= 0 || + anchor.top >= window.innerHeight || + anchor.right <= 0 || + anchor.left >= window.innerWidth) + ) { + setOpen(false); + return; + } + const measuredWidth = Math.min( + anchor.width, + Math.max(0, window.innerWidth - VIEWPORT_MARGIN * 2), + ); + menu.style.width = `${measuredWidth}px`; + menu.style.maxHeight = `${MAX_PANEL_HEIGHT}px`; + const next = optionMenuPosition( + anchor, + menu.scrollHeight, + window.innerWidth, + window.innerHeight, + ); + setPlacement(next.placement); + setPanelStyle({ + top: next.top, + left: next.left, + width: next.width, + maxHeight: next.maxHeight, + visibility: "visible", + }); + }, []); + + useLayoutEffect(() => { + if (!open) return; + placePanel(); + window.addEventListener("resize", placePanel); + window.addEventListener("scroll", placePanel, true); + return () => { + window.removeEventListener("resize", placePanel); + window.removeEventListener("scroll", placePanel, true); + }; + }, [open, placePanel]); + useEffect(() => { if (!open) return; setActiveIndex(preferredIndex); const close = (event: MouseEvent) => { - if (!root.current?.contains(event.target as Node)) setOpen(false); + const target = event.target as Node; + if (!root.current?.contains(target) && !panel.current?.contains(target)) + setOpen(false); }; const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; + const releasePanelPointer = () => { + panelMouseDown.current = false; + }; document.addEventListener("mousedown", close); + document.addEventListener("mouseup", releasePanelPointer); document.addEventListener("keydown", onKey); + window.addEventListener("blur", releasePanelPointer); return () => { + panelMouseDown.current = false; document.removeEventListener("mousedown", close); + document.removeEventListener("mouseup", releasePanelPointer); document.removeEventListener("keydown", onKey); + window.removeEventListener("blur", releasePanelPointer); }; }, [open, preferredIndex]); return ( @@ -105,7 +215,12 @@ export function OptionMenu(props: { } disabled={props.disabled} onBlur={(event) => { - if (!root.current?.contains(event.relatedTarget as Node | null)) + if (panelMouseDown.current) return; + const target = event.relatedTarget as Node | null; + if ( + !root.current?.contains(target) && + !panel.current?.contains(target) + ) setOpen(false); }} onClick={() => { @@ -152,29 +267,43 @@ export function OptionMenu(props: { > {visibleLabel} - {open && ( -
- {props.options.map((item, index) => ( - - ))} -
- )} + {open && + createPortal( +
{ + panelMouseDown.current = true; + }} + > + {props.options.map((item, index) => ( + + ))} +
, + document.body, + )} ); } diff --git a/memhub/apps/web/src/core-visual.css b/memhub/apps/web/src/core-visual.css index 3976c4b..18902aa 100644 --- a/memhub/apps/web/src/core-visual.css +++ b/memhub/apps/web/src/core-visual.css @@ -144,13 +144,12 @@ button svg { background: #edf0f3; } .om-option-menu-panel { - position: absolute; - z-index: 30; - top: calc(100% + 4px); - left: 0; - right: 0; + box-sizing: border-box; + position: fixed; + z-index: 1100; max-height: 280px; overflow: auto; + overscroll-behavior: contain; padding: 6px; background: #fff; border: 1px solid #c0ccd5; @@ -178,10 +177,8 @@ button svg { .om-option-menu-panel [role="option"]:hover:not(:disabled), button.om-option-menu-button:hover:not(:disabled), .cold-start button.om-option-menu-button:hover:not(:disabled), -.cold-start .om-option-menu-panel button[role="option"]:hover:not(:disabled), .r6-settings button.om-option-menu-button:hover:not(:disabled), -.r6-settings .om-option-menu-panel button[role="option"]:hover:not(:disabled), -.r6-memory .om-option-menu-panel button[role="option"]:hover:not(:disabled) { +.r6-memory button.om-option-menu-button:hover:not(:disabled) { color: #262a2e; background: #e7eef2; border-color: transparent; diff --git a/memhub/apps/web/src/r6-settings.css b/memhub/apps/web/src/r6-settings.css index 11a8863..bf83c75 100644 --- a/memhub/apps/web/src/r6-settings.css +++ b/memhub/apps/web/src/r6-settings.css @@ -72,37 +72,27 @@ border-color: #7892a6; box-shadow: 0 0 0 3px rgb(85 124 147 / 12%); } -.r6-settings .r6-language-option-menu .om-option-menu-panel { - top: calc(100% + 6px); +.r6-language-option-menu-panel--settings { padding: 6px; border-color: #c6d2da; box-shadow: 0 12px 32px rgb(48 71 87 / 13%); } -.r6-settings .r6-language-option-menu .om-option-menu-panel [role="option"] { +.r6-language-option-menu-panel--settings [role="option"] { min-height: 38px; padding: 8px 10px; color: #304757; font-size: 13px; line-height: 22px; } -.r6-settings - .r6-language-option-menu - .om-option-menu-panel - [role="option"][aria-selected="true"] { +.r6-language-option-menu-panel--settings [role="option"][aria-selected="true"] { background: #dfe9ef; font-weight: 500; } -.r6-settings - .r6-language-option-menu - .om-option-menu-panel - [role="option"][data-active="true"] { +.r6-language-option-menu-panel--settings [role="option"][data-active="true"] { color: #304757; background: #e9f0f4; } -.r6-settings - .r6-language-option-menu - .om-option-menu-panel - [role="option"]:active:not(:disabled) { +.r6-language-option-menu-panel--settings [role="option"]:active:not(:disabled) { color: #304757; background: #d4e2ea; }