authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-19 16:09:40-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-19 16:09:40-04:00
log3c0665543f0e31db1e3425d4fcf9f4aea295a8ce
tree2ccb7303d5427b6882b812695571928306cb93b4
parent80c37f1a721d36bde86b31c475fcc8d3db75f706
signature Signed by PGP key B5690EEEBB952194

feat: refine thread action menus (#7476)


7 files changed, 353 insertions(+), 38 deletions(-)

apps/desktop/src/electron/ElectronMenu.test.ts+36-1
......@@ -98,7 +98,10 @@ describe("ElectronMenu", () => {
9898 const electronMenu = yield* ElectronMenu.ElectronMenu;
9999 const selectedItemId = yield* electronMenu.showContextMenu({
100100 window: makeWindow(2),
101 items: [{ id: "copy", label: "Copy" }],
101 items: [
102 { id: "copy", label: "Copy" },
103 { id: "delete", label: "Delete", destructive: true, separatorBefore: true },
104 ],
102105 position: Option.some({ x: 10.8, y: 20.2 }),
103106 });
104107
......@@ -110,6 +113,38 @@ describe("ElectronMenu", () => {
110113 enabled: true,
111114 click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,
112115 });
116 assert.deepEqual(
117 buildFromTemplateMock.mock.calls[0]?.[0].map(
118 (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label,
119 ),
120 ["Copy", "separator", "Delete"],
121 );
122 }).pipe(Effect.provide(TestLayer)),
123 );
124
125 it.effect("keeps a preceding non-destructive action in the destructive section", () =>
126 Effect.gen(function* () {
127 buildFromTemplateMock.mockImplementation(() => ({
128 popup: (options: Electron.PopupOptions) => options.callback?.(),
129 }));
130
131 const electronMenu = yield* ElectronMenu.ElectronMenu;
132 yield* electronMenu.showContextMenu({
133 window: makeWindow(),
134 items: [
135 { id: "copy", label: "Copy" },
136 { id: "archive", label: "Archive", separatorBefore: true },
137 { id: "delete", label: "Delete", destructive: true },
138 ],
139 position: Option.none(),
140 });
141
142 assert.deepEqual(
143 buildFromTemplateMock.mock.calls[0]?.[0].map(
144 (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label,
145 ),
146 ["Copy", "separator", "Archive", "Delete"],
147 );
113148 }).pipe(Effect.provide(TestLayer)),
114149 );
115150
apps/desktop/src/electron/ElectronMenu.ts+17-2
......@@ -78,6 +78,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
7878 label: sourceItem.label,
7979 destructive: sourceItem.destructive === true,
8080 disabled: sourceItem.disabled === true,
81 ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}),
8182 };
8283
8384 if (sourceItem.children) {
......@@ -141,10 +142,24 @@ export const make = Effect.gen(function* () {
141142 ): Electron.MenuItemConstructorOptions[] => {
142143 const template: Electron.MenuItemConstructorOptions[] = [];
143144 let hasInsertedDestructiveSeparator = false;
145 let sectionStartedByExplicitSeparator = false;
146 const appendSeparator = () => {
147 if (template.length === 0 || template.at(-1)?.type === "separator") return;
148 template.push({ type: "separator" });
149 };
144150
145151 for (const item of entries) {
146 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
147 template.push({ type: "separator" });
152 if (item.separatorBefore) {
153 appendSeparator();
154 sectionStartedByExplicitSeparator = true;
155 }
156 if (
157 item.destructive &&
158 !hasInsertedDestructiveSeparator &&
159 !sectionStartedByExplicitSeparator &&
160 template.length > 0
161 ) {
162 appendSeparator();
148163 hasInsertedDestructiveSeparator = true;
149164 }
150165
apps/web/src/components/threadActionMenu.logic.test.ts+12-5
......@@ -20,6 +20,12 @@ function ids(state: ThreadActionMenuState): string[] {
2020 return buildThreadActionMenuItems(state).map((item) => item.id);
2121}
2222
23function allIds(state: ThreadActionMenuState): string[] {
24 const flatten = (items: ReturnType<typeof buildThreadActionMenuItems>): string[] =>
25 items.flatMap((item) => [item.id, ...(item.children ? flatten(item.children) : [])]);
26 return flatten(buildThreadActionMenuItems(state));
27}
28
2329describe("buildThreadActionMenuItems", () => {
2430 it("hides lifecycle items when the environment lacks the capabilities", () => {
2531 expect(
......@@ -27,15 +33,15 @@ describe("buildThreadActionMenuItems", () => {
2733 ...baseState,
2834 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
2935 }),
30 ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "archive", "delete"]);
36 ).toEqual(["rename", "mark-unread", "copy", "archive", "delete"]);
3137 });
3238
3339 it("includes branch items only for threads with a branch", () => {
34 const withBranch = ids({ ...baseState, branch: "feat/menu" });
40 const withBranch = allIds({ ...baseState, branch: "feat/menu" });
3541 expect(withBranch).toContain("new-thread-on-branch");
3642 expect(withBranch).toContain("copy-branch");
37 expect(ids(baseState)).not.toContain("new-thread-on-branch");
38 expect(ids(baseState)).not.toContain("copy-branch");
43 expect(allIds(baseState)).not.toContain("new-thread-on-branch");
44 expect(allIds(baseState)).not.toContain("copy-branch");
3945 });
4046
4147 it("flips lifecycle labels with thread state", () => {
......@@ -64,11 +70,12 @@ describe("buildThreadActionMenuItems", () => {
6470 const items = buildThreadActionMenuItems({ ...baseState, branch: "main" });
6571 expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true });
6672 });
67
6873 it("offers archive as a non-destructive action right before delete", () => {
6974 const items = buildThreadActionMenuItems(baseState);
7075 const archiveItem = items.at(-2);
7176 expect(archiveItem?.id).toBe("archive");
77 expect(archiveItem?.icon).toBe("archive");
78 expect(archiveItem?.separatorBefore).toBe(true);
7279 expect(archiveItem?.destructive).toBeFalsy();
7380 expect(items.at(-1)?.id).toBe("delete");
7481 });
apps/web/src/components/threadActionMenu.logic.ts+37-12
......@@ -18,6 +18,7 @@ export type ThreadActionMenuId =
1818 | "rename"
1919 | "regenerate-title"
2020 | "mark-unread"
21 | "copy"
2122 | "copy-path"
2223 | "copy-branch"
2324 | "copy-thread-id"
......@@ -56,14 +57,15 @@ export function buildThreadActionMenuItems(
5657 {
5758 id: "new-thread-on-branch" as const,
5859 label: `New thread on ${state.branch}`,
60 icon: "message-square-plus",
5961 },
6062 ]
6163 : []),
6264 ...(state.supports.pinning
6365 ? [
6466 state.isPinned
65 ? { id: "unpin" as const, label: "Unpin thread" }
66 : { id: "pin" as const, label: "Pin thread" },
67 ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" }
68 : { id: "pin" as const, label: "Pin thread", icon: "pin" },
6769 ]
6870 : []),
6971 // Both lifecycle actions stay available on pinned threads: settling
......@@ -72,17 +74,18 @@ export function buildThreadActionMenuItems(
7274 ...(state.supports.settlement
7375 ? [
7476 state.isSettled
75 ? { id: "unsettle" as const, label: "Un-settle thread" }
76 : { id: "settle" as const, label: "Settle thread" },
77 ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" }
78 : { id: "settle" as const, label: "Settle thread", icon: "circle-check" },
7779 ]
7880 : []),
7981 ...(state.supports.snooze
8082 ? [
8183 state.isSnoozed
82 ? { id: "unsnooze" as const, label: "Wake thread" }
84 ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" }
8385 : {
8486 id: "snooze" as const,
8587 label: "Snooze",
88 icon: "clock",
8689 disabled: !state.canSnoozeNow,
8790 children: state.snoozePresets.map((preset) => ({
8891 id: `snooze:${preset.id}` as const,
......@@ -91,26 +94,48 @@ export function buildThreadActionMenuItems(
9194 },
9295 ]
9396 : []),
94 { id: "rename", label: "Rename thread" },
97 { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true },
9598 ...(state.supports.titleRegeneration
9699 ? [
97100 {
98101 id: "regenerate-title" as const,
99102 label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title",
103 icon: "refresh-cw",
100104 disabled: state.isRegeneratingTitle,
101105 },
102106 ]
103107 : []),
104 { id: "mark-unread", label: "Mark unread" },
105 { id: "copy-path", label: "Copy path", icon: "copy" },
106 ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []),
107 { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" },
108 { id: "mark-unread", label: "Mark unread", icon: "mail-open" },
109 {
110 id: "copy",
111 label: "Copy",
112 icon: "copy",
113 separatorBefore: true,
114 children: [
115 { id: "copy-path", label: "Path", icon: "folder" },
116 ...(state.branch
117 ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }]
118 : []),
119 { id: "copy-thread-id", label: "Thread ID", icon: "hash" },
120 ],
121 },
108122 // Archive removes the thread from the sidebar while keeping its
109123 // conversation under Settings > Archived threads — distinct from Settle
110124 // (stays visible in the Settled shelf) and Delete (clears history for
111125 // good), so it sits beside Delete without borrowing its destructive
112126 // styling.
113 { id: "archive", label: "Archive thread", disabled: state.isRunning },
114 { id: "delete", label: "Delete", destructive: true, icon: "trash" },
127 {
128 id: "archive",
129 label: "Archive thread",
130 icon: "archive",
131 disabled: state.isRunning,
132 separatorBefore: true,
133 },
134 {
135 id: "delete",
136 label: "Delete",
137 destructive: true,
138 icon: "trash",
139 },
115140 ];
116141}
apps/web/src/contextMenuFallback.test.ts+91
......@@ -24,14 +24,24 @@ class FakeElement {
2424 parent: FakeElement | null = null;
2525 style: Record<string, string> & { cssText?: string } = {};
2626 dataset: Record<string, string> = {};
27 attributes = new Map<string, string>();
2728 className = "";
2829 disabled = false;
30 focused = false;
2931 type = "";
3032 private textValue = "";
3133 private readonly listeners = new Map<string, FakeListener[]>();
3234
3335 constructor(readonly tagName: string) {}
3436
37 get isConnected() {
38 let current: FakeElement | null = this;
39 while (current?.parent) {
40 current = current.parent;
41 }
42 return current?.tagName === "body";
43 }
44
3545 appendChild(child: FakeElement) {
3646 child.parent = this;
3747 this.children.push(child);
......@@ -55,6 +65,10 @@ class FakeElement {
5565 this.listeners.set(type, existing);
5666 }
5767
68 setAttribute(name: string, value: string) {
69 this.attributes.set(name, value);
70 }
71
5872 dispatchEvent(event: FakeDomEvent) {
5973 for (const listener of this.listeners.get(event.type) ?? []) {
6074 listener(event);
......@@ -62,6 +76,26 @@ class FakeElement {
6276 return true;
6377 }
6478
79 focus() {
80 const fakeDocument = document as unknown as FakeDocument;
81 if (fakeDocument.activeElement === this) {
82 return;
83 }
84 fakeDocument.activeElement?.blur();
85 fakeDocument.activeElement = this;
86 this.focused = true;
87 this.dispatchEvent(new FakeDomEvent("focus"));
88 }
89
90 blur() {
91 const fakeDocument = document as unknown as FakeDocument;
92 if (fakeDocument.activeElement === this) {
93 fakeDocument.activeElement = null;
94 }
95 this.focused = false;
96 this.dispatchEvent(new FakeDomEvent("blur"));
97 }
98
6599 set textContent(value: string) {
66100 this.textValue = value;
67101 }
......@@ -116,6 +150,7 @@ class FakeBody extends FakeElement {
116150
117151class FakeDocument {
118152 body = new FakeBody();
153 activeElement: FakeElement | null = null;
119154 private readonly listeners = new Map<string, FakeListener[]>();
120155
121156 createElement(tagName: string) {
......@@ -152,6 +187,7 @@ function findButton(label: string): FakeElement | undefined {
152187
153188beforeEach(() => {
154189 vi.stubGlobal("document", new FakeDocument());
190 vi.stubGlobal("HTMLElement", FakeElement);
155191 vi.stubGlobal("window", {
156192 innerWidth: 1280,
157193 innerHeight: 800,
......@@ -183,6 +219,21 @@ afterEach(() => {
183219});
184220
185221describe("showContextMenuFallback", () => {
222 it("renders one separator between menu sections", async () => {
223 const selectionPromise = showContextMenuFallback([
224 { id: "rename", label: "Rename" },
225 { id: "archive", label: "Archive", separatorBefore: true },
226 ]);
227 const separators = (document as unknown as FakeDocument)
228 .querySelectorAll("div")
229 .filter((element) => element.dataset.contextMenuSeparator === "true");
230
231 expect(separators).toHaveLength(1);
232 expect(separators[0]?.attributes.get("role")).toBe("separator");
233 dismissContextMenu();
234 await expect(selectionPromise).resolves.toBeNull();
235 });
236
186237 it("resolves a clicked flat menu item", async () => {
187238 const selectionPromise = showContextMenuFallback([
188239 { id: "rename", label: "Rename" },
......@@ -235,6 +286,46 @@ describe("showContextMenuFallback", () => {
235286
236287 await expect(selectionPromise).resolves.toBe("rename:project-b");
237288 });
289
290 it("opens and focuses nested submenus when the parent is activated", async () => {
291 const invoker = (document as unknown as FakeDocument).createElement("button");
292 (document as unknown as FakeDocument).body.appendChild(invoker);
293 invoker.focus();
294 const selectionPromise = showContextMenuFallback([
295 {
296 id: "copy:submenu",
297 label: "Copy",
298 children: [
299 { id: "copy:path", label: "Path" },
300 { id: "copy:branch", label: "Branch" },
301 ],
302 },
303 ]);
304
305 const parentButton = findButton("Copy");
306 expect(parentButton).toBeTruthy();
307 expect(parentButton?.attributes.get("aria-haspopup")).toBe("menu");
308 expect(parentButton?.attributes.get("aria-expanded")).toBe("false");
309 parentButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
310 expect(parentButton?.attributes.get("aria-expanded")).toBe("true");
311
312 const childButton = findButton("Path");
313 const siblingButton = findButton("Branch");
314 expect(childButton).toBeTruthy();
315 expect(siblingButton).toBeTruthy();
316 expect(childButton?.focused).toBe(true);
317 expect(childButton?.style.background).toBe("var(--accent)");
318 expect(childButton?.style.color).toBe("var(--accent-foreground)");
319 siblingButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
320 expect(childButton?.focused).toBe(false);
321 expect(childButton?.style.background).toBe("transparent");
322 expect(siblingButton?.focused).toBe(true);
323 expect(siblingButton?.style.background).toBe("var(--accent)");
324 siblingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
325
326 await expect(selectionPromise).resolves.toBe("copy:branch");
327 expect(invoker.focused).toBe(true);
328 });
238329});
239330
240331describe("dismissContextMenu", () => {
apps/web/src/contextMenuFallback.ts+156-18
......@@ -4,6 +4,20 @@ const SVG_NS = "http://www.w3.org/2000/svg";
44
55// Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2).
66const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<string, string> }>> = {
7 archive: [
8 { tag: "rect", attrs: { width: "20", height: "5", x: "2", y: "3", rx: "1" } },
9 { tag: "path", attrs: { d: "M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" } },
10 { tag: "path", attrs: { d: "M10 12h4" } },
11 ],
12 "chevron-right": [{ tag: "path", attrs: { d: "m9 19 7-7-7-7" } }],
13 "circle-check": [
14 { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } },
15 { tag: "path", attrs: { d: "m9 12 2 2 4-4" } },
16 ],
17 clock: [
18 { tag: "path", attrs: { d: "M12 6v6l4 2" } },
19 { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } },
20 ],
721 pencil: [
822 {
923 tag: "path",
......@@ -17,6 +31,71 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
1731 { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } },
1832 { tag: "path", attrs: { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" } },
1933 ],
34 folder: [
35 {
36 tag: "path",
37 attrs: {
38 d: "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z",
39 },
40 },
41 ],
42 "git-branch": [
43 { tag: "line", attrs: { x1: "6", x2: "6", y1: "3", y2: "15" } },
44 { tag: "circle", attrs: { cx: "18", cy: "6", r: "3" } },
45 { tag: "circle", attrs: { cx: "6", cy: "18", r: "3" } },
46 { tag: "path", attrs: { d: "M18 9a9 9 0 0 1-9 9" } },
47 ],
48 hash: [
49 { tag: "line", attrs: { x1: "4", x2: "20", y1: "9", y2: "9" } },
50 { tag: "line", attrs: { x1: "4", x2: "20", y1: "15", y2: "15" } },
51 { tag: "line", attrs: { x1: "10", x2: "8", y1: "3", y2: "21" } },
52 { tag: "line", attrs: { x1: "16", x2: "14", y1: "3", y2: "21" } },
53 ],
54 "mail-open": [
55 {
56 tag: "path",
57 attrs: {
58 d: "M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z",
59 },
60 },
61 { tag: "path", attrs: { d: "m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10" } },
62 ],
63 "message-square-plus": [
64 {
65 tag: "path",
66 attrs: {
67 d: "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z",
68 },
69 },
70 { tag: "path", attrs: { d: "M12 8v6" } },
71 { tag: "path", attrs: { d: "M9 11h6" } },
72 ],
73 pin: [
74 { tag: "path", attrs: { d: "M12 17v5" } },
75 {
76 tag: "path",
77 attrs: {
78 d: "M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z",
79 },
80 },
81 ],
82 "pin-off": [
83 { tag: "path", attrs: { d: "M12 17v5" } },
84 { tag: "path", attrs: { d: "M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89" } },
85 { tag: "path", attrs: { d: "m2 2 20 20" } },
86 {
87 tag: "path",
88 attrs: {
89 d: "M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11",
90 },
91 },
92 ],
93 "refresh-cw": [
94 { tag: "path", attrs: { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" } },
95 { tag: "path", attrs: { d: "M21 3v5h-5" } },
96 { tag: "path", attrs: { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" } },
97 { tag: "path", attrs: { d: "M8 16H3v5" } },
98 ],
2099 "folder-tree": [
21100 {
22101 tag: "path",
......@@ -44,7 +123,7 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
44123
45124function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null {
46125 const paths = ICON_PATHS[name];
47 if (!paths) {
126 if (!paths || typeof document.createElementNS !== "function") {
48127 return null;
49128 }
50129 const svg = document.createElementNS(SVG_NS, "svg");
......@@ -57,7 +136,9 @@ function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSV
57136 svg.setAttribute("stroke-linejoin", "round");
58137 svg.setAttribute(
59138 "class",
60 tone === "destructive" ? "size-3.5 shrink-0" : "size-3.5 shrink-0 text-muted-foreground",
139 tone === "destructive"
140 ? "size-4.5 shrink-0 sm:size-4"
141 : "size-4.5 shrink-0 text-muted-foreground sm:size-4",
61142 );
62143 for (const node of paths) {
63144 const child = document.createElementNS(SVG_NS, node.tag);
......@@ -125,7 +206,10 @@ export function showContextMenuFallback<T extends string>(
125206 position?: { x: number; y: number },
126207): Promise<T | null> {
127208 return new Promise<T | null>((resolve) => {
209 const previouslyFocusedElement =
210 document.activeElement instanceof HTMLElement ? document.activeElement : null;
128211 const menuStack: HTMLDivElement[] = [];
212 const submenuTriggerStack: Array<HTMLButtonElement | undefined> = [];
129213 let isDisposed = false;
130214 let canDismissFromPointer = false;
131215
......@@ -142,9 +226,13 @@ export function showContextMenuFallback<T extends string>(
142226 document.removeEventListener("keydown", onKeyDown);
143227 document.removeEventListener("pointerdown", onPointerDown, true);
144228 document.removeEventListener("contextmenu", onContextMenu, true);
229 const shouldRestoreFocus = isNodeWithinMenuStack(document.activeElement, menuStack);
145230 for (const menu of menuStack) {
146231 menu.remove();
147232 }
233 if (shouldRestoreFocus && previouslyFocusedElement?.isConnected) {
234 previouslyFocusedElement.focus({ preventScroll: true });
235 }
148236 resolve(result);
149237 };
150238
......@@ -172,6 +260,7 @@ export function showContextMenuFallback<T extends string>(
172260
173261 const closeMenusFromLevel = (level: number) => {
174262 while (menuStack.length > level) {
263 submenuTriggerStack.pop()?.setAttribute("aria-expanded", "false");
175264 menuStack.pop()?.remove();
176265 }
177266 };
......@@ -181,6 +270,7 @@ export function showContextMenuFallback<T extends string>(
181270 preferredLeft: number,
182271 preferredTop: number,
183272 level: number,
273 parentTrigger?: HTMLButtonElement,
184274 ) => {
185275 closeMenusFromLevel(level);
186276
......@@ -200,6 +290,15 @@ export function showContextMenuFallback<T extends string>(
200290 "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;";
201291
202292 for (const item of entries) {
293 if (item.separatorBefore === true && inner.children.length > 0) {
294 const separator = document.createElement("div");
295 separator.className = "mx-2 my-1 h-px bg-border";
296 separator.style.cssText = "height:1px;margin:0.25rem 0.5rem;background:var(--border);";
297 separator.dataset.contextMenuSeparator = "true";
298 separator.setAttribute("role", "separator");
299 inner.appendChild(separator);
300 }
301
203302 if (item.header === true) {
204303 const header = document.createElement("div");
205304 header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs";
......@@ -247,34 +346,63 @@ export function showContextMenuFallback<T extends string>(
247346 button.appendChild(label);
248347
249348 if (hasChildren) {
250 const chevron = document.createElement("span");
251 chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none";
252 chevron.textContent = ">";
253 button.appendChild(chevron);
349 button.setAttribute("aria-haspopup", "menu");
350 button.setAttribute("aria-expanded", "false");
351 const chevron = createIconElement("chevron-right", "neutral");
352 if (chevron) {
353 chevron.setAttribute(
354 "class",
355 "-me-0.5 ms-auto size-4.5 shrink-0 text-muted-foreground opacity-80 sm:size-4",
356 );
357 chevron.setAttribute("aria-hidden", "true");
358 chevron.dataset.contextMenuChevron = "true";
359 button.appendChild(chevron);
360 }
254361 }
255362
256363 if (!isDisabled) {
364 let isHovered = false;
365 let isFocused = false;
366 const updateHighlight = () => {
367 const isHighlighted = isHovered || isFocused;
368 button.style.background = isHighlighted
369 ? isLeafDestructive
370 ? "color-mix(in srgb, var(--destructive) 10%, transparent)"
371 : "var(--accent)"
372 : "transparent";
373 button.style.color = isHighlighted
374 ? isLeafDestructive
375 ? "var(--destructive-foreground)"
376 : "var(--accent-foreground)"
377 : isLeafDestructive
378 ? "var(--destructive-foreground)"
379 : "var(--foreground)";
380 };
257381 button.addEventListener("mouseenter", () => {
258 button.style.background = isLeafDestructive
259 ? "color-mix(in srgb, var(--destructive) 10%, transparent)"
260 : "var(--accent)";
261 button.style.color = isLeafDestructive
262 ? "var(--destructive-foreground)"
263 : "var(--accent-foreground)";
382 button.focus({ preventScroll: true });
383 isHovered = true;
384 updateHighlight();
264385 });
265386 button.addEventListener("mouseleave", () => {
266 button.style.background = "transparent";
267 button.style.color = isLeafDestructive
268 ? "var(--destructive-foreground)"
269 : "var(--foreground)";
387 isHovered = false;
388 updateHighlight();
389 });
390 button.addEventListener("focus", () => {
391 isFocused = true;
392 updateHighlight();
393 });
394 button.addEventListener("blur", () => {
395 isFocused = false;
396 updateHighlight();
270397 });
271398
272399 if (hasChildren) {
273 button.addEventListener("mouseenter", () => {
400 const openSubmenu = (focusFirstItem = false) => {
274401 const rect = button.getBoundingClientRect();
275402 const nextLeft = rect.right + 4;
276403 const nextTop = rect.top;
277 openMenu(item.children!, nextLeft, nextTop, level + 1);
404 openMenu(item.children!, nextLeft, nextTop, level + 1, button);
405 button.setAttribute("aria-expanded", "true");
278406
279407 const childMenu = menuStack[level + 1];
280408 if (!childMenu) {
......@@ -284,9 +412,18 @@ export function showContextMenuFallback<T extends string>(
284412 if (childRect.right > window.innerWidth) {
285413 clampMenuPosition(childMenu, rect.left - childRect.width - 4, rect.top);
286414 }
415 if (focusFirstItem) {
416 [...childMenu.querySelectorAll<HTMLButtonElement>("button")]
417 .find((childButton) => !childButton.disabled)
418 ?.focus();
419 }
420 };
421 button.addEventListener("mouseenter", () => {
422 openSubmenu();
287423 });
288424 button.addEventListener("click", (event) => {
289425 event.preventDefault();
426 openSubmenu(true);
290427 });
291428 } else {
292429 button.addEventListener("mouseenter", () => {
......@@ -309,6 +446,7 @@ export function showContextMenuFallback<T extends string>(
309446
310447 document.body.appendChild(menu);
311448 menuStack[level] = menu;
449 submenuTriggerStack[level] = parentTrigger;
312450
313451 requestAnimationFrame(() => {
314452 clampMenuPosition(menu, preferredLeft, preferredTop);
packages/contracts/src/ipc.ts+4
......@@ -111,6 +111,8 @@ export interface ContextMenuItem<T extends string = string> {
111111 header?: boolean;
112112 /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */
113113 icon?: string;
114 /** Inserts a visual section divider immediately before this item. */
115 separatorBefore?: boolean;
114116 children?: readonly ContextMenuItem<T>[];
115117}
116118
......@@ -121,6 +123,7 @@ export interface ContextMenuItemSchemaType {
121123 readonly disabled?: boolean;
122124 readonly header?: boolean;
123125 readonly icon?: string;
126 readonly separatorBefore?: boolean;
124127 readonly children?: readonly ContextMenuItemSchemaType[];
125128}
126129
......@@ -131,6 +134,7 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc
131134 disabled: Schema.optionalKey(Schema.Boolean),
132135 header: Schema.optionalKey(Schema.Boolean),
133136 icon: Schema.optionalKey(Schema.String),
137 separatorBefore: Schema.optionalKey(Schema.Boolean),
134138 children: Schema.optionalKey(
135139 Schema.Array(
136140 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),