fix(ui): highlight the menu row under the pointer (#1242)

Menu rows now follow the pointer without taking focus: focusing the row on every pointer enter dropped the hover paint in the Windows webview, so menus looked frozen. Keyboard navigation continues from the hovered row, and an item's onActive fires once per enter. Focus no longer scrolls the page when a menu row takes it.

On Solarized Dark the selection and hover colours were the popup and panel colour, so menu, tree, list and tab selection was invisible there; on Solarized Light selection matched the panels. Both themes get distinct selection and hover colours, and the editor keeps Solarized's own selection colour.

Co-authored-by: tomgor <[email protected]>
Цей коміт міститься в:
tomgorтаtomgor authored and GitHub committed 2026-11-05 00:16:32 +08:00
1 джерело 94490e2b37
коміт c27fea13b3
4 files changed
+77 -11

No files matched your search

+10 -4
Переглянути файл
@@ -1159,8 +1159,8 @@ html {
--palette-fg: #657b83;
--palette-muted: #657b83;
--palette-border: #eee8d5;
--palette-hover: #eee8d5;
--palette-selection: #eee8d5;
--palette-hover: #e4ddc8;
--palette-selection: #c2d3d4;
--palette-line: #eee8d5;
--palette-accent: #268bd2;
--palette-on-accent: #002b36;
@@ -1191,6 +1191,9 @@ html {
--syn-markup-code: #2aa198;
--syn-tag: #268bd2;
--syn-escape: #dc322f;
/* Selection and hover differ from the panel and popup surfaces; the editor keeps Solarized's own selection. */
--bg-editor-selection: #eee8d5;
--bg-editor-selection-inactive: #eee8d5;
}
:root[data-color-theme="solarized-dark"] {
@@ -1208,8 +1211,8 @@ html {
--palette-fg: #839496;
--palette-muted: #839496;
--palette-border: #073642;
--palette-hover: #073642;
--palette-selection: #073642;
--palette-hover: #0b4053;
--palette-selection: #10506d;
--palette-line: #073642;
--palette-accent: #268bd2;
--palette-on-accent: #002b36;
@@ -1240,6 +1243,9 @@ html {
--syn-markup-code: #2aa198;
--syn-tag: #268bd2;
--syn-escape: #dc322f;
/* Selection and hover differ from the panel and popup surfaces; the editor keeps Solarized's own selection. */
--bg-editor-selection: #073642;
--bg-editor-selection-inactive: #073642;
}
:root[data-color-theme="one-light"] {
+28 -7
Переглянути файл
@@ -125,13 +125,22 @@ export function MenuList({ items, onDone, onCloseLevel, autoFocusFirst = true, .
const [subAnchor, setSubAnchor] = useState<HTMLElement | null>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const hoverTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Pointer movement highlights a row without focusing it. Focusing on every
// enter drops the hover paint in the Windows webview, so the menu looks frozen.
const pointerHover = useRef(false);
// Row the pointer last entered: pointerenter and mouseenter both report it.
const hovered = useRef(-1);
const setOpenSub = (i: number | null) => {
setSubAnchor(i === null ? null : (itemRefs.current[i] ?? null));
setOpenSubIndex(i);
};
useLayoutEffect(() => {
if (active >= 0 && openSub === null) itemRefs.current[active]?.focus({ preventScroll: false });
if (pointerHover.current) {
pointerHover.current = false;
return;
}
if (active >= 0 && openSub === null) itemRefs.current[active]?.focus({ preventScroll: true });
}, [active, openSub]);
useEffect(() => () => clearTimeout(hoverTimer.current), []);
@@ -165,6 +174,20 @@ export function MenuList({ items, onDone, onCloseLevel, autoFocusFirst = true, .
item!.run?.();
};
const hover = (i: number) => {
const item = resolved[i];
if (!item || item.disabled || item.kind !== "item") return;
if (hovered.current !== i) {
hovered.current = i;
if (i !== active) {
pointerHover.current = true;
focusItem(i);
}
}
clearTimeout(hoverTimer.current);
hoverTimer.current = setTimeout(() => setOpenSub(item.submenu ? i : null), item.submenu ? 120 : 200);
};
const onKeyDown = (e: ReactKeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
@@ -241,12 +264,10 @@ export function MenuList({ items, onDone, onCloseLevel, autoFocusFirst = true, .
r.disabled && styles.disabled,
r.danger && styles.danger,
)}
onPointerEnter={() => {
if (r.disabled) return;
focusItem(i);
clearTimeout(hoverTimer.current);
hoverTimer.current = setTimeout(() => setOpenSub(hasSub ? i : null), hasSub ? 120 : 200);
}}
onPointerEnter={() => hover(i)}
onMouseEnter={() => hover(i)}
onPointerLeave={() => (hovered.current = -1)}
onMouseLeave={() => (hovered.current = -1)}
onClick={() => activate(i)}
>
<span className={styles.icon} data-menu-icon={!r.checked && r.icon ? "" : undefined}>
+36
Переглянути файл
@@ -5,6 +5,7 @@ import { getContext, setContext } from "@/platform/context";
import { registerAction } from "@/platform/actions";
import { Popup } from "./Popup";
import { ContextMenuHost, Menu, MenuList, hideContextMenu, showContextMenu } from "./Menu";
import menuStyles from "./Menu.module.css";
import { Dialog } from "./Dialog";
function Harness({ onClose }: { onClose?: (r: string) => void }) {
@@ -192,6 +193,41 @@ describe("Menu action enablement", () => {
expect(input).toHaveFocus();
});
it("highlights the row under the pointer without moving focus", () => {
render(
<MenuList
items={[
{ label: "Open", run: () => undefined },
{ label: "Save", run: () => undefined },
]}
onDone={() => {}}
/>,
);
const open = screen.getByRole("menuitem", { name: "Open" });
const save = screen.getByRole("menuitem", { name: "Save" });
expect(open).toHaveFocus();
expect(open.className).toContain(menuStyles.active);
fireEvent.pointerEnter(save);
expect(save.className).toContain(menuStyles.active);
expect(open.className).not.toContain(menuStyles.active);
expect(open).toHaveFocus();
});
it("reports a hovered row once and lets the keyboard continue from it", () => {
const active: string[] = [];
const item = (label: string) => ({ label, onActive: () => active.push(label), run: () => undefined });
render(<MenuList items={[item("Open"), item("Save"), item("Close")]} onDone={() => {}} />);
const save = screen.getByRole("menuitem", { name: "Save" });
fireEvent.pointerEnter(save);
fireEvent.mouseEnter(save);
expect(active).toEqual(["Save"]);
fireEvent.keyDown(save, { key: "ArrowDown" });
expect(screen.getByRole("menuitem", { name: "Close" })).toHaveFocus();
fireEvent.pointerLeave(save);
fireEvent.pointerEnter(save);
expect(active).toEqual(["Save", "Close", "Save"]);
});
it("follows context changes after the menu opened", () => {
const dispose = registerAction({ id: "test.menuWhen", title: "Needs Flag", when: "qaMenuFlag", run: () => {} });
setContext("qaMenuFlag", false);
+3
Переглянути файл
@@ -229,6 +229,9 @@ function MainMenuBar({ merged, onExpandedChange }: MainMenuProps & { merged: boo
onPointerEnter={() => {
if (open !== null) setOpen(i);
}}
onMouseEnter={() => {
if (open !== null) setOpen(i);
}}
onClick={() => {
if (open === i) close();
else setOpen(i);