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", () => {...@@ -98,7 +98,10 @@ describe("ElectronMenu", () => {
98 const electronMenu = yield* ElectronMenu.ElectronMenu;98 const electronMenu = yield* ElectronMenu.ElectronMenu;
99 const selectedItemId = yield* electronMenu.showContextMenu({99 const selectedItemId = yield* electronMenu.showContextMenu({
100 window: makeWindow(2),100 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 ],
102 position: Option.some({ x: 10.8, y: 20.2 }),105 position: Option.some({ x: 10.8, y: 20.2 }),
103 });106 });
104107
...@@ -110,6 +113,38 @@ describe("ElectronMenu", () => {...@@ -110,6 +113,38 @@ describe("ElectronMenu", () => {
110 enabled: true,113 enabled: true,
111 click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,114 click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,
112 });115 });
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 );
113 }).pipe(Effect.provide(TestLayer)),148 }).pipe(Effect.provide(TestLayer)),
114 );149 );
115150
apps/desktop/src/electron/ElectronMenu.ts+17-2
...@@ -78,6 +78,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM...@@ -78,6 +78,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
78 label: sourceItem.label,78 label: sourceItem.label,
79 destructive: sourceItem.destructive === true,79 destructive: sourceItem.destructive === true,
80 disabled: sourceItem.disabled === true,80 disabled: sourceItem.disabled === true,
81 ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}),
81 };82 };
8283
83 if (sourceItem.children) {84 if (sourceItem.children) {
...@@ -141,10 +142,24 @@ export const make = Effect.gen(function* () {...@@ -141,10 +142,24 @@ export const make = Effect.gen(function* () {
141 ): Electron.MenuItemConstructorOptions[] => {142 ): Electron.MenuItemConstructorOptions[] => {
142 const template: Electron.MenuItemConstructorOptions[] = [];143 const template: Electron.MenuItemConstructorOptions[] = [];
143 let hasInsertedDestructiveSeparator = false;144 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
145 for (const item of entries) {151 for (const item of entries) {
146 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {152 if (item.separatorBefore) {
147 template.push({ type: "separator" });153 appendSeparator();
154 sectionStartedByExplicitSeparator = true;
155 }
156 if (
157 item.destructive &&
158 !hasInsertedDestructiveSeparator &&
159 !sectionStartedByExplicitSeparator &&
160 template.length > 0
161 ) {
162 appendSeparator();
148 hasInsertedDestructiveSeparator = true;163 hasInsertedDestructiveSeparator = true;
149 }164 }
150165
apps/web/src/components/threadActionMenu.logic.test.ts+12-5
...@@ -20,6 +20,12 @@ function ids(state: ThreadActionMenuState): string[] {...@@ -20,6 +20,12 @@ function ids(state: ThreadActionMenuState): string[] {
20 return buildThreadActionMenuItems(state).map((item) => item.id);20 return buildThreadActionMenuItems(state).map((item) => item.id);
21}21}
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
23describe("buildThreadActionMenuItems", () => {29describe("buildThreadActionMenuItems", () => {
24 it("hides lifecycle items when the environment lacks the capabilities", () => {30 it("hides lifecycle items when the environment lacks the capabilities", () => {
25 expect(31 expect(
...@@ -27,15 +33,15 @@ describe("buildThreadActionMenuItems", () => {...@@ -27,15 +33,15 @@ describe("buildThreadActionMenuItems", () => {
27 ...baseState,33 ...baseState,
28 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },34 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
29 }),35 }),
30 ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "archive", "delete"]);36 ).toEqual(["rename", "mark-unread", "copy", "archive", "delete"]);
31 });37 });
3238
33 it("includes branch items only for threads with a branch", () => {39 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" });
35 expect(withBranch).toContain("new-thread-on-branch");41 expect(withBranch).toContain("new-thread-on-branch");
36 expect(withBranch).toContain("copy-branch");42 expect(withBranch).toContain("copy-branch");
37 expect(ids(baseState)).not.toContain("new-thread-on-branch");43 expect(allIds(baseState)).not.toContain("new-thread-on-branch");
38 expect(ids(baseState)).not.toContain("copy-branch");44 expect(allIds(baseState)).not.toContain("copy-branch");
39 });45 });
4046
41 it("flips lifecycle labels with thread state", () => {47 it("flips lifecycle labels with thread state", () => {
...@@ -64,11 +70,12 @@ describe("buildThreadActionMenuItems", () => {...@@ -64,11 +70,12 @@ describe("buildThreadActionMenuItems", () => {
64 const items = buildThreadActionMenuItems({ ...baseState, branch: "main" });70 const items = buildThreadActionMenuItems({ ...baseState, branch: "main" });
65 expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true });71 expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true });
66 });72 });
67
68 it("offers archive as a non-destructive action right before delete", () => {73 it("offers archive as a non-destructive action right before delete", () => {
69 const items = buildThreadActionMenuItems(baseState);74 const items = buildThreadActionMenuItems(baseState);
70 const archiveItem = items.at(-2);75 const archiveItem = items.at(-2);
71 expect(archiveItem?.id).toBe("archive");76 expect(archiveItem?.id).toBe("archive");
77 expect(archiveItem?.icon).toBe("archive");
78 expect(archiveItem?.separatorBefore).toBe(true);
72 expect(archiveItem?.destructive).toBeFalsy();79 expect(archiveItem?.destructive).toBeFalsy();
73 expect(items.at(-1)?.id).toBe("delete");80 expect(items.at(-1)?.id).toBe("delete");
74 });81 });
apps/web/src/components/threadActionMenu.logic.ts+37-12
...@@ -18,6 +18,7 @@ export type ThreadActionMenuId =...@@ -18,6 +18,7 @@ export type ThreadActionMenuId =
18 | "rename"18 | "rename"
19 | "regenerate-title"19 | "regenerate-title"
20 | "mark-unread"20 | "mark-unread"
21 | "copy"
21 | "copy-path"22 | "copy-path"
22 | "copy-branch"23 | "copy-branch"
23 | "copy-thread-id"24 | "copy-thread-id"
...@@ -56,14 +57,15 @@ export function buildThreadActionMenuItems(...@@ -56,14 +57,15 @@ export function buildThreadActionMenuItems(
56 {57 {
57 id: "new-thread-on-branch" as const,58 id: "new-thread-on-branch" as const,
58 label: `New thread on ${state.branch}`,59 label: `New thread on ${state.branch}`,
60 icon: "message-square-plus",
59 },61 },
60 ]62 ]
61 : []),63 : []),
62 ...(state.supports.pinning64 ...(state.supports.pinning
63 ? [65 ? [
64 state.isPinned66 state.isPinned
65 ? { id: "unpin" as const, label: "Unpin thread" }67 ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" }
66 : { id: "pin" as const, label: "Pin thread" },68 : { id: "pin" as const, label: "Pin thread", icon: "pin" },
67 ]69 ]
68 : []),70 : []),
69 // Both lifecycle actions stay available on pinned threads: settling71 // Both lifecycle actions stay available on pinned threads: settling
...@@ -72,17 +74,18 @@ export function buildThreadActionMenuItems(...@@ -72,17 +74,18 @@ export function buildThreadActionMenuItems(
72 ...(state.supports.settlement74 ...(state.supports.settlement
73 ? [75 ? [
74 state.isSettled76 state.isSettled
75 ? { id: "unsettle" as const, label: "Un-settle thread" }77 ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" }
76 : { id: "settle" as const, label: "Settle thread" },78 : { id: "settle" as const, label: "Settle thread", icon: "circle-check" },
77 ]79 ]
78 : []),80 : []),
79 ...(state.supports.snooze81 ...(state.supports.snooze
80 ? [82 ? [
81 state.isSnoozed83 state.isSnoozed
82 ? { id: "unsnooze" as const, label: "Wake thread" }84 ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" }
83 : {85 : {
84 id: "snooze" as const,86 id: "snooze" as const,
85 label: "Snooze",87 label: "Snooze",
88 icon: "clock",
86 disabled: !state.canSnoozeNow,89 disabled: !state.canSnoozeNow,
87 children: state.snoozePresets.map((preset) => ({90 children: state.snoozePresets.map((preset) => ({
88 id: `snooze:${preset.id}` as const,91 id: `snooze:${preset.id}` as const,
...@@ -91,26 +94,48 @@ export function buildThreadActionMenuItems(...@@ -91,26 +94,48 @@ export function buildThreadActionMenuItems(
91 },94 },
92 ]95 ]
93 : []),96 : []),
94 { id: "rename", label: "Rename thread" },97 { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true },
95 ...(state.supports.titleRegeneration98 ...(state.supports.titleRegeneration
96 ? [99 ? [
97 {100 {
98 id: "regenerate-title" as const,101 id: "regenerate-title" as const,
99 label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title",102 label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title",
103 icon: "refresh-cw",
100 disabled: state.isRegeneratingTitle,104 disabled: state.isRegeneratingTitle,
101 },105 },
102 ]106 ]
103 : []),107 : []),
104 { id: "mark-unread", label: "Mark unread" },108 { id: "mark-unread", label: "Mark unread", icon: "mail-open" },
105 { id: "copy-path", label: "Copy path", icon: "copy" },109 {
106 ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []),110 id: "copy",
107 { id: "copy-thread-id", label: "Copy thread ID", icon: "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 },
108 // Archive removes the thread from the sidebar while keeping its122 // Archive removes the thread from the sidebar while keeping its
109 // conversation under Settings > Archived threads — distinct from Settle123 // conversation under Settings > Archived threads — distinct from Settle
110 // (stays visible in the Settled shelf) and Delete (clears history for124 // (stays visible in the Settled shelf) and Delete (clears history for
111 // good), so it sits beside Delete without borrowing its destructive125 // good), so it sits beside Delete without borrowing its destructive
112 // styling.126 // styling.
113 { id: "archive", label: "Archive thread", disabled: state.isRunning },127 {
114 { id: "delete", label: "Delete", destructive: true, icon: "trash" },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 },
115 ];140 ];
116}141}
apps/web/src/contextMenuFallback.test.ts+91
...@@ -24,14 +24,24 @@ class FakeElement {...@@ -24,14 +24,24 @@ class FakeElement {
24 parent: FakeElement | null = null;24 parent: FakeElement | null = null;
25 style: Record<string, string> & { cssText?: string } = {};25 style: Record<string, string> & { cssText?: string } = {};
26 dataset: Record<string, string> = {};26 dataset: Record<string, string> = {};
27 attributes = new Map<string, string>();
27 className = "";28 className = "";
28 disabled = false;29 disabled = false;
30 focused = false;
29 type = "";31 type = "";
30 private textValue = "";32 private textValue = "";
31 private readonly listeners = new Map<string, FakeListener[]>();33 private readonly listeners = new Map<string, FakeListener[]>();
3234
33 constructor(readonly tagName: string) {}35 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
35 appendChild(child: FakeElement) {45 appendChild(child: FakeElement) {
36 child.parent = this;46 child.parent = this;
37 this.children.push(child);47 this.children.push(child);
...@@ -55,6 +65,10 @@ class FakeElement {...@@ -55,6 +65,10 @@ class FakeElement {
55 this.listeners.set(type, existing);65 this.listeners.set(type, existing);
56 }66 }
5767
68 setAttribute(name: string, value: string) {
69 this.attributes.set(name, value);
70 }
71
58 dispatchEvent(event: FakeDomEvent) {72 dispatchEvent(event: FakeDomEvent) {
59 for (const listener of this.listeners.get(event.type) ?? []) {73 for (const listener of this.listeners.get(event.type) ?? []) {
60 listener(event);74 listener(event);
...@@ -62,6 +76,26 @@ class FakeElement {...@@ -62,6 +76,26 @@ class FakeElement {
62 return true;76 return true;
63 }77 }
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
65 set textContent(value: string) {99 set textContent(value: string) {
66 this.textValue = value;100 this.textValue = value;
67 }101 }
...@@ -116,6 +150,7 @@ class FakeBody extends FakeElement {...@@ -116,6 +150,7 @@ class FakeBody extends FakeElement {
116150
117class FakeDocument {151class FakeDocument {
118 body = new FakeBody();152 body = new FakeBody();
153 activeElement: FakeElement | null = null;
119 private readonly listeners = new Map<string, FakeListener[]>();154 private readonly listeners = new Map<string, FakeListener[]>();
120155
121 createElement(tagName: string) {156 createElement(tagName: string) {
...@@ -152,6 +187,7 @@ function findButton(label: string): FakeElement | undefined {...@@ -152,6 +187,7 @@ function findButton(label: string): FakeElement | undefined {
152187
153beforeEach(() => {188beforeEach(() => {
154 vi.stubGlobal("document", new FakeDocument());189 vi.stubGlobal("document", new FakeDocument());
190 vi.stubGlobal("HTMLElement", FakeElement);
155 vi.stubGlobal("window", {191 vi.stubGlobal("window", {
156 innerWidth: 1280,192 innerWidth: 1280,
157 innerHeight: 800,193 innerHeight: 800,
...@@ -183,6 +219,21 @@ afterEach(() => {...@@ -183,6 +219,21 @@ afterEach(() => {
183});219});
184220
185describe("showContextMenuFallback", () => {221describe("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
186 it("resolves a clicked flat menu item", async () => {237 it("resolves a clicked flat menu item", async () => {
187 const selectionPromise = showContextMenuFallback([238 const selectionPromise = showContextMenuFallback([
188 { id: "rename", label: "Rename" },239 { id: "rename", label: "Rename" },
...@@ -235,6 +286,46 @@ describe("showContextMenuFallback", () => {...@@ -235,6 +286,46 @@ describe("showContextMenuFallback", () => {
235286
236 await expect(selectionPromise).resolves.toBe("rename:project-b");287 await expect(selectionPromise).resolves.toBe("rename:project-b");
237 });288 });
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 });
238});329});
239330
240describe("dismissContextMenu", () => {331describe("dismissContextMenu", () => {
apps/web/src/contextMenuFallback.ts+156-18
...@@ -4,6 +4,20 @@ const SVG_NS = "http://www.w3.org/2000/svg";...@@ -4,6 +4,20 @@ const SVG_NS = "http://www.w3.org/2000/svg";
44
5// Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2).5// Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2).
6const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<string, string> }>> = {6const 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 ],
7 pencil: [21 pencil: [
8 {22 {
9 tag: "path",23 tag: "path",
...@@ -17,6 +31,71 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri...@@ -17,6 +31,71 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
17 { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } },31 { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } },
18 { 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" } },32 { 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" } },
19 ],33 ],
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 ],
20 "folder-tree": [99 "folder-tree": [
21 {100 {
22 tag: "path",101 tag: "path",
...@@ -44,7 +123,7 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri...@@ -44,7 +123,7 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
44123
45function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null {124function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null {
46 const paths = ICON_PATHS[name];125 const paths = ICON_PATHS[name];
47 if (!paths) {126 if (!paths || typeof document.createElementNS !== "function") {
48 return null;127 return null;
49 }128 }
50 const svg = document.createElementNS(SVG_NS, "svg");129 const svg = document.createElementNS(SVG_NS, "svg");
...@@ -57,7 +136,9 @@ function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSV...@@ -57,7 +136,9 @@ function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSV
57 svg.setAttribute("stroke-linejoin", "round");136 svg.setAttribute("stroke-linejoin", "round");
58 svg.setAttribute(137 svg.setAttribute(
59 "class",138 "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",
61 );142 );
62 for (const node of paths) {143 for (const node of paths) {
63 const child = document.createElementNS(SVG_NS, node.tag);144 const child = document.createElementNS(SVG_NS, node.tag);
...@@ -125,7 +206,10 @@ export function showContextMenuFallback<T extends string>(...@@ -125,7 +206,10 @@ export function showContextMenuFallback<T extends string>(
125 position?: { x: number; y: number },206 position?: { x: number; y: number },
126): Promise<T | null> {207): Promise<T | null> {
127 return new Promise<T | null>((resolve) => {208 return new Promise<T | null>((resolve) => {
209 const previouslyFocusedElement =
210 document.activeElement instanceof HTMLElement ? document.activeElement : null;
128 const menuStack: HTMLDivElement[] = [];211 const menuStack: HTMLDivElement[] = [];
212 const submenuTriggerStack: Array<HTMLButtonElement | undefined> = [];
129 let isDisposed = false;213 let isDisposed = false;
130 let canDismissFromPointer = false;214 let canDismissFromPointer = false;
131215
...@@ -142,9 +226,13 @@ export function showContextMenuFallback<T extends string>(...@@ -142,9 +226,13 @@ export function showContextMenuFallback<T extends string>(
142 document.removeEventListener("keydown", onKeyDown);226 document.removeEventListener("keydown", onKeyDown);
143 document.removeEventListener("pointerdown", onPointerDown, true);227 document.removeEventListener("pointerdown", onPointerDown, true);
144 document.removeEventListener("contextmenu", onContextMenu, true);228 document.removeEventListener("contextmenu", onContextMenu, true);
229 const shouldRestoreFocus = isNodeWithinMenuStack(document.activeElement, menuStack);
145 for (const menu of menuStack) {230 for (const menu of menuStack) {
146 menu.remove();231 menu.remove();
147 }232 }
233 if (shouldRestoreFocus && previouslyFocusedElement?.isConnected) {
234 previouslyFocusedElement.focus({ preventScroll: true });
235 }
148 resolve(result);236 resolve(result);
149 };237 };
150238
...@@ -172,6 +260,7 @@ export function showContextMenuFallback<T extends string>(...@@ -172,6 +260,7 @@ export function showContextMenuFallback<T extends string>(
172260
173 const closeMenusFromLevel = (level: number) => {261 const closeMenusFromLevel = (level: number) => {
174 while (menuStack.length > level) {262 while (menuStack.length > level) {
263 submenuTriggerStack.pop()?.setAttribute("aria-expanded", "false");
175 menuStack.pop()?.remove();264 menuStack.pop()?.remove();
176 }265 }
177 };266 };
...@@ -181,6 +270,7 @@ export function showContextMenuFallback<T extends string>(...@@ -181,6 +270,7 @@ export function showContextMenuFallback<T extends string>(
181 preferredLeft: number,270 preferredLeft: number,
182 preferredTop: number,271 preferredTop: number,
183 level: number,272 level: number,
273 parentTrigger?: HTMLButtonElement,
184 ) => {274 ) => {
185 closeMenusFromLevel(level);275 closeMenusFromLevel(level);
186276
...@@ -200,6 +290,15 @@ export function showContextMenuFallback<T extends string>(...@@ -200,6 +290,15 @@ export function showContextMenuFallback<T extends string>(
200 "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;";290 "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;";
201291
202 for (const item of entries) {292 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
203 if (item.header === true) {302 if (item.header === true) {
204 const header = document.createElement("div");303 const header = document.createElement("div");
205 header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs";304 header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs";
...@@ -247,34 +346,63 @@ export function showContextMenuFallback<T extends string>(...@@ -247,34 +346,63 @@ export function showContextMenuFallback<T extends string>(
247 button.appendChild(label);346 button.appendChild(label);
248347
249 if (hasChildren) {348 if (hasChildren) {
250 const chevron = document.createElement("span");349 button.setAttribute("aria-haspopup", "menu");
251 chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none";350 button.setAttribute("aria-expanded", "false");
252 chevron.textContent = ">";351 const chevron = createIconElement("chevron-right", "neutral");
253 button.appendChild(chevron);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 }
254 }361 }
255362
256 if (!isDisabled) {363 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 };
257 button.addEventListener("mouseenter", () => {381 button.addEventListener("mouseenter", () => {
258 button.style.background = isLeafDestructive382 button.focus({ preventScroll: true });
259 ? "color-mix(in srgb, var(--destructive) 10%, transparent)"383 isHovered = true;
260 : "var(--accent)";384 updateHighlight();
261 button.style.color = isLeafDestructive
262 ? "var(--destructive-foreground)"
263 : "var(--accent-foreground)";
264 });385 });
265 button.addEventListener("mouseleave", () => {386 button.addEventListener("mouseleave", () => {
266 button.style.background = "transparent";387 isHovered = false;
267 button.style.color = isLeafDestructive388 updateHighlight();
268 ? "var(--destructive-foreground)"389 });
269 : "var(--foreground)";390 button.addEventListener("focus", () => {
391 isFocused = true;
392 updateHighlight();
393 });
394 button.addEventListener("blur", () => {
395 isFocused = false;
396 updateHighlight();
270 });397 });
271398
272 if (hasChildren) {399 if (hasChildren) {
273 button.addEventListener("mouseenter", () => {400 const openSubmenu = (focusFirstItem = false) => {
274 const rect = button.getBoundingClientRect();401 const rect = button.getBoundingClientRect();
275 const nextLeft = rect.right + 4;402 const nextLeft = rect.right + 4;
276 const nextTop = rect.top;403 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
279 const childMenu = menuStack[level + 1];407 const childMenu = menuStack[level + 1];
280 if (!childMenu) {408 if (!childMenu) {
...@@ -284,9 +412,18 @@ export function showContextMenuFallback<T extends string>(...@@ -284,9 +412,18 @@ export function showContextMenuFallback<T extends string>(
284 if (childRect.right > window.innerWidth) {412 if (childRect.right > window.innerWidth) {
285 clampMenuPosition(childMenu, rect.left - childRect.width - 4, rect.top);413 clampMenuPosition(childMenu, rect.left - childRect.width - 4, rect.top);
286 }414 }
415 if (focusFirstItem) {
416 [...childMenu.querySelectorAll<HTMLButtonElement>("button")]
417 .find((childButton) => !childButton.disabled)
418 ?.focus();
419 }
420 };
421 button.addEventListener("mouseenter", () => {
422 openSubmenu();
287 });423 });
288 button.addEventListener("click", (event) => {424 button.addEventListener("click", (event) => {
289 event.preventDefault();425 event.preventDefault();
426 openSubmenu(true);
290 });427 });
291 } else {428 } else {
292 button.addEventListener("mouseenter", () => {429 button.addEventListener("mouseenter", () => {
...@@ -309,6 +446,7 @@ export function showContextMenuFallback<T extends string>(...@@ -309,6 +446,7 @@ export function showContextMenuFallback<T extends string>(
309446
310 document.body.appendChild(menu);447 document.body.appendChild(menu);
311 menuStack[level] = menu;448 menuStack[level] = menu;
449 submenuTriggerStack[level] = parentTrigger;
312450
313 requestAnimationFrame(() => {451 requestAnimationFrame(() => {
314 clampMenuPosition(menu, preferredLeft, preferredTop);452 clampMenuPosition(menu, preferredLeft, preferredTop);
packages/contracts/src/ipc.ts+4
...@@ -111,6 +111,8 @@ export interface ContextMenuItem<T extends string = string> {...@@ -111,6 +111,8 @@ export interface ContextMenuItem<T extends string = string> {
111 header?: boolean;111 header?: boolean;
112 /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */112 /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */
113 icon?: string;113 icon?: string;
114 /** Inserts a visual section divider immediately before this item. */
115 separatorBefore?: boolean;
114 children?: readonly ContextMenuItem<T>[];116 children?: readonly ContextMenuItem<T>[];
115}117}
116118
...@@ -121,6 +123,7 @@ export interface ContextMenuItemSchemaType {...@@ -121,6 +123,7 @@ export interface ContextMenuItemSchemaType {
121 readonly disabled?: boolean;123 readonly disabled?: boolean;
122 readonly header?: boolean;124 readonly header?: boolean;
123 readonly icon?: string;125 readonly icon?: string;
126 readonly separatorBefore?: boolean;
124 readonly children?: readonly ContextMenuItemSchemaType[];127 readonly children?: readonly ContextMenuItemSchemaType[];
125}128}
126129
...@@ -131,6 +134,7 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc...@@ -131,6 +134,7 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc
131 disabled: Schema.optionalKey(Schema.Boolean),134 disabled: Schema.optionalKey(Schema.Boolean),
132 header: Schema.optionalKey(Schema.Boolean),135 header: Schema.optionalKey(Schema.Boolean),
133 icon: Schema.optionalKey(Schema.String),136 icon: Schema.optionalKey(Schema.String),
137 separatorBefore: Schema.optionalKey(Schema.Boolean),
134 children: Schema.optionalKey(138 children: Schema.optionalKey(
135 Schema.Array(139 Schema.Array(
136 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),140 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),