mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
feat(workbench): add merged main menu style
This commit is contained in:
1 parent
4796265b5c
commit
f566efb91a
10 files changed
+514
-59
No files matched your search
@@ -285,6 +285,8 @@ pub struct AppearanceSettings {
|
||||
pub project_colors_in_toolbar: bool,
|
||||
pub dnd_with_alt_only: bool,
|
||||
pub menu_icons: bool,
|
||||
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
|
||||
pub main_menu_style: String,
|
||||
/// `subpixel` | `greyscale` | `none`
|
||||
pub ide_antialiasing: String,
|
||||
pub editor_antialiasing: String,
|
||||
@@ -331,6 +333,7 @@ impl Default for AppearanceSettings {
|
||||
project_colors_in_toolbar: true,
|
||||
dnd_with_alt_only: false,
|
||||
menu_icons: true,
|
||||
main_menu_style: "hamburger".into(),
|
||||
ide_antialiasing: "greyscale".into(),
|
||||
editor_antialiasing: "greyscale".into(),
|
||||
show_tool_window_bars: true,
|
||||
@@ -713,6 +716,15 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn main_menu_style_defaults_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
|
||||
assert_eq!(old.appearance.main_menu_style, "hamburger");
|
||||
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
|
||||
assert_eq!(merged.appearance.main_menu_style, "merged");
|
||||
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_font_weights_default_to_regular_and_bold() {
|
||||
let json = serde_json::to_value(Settings::default()).unwrap();
|
||||
|
||||
@@ -1407,6 +1407,8 @@ export type AppearanceSettings = {
|
||||
projectColorsInToolbar?: boolean,
|
||||
dndWithAltOnly?: boolean,
|
||||
menuIcons?: boolean,
|
||||
/** `hamburger` | `merged`: main menu presentation in the frameless title bar. */
|
||||
mainMenuStyle?: string,
|
||||
/** `subpixel` | `greyscale` | `none` */
|
||||
ideAntialiasing?: string,
|
||||
editorAntialiasing?: string,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { validateAppearanceExtras } from "../model/behaviorSchema";
|
||||
import { useSettingsDialog } from "../model/staging";
|
||||
import { BEHAVIOR_PAGES } from "../pages/behavior";
|
||||
|
||||
const savedPlatform = usePlatform.getState();
|
||||
const savedDialog = useSettingsDialog.getState();
|
||||
afterEach(() => {
|
||||
usePlatform.setState(savedPlatform);
|
||||
useSettingsDialog.setState(savedDialog);
|
||||
});
|
||||
const option = BEHAVIOR_PAGES.find((p) => p.id === "appearance")!
|
||||
.sections!.find((s) => s.title === "UI Options")!
|
||||
.options.find((o) => o.kind === "custom")!;
|
||||
if (option.kind !== "custom") throw new Error("UI Options missing");
|
||||
const MainMenuStyleRow = option.component;
|
||||
|
||||
describe("Main menu appearance setting", () => {
|
||||
it.each(["windows", "linux"])("offers both styles on a frameless %s title bar", async (os) => {
|
||||
usePlatform.setState({ os, framelessTitleBar: true });
|
||||
render(<MainMenuStyleRow />);
|
||||
const select = screen.getByRole("button", { name: "Main menu" });
|
||||
expect(select).toHaveTextContent("Hamburger button");
|
||||
fireEvent.click(select);
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Merged with main toolbar" }));
|
||||
expect(select).toHaveTextContent("Merged with main toolbar");
|
||||
expect(useSettingsDialog.getState().staged["global|appearance.mainMenuStyle"]?.value).toBe("merged");
|
||||
});
|
||||
|
||||
it.each(["macos", "web", "linux"])("hides the setting without the frameless menu (%s)", (os) => {
|
||||
usePlatform.setState({ os, framelessTitleBar: false });
|
||||
render(<MainMenuStyleRow />);
|
||||
expect(screen.queryByText("Main menu")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Main menu" })).toBeNull();
|
||||
act(() => usePlatform.setState({ os: "windows", framelessTitleBar: true }));
|
||||
expect(screen.getByRole("button", { name: "Main menu" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves the hamburger default for existing or invalid settings", () => {
|
||||
expect(validateAppearanceExtras({}).mainMenuStyle).toBe("hamburger");
|
||||
expect(validateAppearanceExtras({ mainMenuStyle: "unknown" }).mainMenuStyle).toBe("hamburger");
|
||||
expect(validateAppearanceExtras({ mainMenuStyle: "merged" }).mainMenuStyle).toBe("merged");
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ export interface AppearanceExtras {
|
||||
projectColorsInToolbar: boolean;
|
||||
dndWithAltOnly: boolean;
|
||||
menuIcons: boolean;
|
||||
mainMenuStyle: "hamburger" | "merged";
|
||||
ideAntialiasing: Antialiasing;
|
||||
editorAntialiasing: Antialiasing;
|
||||
showToolWindowBars: boolean;
|
||||
@@ -78,6 +79,7 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = {
|
||||
projectColorsInToolbar: true,
|
||||
dndWithAltOnly: false,
|
||||
menuIcons: true,
|
||||
mainMenuStyle: "hamburger",
|
||||
ideAntialiasing: "greyscale",
|
||||
editorAntialiasing: "greyscale",
|
||||
showToolWindowBars: true,
|
||||
@@ -127,6 +129,7 @@ export function validateAppearanceExtras(raw: unknown): AppearanceExtras {
|
||||
projectColorsInToolbar: b("projectColorsInToolbar"),
|
||||
dndWithAltOnly: b("dndWithAltOnly"),
|
||||
menuIcons: b("menuIcons"),
|
||||
mainMenuStyle: oneOf(a.mainMenuStyle, ["hamburger", "merged"], d.mainMenuStyle),
|
||||
ideAntialiasing: oneOf(a.ideAntialiasing, AA, d.ideAntialiasing),
|
||||
editorAntialiasing: oneOf(a.editorAntialiasing, AA, d.editorAntialiasing),
|
||||
showToolWindowBars: b("showToolWindowBars"),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FontPicker } from "../components/FontPicker";
|
||||
import { useId, useRef, useState, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { isTauri } from "@/platform/ipc";
|
||||
import { SCHEMES as EDITOR_SCHEMES, listSchemes, customSchemes } from "@/platform/colorScheme";
|
||||
import { formatTimestamp } from "@/platform/dateFormat";
|
||||
@@ -223,6 +224,25 @@ function Grid({ left, right }: { left: ReactNode; right: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MainMenuStyleRow() {
|
||||
const frameless = usePlatform((s) => s.framelessTitleBar);
|
||||
const [mainMenuStyle, setMainMenuStyle] = useSetting<"hamburger" | "merged">("appearance.mainMenuStyle");
|
||||
if (!frameless) return null;
|
||||
return (
|
||||
<Row label="Main menu">
|
||||
<Select
|
||||
value={mainMenuStyle ?? "hamburger"}
|
||||
options={[
|
||||
{ value: "hamburger", label: "Hamburger button" },
|
||||
{ value: "merged", label: "Merged with main toolbar" },
|
||||
]}
|
||||
onChange={setMainMenuStyle}
|
||||
aria-label="Main menu"
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function UiOptionsGrid() {
|
||||
return (
|
||||
<Grid
|
||||
@@ -521,6 +541,12 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
|
||||
{
|
||||
title: "UI Options",
|
||||
options: [
|
||||
{
|
||||
kind: "custom",
|
||||
label: "Main menu",
|
||||
keywords: ["Hamburger button", "Merged with main toolbar"],
|
||||
component: MainMenuStyleRow,
|
||||
},
|
||||
{
|
||||
kind: "custom",
|
||||
label: "Compact mode",
|
||||
|
||||
@@ -2189,6 +2189,9 @@
|
||||
"TypeScript version": "TypeScript 版本",
|
||||
"Types": "类型",
|
||||
"Typography Settings": "排版设置",
|
||||
"Main menu": "主菜单",
|
||||
"Hamburger button": "汉堡菜单按钮",
|
||||
"Merged with main toolbar": "与主工具栏合并",
|
||||
"UI Options": "UI 选项",
|
||||
"UI elements take up less screen space": "UI 元素占用更少的屏幕空间",
|
||||
"UI font": "UI 字体",
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { commands, type Settings } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { MainMenu } from "./MainMenu";
|
||||
|
||||
let available = 500;
|
||||
let resize: () => void;
|
||||
const savedProject = useProject.getState();
|
||||
let dispose: () => void;
|
||||
const setStyle = (style: string) =>
|
||||
useProject.setState({ settings: { appearance: { mainMenuStyle: style } } as Settings });
|
||||
const alt = () => {
|
||||
fireEvent.keyDown(window, { key: "Alt", altKey: true });
|
||||
fireEvent.keyUp(window, { key: "Alt" });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
available = 500;
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
constructor(private callback: () => void) {}
|
||||
observe(element: HTMLElement) {
|
||||
if (element.className.includes("mainMenuSlot")) resize = this.callback;
|
||||
}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(() => available);
|
||||
vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockReturnValue(300);
|
||||
vi.spyOn(commands, "menuModel").mockResolvedValue([
|
||||
{
|
||||
kind: "submenu",
|
||||
id: "file",
|
||||
label: "File",
|
||||
children: [{ kind: "action", id: "test.open", label: "Open", hint: null, arg: null }],
|
||||
},
|
||||
{
|
||||
kind: "submenu",
|
||||
id: "edit",
|
||||
label: "Edit",
|
||||
children: [{ kind: "action", id: "test.undo", label: "Undo", hint: null, arg: null }],
|
||||
},
|
||||
]);
|
||||
dispose = registerActions([
|
||||
{ id: "test.open", title: "Open", category: "Test", run: vi.fn() },
|
||||
{ id: "test.undo", title: "Undo", category: "Test", run: vi.fn() },
|
||||
]);
|
||||
setStyle("hamburger");
|
||||
});
|
||||
afterEach(() => {
|
||||
dispose();
|
||||
useProject.setState(savedProject);
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const titles = () => within(screen.getByRole("menubar"));
|
||||
|
||||
describe("main menu presentation", () => {
|
||||
it("keeps the hamburger default, expanding on click and collapsing on Escape", async () => {
|
||||
const expanded = vi.fn();
|
||||
render(<MainMenu onExpandedChange={expanded} />);
|
||||
expect(screen.queryByRole("menubar")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Main Menu" }));
|
||||
expect(await screen.findByRole("menu", { name: "File" })).toBeInTheDocument();
|
||||
expect(expanded).toHaveBeenLastCalledWith(true);
|
||||
fireEvent.keyDown(document.activeElement!, { key: "ArrowRight" });
|
||||
expect(await screen.findByRole("menu", { name: "Edit" })).toBeInTheDocument();
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||
expect(screen.queryByRole("menubar")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Main Menu" })).toBeInTheDocument();
|
||||
expect(expanded).toHaveBeenLastCalledWith(false);
|
||||
alt();
|
||||
expect(await screen.findByRole("menu", { name: "File" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows merged titles, switches menus with arrows, and retains titles after dismissal or an action", async () => {
|
||||
setStyle("merged");
|
||||
const expanded = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<MainMenu onExpandedChange={expanded} />
|
||||
<button>Toolbar control</button>
|
||||
</>,
|
||||
);
|
||||
await screen.findByRole("menubar");
|
||||
expect(screen.queryByRole("button", { name: "Main Menu" })).toBeNull();
|
||||
fireEvent.click(titles().getByRole("menuitem", { name: "File" }));
|
||||
expect(await screen.findByRole("menu", { name: "File" })).toBeInTheDocument();
|
||||
fireEvent.keyDown(document.activeElement!, { key: "ArrowRight" });
|
||||
expect(await screen.findByRole("menu", { name: "Edit" })).toBeInTheDocument();
|
||||
fireEvent.keyDown(document.activeElement!, { key: "ArrowLeft" });
|
||||
expect(await screen.findByRole("menu", { name: "File" })).toBeInTheDocument();
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
expect(screen.getByRole("menubar")).toBeInTheDocument();
|
||||
fireEvent.click(titles().getByRole("menuitem", { name: "Edit" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Undo" }));
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
expect(screen.getByRole("menubar")).toBeInTheDocument();
|
||||
fireEvent.click(titles().getByRole("menuitem", { name: "File" }));
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Toolbar control" }));
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve)));
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
expect(expanded).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it.each(["hamburger", "merged"])(
|
||||
"%s switches on title clicks without an outside dismissal, then closes for toolbar clicks",
|
||||
async (style) => {
|
||||
setStyle(style);
|
||||
render(
|
||||
<>
|
||||
<MainMenu />
|
||||
<button>Toolbar control</button>
|
||||
</>,
|
||||
);
|
||||
if (style === "hamburger") fireEvent.click(screen.getByRole("button", { name: "Main Menu" }));
|
||||
await screen.findByRole("menubar");
|
||||
if (style === "merged") fireEvent.click(titles().getByRole("menuitem", { name: "File" }));
|
||||
const edit = titles().getByRole("menuitem", { name: "Edit" });
|
||||
fireEvent.pointerDown(edit);
|
||||
fireEvent.click(edit);
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve)));
|
||||
expect(screen.getByRole("menu", { name: "Edit" })).toBeInTheDocument();
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Toolbar control" }));
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve)));
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("focuses the merged titles with lone Alt and navigates before opening", async () => {
|
||||
setStyle("merged");
|
||||
render(
|
||||
<>
|
||||
<button>Editor</button>
|
||||
<MainMenu />
|
||||
</>,
|
||||
);
|
||||
await screen.findByRole("menubar");
|
||||
const editor = screen.getByRole("button", { name: "Editor" });
|
||||
editor.focus();
|
||||
alt();
|
||||
expect(titles().getByRole("menuitem", { name: "File" })).toHaveFocus();
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
fireEvent.keyDown(document.activeElement!, { key: "ArrowLeft" });
|
||||
expect(titles().getByRole("menuitem", { name: "Edit" })).toHaveFocus();
|
||||
fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
|
||||
expect(await screen.findByRole("menu", { name: "Edit" })).toBeInTheDocument();
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||
expect(titles().getByRole("menuitem", { name: "Edit" })).toHaveFocus();
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||
expect(editor).toHaveFocus();
|
||||
});
|
||||
|
||||
it("falls back to a hamburger popup when narrow and restores titles when space returns", async () => {
|
||||
available = 100;
|
||||
setStyle("merged");
|
||||
const expanded = vi.fn();
|
||||
render(<MainMenu onExpandedChange={expanded} />);
|
||||
await act(async () => {});
|
||||
expect(screen.queryByRole("menubar")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Main Menu" }));
|
||||
const root = await screen.findByRole("menu", { name: "Main Menu" });
|
||||
fireEvent.click(within(root).getByRole("menuitem", { name: /File/ }));
|
||||
expect(await screen.findByRole("menuitem", { name: "Open" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Open" }));
|
||||
expect(screen.getByRole("button", { name: "Main Menu" })).toBeInTheDocument();
|
||||
expect(expanded).not.toHaveBeenCalledWith(true);
|
||||
act(() => {
|
||||
available = 500;
|
||||
resize();
|
||||
});
|
||||
expect(screen.getByRole("menubar")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Main Menu" })).toBeNull();
|
||||
act(() => {
|
||||
available = 100;
|
||||
resize();
|
||||
});
|
||||
expect(screen.queryByRole("menubar")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Main Menu" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies the setting immediately and restores toolbar visibility when switching from an expanded hamburger", async () => {
|
||||
const expanded = vi.fn();
|
||||
render(<MainMenu onExpandedChange={expanded} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Main Menu" }));
|
||||
await screen.findByRole("menu");
|
||||
expect(expanded).toHaveBeenLastCalledWith(true);
|
||||
act(() => setStyle("merged"));
|
||||
await screen.findByRole("menubar");
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
expect(expanded).toHaveBeenLastCalledWith(false);
|
||||
act(() => setStyle("hamburger"));
|
||||
expect(screen.queryByRole("menubar")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Main Menu" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+163
-57
@@ -1,13 +1,8 @@
|
||||
/**
|
||||
* Frameless title bar (Windows, Linux): IDEA's main menu button. Clicking ☰ expands the menu bar
|
||||
* in place (File Edit View …) with File open; hovering another title switches
|
||||
* menus, ←/→ walk them, and closing the menu collapses the bar again. The
|
||||
* entries come from the same spec as the native menu (`menu_model`). As in
|
||||
* Windows apps and IDEA, pressing and releasing Alt alone opens it too.
|
||||
*/
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
/** Main menu for the frameless title bar, with hamburger and persistent title modes. */
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { lookup, t } from "@/platform/i18n";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { lookup, t, useI18n } from "@/platform/i18n";
|
||||
import { commands, type MenuNode } from "@/platform/ipc";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { Icon, MenuList, Popup, type MenuItem } from "@/ui";
|
||||
@@ -70,77 +65,163 @@ export function onLoneAlt(target: Window, onAlt: () => void): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
export function MainMenu({ onExpandedChange }: { onExpandedChange?: (expanded: boolean) => void }) {
|
||||
const [menus, setMenus] = useState<Submenu[] | null>(null);
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
const [bar, setBar] = useState<HTMLDivElement | null>(null);
|
||||
// A press on another title closes the open menu first (outside click); keep the bar expanded.
|
||||
const pressedTitle = useRef(false);
|
||||
async function loadMenus(): Promise<Submenu[]> {
|
||||
const model: MenuNode[] = await commands.menuModel().catch(() => []);
|
||||
return model.filter(isSubmenu);
|
||||
}
|
||||
|
||||
type MainMenuProps = { onExpandedChange?: (expanded: boolean) => void };
|
||||
|
||||
export function MainMenu(props: MainMenuProps) {
|
||||
const merged = useProject((s) => s.settings?.appearance?.mainMenuStyle === "merged");
|
||||
return <MainMenuBar key={String(merged)} merged={merged} {...props} />;
|
||||
}
|
||||
|
||||
function MainMenuBar({ merged, onExpandedChange }: MainMenuProps & { merged: boolean }) {
|
||||
useI18n((s) => s.locale);
|
||||
const [menus, setMenus] = useState<Submenu[]>([]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
const [focused, setFocused] = useState(0);
|
||||
const [bar, setBar] = useState<HTMLDivElement | null>(null);
|
||||
const slot = useRef<HTMLDivElement>(null);
|
||||
const measure = useRef<HTMLDivElement>(null);
|
||||
const hamburger = useRef<HTMLButtonElement>(null);
|
||||
const previousFocus = useRef<HTMLElement | null>(null);
|
||||
const [fits, setFits] = useState(false);
|
||||
const pressedTitle = useRef(false);
|
||||
const persistent = merged && fits;
|
||||
const showTitles = persistent || (!merged && expanded);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
if (merged)
|
||||
void loadMenus().then((top) => {
|
||||
if (!disposed) setMenus(top);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [merged]);
|
||||
useEffect(() => {
|
||||
if (!merged || open === null) return;
|
||||
let disposed = false;
|
||||
void loadMenus().then((top) => {
|
||||
if (!disposed && top.length) setMenus(top);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [merged, open]);
|
||||
useEffect(() => {
|
||||
onExpandedChange?.(!merged && expanded);
|
||||
return () => onExpandedChange?.(false);
|
||||
}, [merged, expanded, onExpandedChange]);
|
||||
|
||||
// The invisible copy keeps the natural flex basis even while the slot shrinks.
|
||||
// Measuring the allocated slot includes all neighbouring toolbar/caption controls.
|
||||
useLayoutEffect(() => {
|
||||
if (!merged || !slot.current || !measure.current) return;
|
||||
const update = () => setFits(menus.length > 0 && slot.current!.clientWidth >= measure.current!.scrollWidth);
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(slot.current);
|
||||
observer.observe(measure.current);
|
||||
update();
|
||||
return () => observer.disconnect();
|
||||
}, [merged, menus]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(null);
|
||||
setExpanded(false);
|
||||
};
|
||||
const expand = async () => {
|
||||
const model: MenuNode[] = await commands.menuModel().catch(() => []);
|
||||
const top = model.filter(isSubmenu);
|
||||
const top = await loadMenus();
|
||||
if (!top.length) return;
|
||||
setMenus(top);
|
||||
setExpanded(true);
|
||||
setOpen(0);
|
||||
onExpandedChange?.(true);
|
||||
};
|
||||
const collapse = () => {
|
||||
setOpen(null);
|
||||
setMenus(null);
|
||||
onExpandedChange?.(false);
|
||||
const focusTitle = (i: number) => {
|
||||
setFocused(i);
|
||||
(bar?.children[i] as HTMLElement | undefined)?.focus();
|
||||
};
|
||||
const onMenuClose = (reason?: string) => {
|
||||
if (reason !== "outside") return collapse();
|
||||
if (reason !== "outside") {
|
||||
if (reason === "escape" && persistent) focusTitle(open ?? focused);
|
||||
return close();
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (pressedTitle.current) pressedTitle.current = false;
|
||||
else collapse();
|
||||
else close();
|
||||
});
|
||||
};
|
||||
useEffect(() => onLoneAlt(window, () => (menus ? collapse() : void expand())));
|
||||
const step = (delta: number) => {
|
||||
if (!menus || open === null) return;
|
||||
setOpen((open + delta + menus.length) % menus.length);
|
||||
};
|
||||
// ←/→ not used by the menu (top level, or a leaf item) move to the neighbouring menu.
|
||||
useEffect(() =>
|
||||
onLoneAlt(window, () => {
|
||||
if (open !== null || expanded) {
|
||||
close();
|
||||
if (persistent) focusTitle(focused);
|
||||
} else if (persistent) {
|
||||
previousFocus.current = document.activeElement as HTMLElement;
|
||||
focusTitle(0);
|
||||
} else void expand();
|
||||
}),
|
||||
);
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
e.preventDefault();
|
||||
step(e.key === "ArrowRight" ? 1 : -1);
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
if (!menus.length) return;
|
||||
e.preventDefault();
|
||||
const next = ((open ?? focused) + (e.key === "ArrowRight" ? 1 : -1) + menus.length) % menus.length;
|
||||
if (open !== null) setOpen(next);
|
||||
else focusTitle(next);
|
||||
} else if (e.key === "ArrowDown" && open === null) {
|
||||
e.preventDefault();
|
||||
setOpen(focused);
|
||||
} else if (e.key === "Escape" && open === null) {
|
||||
close();
|
||||
previousFocus.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
if (!menus)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.mainMenuButton}
|
||||
aria-label={t("menu.mainMenu")}
|
||||
title={t("menu.mainMenu")}
|
||||
onClick={() => void expand()}
|
||||
>
|
||||
<Icon name="main-menu" size={16} />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={setBar} role="menubar" className={styles.menuBar}>
|
||||
const button = (
|
||||
<button
|
||||
ref={hamburger}
|
||||
type="button"
|
||||
className={styles.mainMenuButton}
|
||||
aria-label={t("menu.mainMenu")}
|
||||
title={t("menu.mainMenu")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => {
|
||||
if (expanded) close();
|
||||
else void expand();
|
||||
}}
|
||||
>
|
||||
<Icon name="main-menu" size={16} />
|
||||
</button>
|
||||
);
|
||||
const titles = (
|
||||
<div ref={setBar} role="menubar" className={styles.menuBar} onKeyDown={onKeyDown}>
|
||||
{menus.map((m, i) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
tabIndex={focused === i ? 0 : -1}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open === i}
|
||||
className={clsx(styles.menuTitle, open === i && styles.menuTitleOpen)}
|
||||
onFocus={() => setFocused(i)}
|
||||
onPointerDown={() => {
|
||||
pressedTitle.current = true;
|
||||
setTimeout(() => {
|
||||
pressedTitle.current = false;
|
||||
});
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
if (open !== null) setOpen(i);
|
||||
}}
|
||||
onClick={() => {
|
||||
pressedTitle.current = false;
|
||||
if (open === i) collapse();
|
||||
if (open === i) close();
|
||||
else setOpen(i);
|
||||
}}
|
||||
>
|
||||
@@ -156,16 +237,41 @@ export function MainMenu({ onExpandedChange }: { onExpandedChange?: (expanded: b
|
||||
offset={2}
|
||||
role="presentation"
|
||||
autoFocus={false}
|
||||
restoreFocus={!persistent}
|
||||
onClose={onMenuClose}
|
||||
className={menuStyles.popup}
|
||||
>
|
||||
<div onKeyDown={onKeyDown}>
|
||||
<MenuList
|
||||
items={menuItems(menus[open].children)}
|
||||
onDone={collapse}
|
||||
aria-label={label(menus[open].id, menus[open].label)}
|
||||
/>
|
||||
</div>
|
||||
<MenuList
|
||||
items={menuItems(menus[open].children)}
|
||||
onDone={close}
|
||||
aria-label={label(menus[open].id, menus[open].label)}
|
||||
/>
|
||||
</Popup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
if (!merged) return showTitles ? titles : button;
|
||||
return (
|
||||
<div ref={slot} className={styles.mainMenuSlot}>
|
||||
<div ref={measure} className={clsx(styles.menuBar, styles.menuMeasure)} aria-hidden="true" inert>
|
||||
{menus.map((m) => (
|
||||
<span key={m.id} className={styles.menuTitle}>
|
||||
{label(m.id, m.label)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.menuPresentation}>{showTitles ? titles : button}</div>
|
||||
{!showTitles && expanded && (
|
||||
<Popup
|
||||
open
|
||||
anchor={hamburger}
|
||||
onClose={close}
|
||||
placement="bottom-start"
|
||||
role="presentation"
|
||||
autoFocus={false}
|
||||
className={menuStyles.popup}
|
||||
>
|
||||
<MenuList items={menuItems(menus)} onDone={close} aria-label={t("menu.mainMenu")} />
|
||||
</Popup>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,32 @@
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* The expanded main menu: File Edit View … in place of the project widgets. */
|
||||
/* Preserve the menu's natural width while allowing its slot to yield to toolbar controls. */
|
||||
.mainMenuSlot {
|
||||
position: relative;
|
||||
flex: 0 1 auto;
|
||||
min-width: 34px;
|
||||
height: 30px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mainMenuSlot ~ .item,
|
||||
.mainMenuSlot ~ .tools {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.menuMeasure {
|
||||
width: max-content;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.menuPresentation {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
/* The main menu titles. */
|
||||
.menuBar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./WindowControls", () => ({ WindowControls: () => null }));
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { commands, type Settings } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { useThemeOverride } from "./theme";
|
||||
import { customizeToolbarItems, gearMenuItems, TitleBar } from "./TitleBar";
|
||||
@@ -103,3 +105,32 @@ describe("title bar in Distraction Free Mode (bare)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("merged title bar menu", () => {
|
||||
it.each(["windows", "linux", "macos"])(
|
||||
"%s keeps toolbar controls available and uses the menu only when frameless",
|
||||
async (os) => {
|
||||
const savedPlatform = usePlatform.getState();
|
||||
const savedProject = useProject.getState();
|
||||
const model = vi
|
||||
.spyOn(commands, "menuModel")
|
||||
.mockResolvedValue([{ kind: "submenu", id: "file", label: "File", children: [] }]);
|
||||
usePlatform.setState({ os, framelessTitleBar: os !== "macos", overlayTitleBar: os === "macos" });
|
||||
useProject.setState({ settings: { appearance: { mainMenuStyle: "merged" } } as Settings });
|
||||
try {
|
||||
render(<TitleBar />);
|
||||
await act(async () => {});
|
||||
expect(!!screen.queryByRole("menubar")).toBe(os !== "macos");
|
||||
expect(screen.queryByRole("button", { name: "Main Menu" })).toBeNull();
|
||||
if (os !== "macos") fireEvent.click(screen.getByRole("menuitem", { name: "File" }));
|
||||
expect(screen.getByRole("button", { name: "OpenIDEA" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "IDE and Project Settings" }));
|
||||
expect(await screen.findByRole("menu", { name: "IDE and Project Settings" })).toBeInTheDocument();
|
||||
} finally {
|
||||
usePlatform.setState(savedPlatform);
|
||||
useProject.setState(savedProject);
|
||||
model.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user