| author | |
| committer | |
| log | 3c0665543f0e31db1e3425d4fcf9f4aea295a8ce |
| tree | 2ccb7303d5427b6882b812695571928306cb93b4 |
| parent | 80c37f1a721d36bde86b31c475fcc8d3db75f706 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 353 insertions(+), 38 deletions(-)
apps/desktop/src/electron/ElectronMenu.test.ts+36-1| ... | ... | @@ -98,7 +98,10 @@ describe("ElectronMenu", () => { |
| 98 | 98 | const electronMenu = yield* ElectronMenu.ElectronMenu; |
| 99 | 99 | const selectedItemId = yield* electronMenu.showContextMenu({ |
| 100 | 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 | 105 | position: Option.some({ x: 10.8, y: 20.2 }), |
| 103 | 106 | }); |
| 104 | 107 | |
| ... | ... | @@ -110,6 +113,38 @@ describe("ElectronMenu", () => { |
| 110 | 113 | enabled: true, |
| 111 | 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 | 148 | }).pipe(Effect.provide(TestLayer)), |
| 114 | 149 | ); |
| 115 | 150 |
apps/desktop/src/electron/ElectronMenu.ts+17-2| ... | ... | @@ -78,6 +78,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM |
| 78 | 78 | label: sourceItem.label, |
| 79 | 79 | destructive: sourceItem.destructive === true, |
| 80 | 80 | disabled: sourceItem.disabled === true, |
| 81 | ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), | |
| 81 | 82 | }; |
| 82 | 83 | |
| 83 | 84 | if (sourceItem.children) { |
| ... | ... | @@ -141,10 +142,24 @@ export const make = Effect.gen(function* () { |
| 141 | 142 | ): Electron.MenuItemConstructorOptions[] => { |
| 142 | 143 | const template: Electron.MenuItemConstructorOptions[] = []; |
| 143 | 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 | }; | |
| 144 | 150 | |
| 145 | 151 | 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(); | |
| 148 | 163 | hasInsertedDestructiveSeparator = true; |
| 149 | 164 | } |
| 150 | 165 |
apps/web/src/components/threadActionMenu.logic.test.ts+12-5| ... | ... | @@ -20,6 +20,12 @@ function ids(state: ThreadActionMenuState): string[] { |
| 20 | 20 | return buildThreadActionMenuItems(state).map((item) => item.id); |
| 21 | 21 | } |
| 22 | 22 | |
| 23 | function 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 | ||
| 23 | 29 | describe("buildThreadActionMenuItems", () => { |
| 24 | 30 | it("hides lifecycle items when the environment lacks the capabilities", () => { |
| 25 | 31 | expect( |
| ... | ... | @@ -27,15 +33,15 @@ describe("buildThreadActionMenuItems", () => { |
| 27 | 33 | ...baseState, |
| 28 | 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 | }); |
| 32 | 38 | |
| 33 | 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 | 41 | expect(withBranch).toContain("new-thread-on-branch"); |
| 36 | 42 | 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"); | |
| 39 | 45 | }); |
| 40 | 46 | |
| 41 | 47 | it("flips lifecycle labels with thread state", () => { |
| ... | ... | @@ -64,11 +70,12 @@ describe("buildThreadActionMenuItems", () => { |
| 64 | 70 | const items = buildThreadActionMenuItems({ ...baseState, branch: "main" }); |
| 65 | 71 | expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true }); |
| 66 | 72 | }); |
| 67 | ||
| 68 | 73 | it("offers archive as a non-destructive action right before delete", () => { |
| 69 | 74 | const items = buildThreadActionMenuItems(baseState); |
| 70 | 75 | const archiveItem = items.at(-2); |
| 71 | 76 | expect(archiveItem?.id).toBe("archive"); |
| 77 | expect(archiveItem?.icon).toBe("archive"); | |
| 78 | expect(archiveItem?.separatorBefore).toBe(true); | |
| 72 | 79 | expect(archiveItem?.destructive).toBeFalsy(); |
| 73 | 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 | 18 | | "rename" |
| 19 | 19 | | "regenerate-title" |
| 20 | 20 | | "mark-unread" |
| 21 | | "copy" | |
| 21 | 22 | | "copy-path" |
| 22 | 23 | | "copy-branch" |
| 23 | 24 | | "copy-thread-id" |
| ... | ... | @@ -56,14 +57,15 @@ export function buildThreadActionMenuItems( |
| 56 | 57 | { |
| 57 | 58 | id: "new-thread-on-branch" as const, |
| 58 | 59 | label: `New thread on ${state.branch}`, |
| 60 | icon: "message-square-plus", | |
| 59 | 61 | }, |
| 60 | 62 | ] |
| 61 | 63 | : []), |
| 62 | 64 | ...(state.supports.pinning |
| 63 | 65 | ? [ |
| 64 | 66 | 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" }, | |
| 67 | 69 | ] |
| 68 | 70 | : []), |
| 69 | 71 | // Both lifecycle actions stay available on pinned threads: settling |
| ... | ... | @@ -72,17 +74,18 @@ export function buildThreadActionMenuItems( |
| 72 | 74 | ...(state.supports.settlement |
| 73 | 75 | ? [ |
| 74 | 76 | 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" }, | |
| 77 | 79 | ] |
| 78 | 80 | : []), |
| 79 | 81 | ...(state.supports.snooze |
| 80 | 82 | ? [ |
| 81 | 83 | state.isSnoozed |
| 82 | ? { id: "unsnooze" as const, label: "Wake thread" } | |
| 84 | ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" } | |
| 83 | 85 | : { |
| 84 | 86 | id: "snooze" as const, |
| 85 | 87 | label: "Snooze", |
| 88 | icon: "clock", | |
| 86 | 89 | disabled: !state.canSnoozeNow, |
| 87 | 90 | children: state.snoozePresets.map((preset) => ({ |
| 88 | 91 | id: `snooze:${preset.id}` as const, |
| ... | ... | @@ -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 | 98 | ...(state.supports.titleRegeneration |
| 96 | 99 | ? [ |
| 97 | 100 | { |
| 98 | 101 | id: "regenerate-title" as const, |
| 99 | 102 | label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title", |
| 103 | icon: "refresh-cw", | |
| 100 | 104 | disabled: state.isRegeneratingTitle, |
| 101 | 105 | }, |
| 102 | 106 | ] |
| 103 | 107 | : []), |
| 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 | }, | |
| 108 | 122 | // Archive removes the thread from the sidebar while keeping its |
| 109 | 123 | // conversation under Settings > Archived threads — distinct from Settle |
| 110 | 124 | // (stays visible in the Settled shelf) and Delete (clears history for |
| 111 | 125 | // good), so it sits beside Delete without borrowing its destructive |
| 112 | 126 | // 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 | }, | |
| 115 | 140 | ]; |
| 116 | 141 | } |
apps/web/src/contextMenuFallback.test.ts+91| ... | ... | @@ -24,14 +24,24 @@ class FakeElement { |
| 24 | 24 | parent: FakeElement | null = null; |
| 25 | 25 | style: Record<string, string> & { cssText?: string } = {}; |
| 26 | 26 | dataset: Record<string, string> = {}; |
| 27 | attributes = new Map<string, string>(); | |
| 27 | 28 | className = ""; |
| 28 | 29 | disabled = false; |
| 30 | focused = false; | |
| 29 | 31 | type = ""; |
| 30 | 32 | private textValue = ""; |
| 31 | 33 | private readonly listeners = new Map<string, FakeListener[]>(); |
| 32 | 34 | |
| 33 | 35 | constructor(readonly tagName: string) {} |
| 34 | 36 | |
| 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 | 45 | appendChild(child: FakeElement) { |
| 36 | 46 | child.parent = this; |
| 37 | 47 | this.children.push(child); |
| ... | ... | @@ -55,6 +65,10 @@ class FakeElement { |
| 55 | 65 | this.listeners.set(type, existing); |
| 56 | 66 | } |
| 57 | 67 | |
| 68 | setAttribute(name: string, value: string) { | |
| 69 | this.attributes.set(name, value); | |
| 70 | } | |
| 71 | ||
| 58 | 72 | dispatchEvent(event: FakeDomEvent) { |
| 59 | 73 | for (const listener of this.listeners.get(event.type) ?? []) { |
| 60 | 74 | listener(event); |
| ... | ... | @@ -62,6 +76,26 @@ class FakeElement { |
| 62 | 76 | return true; |
| 63 | 77 | } |
| 64 | 78 | |
| 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 | 99 | set textContent(value: string) { |
| 66 | 100 | this.textValue = value; |
| 67 | 101 | } |
| ... | ... | @@ -116,6 +150,7 @@ class FakeBody extends FakeElement { |
| 116 | 150 | |
| 117 | 151 | class FakeDocument { |
| 118 | 152 | body = new FakeBody(); |
| 153 | activeElement: FakeElement | null = null; | |
| 119 | 154 | private readonly listeners = new Map<string, FakeListener[]>(); |
| 120 | 155 | |
| 121 | 156 | createElement(tagName: string) { |
| ... | ... | @@ -152,6 +187,7 @@ function findButton(label: string): FakeElement | undefined { |
| 152 | 187 | |
| 153 | 188 | beforeEach(() => { |
| 154 | 189 | vi.stubGlobal("document", new FakeDocument()); |
| 190 | vi.stubGlobal("HTMLElement", FakeElement); | |
| 155 | 191 | vi.stubGlobal("window", { |
| 156 | 192 | innerWidth: 1280, |
| 157 | 193 | innerHeight: 800, |
| ... | ... | @@ -183,6 +219,21 @@ afterEach(() => { |
| 183 | 219 | }); |
| 184 | 220 | |
| 185 | 221 | describe("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 | 237 | it("resolves a clicked flat menu item", async () => { |
| 187 | 238 | const selectionPromise = showContextMenuFallback([ |
| 188 | 239 | { id: "rename", label: "Rename" }, |
| ... | ... | @@ -235,6 +286,46 @@ describe("showContextMenuFallback", () => { |
| 235 | 286 | |
| 236 | 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 | }); |
| 239 | 330 | |
| 240 | 331 | describe("dismissContextMenu", () => { |
apps/web/src/contextMenuFallback.ts+156-18| ... | ... | @@ -4,6 +4,20 @@ const SVG_NS = "http://www.w3.org/2000/svg"; |
| 4 | 4 | |
| 5 | 5 | // Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2). |
| 6 | 6 | const 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 | 21 | pencil: [ |
| 8 | 22 | { |
| 9 | 23 | tag: "path", |
| ... | ... | @@ -17,6 +31,71 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri |
| 17 | 31 | { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } }, |
| 18 | 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 | 99 | "folder-tree": [ |
| 21 | 100 | { |
| 22 | 101 | tag: "path", |
| ... | ... | @@ -44,7 +123,7 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri |
| 44 | 123 | |
| 45 | 124 | function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null { |
| 46 | 125 | const paths = ICON_PATHS[name]; |
| 47 | if (!paths) { | |
| 126 | if (!paths || typeof document.createElementNS !== "function") { | |
| 48 | 127 | return null; |
| 49 | 128 | } |
| 50 | 129 | const svg = document.createElementNS(SVG_NS, "svg"); |
| ... | ... | @@ -57,7 +136,9 @@ function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSV |
| 57 | 136 | svg.setAttribute("stroke-linejoin", "round"); |
| 58 | 137 | svg.setAttribute( |
| 59 | 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 | 143 | for (const node of paths) { |
| 63 | 144 | const child = document.createElementNS(SVG_NS, node.tag); |
| ... | ... | @@ -125,7 +206,10 @@ export function showContextMenuFallback<T extends string>( |
| 125 | 206 | position?: { x: number; y: number }, |
| 126 | 207 | ): Promise<T | null> { |
| 127 | 208 | return new Promise<T | null>((resolve) => { |
| 209 | const previouslyFocusedElement = | |
| 210 | document.activeElement instanceof HTMLElement ? document.activeElement : null; | |
| 128 | 211 | const menuStack: HTMLDivElement[] = []; |
| 212 | const submenuTriggerStack: Array<HTMLButtonElement | undefined> = []; | |
| 129 | 213 | let isDisposed = false; |
| 130 | 214 | let canDismissFromPointer = false; |
| 131 | 215 | |
| ... | ... | @@ -142,9 +226,13 @@ export function showContextMenuFallback<T extends string>( |
| 142 | 226 | document.removeEventListener("keydown", onKeyDown); |
| 143 | 227 | document.removeEventListener("pointerdown", onPointerDown, true); |
| 144 | 228 | document.removeEventListener("contextmenu", onContextMenu, true); |
| 229 | const shouldRestoreFocus = isNodeWithinMenuStack(document.activeElement, menuStack); | |
| 145 | 230 | for (const menu of menuStack) { |
| 146 | 231 | menu.remove(); |
| 147 | 232 | } |
| 233 | if (shouldRestoreFocus && previouslyFocusedElement?.isConnected) { | |
| 234 | previouslyFocusedElement.focus({ preventScroll: true }); | |
| 235 | } | |
| 148 | 236 | resolve(result); |
| 149 | 237 | }; |
| 150 | 238 | |
| ... | ... | @@ -172,6 +260,7 @@ export function showContextMenuFallback<T extends string>( |
| 172 | 260 | |
| 173 | 261 | const closeMenusFromLevel = (level: number) => { |
| 174 | 262 | while (menuStack.length > level) { |
| 263 | submenuTriggerStack.pop()?.setAttribute("aria-expanded", "false"); | |
| 175 | 264 | menuStack.pop()?.remove(); |
| 176 | 265 | } |
| 177 | 266 | }; |
| ... | ... | @@ -181,6 +270,7 @@ export function showContextMenuFallback<T extends string>( |
| 181 | 270 | preferredLeft: number, |
| 182 | 271 | preferredTop: number, |
| 183 | 272 | level: number, |
| 273 | parentTrigger?: HTMLButtonElement, | |
| 184 | 274 | ) => { |
| 185 | 275 | closeMenusFromLevel(level); |
| 186 | 276 | |
| ... | ... | @@ -200,6 +290,15 @@ export function showContextMenuFallback<T extends string>( |
| 200 | 290 | "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;"; |
| 201 | 291 | |
| 202 | 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 | 302 | if (item.header === true) { |
| 204 | 303 | const header = document.createElement("div"); |
| 205 | 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 | 346 | button.appendChild(label); |
| 248 | 347 | |
| 249 | 348 | 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 | } | |
| 254 | 361 | } |
| 255 | 362 | |
| 256 | 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 | 381 | 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(); | |
| 264 | 385 | }); |
| 265 | 386 | 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(); | |
| 270 | 397 | }); |
| 271 | 398 | |
| 272 | 399 | if (hasChildren) { |
| 273 | button.addEventListener("mouseenter", () => { | |
| 400 | const openSubmenu = (focusFirstItem = false) => { | |
| 274 | 401 | const rect = button.getBoundingClientRect(); |
| 275 | 402 | const nextLeft = rect.right + 4; |
| 276 | 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"); | |
| 278 | 406 | |
| 279 | 407 | const childMenu = menuStack[level + 1]; |
| 280 | 408 | if (!childMenu) { |
| ... | ... | @@ -284,9 +412,18 @@ export function showContextMenuFallback<T extends string>( |
| 284 | 412 | if (childRect.right > window.innerWidth) { |
| 285 | 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 | 424 | button.addEventListener("click", (event) => { |
| 289 | 425 | event.preventDefault(); |
| 426 | openSubmenu(true); | |
| 290 | 427 | }); |
| 291 | 428 | } else { |
| 292 | 429 | button.addEventListener("mouseenter", () => { |
| ... | ... | @@ -309,6 +446,7 @@ export function showContextMenuFallback<T extends string>( |
| 309 | 446 | |
| 310 | 447 | document.body.appendChild(menu); |
| 311 | 448 | menuStack[level] = menu; |
| 449 | submenuTriggerStack[level] = parentTrigger; | |
| 312 | 450 | |
| 313 | 451 | requestAnimationFrame(() => { |
| 314 | 452 | clampMenuPosition(menu, preferredLeft, preferredTop); |
packages/contracts/src/ipc.ts+4| ... | ... | @@ -111,6 +111,8 @@ export interface ContextMenuItem<T extends string = string> { |
| 111 | 111 | header?: boolean; |
| 112 | 112 | /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */ |
| 113 | 113 | icon?: string; |
| 114 | /** Inserts a visual section divider immediately before this item. */ | |
| 115 | separatorBefore?: boolean; | |
| 114 | 116 | children?: readonly ContextMenuItem<T>[]; |
| 115 | 117 | } |
| 116 | 118 | |
| ... | ... | @@ -121,6 +123,7 @@ export interface ContextMenuItemSchemaType { |
| 121 | 123 | readonly disabled?: boolean; |
| 122 | 124 | readonly header?: boolean; |
| 123 | 125 | readonly icon?: string; |
| 126 | readonly separatorBefore?: boolean; | |
| 124 | 127 | readonly children?: readonly ContextMenuItemSchemaType[]; |
| 125 | 128 | } |
| 126 | 129 | |
| ... | ... | @@ -131,6 +134,7 @@ export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Sc |
| 131 | 134 | disabled: Schema.optionalKey(Schema.Boolean), |
| 132 | 135 | header: Schema.optionalKey(Schema.Boolean), |
| 133 | 136 | icon: Schema.optionalKey(Schema.String), |
| 137 | separatorBefore: Schema.optionalKey(Schema.Boolean), | |
| 134 | 138 | children: Schema.optionalKey( |
| 135 | 139 | Schema.Array( |
| 136 | 140 | Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema), |