| 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,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 | }); |
| 104 | 107 | ||
| ... | @@ -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 | ); |
| 115 | 150 |
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 | }; |
| 82 | 83 | ||
| 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 | }; | ||
| 144 | 150 | ||
| 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 | } |
| 150 | 165 |
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 | } |
| 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 | describe("buildThreadActionMenuItems", () => { | 29 | describe("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 | }); |
| 32 | 38 | ||
| 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 | }); |
| 40 | 46 | ||
| 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.pinning | 64 | ...(state.supports.pinning |
| 63 | ? [ | 65 | ? [ |
| 64 | state.isPinned | 66 | 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: settling | 71 | // 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.settlement | 74 | ...(state.supports.settlement |
| 73 | ? [ | 75 | ? [ |
| 74 | state.isSettled | 76 | 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.snooze | 81 | ...(state.supports.snooze |
| 80 | ? [ | 82 | ? [ |
| 81 | state.isSnoozed | 83 | 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.titleRegeneration | 98 | ...(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 its | 122 | // Archive removes the thread from the sidebar while keeping its |
| 109 | // conversation under Settings > Archived threads — distinct from Settle | 123 | // conversation under Settings > Archived threads — distinct from Settle |
| 110 | // (stays visible in the Settled shelf) and Delete (clears history for | 124 | // (stays visible in the Settled shelf) and Delete (clears history for |
| 111 | // good), so it sits beside Delete without borrowing its destructive | 125 | // 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[]>(); |
| 32 | 34 | ||
| 33 | constructor(readonly tagName: string) {} | 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 | 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 | } |
| 57 | 67 | ||
| 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 | } |
| 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 | 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 { |
| 116 | 150 | ||
| 117 | class FakeDocument { | 151 | class 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[]>(); |
| 120 | 155 | ||
| 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 { |
| 152 | 187 | ||
| 153 | beforeEach(() => { | 188 | beforeEach(() => { |
| 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 | }); |
| 184 | 220 | ||
| 185 | describe("showContextMenuFallback", () => { | 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 | 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", () => { |
| 235 | 286 | ||
| 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 | }); |
| 239 | 330 | ||
| 240 | describe("dismissContextMenu", () => { | 331 | describe("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"; |
| 4 | 4 | ||
| 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). |
| 6 | const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<string, string> }>> = { | 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 | 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 |
| 44 | 123 | ||
| 45 | function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null { | 124 | function 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; |
| 131 | 215 | ||
| ... | @@ -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 | }; |
| 150 | 238 | ||
| ... | @@ -172,6 +260,7 @@ export function showContextMenuFallback<T extends string>( | ... | @@ -172,6 +260,7 @@ export function showContextMenuFallback<T extends string>( |
| 172 | 260 | ||
| 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); |
| 186 | 276 | ||
| ... | @@ -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;"; |
| 201 | 291 | ||
| 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); |
| 248 | 347 | ||
| 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 | } |
| 255 | 362 | ||
| 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 = isLeafDestructive | 382 | 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 = isLeafDestructive | 388 | 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 | }); |
| 271 | 398 | ||
| 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"); | ||
| 278 | 406 | ||
| 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>( |
| 309 | 446 | ||
| 310 | document.body.appendChild(menu); | 447 | document.body.appendChild(menu); |
| 311 | menuStack[level] = menu; | 448 | menuStack[level] = menu; |
| 449 | submenuTriggerStack[level] = parentTrigger; | ||
| 312 | 450 | ||
| 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 | } |
| 116 | 118 | ||
| ... | @@ -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 | } |
| 126 | 129 | ||
| ... | @@ -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), |