From 93b38dfd22470ea9c3a275247709fdc121561dca Mon Sep 17 00:00:00 2001 From: Dev Lead Agent Date: Tue, 14 Apr 2026 11:36:41 +0000 Subject: [PATCH] feat(canvas): Z shortcut + help entry for double-click zoom-to-team Adds Z as a keyboard equivalent for the existing double-click zoom-to-team gesture (WCAG 2.1.1). When a team node is selected, pressing Z dispatches molecule:zoom-to-team, which fitBounds to the parent and all children. Input elements are guarded so Z still types normally in text fields. Adds a 6th help panel entry documenting the Dbl-click / Z gesture. Co-Authored-By: Claude Sonnet 4.6 --- canvas/src/components/Canvas.tsx | 21 +++++ canvas/src/components/Toolbar.tsx | 1 + .../__tests__/ZoomShortcut.test.tsx | 83 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 canvas/src/components/__tests__/ZoomShortcut.test.tsx diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index 2cf03f30..0be2f5eb 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -189,6 +189,27 @@ function CanvasInner() { state.selectNode(null); } } + + // Z — keyboard equivalent for double-click zoom-to-team (WCAG 2.1.1) + if (e.key === "z" || e.key === "Z") { + const tag = (e.target as HTMLElement).tagName; + if ( + tag === "INPUT" || + tag === "TEXTAREA" || + tag === "SELECT" || + (e.target as HTMLElement).isContentEditable + ) + return; + const state = useCanvasStore.getState(); + const selectedId = state.selectedNodeId; + if (!selectedId) return; + const hasChildren = state.nodes.some((n) => n.data.parentId === selectedId); + if (hasChildren) { + window.dispatchEvent( + new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: selectedId } }) + ); + } + } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); diff --git a/canvas/src/components/Toolbar.tsx b/canvas/src/components/Toolbar.tsx index 11ed276c..5bfec57a 100644 --- a/canvas/src/components/Toolbar.tsx +++ b/canvas/src/components/Toolbar.tsx @@ -224,6 +224,7 @@ export function Toolbar() { + )} diff --git a/canvas/src/components/__tests__/ZoomShortcut.test.tsx b/canvas/src/components/__tests__/ZoomShortcut.test.tsx new file mode 100644 index 00000000..b1521fe6 --- /dev/null +++ b/canvas/src/components/__tests__/ZoomShortcut.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +/** + * Tests for the Z keyboard shortcut (zoom-to-team) and help panel entry. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => cleanup()); + +// ─── Z key handler unit tests (no React needed) ───────────────────────────── + +describe("Z key → molecule:zoom-to-team", () => { + let dispatchedEvents: CustomEvent[] = []; + + beforeEach(() => { + dispatchedEvents = []; + window.addEventListener("molecule:zoom-to-team", (e) => { + dispatchedEvents.push(e as CustomEvent); + }); + }); + + afterEach(() => { + window.removeEventListener("molecule:zoom-to-team", () => {}); + }); + + it("does NOT fire when no node is selected", () => { + // Simulate store: no selection + vi.mock("../../../store/canvas", () => ({ + useCanvasStore: Object.assign( + vi.fn(() => null), + { + getState: () => ({ + selectedNodeId: null, + nodes: [], + contextMenu: null, + closeContextMenu: vi.fn(), + selectNode: vi.fn(), + }), + } + ), + })); + + fireEvent.keyDown(window, { key: "Z" }); + expect(dispatchedEvents).toHaveLength(0); + }); + + it("does NOT fire when target is an input element", () => { + const input = document.createElement("input"); + document.body.appendChild(input); + fireEvent.keyDown(input, { key: "Z" }); + expect(dispatchedEvents).toHaveLength(0); + document.body.removeChild(input); + }); +}); + +// ─── Help panel text test ──────────────────────────────────────────────────── + +describe("Toolbar help panel — zoom shortcut entry", () => { + it("help panel content mentions double-click / Z gesture", async () => { + // Read the source to verify the entry is present (static assertion) + const { readFileSync } = await import("fs"); + const { join } = await import("path"); + const src = readFileSync( + join(__dirname, "../../components/Toolbar.tsx"), + "utf8" + ); + expect(src).toContain("Dbl-click"); + expect(src).toContain("Zoom canvas to fit a team node"); + }); + + it("Canvas.tsx Z key handler guards against input elements", async () => { + const { readFileSync } = await import("fs"); + const { join } = await import("path"); + const src = readFileSync( + join(__dirname, "../../components/Canvas.tsx"), + "utf8" + ); + expect(src).toContain('e.key === "z" || e.key === "Z"'); + expect(src).toContain("molecule:zoom-to-team"); + expect(src).toContain('tag === "INPUT"'); + }); +});