Compare commits

...
Author SHA1 Message Date
Miguel Palhas e0edbc09d2 feat(maestro): port kill dialog, help overlay, context menu, drawer to React
Closes #250: kill confirmation/force-kill (dialogs/KillDialog), the ?
shortcuts overlay (dialogs/HelpOverlay), the general-purpose right-click
menu (common/ContextMenu, closes on scroll/resize unlike the sidebar's
own), the mobile drawer with swipe support (layout/Drawer), and the
visual-viewport hook (hooks/useViewport). Each is a standalone, tested
port of its vanilla-DOM source; wiring into the shell is a later issue.
2026-09-09 01:33:56 +01:00
12 changed files with 863 additions and 0 deletions
@@ -0,0 +1,68 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ContextMenu } from "./ContextMenu.js";
afterEach(() => {
cleanup();
});
function labels(): string[] {
return [...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent ?? "");
}
describe("ContextMenu (#125)", () => {
it("renders one item per entry, in order", () => {
render(
<ContextMenu
x={10}
y={20}
items={[
{ label: "Rename…", onSelect: () => {} },
{ label: "Kill session", onSelect: () => {}, danger: true },
]}
onClose={vi.fn()}
/>,
);
expect(labels()).toEqual(["Rename…", "Kill session"]);
expect(document.querySelector(".context-menu-item.is-danger")?.textContent).toBe("Kill session");
});
it("runs the selected entry and closes", () => {
const chosen = vi.fn();
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: chosen }]} onClose={onClose} />);
fireEvent.click(screen.getByRole("menuitem"));
expect(chosen).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
});
it("closes on Escape and on a press outside it", () => {
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent(document.body, new Event("pointerdown", { bubbles: true }));
expect(onClose).toHaveBeenCalledTimes(2);
});
it("stays open for a press inside itself", () => {
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
const menu = document.querySelector(".context-menu") as HTMLElement;
fireEvent(menu, new Event("pointerdown", { bubbles: true }));
expect(onClose).not.toHaveBeenCalled();
});
it("closes on scroll and on resize", () => {
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
fireEvent.scroll(window);
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.resize(window);
expect(onClose).toHaveBeenCalledTimes(2);
});
});
+86
View File
@@ -0,0 +1,86 @@
// React port of ../../contextMenu.ts (#125), the general-purpose version —
// unlike the sidebar's own menu, this one also closes on scroll/resize, since
// a menu anchored to coordinates goes stale the moment the page moves under it.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
export interface ContextMenuItem {
label: string;
onSelect(): void;
/** Renders the entry in the destructive style. */
danger?: boolean;
}
export interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuItem[];
onClose(): void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: x, top: y });
// Nudge back inside the viewport once the menu has a measurable size.
useLayoutEffect(() => {
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
setPos({ left: Math.max(0, Math.min(x, maxX)), top: Math.max(0, Math.min(y, maxY)) });
}, [x, y]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const onPointerDown = (e: Event) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
document.addEventListener("keydown", onKeyDown, true);
window.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("scroll", onClose, true);
window.addEventListener("resize", onClose);
return () => {
document.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("scroll", onClose, true);
window.removeEventListener("resize", onClose);
};
}, [onClose]);
useEffect(() => {
(ref.current?.firstElementChild as HTMLElement | null)?.focus();
}, []);
return createPortal(
<div
ref={ref}
role="menu"
className="context-menu fixed z-modal min-w-[160px] rounded-r-2 border border-border-strong bg-surface py-sp-1 shadow-2"
style={{ left: pos.left, top: pos.top }}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
className={`context-menu-item block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
item.danger ? "is-danger text-danger" : "text-ink"
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClose();
item.onSelect();
}}
>
{item.label}
</button>
))}
</div>,
document.body,
);
}
+1
View File
@@ -0,0 +1 @@
export { ContextMenu, type ContextMenuItem, type ContextMenuProps } from "./ContextMenu.js";
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { keyCaps } from "../../commands.js";
import { groupedCommands } from "../../help.js";
import { HelpOverlay } from "./HelpOverlay.js";
afterEach(() => {
cleanup();
});
function press(key: string, init: KeyboardEventInit = {}): void {
fireEvent.keyDown(window, { key, ...init });
}
describe("HelpOverlay", () => {
it("opens on ? and closes on a second ?", () => {
render(<HelpOverlay />);
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { shiftKey: true });
expect(screen.queryByRole("dialog")).not.toBeNull();
press("?", { shiftKey: true });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("ignores ? while typing and when a non-shift modifier is held", () => {
render(<HelpOverlay />);
const input = document.createElement("input");
document.body.appendChild(input);
fireEvent.keyDown(input, { key: "?", shiftKey: true });
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { ctrlKey: true });
expect(screen.queryByRole("dialog")).toBeNull();
input.remove();
});
it("closes on Escape, on the close button, and on a click outside the panel", () => {
render(<HelpOverlay />);
press("?", { shiftKey: true });
press("Escape");
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { shiftKey: true });
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { shiftKey: true });
fireEvent.click(screen.getByRole("dialog"));
expect(screen.queryByRole("dialog")).toBeNull();
});
it("stays open for a click inside the panel", () => {
render(<HelpOverlay />);
press("?", { shiftKey: true });
fireEvent.click(document.querySelector(".help-panel") as HTMLElement);
expect(screen.queryByRole("dialog")).not.toBeNull();
});
it("renders exactly the commands the registry has bound, grouped", () => {
render(<HelpOverlay />);
press("?", { shiftKey: true });
const groups = groupedCommands();
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
groups.map((g) => g.title),
);
expect(
[...document.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId),
).toEqual(groups.flatMap((g) => g.items.map((c) => c.id)));
expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
groups.flatMap((g) => g.items.map((c) => c.label)),
);
expect(
[...document.querySelectorAll(".help-row")].map((r) =>
[...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
),
).toEqual(groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))));
});
});
@@ -0,0 +1,78 @@
// React port of ../../help.ts: the `?` overlay renders the command registry
// (commands.ts) directly — the same list that dispatches the keys — so a
// binding can never be documented as one thing and do another.
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { CommandSet, defineCommands, keyCaps } from "../../commands.js";
import { groupedCommands } from "../../help.js";
export function HelpOverlay() {
const [open, setOpen] = useState(false);
useEffect(() => {
const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => setOpen((o) => !o) }));
const overlayKeys = new CommandSet(defineCommands({ "help.close": () => setOpen(false) }));
const onKeydown = (e: KeyboardEvent) => {
if (open && overlayKeys.handle(e)) return;
helpKeys.handle(e);
};
window.addEventListener("keydown", onKeydown);
return () => window.removeEventListener("keydown", onKeydown);
}, [open]);
if (!open) return null;
const close = () => setOpen(false);
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
className="help-overlay fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
onClick={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<div className="help-panel max-h-[80vh] w-full max-w-[560px] overflow-auto rounded-r-3 border border-border bg-surface p-sp-4 shadow-2">
<header className="help-head flex items-center justify-between">
<h2 className="help-title m-0 text-fs-15 font-semibold text-ink">Keyboard shortcuts</h2>
<button
type="button"
aria-label="Close"
className="help-close rounded-r-1 px-sp-2 py-1 text-fs-15 text-muted hover:text-ink"
onClick={close}
>
✕
</button>
</header>
{groupedCommands().map((group) => (
<section key={group.title} className="help-group mt-sp-3">
<h3 className="help-group-title text-fs-12 font-semibold text-muted">{group.title}</h3>
<dl className="help-list mt-sp-1">
{group.items.map((cmd) => (
<div
key={cmd.id}
className="help-row flex items-center justify-between gap-sp-3 py-sp-1"
data-command-id={cmd.id}
>
<dt className="help-keys m-0 flex gap-sp-1">
{keyCaps(cmd.keys[0]).map((cap, i) => (
<kbd key={i} className="rounded-r-1 border border-border px-sp-1 text-fs-11">
{cap}
</kbd>
))}
</dt>
<dd className="help-desc m-0 text-fs-12 text-ink">{cmd.label}</dd>
</div>
))}
</dl>
</section>
))}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { KillDialog } from "./KillDialog.js";
afterEach(() => {
cleanup();
});
describe("KillDialog", () => {
it("focuses the kill button, so Enter confirms", () => {
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={vi.fn()} />);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Kill" }));
});
it("focuses the force button too", () => {
render(
<KillDialog
name="alpha"
blockers={null}
force
reason="worktree is dirty."
onConfirm={() => Promise.resolve()}
onCancel={vi.fn()}
/>,
);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Force kill" }));
});
it("names the blockers in the confirm body", () => {
render(
<KillDialog
name="alpha"
blockers={{ uncommitted_files: 2, untracked_files: 0, unpushed_commits: 0 }}
onConfirm={() => Promise.resolve()}
onCancel={vi.fn()}
/>,
);
expect(screen.getByText(/will be refused unless it is forced/)).not.toBeNull();
});
it("cancels on Cancel, on Escape and on a backdrop click", () => {
const onCancel = vi.fn();
const first = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
first.unmount();
const second = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
expect(onCancel).toHaveBeenCalledTimes(2);
second.unmount();
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("dialog"));
expect(onCancel).toHaveBeenCalledTimes(3);
});
it("disables the button while pending and shows a rejection inline, so the press can be retried", async () => {
let reject: (e: Error) => void = () => {};
const onConfirm = vi.fn(() => new Promise<void>((_, r) => (reject = r)));
render(<KillDialog name="alpha" blockers={null} onConfirm={onConfirm} onCancel={vi.fn()} />);
const kill = screen.getByRole("button", { name: "Kill" }) as HTMLButtonElement;
fireEvent.click(kill);
expect(onConfirm).toHaveBeenCalledOnce();
expect(kill.disabled).toBe(true);
reject(new Error("daemon unreachable"));
await waitFor(() => expect(kill.disabled).toBe(false));
expect(screen.getByText("Failed to kill: daemon unreachable")).not.toBeNull();
});
});
+95
View File
@@ -0,0 +1,95 @@
// React port of ../../killDialog.ts: the two kill prompts (#81, #160). The
// daemon refuses an unforced kill over a worktree holding unsaved work; the
// same component in `force` mode is the override that follows that refusal.
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { formatInTheWay, type InTheWay } from "../../types.js";
export interface KillDialogProps {
/** Session name, as the sidebar shows it. */
name: string;
/** What the card last saw in the way, when anything was. */
blockers: InTheWay | null;
/** Set once the daemon has refused an unforced kill: this pass forces it,
* and `reason` carries the daemon's refusal message. */
force?: boolean;
/** Message from the daemon's refusal, shown when there are no blockers to
* spell out. */
reason?: string;
/** Run the kill (or the forced kill). A rejection is rendered inline, and
* the dialog stays open so the press can be retried. */
onConfirm(): Promise<void>;
onCancel(): void;
}
export function KillDialog(props: KillDialogProps) {
const { name, blockers, force, reason, onConfirm, onCancel } = props;
const confirmRef = useRef<HTMLButtonElement>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
// The acting button takes the focus in both modes: the operator already
// asked for the kill, so Enter confirms it.
useEffect(() => confirmRef.current?.focus(), []);
const handleConfirm = () => {
setPending(true);
setError(null);
void onConfirm().catch((e: unknown) => {
setPending(false);
setError(`Failed to kill: ${(e as Error).message}`);
});
};
const body = blockers
? force
? `Its worktree still has ${formatInTheWay(blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `Its worktree still has ${formatInTheWay(blockers)}. The kill will be refused unless it is forced.`
: force
? `${reason ?? ""} Forcing the kill removes the session and leaves the worktree on disk.`
: "The session and everything running in it go away. Its worktree stays on disk.";
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={`Kill ${name}`}
className="kill-dialog fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
>
<div className="kill-dialog-panel min-w-[280px] max-w-[420px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
<h2 className="kill-dialog-title m-0 text-fs-15 font-semibold text-ink">Kill &quot;{name}&quot;?</h2>
<p className="kill-dialog-body mt-sp-2 text-fs-13 text-ink">{body}</p>
{error && <p className="kill-dialog-error mt-sp-2 text-fs-12 text-danger">{error}</p>}
<div className="kill-dialog-actions mt-sp-3 flex justify-end gap-sp-2">
<button
type="button"
className="kill-dialog-cancel rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
onClick={onCancel}
>
Cancel
</button>
<button
ref={confirmRef}
type="button"
disabled={pending}
className={`rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink disabled:opacity-60 ${
force ? "kill-dialog-force" : "kill-dialog-kill"
}`}
onClick={handleConfirm}
>
{force ? "Force kill" : "Kill"}
</button>
</div>
</div>
</div>,
document.body,
);
}
+2
View File
@@ -0,0 +1,2 @@
export { KillDialog, type KillDialogProps } from "./KillDialog.js";
export { HelpOverlay } from "./HelpOverlay.js";
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useViewport } from "./useViewport.js";
function Probe() {
useViewport();
return null;
}
class FakeVisualViewport {
height = 800;
offsetTop = 0;
private listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
addEventListener(type: string, cb: () => void): void {
this.listeners[type].push(cb);
}
removeEventListener(type: string, cb: () => void): void {
this.listeners[type] = this.listeners[type].filter((l) => l !== cb);
}
fire(type: "resize" | "scroll"): void {
for (const l of this.listeners[type]) l();
}
}
let vv: FakeVisualViewport;
beforeEach(() => {
vv = new FakeVisualViewport();
vi.stubGlobal("visualViewport", vv);
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
document.documentElement.className = "";
document.documentElement.removeAttribute("style");
});
describe("useViewport", () => {
it("mirrors the visual viewport's height and offset onto CSS custom properties", () => {
vv.height = 640;
vv.offsetTop = 12;
render(<Probe />);
const root = document.documentElement.style;
expect(root.getPropertyValue("--app-vh")).toBe("640px");
expect(root.getPropertyValue("--app-top")).toBe("12px");
});
it("no-ops when visualViewport is unavailable", () => {
vi.stubGlobal("visualViewport", undefined);
expect(() => render(<Probe />)).not.toThrow();
expect(document.documentElement.style.getPropertyValue("--app-vh")).toBe("");
});
it("marks kb-open once an editable shrinks the viewport well past the baseline", () => {
render(<Probe />);
const input = document.createElement("input");
document.body.appendChild(input);
input.focus();
vv.height = 800 - 200;
vv.fire("resize");
expect(document.documentElement.classList.contains("kb-open")).toBe(true);
input.blur();
vv.height = 800;
vv.fire("resize");
expect(document.documentElement.classList.contains("kb-open")).toBe(false);
input.remove();
});
});
+51
View File
@@ -0,0 +1,51 @@
// React port of ../../viewport.ts. Binds the app shell's height and position
// to the *visual* viewport rather than the layout one, so a pinned bottom bar
// (the attach view's special-key bar, NAP-342) lands above an open on-screen
// keyboard instead of behind it. See viewport.ts for the full mechanism.
import { useEffect } from "react";
const KB_OPEN_THRESHOLD = 150;
export function useViewport(): void {
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const root = document.documentElement;
let raf = 0;
let baseline = vv.height;
const isEditing = () => {
const a = document.activeElement as HTMLElement | null;
if (!a) return false;
return a.tagName === "TEXTAREA" || a.tagName === "INPUT" || a.isContentEditable;
};
const sync = () => {
raf = 0;
root.style.setProperty("--app-vh", `${Math.round(vv.height)}px`);
root.style.setProperty("--app-top", `${Math.round(vv.offsetTop)}px`);
// Nothing focused ⇒ keyboard can't be open ⇒ this height is the baseline.
const editing = isEditing();
if (!editing) baseline = vv.height;
const kbOpen = editing && baseline - vv.height > KB_OPEN_THRESHOLD;
root.classList.toggle("kb-open", kbOpen);
};
const schedule = () => {
if (raf) return;
raf = requestAnimationFrame(sync);
};
vv.addEventListener("resize", schedule);
vv.addEventListener("scroll", schedule);
sync();
return () => {
vv.removeEventListener("resize", schedule);
vv.removeEventListener("scroll", schedule);
if (raf) cancelAnimationFrame(raf);
};
}, []);
}
+154
View File
@@ -0,0 +1,154 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../catalog.js";
import { TOGGLE_DRAWER_EVENT } from "../../drawer.js";
import { Drawer } from "./Drawer.js";
/** Force the mobile media query on, whatever happy-dom's viewport says
* (mirrors ../layout/MultiView's own `forceMobile` in multiview.test.ts). */
function forceMobile(matches: boolean): void {
vi.spyOn(window, "matchMedia").mockImplementation(
(query: string) =>
({
matches: query.includes("859") && matches,
media: query,
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList,
);
}
function shell(): HTMLElement {
return document.querySelector(".app-shell") as HTMLElement;
}
function touch(type: "touchstart" | "touchend", x: number, y: number): void {
const touches = [{ clientX: x, clientY: y }];
const event = new Event(type, { bubbles: true }) as unknown as {
touches: typeof touches;
changedTouches: typeof touches;
};
event.touches = touches;
event.changedTouches = touches;
act(() => {
document.dispatchEvent(event as unknown as Event);
});
}
beforeEach(() => forceMobile(true));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function openViaToggle(): void {
act(() => {
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
});
}
describe("Drawer (#35)", () => {
it("toggles open on the toggle event, off on desktop by class alone", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
expect(shell().classList.contains("drawer-open")).toBe(false);
openViaToggle();
expect(shell().classList.contains("drawer-open")).toBe(true);
});
it("closes when a session row is tapped", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
fireEvent.click(document.querySelector(".tree-leaf") as HTMLElement);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("closes when a spawn is requested", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
act(() => {
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
});
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("closes when spawn similar is requested", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
act(() => {
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
});
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("closes on a backdrop click", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
fireEvent.click(document.querySelector(".mv-backdrop") as HTMLElement);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("opens on a rightward swipe and closes on a leftward one, only on mobile", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
touch("touchstart", 0, 100);
touch("touchend", 100, 100);
expect(shell().classList.contains("drawer-open")).toBe(true);
touch("touchstart", 100, 100);
touch("touchend", 0, 100);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("ignores swipes on desktop", () => {
forceMobile(false);
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
touch("touchstart", 0, 100);
touch("touchend", 100, 100);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("ignores a mostly-vertical or too-short gesture", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
touch("touchstart", 0, 0);
touch("touchend", 10, 100);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
});
+93
View File
@@ -0,0 +1,93 @@
// React port of ../../drawer.ts (#35): the mobile off-canvas sidebar. On
// desktop the `drawer-open` class is inert — CSS gates the drawer styling to
// narrow viewports (styles.css, `.app-shell`).
//
// Anything that puts a new view in the content area must close the drawer, or
// it renders behind the sidebar and the tap reads as a dead button — this
// listens for the same TOGGLE_DRAWER_EVENT/OPEN_SPAWN_EVENT/SPAWN_SIMILAR_EVENT
// the rest of the shell already raises, so it needs no wiring from callers.
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../catalog.js";
import { TOGGLE_DRAWER_EVENT } from "../../drawer.js";
export interface DrawerProps {
sidebar: ReactNode;
children: ReactNode;
className?: string;
}
export function Drawer({ sidebar, children, className }: DrawerProps) {
const [open, setOpen] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onToggle = () => setOpen((o) => !o);
const onClose = () => setOpen(false);
document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle);
document.addEventListener(OPEN_SPAWN_EVENT, onClose);
document.addEventListener(SPAWN_SIMILAR_EVENT, onClose);
return () => {
document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle);
document.removeEventListener(OPEN_SPAWN_EVENT, onClose);
document.removeEventListener(SPAWN_SIMILAR_EVENT, onClose);
};
}, []);
// Horizontal swipes drive the drawer (right opens, left closes) rather than
// the browser's back/forward navigation. Only a clearly horizontal gesture
// past a threshold counts, so vertical terminal scrolling (which owns its
// own touch handler) is untouched.
useEffect(() => {
const mobileMq = window.matchMedia("(max-width: 859px)");
let sx = 0;
let sy = 0;
let tracking = false;
const onTouchStart = (e: TouchEvent) => {
tracking = mobileMq.matches && e.touches.length === 1;
if (tracking) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}
};
const onTouchEnd = (e: TouchEvent) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx;
const dy = t.clientY - sy;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
setOpen((o) => {
if (dx > 0 && !o) return true;
if (dx < 0 && o) return false;
return o;
});
};
document.addEventListener("touchstart", onTouchStart, { passive: true });
document.addEventListener("touchend", onTouchEnd, { passive: true });
return () => {
document.removeEventListener("touchstart", onTouchStart);
document.removeEventListener("touchend", onTouchEnd);
};
}, []);
return (
<div className={`app-shell flex h-full min-h-0 ${open ? "drawer-open" : ""} ${className ?? ""}`}>
<div
ref={sidebarRef}
className="sidebar flex h-full min-h-0 flex-col bg-surface"
onClick={(e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) setOpen(false);
}}
>
{sidebar}
</div>
<section className="content min-h-0 flex-1">{children}</section>
<div className="mv-backdrop" onClick={() => setOpen(false)} />
</div>
);
}