diff --git a/README.md b/README.md
index 0c0048ae..bde9c7b5 100644
--- a/README.md
+++ b/README.md
@@ -95,7 +95,7 @@ The **ZPL** panel at the bottom shows the generated ZPL, **Copy** puts it on the
The **ZPL** panel highlights syntax and collapses long blocks of image data. Selecting a canvas object marks its ZPL lines. A notice above the code lists commands that change saved printer settings or control the printer.
-The **ZPL reference** beside the code follows the cursor. Search to filter the list, or click a command to pin its details. **Insert** adds the command at the cursor, or before the current page's `^XZ` when the cursor is not in the code.
+The **ZPL reference** beside the code follows the cursor. Search to filter the list, or click a command to pin its details. A double-click or the right-click menu inserts the command at the cursor. The chevron in the heading folds the list.
While you edit, the canvas shows a live preview. Canvas editing and the **Properties** tab are disabled. Page switching stays enabled. The editor refuses source that is too large or has mismatched `^XA`/`^XZ` commands, and marks the mismatches.
diff --git a/src/components/Output/ZPLOutput.catalog.test.tsx b/src/components/Output/ZPLOutput.catalog.test.tsx
index 2101349b..bfa621cb 100644
--- a/src/components/Output/ZPLOutput.catalog.test.tsx
+++ b/src/components/Output/ZPLOutput.catalog.test.tsx
@@ -80,8 +80,8 @@ describe("reference panel wiring", () => {
const view = mount();
const pos = placePointerCaret(view, "^FO");
expect(detail().getByText("field origin")).toBeTruthy();
- // The detail shows the caret's own command, so that is what the button inserts, at the caret.
- fireEvent.click(detail().getByRole("button", { name: /^Insert$/ }));
+ fireEvent.contextMenu(screen.getByRole("option", { name: /\^FO/ }), { clientX: 10, clientY: 10 });
+ fireEvent.click(within(screen.getByRole("menu")).getByRole("button", { name: /^Insert$/ }));
expect(view.state.doc.toString().slice(pos, pos + 6)).toBe("^FO^FO");
});
@@ -111,12 +111,12 @@ describe("reference panel wiring", () => {
expect(detail().getByText("label length")).toBeTruthy();
});
- it("refuses pointer focus for rows and the Insert button while the editor holds it, never for search or prose", () => {
+ it("refuses pointer focus for rows and the list toggle while the editor holds it, never for search or prose", () => {
const view = mount();
placePointerCaret(view, "hello");
// fireEvent returns false when a handler called preventDefault. Here that default is the focus move.
expect(fireEvent.mouseDown(screen.getByRole("option", { name: /\^LL/ }))).toBe(false);
- expect(fireEvent.mouseDown(detail().getByRole("button", { name: /^Insert$/ }))).toBe(false);
+ expect(fireEvent.mouseDown(screen.getByRole("button", { name: "Command list" }))).toBe(false);
expect(fireEvent.mouseDown(screen.getByRole("searchbox"))).toBe(true);
expect(fireEvent.mouseDown(detail().getByText("field data"))).toBe(true);
});
@@ -124,7 +124,7 @@ describe("reference panel wiring", () => {
it("takes pointer focus as before while the editor does not hold it, or no longer holds it", () => {
const view = mount();
expect(fireEvent.mouseDown(screen.getByRole("option", { name: /\^LL/ }))).toBe(true);
- expect(fireEvent.mouseDown(detail().getByRole("button", { name: /^Insert$/ }))).toBe(true);
+ expect(fireEvent.mouseDown(screen.getByRole("button", { name: "Command list" }))).toBe(true);
placePointerCaret(view, "hello");
act(() => view.contentDOM.blur());
expect(fireEvent.mouseDown(screen.getByRole("option", { name: /\^LL/ }))).toBe(true);
diff --git a/src/components/Output/ZplCatalogPanel.test.tsx b/src/components/Output/ZplCatalogPanel.test.tsx
index 2c9beea2..285a4394 100644
--- a/src/components/Output/ZplCatalogPanel.test.tsx
+++ b/src/components/Output/ZplCatalogPanel.test.tsx
@@ -5,7 +5,10 @@ import { ZplCatalogPanel } from "./ZplCatalogPanel";
import { useCatalogSelection } from "../../hooks/useCatalogSelection";
import type { CursorCommand } from "../../lib/zplLanguage";
-afterEach(cleanup);
+afterEach(() => {
+ cleanup();
+ localStorage.removeItem("zpl:section:catalog-list");
+});
/** The panel over its own selection, as the source editor mounts it. */
function Panel({ cursor, onInsert }: { cursor: CursorCommand | null; onInsert?: (text: string) => void }) {
@@ -15,13 +18,38 @@ function Panel({ cursor, onInsert }: { cursor: CursorCommand | null; onInsert?:
describe("ZplCatalogPanel", () => {
const detail = (getByTestId: (id: string) => HTMLElement) => within(getByTestId("catalog-detail"));
- it("shows the command under the caret with its description and inserts it on request", () => {
- const onInsert = vi.fn();
- const { getByTestId, getByRole } = render();
+ it("shows the command under the caret with its description", () => {
+ const { getByTestId } = render();
expect(detail(getByTestId).getByText("label length")).toBeTruthy();
expect(getByTestId("catalog-detail").querySelector("p")?.textContent).toMatch(/\w+\.$/);
- fireEvent.click(getByRole("button", { name: /^Insert$/ }));
- expect(onInsert).toHaveBeenCalledWith("^LL");
+ });
+
+ it("scrolls the list on an unfold and not on a fold", () => {
+ const scrolled = vi.fn();
+ const original = Element.prototype.scrollIntoView;
+ Element.prototype.scrollIntoView = scrolled;
+ try {
+ const { getByRole } = render();
+ scrolled.mockClear();
+ fireEvent.click(getByRole("button", { name: "Command list" }));
+ expect(scrolled).not.toHaveBeenCalled();
+ fireEvent.click(getByRole("button", { name: "Command list" }));
+ expect(scrolled).toHaveBeenCalledTimes(1);
+ } finally {
+ Element.prototype.scrollIntoView = original;
+ }
+ });
+
+ it("folds the command list away, keeps the description and remembers the fold", () => {
+ const first = render();
+ fireEvent.click(first.getByRole("button", { name: "Command list" }));
+ expect(first.queryByRole("listbox")).toBeNull();
+ expect(detail(first.getByTestId).getByText("label length")).toBeTruthy();
+ first.unmount();
+ const second = render();
+ expect(second.queryByRole("listbox")).toBeNull();
+ fireEvent.click(second.getByRole("button", { name: "Command list" }));
+ expect(second.getByRole("listbox")).toBeTruthy();
});
it("inserts on a double-click, whose first click pins the row", () => {
@@ -61,12 +89,6 @@ describe("ZplCatalogPanel", () => {
expect((within(getByRole("menu")).getByRole("button", { name: /^Insert$/ }) as HTMLButtonElement).disabled).toBe(true);
});
- it("marks the insert button aria-disabled when inserting is unavailable", () => {
- const { getByRole } = render();
- // Pins aria-disabled: a disabled button would drop its focus to body.
- expect(getByRole("button", { name: /^Insert$/ }).getAttribute("aria-disabled")).toBe("true");
- });
-
it("shows the three support levels in web, desktop, lint order", () => {
const levelsOf = (id: string) => {
const { getByTestId, unmount } = render();
@@ -83,7 +105,8 @@ describe("ZplCatalogPanel", () => {
// ~HL shares the row with ^HL but is not the same thing on the printer.
const onInsert = vi.fn();
const { getByRole } = render();
- fireEvent.click(getByRole("button", { name: /^Insert$/ }));
+ fireEvent.contextMenu(getByRole("option", { name: /\^HL/ }), { clientX: 10, clientY: 10 });
+ fireEvent.click(within(getByRole("menu")).getByRole("button", { name: /^Insert$/ }));
expect(onInsert).toHaveBeenCalledWith("~HL");
});
@@ -105,7 +128,7 @@ describe("ZplCatalogPanel", () => {
it("dismisses on Escape without letting focus leave the panel", () => {
const { getByRole, getByTestId } = render();
- const button = getByRole("button", { name: /^Insert$/ });
+ const button = getByRole("button", { name: "Command list" });
button.focus();
fireEvent.keyDown(button, { key: "Escape" });
expect(detail(getByTestId).getByText(/Reference hidden/)).toBeTruthy();
@@ -207,13 +230,13 @@ describe("ZplCatalogPanel", () => {
expect(detail(getByTestId).getByText(/Reference hidden/)).toBeTruthy();
});
- it("keeps the caret's spelling for Insert after ArrowDown out of the empty state", () => {
+ it("inserts the caret's spelling on Enter after ArrowDown out of the empty state", () => {
const onInsert = vi.fn();
const { getByRole } = render();
const list = getByRole("listbox");
fireEvent.keyDown(list, { key: "Escape" });
fireEvent.keyDown(list, { key: "ArrowDown" });
- fireEvent.click(getByRole("button", { name: /^Insert$/ }));
+ fireEvent.keyDown(list, { key: "Enter" });
expect(onInsert).toHaveBeenCalledWith("~HL");
});
@@ -251,7 +274,6 @@ describe("ZplCatalogPanel", () => {
const after = [getByTestId("catalog-detail").textContent, queryAllByRole("option", { selected: true }).length];
expect(after).toEqual(before);
expect(detail(getByTestId).getByText("^ZQ is not in the reference.")).toBeTruthy();
- expect(getByRole("button", { name: /^Insert$/ }).getAttribute("aria-disabled")).toBe("true");
});
it("keeps a pin the search hides when there is nothing shown to step back from", () => {
diff --git a/src/components/Output/ZplCatalogPanel.tsx b/src/components/Output/ZplCatalogPanel.tsx
index a9aaaaf9..27891336 100644
--- a/src/components/Output/ZplCatalogPanel.tsx
+++ b/src/components/Output/ZplCatalogPanel.tsx
@@ -1,5 +1,6 @@
import { useEffect, useId, useRef, useState } from "react";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
+import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { CATALOG_SECTIONS, commandId, commandLabel, type CommandSupport, type SupportLevel } from "@zplab/core/catalog";
import { useT } from "../../hooks/useT";
import { useCatalogSummaries } from "../../hooks/useCatalogSummaries";
@@ -9,6 +10,8 @@ import type { Translations } from "../../locales";
import { inputCls } from "../Properties/styles";
import { ContextMenu, type MenuSection } from "../ui/ContextMenu";
import { useContextMenu } from "../../hooks/useContextMenu";
+import { useCollapsibleState } from "../ui/useCollapsibleState";
+import { Tooltip } from "../ui/Tooltip";
type OutputKey = keyof Translations["output"];
@@ -30,7 +33,7 @@ const LEVEL: Record = {
const domId = (listId: string, key: string): string =>
`${listId}-${key.replace(/^\^/, "c").replace(/^~/, "t").replace(/[^A-Za-z0-9]/g, "_")}`;
-/** One entry: the same insert the row's button and double-click already trigger. */
+/** One entry: the insert a double-click and Enter also run. */
function buildCatalogRowMenu(label: string, run: (() => void) | undefined): MenuSection[] {
return [{ id: "row", items: [{ id: "insert", label, run, disabled: run === undefined }] }];
}
@@ -50,6 +53,7 @@ export function ZplCatalogPanel({
const summaries = useCatalogSummaries();
const listId = useId();
const listRef = useRef(null);
+ const [listOpen, setListOpen] = useCollapsibleState("catalog-list", true);
// State, not a ref: the menu's container is read during render.
const [panelEl, setPanelEl] = useState(null);
// A row chosen under the pointer is in view already, and scrolling would close the menu that opens with it.
@@ -61,7 +65,6 @@ export function ZplCatalogPanel({
(g) => g.rows.length > 0,
);
const empty = ids.length === 0;
- const requestInsert = onInsert && shownId ? () => onInsert(insertTextFor(shownId)) : undefined;
const emptyLine = emptyReason &&
{catalogEmptyText(emptyReason, t)}
;
// Only the caret-driven lines are announced. The no-cursor line would be read on every step through plain text.
const announced = emptyReason?.kind !== "noCursor";
@@ -83,8 +86,8 @@ export function ZplCatalogPanel({
if (!shownId || shownId === justChosen) return;
const row = listRef.current?.querySelector(`#${CSS.escape(domId(listId, shownId))}`);
row?.scrollIntoView?.({ block: "nearest" });
- // `query` re-runs this when a cleared filter re-mounts the active row.
- }, [shownId, listId, query]);
+ // `query` and `listOpen` re-run this when a cleared filter or an unfold re-mounts the active row.
+ }, [shownId, listId, query, listOpen]);
const onListKeyDown = (e: React.KeyboardEvent): void => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
@@ -120,12 +123,29 @@ export function ZplCatalogPanel({
data-session-exit-ignore
className="flex flex-col basis-2/5 min-w-[14rem] max-w-[52rem] shrink border-l border-border bg-surface text-xs @container outline-none"
>
-
- {t.output.catalogHeading}
-
-
+
+
{t.output.catalogHeading}
+
+
+
+
+
@@ -150,94 +170,83 @@ export function ZplCatalogPanel({
{announced && emptyLine}