From c27fea13b34c2ffa6894fcf65a63c93ec9a5113a Mon Sep 17 00:00:00 2001 From: tomgor <22257170@qq.com> Date: Thu, 5 Nov 2026 00:16:32 +0800 Subject: [PATCH] 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> --- src/styles/tokens.css | 14 ++++++++++---- src/ui/Menu.tsx | 35 ++++++++++++++++++++++++++++------- src/ui/Popup.test.tsx | 36 ++++++++++++++++++++++++++++++++++++ src/workbench/MainMenu.tsx | 3 +++ 4 files changed, 77 insertions(+), 11 deletions(-) diff --git a/src/styles/tokens.css b/src/styles/tokens.css index a572274a..d9eb0eb9 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -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"] { diff --git a/src/ui/Menu.tsx b/src/ui/Menu.tsx index 74ef0dcc..0b083979 100644 --- a/src/ui/Menu.tsx +++ b/src/ui/Menu.tsx @@ -125,13 +125,22 @@ export function MenuList({ items, onDone, onCloseLevel, autoFocusFirst = true, . const [subAnchor, setSubAnchor] = useState(null); const itemRefs = useRef>([]); const hoverTimer = useRef | 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)} > diff --git a/src/ui/Popup.test.tsx b/src/ui/Popup.test.tsx index ed98b039..2d0c2492 100644 --- a/src/ui/Popup.test.tsx +++ b/src/ui/Popup.test.tsx @@ -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( + 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( {}} />); + 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); diff --git a/src/workbench/MainMenu.tsx b/src/workbench/MainMenu.tsx index f9adf94a..53dc658e 100644 --- a/src/workbench/MainMenu.tsx +++ b/src/workbench/MainMenu.tsx @@ -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);