fix(memhub): keep option menus inside viewport

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kristinheandCursor committed 2026-10-28 19:04:11 +08:00
1 parent 42c0ca1d14
commit 201901d35e
5 files changed
+316 -50

No files matched your search

+146 -1
View File
@@ -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(
<LanguageProvider>
<div data-testid="clipping-parent">
<LanguageSelector compact />
</div>
</LanguageProvider>,
);
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(
<LanguageProvider>
<LanguageSelector compact />
</LanguageProvider>,
);
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(
<LanguageProvider>
<LanguageSelector compact />
</LanguageProvider>,
);
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(
<LanguageProvider>
<LanguageSelector compact />
</LanguageProvider>,
);
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(
<LanguageProvider>
<LanguageSelector compact />
</LanguageProvider>,
);
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();
});
+5
View File
@@ -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);
+155 -26
View File
@@ -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<DOMRect, "top" | "right" | "bottom" | "left" | "width">,
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<CSSProperties>({
visibility: "hidden",
});
const [placement, setPlacement] = useState<"above" | "below">("below");
const root = useRef<HTMLDivElement>(null);
const button = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(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}
</button>
{open && (
<div id={panelId} className="om-option-menu-panel" role="listbox">
{props.options.map((item, index) => (
<button
key={item.value || item.label}
id={`${panelId}-option-${index}`}
type="button"
role="option"
lang={item.lang}
data-active={activeIndex === index || undefined}
aria-selected={item.value === props.value}
disabled={item.disabled}
tabIndex={-1}
onMouseEnter={() => {
if (!item.disabled) setActiveIndex(index);
}}
onClick={() => choose(index)}
>
{item.label}
</button>
))}
</div>
)}
{open &&
createPortal(
<div
ref={panel}
id={panelId}
className={["om-option-menu-panel", props.panelClassName]
.filter(Boolean)
.join(" ")}
role="listbox"
data-placement={placement}
style={panelStyle}
onMouseDownCapture={() => {
panelMouseDown.current = true;
}}
>
{props.options.map((item, index) => (
<button
key={item.value || item.label}
id={`${panelId}-option-${index}`}
type="button"
role="option"
lang={item.lang}
data-active={activeIndex === index || undefined}
aria-selected={item.value === props.value}
disabled={item.disabled}
tabIndex={-1}
onMouseEnter={() => {
if (!item.disabled) setActiveIndex(index);
}}
onClick={() => choose(index)}
>
{item.label}
</button>
))}
</div>,
document.body,
)}
</div>
);
}
+5 -8
View File
@@ -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;
+5 -15
View File
@@ -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;
}