Skip to content

Commit dfe595d

Browse files
committed
feat(catalog): a right-click on a command offers the insert
1 parent 05200d1 commit dfe595d

5 files changed

Lines changed: 88 additions & 12 deletions

File tree

‎src/components/Output/ZPLOutput.sourceEdit.test.tsx‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ vi.mock("./ZplCodeMirror", () => {
2121
readOnly?: boolean;
2222
highlightLines?: ReadonlySet<number>;
2323
diagnostics?: readonly { from: number; to: number; message: string }[] | null;
24-
ref?: React.Ref<{ focus(): void }>;
24+
ref?: React.Ref<{ focus(): void; insertCommand(text: string): void }>;
2525
}) {
2626
const el = React.useRef<HTMLTextAreaElement>(null);
27-
React.useImperativeHandle(ref, () => ({ focus: () => el.current?.focus() }), []);
27+
// Appends and refocuses like the real insert. Enough to prove a command reached the buffer.
28+
React.useImperativeHandle(ref, () => ({ focus: () => el.current?.focus(), insertCommand: (text: string) => { onChange(value + text); el.current?.focus(); } }), [onChange, value]);
2829
return (
2930
<textarea
3031
ref={el}
@@ -524,6 +525,23 @@ describe("outside pointerdown", () => {
524525
expect(outsideAction).toHaveBeenCalledTimes(1);
525526
});
526527

528+
it("holds the session on the reference's context menu and inserts the command", async () => {
529+
render(<ZPLOutput onResizeMouseDown={vi.fn()} />);
530+
typeDraft("^XA^FO10,10^A0N,30,30^FDedited^FS^XZ");
531+
fireEvent.contextMenu(screen.getByRole("option", { name: /\^PW/ }), { clientX: 10, clientY: 10 });
532+
const insert = within(screen.getByRole("menu")).getByRole("button", { name: t().output.catalogInsert });
533+
fireEvent.pointerDown(insert);
534+
expect(useLabelStore.getState().sourceEdit.status).toBe("editing");
535+
fireEvent.click(insert);
536+
expect(useLabelStore.getState().sourceEdit.status).toBe("editing");
537+
expect(editor().value).toContain("^PW");
538+
fireEvent.contextMenu(screen.getByRole("option", { name: /\^PW/ }), { clientX: 10, clientY: 10 });
539+
await act(() => new Promise((r) => setTimeout(r, 0)));
540+
fireEvent.keyDown(window, { key: "Escape" });
541+
expect(screen.queryByRole("menu")).toBeNull();
542+
expect(useLabelStore.getState().sourceEdit.status).toBe("editing");
543+
});
544+
527545
it("holds the session on panel chrome (the resize rail lives inside the root)", () => {
528546
const { container } = render(<ZPLOutput onResizeMouseDown={vi.fn()} />);
529547
typeDraft("^XA^FO10,10^A0N,30,30^FDedited^FS^XZ");

‎src/components/Output/ZplCatalogPanel.test.tsx‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// @vitest-environment jsdom
22
import { describe, it, expect, afterEach, vi } from "vitest";
3-
import { render, cleanup, fireEvent, within } from "@testing-library/react";
3+
import { render, cleanup, fireEvent, within, act } from "@testing-library/react";
44
import { ZplCatalogPanel } from "./ZplCatalogPanel";
55
import { useCatalogSelection } from "../../hooks/useCatalogSelection";
66
import type { CursorCommand } from "../../lib/zplLanguage";
@@ -36,6 +36,31 @@ describe("ZplCatalogPanel", () => {
3636
expect(detail(getByTestId).getByText("print width")).toBeTruthy();
3737
});
3838

39+
it("shows a context menu that selects the row and offers insert", () => {
40+
const onInsert = vi.fn();
41+
const { getByRole, getByTestId } = render(<Panel cursor={{ id: "^LL", from: 0, pointed: true }} onInsert={onInsert} />);
42+
fireEvent.contextMenu(getByRole("option", { name: /\^PW/ }), { clientX: 10, clientY: 10 });
43+
expect(detail(getByTestId).getByText("print width")).toBeTruthy();
44+
fireEvent.click(within(getByRole("menu")).getByRole("button", { name: /^Insert$/ }));
45+
expect(onInsert).toHaveBeenCalledWith("^PW");
46+
});
47+
48+
it("closes the menu on Escape and keeps the row it selected", async () => {
49+
const { getByRole, queryByRole, getByTestId } = render(<Panel cursor={{ id: "^LL", from: 0, pointed: true }} onInsert={vi.fn()} />);
50+
fireEvent.contextMenu(getByRole("option", { name: /\^PW/ }), { clientX: 10, clientY: 10 });
51+
// The menu's listeners attach a tick after the opening gesture.
52+
await act(() => new Promise((r) => setTimeout(r, 0)));
53+
fireEvent.keyDown(window, { key: "Escape" });
54+
expect(queryByRole("menu")).toBeNull();
55+
expect(detail(getByTestId).getByText("print width")).toBeTruthy();
56+
});
57+
58+
it("disables the menu's insert when inserting is unavailable", () => {
59+
const { getByRole } = render(<Panel cursor={{ id: "^LL", from: 0, pointed: true }} />);
60+
fireEvent.contextMenu(getByRole("option", { name: /\^PW/ }), { clientX: 10, clientY: 10 });
61+
expect((within(getByRole("menu")).getByRole("button", { name: /^Insert$/ }) as HTMLButtonElement).disabled).toBe(true);
62+
});
63+
3964
it("marks the insert button aria-disabled when inserting is unavailable", () => {
4065
const { getByRole } = render(<Panel cursor={{ id: "^LL", from: 0, pointed: true }} />);
4166
// Pins aria-disabled: a disabled button would drop its focus to body.

‎src/components/Output/ZplCatalogPanel.tsx‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useId, useRef } from "react";
1+
import { useEffect, useId, useRef, useState } from "react";
22
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
33
import { CATALOG_SECTIONS, commandId, commandLabel, type CommandSupport, type SupportLevel } from "@zplab/core/catalog";
44
import { useT } from "../../hooks/useT";
@@ -7,6 +7,8 @@ import type { CatalogSelection } from "../../hooks/useCatalogSelection";
77
import { catalogEmptyText } from "../../lib/catalogText";
88
import type { Translations } from "../../locales";
99
import { inputCls } from "../Properties/styles";
10+
import { ContextMenu, type MenuSection } from "../ui/ContextMenu";
11+
import { useContextMenu } from "../../hooks/useContextMenu";
1012

1113
type OutputKey = keyof Translations["output"];
1214

@@ -28,6 +30,11 @@ const LEVEL: Record<SupportLevel, { cls: string; key: OutputKey }> = {
2830
const domId = (listId: string, key: string): string =>
2931
`${listId}-${key.replace(/^\^/, "c").replace(/^~/, "t").replace(/[^A-Za-z0-9]/g, "_")}`;
3032

33+
/** One entry: the same insert the row's button and double-click already trigger. */
34+
function buildCatalogRowMenu(label: string, run: (() => void) | undefined): MenuSection[] {
35+
return [{ id: "row", items: [{ id: "insert", label, run, disabled: run === undefined }] }];
36+
}
37+
3138
/** Command reference beside the source pane; no `onInsert` means inserting is unavailable. */
3239
export function ZplCatalogPanel({
3340
selection,
@@ -43,7 +50,12 @@ export function ZplCatalogPanel({
4350
const summaries = useCatalogSummaries();
4451
const listId = useId();
4552
const listRef = useRef<HTMLUListElement>(null);
53+
// State, not a ref: the menu's container is read during render.
54+
const [panelEl, setPanelEl] = useState<HTMLElement | null>(null);
55+
// A row chosen under the pointer is in view already, and scrolling would close the menu that opens with it.
56+
const chosenByPointer = useRef<string | null>(null);
4657
const { query, setQuery, results, ids, entry, emptyReason, shownId, activeIndex, caretVisible, choose, toggle, stepBack, insertTextFor } = selection;
58+
const { menu, openAtPointer, close } = useContextMenu<MenuSection[]>();
4759
// Headings only: the catalog is stored in section order, so the arrow walk reads `ids` as is.
4860
const groups = CATALOG_SECTIONS.map((section) => ({ section, rows: results.filter((e) => e.section === section.name) })).filter(
4961
(g) => g.rows.length > 0,
@@ -57,9 +69,18 @@ export function ZplCatalogPanel({
5769
const keepEditorFocus = (e: React.MouseEvent): void => {
5870
if (editorHasFocus?.()) e.preventDefault();
5971
};
72+
// A right-click selects the row like a click does, then offers the insert that double-click hides.
73+
const openRowMenu = (e: React.MouseEvent, id: string): void => {
74+
e.preventDefault();
75+
chosenByPointer.current = id;
76+
choose(id);
77+
openAtPointer(e, buildCatalogRowMenu(t.output.catalogInsert, onInsert ? () => onInsert(insertTextFor(id)) : undefined));
78+
};
6079

6180
useEffect(() => {
62-
if (!shownId) return;
81+
const justChosen = chosenByPointer.current;
82+
chosenByPointer.current = null;
83+
if (!shownId || shownId === justChosen) return;
6384
const row = listRef.current?.querySelector(`#${CSS.escape(domId(listId, shownId))}`);
6485
row?.scrollIntoView?.({ block: "nearest" });
6586
// `query` re-runs this when a cleared filter re-mounts the active row.
@@ -78,7 +99,7 @@ export function ZplCatalogPanel({
7899
const next = ids[Math.min(ids.length - 1, Math.max(0, activeIndex < 0 ? enterFrom : activeIndex + step))];
79100
if (next) choose(next);
80101
} else if (e.key === "Enter") {
81-
// Enter inserts only the highlighted row. Anything else goes through the detail's button.
102+
// Enter inserts only the highlighted row. Without one, it only selects the caret.
82103
const activeId = ids[activeIndex];
83104
if (activeId) onInsert?.(insertTextFor(activeId));
84105
else if (caretVisible) choose(caretVisible);
@@ -92,6 +113,7 @@ export function ZplCatalogPanel({
92113
// tabIndex -1 keeps a description click's focus inside the pane, else the session exit's focusout fires.
93114
// The data attributes keep the canvas shortcuts and the session's Escape away from the panel's own keys.
94115
<aside
116+
ref={setPanelEl}
95117
tabIndex={-1}
96118
onKeyDown={onPanelKeyDown}
97119
data-text-surface
@@ -196,9 +218,12 @@ export function ZplCatalogPanel({
196218
onMouseDown={keepEditorFocus}
197219
// The clicks of a double-click must not toggle the pin twice.
198220
onClick={(e) => {
199-
if (e.detail <= 1) toggle(id);
221+
if (e.detail > 1) return;
222+
chosenByPointer.current = id;
223+
toggle(id);
200224
}}
201225
onDoubleClick={() => onInsert?.(insertTextFor(id))}
226+
onContextMenu={(e) => openRowMenu(e, id)}
202227
className={`flex items-baseline gap-2 px-3 py-0.5 cursor-default select-none hover:bg-border/60 ${active ? "bg-border/60" : ""}`}
203228
>
204229
{/* Coloured by web support: the answer most users need at a glance. */}
@@ -214,6 +239,8 @@ export function ZplCatalogPanel({
214239
</ul>
215240
</div>
216241
</div>
242+
{/* Mounted inside the panel: the session exit reads a pointerdown outside it as leaving the edit. */}
243+
{menu && <ContextMenu sections={menu.data} x={menu.x} y={menu.y} onClose={close} container={panelEl} />}
217244
</aside>
218245
);
219246
}

‎src/components/ui/ContextMenu.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ interface Props {
3636
/** Icon per action; consumer-owned so the menu model stays pure data. */
3737
iconFor?: (item: MenuAction) => IconType | undefined;
3838
onClose: () => void;
39+
/** Where the menu mounts. A panel that watches for pointer events leaving it passes itself. */
40+
container?: Element | null;
3941
}
4042

4143
// Close-grace for submenu hover: long enough to survive a diagonal pointer
@@ -184,7 +186,7 @@ function Row({
184186
}
185187

186188
/** Pure view over `MenuSection[]`; builders stay data-only. */
187-
export function ContextMenu({ sections, x, y, labels, iconFor, onClose }: Props) {
189+
export function ContextMenu({ sections, x, y, labels, iconFor, onClose, container }: Props) {
188190
const ref = useRef<HTMLDivElement>(null);
189191
const [pos, setPos] = useState({ x, y });
190192
// One open submenu across the whole root level (sections included).
@@ -243,6 +245,6 @@ export function ContextMenu({ sections, x, y, labels, iconFor, onClose }: Props)
243245
</div>
244246
))}
245247
</div>,
246-
document.body,
248+
container ?? document.body,
247249
);
248250
}

‎src/hooks/useDismiss.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,24 @@ export function useDismiss(
2525
const onPointer = (e: PointerEvent) => {
2626
if (!ref.current?.contains(e.target as Node)) onDismissRef.current();
2727
};
28+
// Capture phase and consumed: the open popover is the top layer, so its Escape reaches nothing beneath.
2829
const onKey = (e: KeyboardEvent) => {
29-
if (e.key === "Escape") onDismissRef.current();
30+
if (e.key !== "Escape") return;
31+
e.preventDefault();
32+
e.stopPropagation();
33+
onDismissRef.current();
3034
};
3135
const attach = () => {
3236
window.addEventListener("pointerdown", onPointer);
33-
window.addEventListener("keydown", onKey);
37+
window.addEventListener("keydown", onKey, true);
3438
};
3539
let timer: number | undefined;
3640
if (defer) timer = window.setTimeout(attach, 0);
3741
else attach();
3842
return () => {
3943
if (timer !== undefined) window.clearTimeout(timer);
4044
window.removeEventListener("pointerdown", onPointer);
41-
window.removeEventListener("keydown", onKey);
45+
window.removeEventListener("keydown", onKey, true);
4246
};
4347
}, [ref, active, defer]);
4448
}

0 commit comments

Comments
 (0)