Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/desktop/src/electron/ElectronMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

Expand All @@ -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 }),
Expand All @@ -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,
});
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down Expand Up @@ -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) {
Expand Down
166 changes: 165 additions & 1 deletion apps/web/src/contextMenuFallback.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -17,6 +21,8 @@ class FakeDomEvent {
preventDefault() {
this.defaultPrevented = true;
}

stopPropagation() {}
}

class FakeElement {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -328,6 +451,47 @@ describe("showContextMenuFallback", () => {
});
});

describe("contextMenuAcceleratorAction", () => {
const event = (overrides: Partial<Parameters<typeof contextMenuAcceleratorAction>[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([
Expand Down
Loading
Loading