diff --git a/web/src/components/common/ContextMenu.test.tsx b/web/src/components/common/ContextMenu.test.tsx new file mode 100644 index 0000000..0b72451 --- /dev/null +++ b/web/src/components/common/ContextMenu.test.tsx @@ -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( + {} }, + { 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(); + 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( {} }]} 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( {} }]} 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( {} }]} onClose={onClose} />); + fireEvent.scroll(window); + expect(onClose).toHaveBeenCalledTimes(1); + + fireEvent.resize(window); + expect(onClose).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web/src/components/common/ContextMenu.tsx b/web/src/components/common/ContextMenu.tsx new file mode 100644 index 0000000..5dc5407 --- /dev/null +++ b/web/src/components/common/ContextMenu.tsx @@ -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(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( +
+ {items.map((item) => ( + + ))} +
, + document.body, + ); +} diff --git a/web/src/components/common/index.ts b/web/src/components/common/index.ts new file mode 100644 index 0000000..af27b97 --- /dev/null +++ b/web/src/components/common/index.ts @@ -0,0 +1 @@ +export { ContextMenu, type ContextMenuItem, type ContextMenuProps } from "./ContextMenu.js"; diff --git a/web/src/components/dialogs/HelpOverlay.test.tsx b/web/src/components/dialogs/HelpOverlay.test.tsx new file mode 100644 index 0000000..6183603 --- /dev/null +++ b/web/src/components/dialogs/HelpOverlay.test.tsx @@ -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(); + 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(); + 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(); + + 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(); + 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(); + 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(".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(" ")))); + }); +}); diff --git a/web/src/components/dialogs/HelpOverlay.tsx b/web/src/components/dialogs/HelpOverlay.tsx new file mode 100644 index 0000000..88f8756 --- /dev/null +++ b/web/src/components/dialogs/HelpOverlay.tsx @@ -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( +
{ + if (e.target === e.currentTarget) close(); + }} + > +
+
+

Keyboard shortcuts

+ +
+ {groupedCommands().map((group) => ( +
+

{group.title}

+
+ {group.items.map((cmd) => ( +
+
+ {keyCaps(cmd.keys[0]).map((cap, i) => ( + + {cap} + + ))} +
+
{cmd.label}
+
+ ))} +
+
+ ))} +
+
, + document.body, + ); +} diff --git a/web/src/components/dialogs/KillDialog.test.tsx b/web/src/components/dialogs/KillDialog.test.tsx new file mode 100644 index 0000000..5046a27 --- /dev/null +++ b/web/src/components/dialogs/KillDialog.test.tsx @@ -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( Promise.resolve()} onCancel={vi.fn()} />); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Kill" })); + }); + + it("focuses the force button too", () => { + render( + Promise.resolve()} + onCancel={vi.fn()} + />, + ); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Force kill" })); + }); + + it("names the blockers in the confirm body", () => { + render( + 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( Promise.resolve()} onCancel={onCancel} />); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).toHaveBeenCalledTimes(1); + first.unmount(); + + const second = render( Promise.resolve()} onCancel={onCancel} />); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + expect(onCancel).toHaveBeenCalledTimes(2); + second.unmount(); + + render( 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((_, r) => (reject = r))); + render(); + + 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(); + }); +}); diff --git a/web/src/components/dialogs/KillDialog.tsx b/web/src/components/dialogs/KillDialog.tsx new file mode 100644 index 0000000..6674480 --- /dev/null +++ b/web/src/components/dialogs/KillDialog.tsx @@ -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; + onCancel(): void; +} + +export function KillDialog(props: KillDialogProps) { + const { name, blockers, force, reason, onConfirm, onCancel } = props; + const confirmRef = useRef(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(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( +
{ + if (e.target === e.currentTarget) onCancel(); + }} + onKeyDown={(e) => { + if (e.key === "Escape") onCancel(); + }} + > +
+

Kill "{name}"?

+

{body}

+ {error &&

{error}

} +
+ + +
+
+
, + document.body, + ); +} diff --git a/web/src/components/dialogs/index.ts b/web/src/components/dialogs/index.ts new file mode 100644 index 0000000..911d52e --- /dev/null +++ b/web/src/components/dialogs/index.ts @@ -0,0 +1,2 @@ +export { KillDialog, type KillDialogProps } from "./KillDialog.js"; +export { HelpOverlay } from "./HelpOverlay.js"; diff --git a/web/src/components/hooks/useViewport.test.tsx b/web/src/components/hooks/useViewport.test.tsx new file mode 100644 index 0000000..63cd4f9 --- /dev/null +++ b/web/src/components/hooks/useViewport.test.tsx @@ -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 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(); + 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()).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(); + 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(); + }); +}); diff --git a/web/src/components/hooks/useViewport.ts b/web/src/components/hooks/useViewport.ts new file mode 100644 index 0000000..2c6ca0e --- /dev/null +++ b/web/src/components/hooks/useViewport.ts @@ -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); + }; + }, []); +} diff --git a/web/src/components/layout/Drawer.test.tsx b/web/src/components/layout/Drawer.test.tsx new file mode 100644 index 0000000..b93c8e0 --- /dev/null +++ b/web/src/components/layout/Drawer.test.tsx @@ -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( + row}> +
content
+
, + ); + 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( + row}> +
content
+
, + ); + 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( + row}> +
content
+
, + ); + 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( + row}> +
content
+
, + ); + 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( + row}> +
content
+
, + ); + 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( + row}> +
content
+
, + ); + 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( + row}> +
content
+
, + ); + 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( + row}> +
content
+
, + ); + touch("touchstart", 0, 0); + touch("touchend", 10, 100); + expect(shell().classList.contains("drawer-open")).toBe(false); + }); +}); diff --git a/web/src/components/layout/Drawer.tsx b/web/src/components/layout/Drawer.tsx new file mode 100644 index 0000000..e12887f --- /dev/null +++ b/web/src/components/layout/Drawer.tsx @@ -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(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 ( +
+
{ + if ((e.target as HTMLElement).closest(".tree-leaf")) setOpen(false); + }} + > + {sidebar} +
+
{children}
+
setOpen(false)} /> +
+ ); +}