diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 756274a614d7..d3ce646e4a02 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -77,7 +77,7 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ window: makeWindow(), - items: [{ id: "copy", label: "Copy" }], + items: [{ id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }], position: Option.none(), }); @@ -99,7 +99,7 @@ describe("ElectronMenu", () => { const selectedItemId = yield* electronMenu.showContextMenu({ window: makeWindow(2), items: [ - { id: "copy", label: "Copy" }, + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }, { id: "delete", label: "Delete", destructive: true, separatorBefore: true }, ], position: Option.some({ x: 10.8, y: 20.2 }), @@ -110,6 +110,7 @@ describe("ElectronMenu", () => { assert.equal(popupOptions?.y, 40); assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], { label: "Copy", + accelerator: "Ctrl+Shift+C", enabled: true, click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, }); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index b241619cd296..6c7c861b09f7 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -76,6 +76,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM const normalizedItem: ContextMenuItem = { id: sourceItem.id, label: sourceItem.label, + ...(sourceItem.accelerator ? { accelerator: sourceItem.accelerator } : {}), destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), @@ -165,6 +166,7 @@ export const make = Effect.gen(function* () { const itemOption: Electron.MenuItemConstructorOptions = { label: item.label, + ...(item.accelerator ? { accelerator: item.accelerator } : {}), enabled: !item.disabled, }; if (item.children && item.children.length > 0) { diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index ddc5da05f806..24f016ac3ac3 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback"; +import { + contextMenuAcceleratorAction, + dismissContextMenu, + showContextMenuFallback, +} from "./contextMenuFallback"; type FakeListener = (event: FakeDomEvent) => void; @@ -17,6 +21,8 @@ class FakeDomEvent { preventDefault() { this.defaultPrevented = true; } + + stopPropagation() {} } class FakeElement { @@ -174,6 +180,13 @@ class FakeDocument { } } + dispatchEvent(event: FakeDomEvent) { + for (const listener of this.listeners.get(event.type) ?? []) { + listener(event); + } + return true; + } + querySelectorAll(tagName: string) { return this.body.querySelectorAll(tagName); } @@ -247,6 +260,116 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("rename"); }); + it("renders shortcut hints next to menu labels", () => { + void showContextMenuFallback( + [ + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }, + { id: "paste", label: "Paste", accelerator: "Command+Shift+V" }, + ], + undefined, + { layout: "compact" }, + ); + + const shortcuts = (document as unknown as FakeDocument) + .querySelectorAll("kbd") + .map((element) => element.textContent); + expect(shortcuts).toEqual(["Ctrl+Shift+C", "⇧⌘V"]); + const menu = (document as unknown as FakeDocument) + .querySelectorAll("div") + .find((element) => element.className.includes("dropdown-glass")); + expect(menu?.className).toContain("min-w-56"); + }); + + it("keeps the default menu layout when no layout is requested", () => { + void showContextMenuFallback([{ id: "rename", label: "Rename" }]); + + const menu = (document as unknown as FakeDocument) + .querySelectorAll("div") + .find((element) => element.className.includes("dropdown-glass")); + expect(menu?.className).toContain("min-w-32"); + expect(menu?.className).not.toContain("min-w-56"); + }); + + it("runs an accelerator before the focused terminal handles it", async () => { + const selectionPromise = showContextMenuFallback([ + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }, + ]); + const event = new KeyboardEvent("keydown", { + altKey: false, + ctrlKey: true, + isComposing: false, + key: "c", + metaKey: false, + shiftKey: true, + }); + + (document as unknown as FakeDocument).dispatchEvent(event as unknown as FakeDomEvent); + + expect(event.defaultPrevented).toBe(true); + await expect(selectionPromise).resolves.toBe("copy"); + }); + + it("closes when the page or terminal is scrolled", async () => { + const selectionPromise = showContextMenuFallback([{ id: "copy", label: "Copy" }]); + + (document as unknown as FakeDocument).dispatchEvent(new FakeDomEvent("wheel")); + + await expect(selectionPromise).resolves.toBeNull(); + }); + + it("closes without restoring focus when its owner aborts", async () => { + const invoker = (document as unknown as FakeDocument).createElement("button"); + (document as unknown as FakeDocument).body.appendChild(invoker); + invoker.focus(); + const abortController = new AbortController(); + const selectionPromise = showContextMenuFallback([{ id: "copy", label: "Copy" }], undefined, { + restoreFocus: "on-dismiss", + signal: abortController.signal, + }); + const action = findButton("Copy"); + action?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + + abortController.abort(); + + await expect(selectionPromise).resolves.toBeNull(); + expect(findButton("Copy")).toBeUndefined(); + expect(invoker.focused).toBe(false); + }); + + it("restores focus after interactive dismissal when requested", async () => { + const invoker = (document as unknown as FakeDocument).createElement("button"); + (document as unknown as FakeDocument).body.appendChild(invoker); + invoker.focus(); + const selectionPromise = showContextMenuFallback([{ id: "copy", label: "Copy" }], undefined, { + restoreFocus: "on-dismiss", + }); + findButton("Copy")?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + + (document as unknown as FakeDocument).dispatchEvent( + new KeyboardEvent("keydown", { isComposing: false, key: "Escape" }), + ); + + await expect(selectionPromise).resolves.toBeNull(); + expect(invoker.focused).toBe(true); + }); + + it("leaves action focus restoration to the caller", async () => { + const invoker = (document as unknown as FakeDocument).createElement("button"); + (document as unknown as FakeDocument).body.appendChild(invoker); + invoker.focus(); + const selectionPromise = showContextMenuFallback( + [{ id: "add-to-chat", label: "Add to chat" }], + undefined, + { restoreFocus: "on-dismiss" }, + ); + const action = findButton("Add to chat"); + action?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + action?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("add-to-chat"); + expect(invoker.focused).toBe(false); + }); + it("ignores a click from the gesture that opened the menu", async () => { let enablePointerSelection: ((time: number) => void) | undefined; vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { @@ -328,6 +451,47 @@ describe("showContextMenuFallback", () => { }); }); +describe("contextMenuAcceleratorAction", () => { + const event = (overrides: Partial[1]> = {}) => ({ + altKey: false, + ctrlKey: false, + isComposing: false, + key: "", + metaKey: false, + shiftKey: false, + ...overrides, + }); + + it("matches Windows, Linux, and macOS accelerators", () => { + const items = [ + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }, + { id: "paste", label: "Paste", accelerator: "Command+V" }, + ] as const; + + expect( + contextMenuAcceleratorAction(items, event({ key: "c", ctrlKey: true, shiftKey: true })), + ).toBe("copy"); + expect(contextMenuAcceleratorAction(items, event({ key: "V", metaKey: true }))).toBe("paste"); + }); + + it("ignores disabled, partial, and composing shortcuts", () => { + const items = [ + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C", disabled: true }, + ] as const; + + expect( + contextMenuAcceleratorAction(items, event({ key: "c", ctrlKey: true, shiftKey: true })), + ).toBeNull(); + expect(contextMenuAcceleratorAction(items, event({ key: "c", ctrlKey: true }))).toBeNull(); + expect( + contextMenuAcceleratorAction(items, { + ...event({ key: "c", ctrlKey: true, shiftKey: true }), + isComposing: true, + }), + ).toBeNull(); + }); +}); + describe("dismissContextMenu", () => { it("resolves an open menu with null", async () => { const selectionPromise = showContextMenuFallback([ diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index ce8b8950a8b9..8592e4b1afc5 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -197,6 +197,44 @@ export function dismissContextMenu(): void { activeContextMenuDismiss = null; } +export function contextMenuAcceleratorAction( + items: readonly ContextMenuItem[], + event: Pick, +): T | null { + if (event.isComposing) return null; + + for (const item of items) { + if (item.disabled) continue; + if (item.children) { + const childAction = contextMenuAcceleratorAction(item.children, event); + if (childAction !== null) return childAction; + } + if (!item.accelerator) continue; + + const parts = item.accelerator.toLowerCase().split("+"); + if ( + event.key.toLowerCase() === parts.at(-1) && + event.altKey === parts.includes("alt") && + event.ctrlKey === parts.includes("ctrl") && + event.metaKey === (parts.includes("command") || parts.includes("cmd")) && + event.shiftKey === parts.includes("shift") + ) { + return item.id; + } + } + + return null; +} + +function formatContextMenuAccelerator(accelerator: string): string { + const parts = accelerator.split("+"); + if (!parts.some((part) => part === "Command" || part === "Cmd")) return accelerator; + const key = parts.at(-1) ?? ""; + return `${parts.includes("Ctrl") ? "⌃" : ""}${parts.includes("Alt") ? "⌥" : ""}${ + parts.includes("Shift") ? "⇧" : "" + }⌘${key}`; +} + /** * Imperative DOM-based context menu for non-Electron environments. * Supports nested submenus and resolves with the clicked leaf item id. @@ -204,8 +242,17 @@ export function dismissContextMenu(): void { export function showContextMenuFallback( items: readonly ContextMenuItem[], position?: { x: number; y: number }, + options?: { + readonly layout?: "default" | "compact"; + readonly restoreFocus?: boolean | "on-dismiss"; + readonly signal?: AbortSignal; + }, ): Promise { return new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve(null); + return; + } const previouslyFocusedElement = document.activeElement instanceof HTMLElement ? document.activeElement : null; const menuStack: HTMLDivElement[] = []; @@ -213,9 +260,9 @@ export function showContextMenuFallback( let isDisposed = false; let canDismissFromPointer = false; - const dismiss = () => cleanup(null); + const dismiss = () => cleanup(null, "programmatic"); - const cleanup = (result: T | null) => { + const cleanup = (result: T | null, reason: "action" | "interaction" | "programmatic") => { if (isDisposed) { return; } @@ -223,23 +270,40 @@ export function showContextMenuFallback( if (activeContextMenuDismiss === dismiss) { activeContextMenuDismiss = null; } - document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("keydown", onKeyDown, true); document.removeEventListener("pointerdown", onPointerDown, true); document.removeEventListener("contextmenu", onContextMenu, true); + document.removeEventListener("wheel", onWheel, true); + options?.signal?.removeEventListener("abort", onAbort); const shouldRestoreFocus = isNodeWithinMenuStack(document.activeElement, menuStack); for (const menu of menuStack) { menu.remove(); } - if (shouldRestoreFocus && previouslyFocusedElement?.isConnected) { + if ( + options?.restoreFocus !== false && + (options?.restoreFocus !== "on-dismiss" || reason === "interaction") && + shouldRestoreFocus && + previouslyFocusedElement?.isConnected + ) { previouslyFocusedElement.focus({ preventScroll: true }); } resolve(result); }; + const onAbort = () => cleanup(null, "programmatic"); + const onKeyDown = (event: KeyboardEvent) => { + if (event.isComposing) return; if (event.key === "Escape") { event.preventDefault(); - cleanup(null); + cleanup(null, "interaction"); + return; + } + const action = contextMenuAcceleratorAction(items, event); + if (action !== null) { + event.preventDefault(); + event.stopPropagation(); + cleanup(action, "action"); } }; @@ -247,7 +311,7 @@ export function showContextMenuFallback( if (!canDismissFromPointer || isNodeWithinMenuStack(event.target, menuStack)) { return; } - cleanup(null); + cleanup(null, "interaction"); }; const onContextMenu = (event: MouseEvent) => { @@ -255,7 +319,13 @@ export function showContextMenuFallback( return; } event.preventDefault(); - cleanup(null); + cleanup(null, "interaction"); + }; + + const onWheel = (event: WheelEvent) => { + if (!isNodeWithinMenuStack(event.target, menuStack)) { + cleanup(null, "interaction"); + } }; const closeMenusFromLevel = (level: number) => { @@ -274,11 +344,14 @@ export function showContextMenuFallback( ) => { closeMenusFromLevel(level); + const usesCompactLayout = options?.layout === "compact"; const menu = document.createElement("div"); - menu.className = - "dropdown-glass fixed z-[10000] min-w-32 max-w-sm overflow-hidden rounded-lg bg-clip-padding text-popover-foreground outline-none"; - menu.style.cssText = - "position:fixed;z-index:10000;min-width:8rem;max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);background-clip:padding-box;color:var(--contrast-popover-foreground);outline:none;pointer-events:auto;"; + menu.className = `dropdown-glass fixed z-[10000] max-w-sm overflow-hidden rounded-lg bg-clip-padding text-popover-foreground outline-none ${ + usesCompactLayout ? "min-w-56" : "min-w-32" + }`; + menu.style.cssText = `position:fixed;z-index:10000;min-width:${ + usesCompactLayout ? "min(14rem,calc(100vw - 0.75rem))" : "8rem" + };max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);background-clip:padding-box;color:var(--contrast-popover-foreground);outline:none;pointer-events:auto;`; menu.style.left = `${preferredLeft}px`; menu.style.top = `${preferredTop}px`; menu.dataset.level = String(level); @@ -316,15 +389,16 @@ export function showContextMenuFallback( button.type = "button"; const isDisabled = item.disabled === true; button.disabled = isDisabled; - const rowBase = - "flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left outline-none transition-colors sm:min-h-7 sm:text-sm min-h-8 text-base"; + const rowBase = usesCompactLayout + ? "flex min-h-8 w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left text-sm outline-none transition-colors sm:min-h-7 sm:text-xs" + : "flex min-h-8 w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left text-base outline-none transition-colors sm:min-h-7 sm:text-sm"; button.className = isDisabled ? `${rowBase} pointer-events-none cursor-not-allowed text-muted-foreground opacity-64` : isLeafDestructive ? `${rowBase} text-destructive-foreground hover:bg-destructive/10 hover:text-destructive-foreground` : `${rowBase} text-foreground hover:bg-accent hover:text-accent-foreground`; button.style.cssText = - "display:flex;width:100%;min-height:1.75rem;align-items:center;gap:0.5rem;border:0;border-radius:var(--radius-sm);background:transparent;padding:0.25rem 0.5rem;color:var(--contrast-foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.875rem;line-height:1.25rem;text-align:left;cursor:default;"; + "display:flex;width:100%;align-items:center;gap:0.5rem;border:0;border-radius:var(--radius-sm);background:transparent;padding:0.25rem 0.5rem;color:var(--contrast-foreground);font-family:var(--font-sans,system-ui,sans-serif);font-weight:400;line-height:1.25rem;text-align:left;cursor:default;"; if (isLeafDestructive) { button.style.color = "var(--destructive-foreground)"; } @@ -346,6 +420,16 @@ export function showContextMenuFallback( label.textContent = item.label; button.appendChild(label); + if (item.accelerator) { + const accelerator = document.createElement("kbd"); + accelerator.className = + "ms-auto shrink-0 font-medium font-sans text-secondary-label text-xs tracking-widest"; + accelerator.style.cssText = + "margin-inline-start:auto;flex-shrink:0;color:var(--contrast-secondary-label);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.75rem;font-weight:500;letter-spacing:0.1em;"; + accelerator.textContent = formatContextMenuAccelerator(item.accelerator); + button.appendChild(accelerator); + } + if (hasChildren) { button.setAttribute("aria-haspopup", "menu"); button.setAttribute("aria-expanded", "false"); @@ -431,7 +515,7 @@ export function showContextMenuFallback( closeMenusFromLevel(level + 1); }); button.addEventListener("click", () => { - if (canDismissFromPointer) cleanup(item.id); + if (canDismissFromPointer) cleanup(item.id, "action"); }); } } @@ -454,9 +538,11 @@ export function showContextMenuFallback( }); }; - document.addEventListener("keydown", onKeyDown); + document.addEventListener("keydown", onKeyDown, true); document.addEventListener("pointerdown", onPointerDown, true); document.addEventListener("contextmenu", onContextMenu, true); + document.addEventListener("wheel", onWheel, { capture: true, passive: true }); + options?.signal?.addEventListener("abort", onAbort, { once: true }); openMenu(items, position?.x ?? 0, position?.y ?? 0, 0); // Only one fallback menu can be open at a time: a new show must dismiss // any prior one, or its DOM and listeners leak and close() can only ever diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 9220252cb20e..7f3620613c64 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -6,13 +6,17 @@ import { } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -const showContextMenuFallbackMock = - vi.fn< - ( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, - ) => Promise - >(); +const showContextMenuFallbackMock = vi.fn< + ( + items: readonly ContextMenuItem[], + position?: { x: number; y: number }, + options?: { + readonly layout?: "default" | "compact"; + readonly restoreFocus?: boolean | "on-dismiss"; + readonly signal?: AbortSignal; + }, + ) => Promise +>(); const dismissContextMenuMock = vi.fn<() => void>(); const requestConfirmDialogMock = @@ -84,7 +88,7 @@ describe("LocalApi", () => { const items = [{ id: "rename", label: "Rename" }] as const; await expect(createLocalApi().contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); - expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); + expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }, undefined); }); it("dismisses an open browser context menu without a desktop bridge", async () => { @@ -95,6 +99,40 @@ describe("LocalApi", () => { expect(dismissContextMenuMock).toHaveBeenCalledOnce(); }); + it("uses the styled fallback on desktop when the caller owns its lifecycle", async () => { + const showContextMenu = vi.fn().mockResolvedValue("native"); + testWindow().desktopBridge = { showContextMenu } as unknown as DesktopBridge; + showContextMenuFallbackMock.mockResolvedValue("copy"); + const abortController = new AbortController(); + const { createLocalApi } = await import("./localApi"); + const items = [{ id: "copy", label: "Copy" }] as const; + + await expect( + createLocalApi().contextMenu.show(items, undefined, { + layout: "compact", + presentation: "styled", + restoreFocus: "on-dismiss", + signal: abortController.signal, + }), + ).resolves.toBe("copy"); + + expect(showContextMenu).not.toHaveBeenCalled(); + expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, undefined, { + layout: "compact", + restoreFocus: "on-dismiss", + signal: abortController.signal, + }); + }); + + it("dismisses a styled fallback when a desktop bridge exists", async () => { + testWindow().desktopBridge = { showContextMenu: vi.fn() } as unknown as DesktopBridge; + const { createLocalApi } = await import("./localApi"); + + await createLocalApi().contextMenu.close(); + + expect(dismissContextMenuMock).toHaveBeenCalledOnce(); + }); + it("uses the themed confirmation host when it is available", async () => { requestConfirmDialogMock.mockResolvedValue(true); const { createLocalApi } = await import("./localApi"); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 863388106a3e..eef5a0e197d2 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -35,19 +35,36 @@ function createBrowserLocalApi(): LocalApi { show: async ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, + options?: { + readonly layout?: "default" | "compact"; + readonly presentation?: "native" | "styled"; + readonly restoreFocus?: boolean | "on-dismiss"; + readonly signal?: AbortSignal; + }, ): Promise => { - if (window.desktopBridge) { + if (window.desktopBridge && options?.presentation !== "styled") { return window.desktopBridge.showContextMenu(items, position) as Promise; } - return showContextMenuFallback(items, position); + return showContextMenuFallback( + items, + position, + options?.layout !== undefined || + options?.restoreFocus !== undefined || + options?.signal !== undefined + ? { + ...(options.layout === undefined ? {} : { layout: options.layout }), + ...(options.restoreFocus === undefined + ? {} + : { restoreFocus: options.restoreFocus }), + ...(options.signal === undefined ? {} : { signal: options.signal }), + } + : undefined, + ); }, - // A native desktop menu blocks keyboard input and closes on outside - // interaction, so nothing to do there; the DOM fallback needs an explicit - // dismiss when the state behind it goes away. + // Native desktop menus close themselves. Callers can still request the + // styled fallback on desktop, so always dismiss that host as well. close: async () => { - if (!window.desktopBridge) { - dismissContextMenu(); - } + dismissContextMenu(); }, }, persistence: { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e753596f3d33..42c9042e2e91 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -105,6 +105,8 @@ import type { export interface ContextMenuItem { id: T; label: string; + /** Shortcut notation displayed and handled by menu hosts that support accelerators. */ + accelerator?: string; destructive?: boolean; disabled?: boolean; /** Renders as a non-interactive section header label. Web fallback only — stripped on desktop native menus. */ @@ -119,6 +121,7 @@ export interface ContextMenuItem { export interface ContextMenuItemSchemaType { readonly id: string; readonly label: string; + readonly accelerator?: string; readonly destructive?: boolean; readonly disabled?: boolean; readonly header?: boolean; @@ -130,6 +133,7 @@ export interface ContextMenuItemSchemaType { export const ContextMenuItemSchema: Schema.Codec = Schema.Struct({ id: Schema.String, label: Schema.String, + accelerator: Schema.optionalKey(Schema.String), destructive: Schema.optionalKey(Schema.Boolean), disabled: Schema.optionalKey(Schema.Boolean), header: Schema.optionalKey(Schema.Boolean), @@ -1258,6 +1262,12 @@ export interface LocalApi { show: ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, + options?: { + readonly layout?: "default" | "compact"; + readonly presentation?: "native" | "styled"; + readonly restoreFocus?: boolean | "on-dismiss"; + readonly signal?: AbortSignal; + }, ) => Promise; close: () => Promise; };