fix(design): a right-click on selected words opens the browser's own menu (E28 S3e4b)

The row menu took every right-click, so selected text could only be copied
with Ctrl+C. On the selection (its laid-out line boxes) the event is left to
the browser; beside it the object's menu opens as before.
This commit is contained in:
2026-09-26 19:03:34 +02:00
parent 079cee4e9c
commit bd0914b7ed
3 changed files with 72 additions and 3 deletions
@@ -8,7 +8,7 @@
// What is worth asserting here is not "a menu opens" but the two things that make it a CONTEXT menu:
// the left button keeps the row's own action, and the gesture that opens it doesn't also fire that
// action underneath.
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { describe, it, expect, vi, afterEach, beforeAll, onTestFinished } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { type ActionMenuItem } from "./ActionMenu";
@@ -108,6 +108,50 @@ describe("useContextMenu / ContextMenu", () => {
expect(menuitems()).toEqual([]);
});
describe("on selected words the browser's own menu opens (DESIGN: Text stays selectable)", () => {
const selectText = (el: Element) => {
const range = document.createRange();
range.selectNodeContents(el);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
};
/** Right-click `el` at (x, y); true when the app left the event to the browser (not prevented). */
const rightClick = (el: Element, x = 10, y = 10) =>
fireEvent.contextMenu(el, { clientX: x, clientY: y });
afterEach(() => {
window.getSelection()!.removeAllRanges();
});
it("lets the browser have a right-click on the selection, and keeps the app menu elsewhere", () => {
renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]);
selectText(row("beta"));
expect(rightClick(row("beta"))).toBe(true); // not prevented: the native menu opens
expect(menuitems()).toEqual([]);
expect(rightClick(row("alpha"))).toBe(false);
expect(menuitems()).toEqual(["Play alpha"]);
});
it("where ranges are laid out, only a point ON the selected words counts", () => {
renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]);
// A real engine: the selected words' line box.
// jsdom lays nothing out and has no Range.getClientRects at all; lend it one for this test.
const box = { left: 0, top: 0, right: 50, bottom: 20 } as DOMRect;
Object.defineProperty(Range.prototype, "getClientRects", {
configurable: true,
value: () => [box],
});
onTestFinished(() => {
delete (Range.prototype as { getClientRects?: unknown }).getClientRects;
});
selectText(row("beta"));
expect(rightClick(row("beta"), 10, 10)).toBe(true);
expect(menuitems()).toEqual([]);
// The same row, beside its selected words (its picture, its padding): the object's menu.
expect(rightClick(row("beta"), 200, 10)).toBe(false);
expect(menuitems()).toEqual(["Play beta"]);
});
});
it("selecting an item closes the menu, then fires it (see ActionMenu's order test)", async () => {
const onSelect = vi.fn();
renderList(() => [{ id: "a", label: "Remove", onSelect }]);