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 <12905387+tomgor@users.noreply.github.com>
This commit is contained in:
tomgor
2026-11-05 00:16:32 +08:00
committed by GitHub
co-authored by tomgor
parent 94490e2b37
commit c27fea13b3
4 changed files with 77 additions and 11 deletions
+10 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);