mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
+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}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user