fix(memhub): keep option menus inside viewport
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
42c0ca1d14
commit
201901d35e
5 files changed
+316
-50
No files matched your search
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user