diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 09d5e42..03d7cf0 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -37,6 +37,10 @@ export function requestOpenSpawn(): void { * connection pool. */ export const FOCUS_PANE_EVENT = "catalog-focus-pane"; +/** Ask the shell to open a draft pane seeded from an existing session + * ("Spawn similar"). Detail is the session being cloned. */ +export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; + /** The rows a keyboard walk stops on, in visual (document) order. Expanded * folder headers are skipped: a directory is not something to focus, and the * cards under it are one row away anyway. A *collapsed* folder still counts — @@ -169,7 +173,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { similarBtn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); - document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })); + document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })); }); const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement; killBtn.addEventListener("click", (ev) => { @@ -185,7 +189,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { { label: "Spawn similar", onSelect: () => - document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })), + document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })), }, { label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) }, ]); diff --git a/web/src/drawer.test.ts b/web/src/drawer.test.ts new file mode 100644 index 0000000..4f8c216 --- /dev/null +++ b/web/src/drawer.test.ts @@ -0,0 +1,47 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it } from "vitest"; + +import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js"; +import { installDrawer, type Drawer } from "./drawer.js"; + +function mount(): { shell: HTMLElement; sidebar: HTMLElement; drawer: Drawer } { + const shell = document.createElement("div"); + const sidebar = document.createElement("aside"); + const backdrop = document.createElement("div"); + const row = document.createElement("div"); + row.className = "tree-leaf"; + sidebar.appendChild(row); + shell.append(sidebar, backdrop); + document.body.replaceChildren(shell); + const drawer = installDrawer(shell, sidebar, backdrop); + drawer.open(); + return { shell, sidebar, drawer }; +} + +describe("mobile drawer", () => { + beforeEach(() => document.body.replaceChildren()); + + it("closes when a session row is tapped", () => { + const { shell, sidebar } = mount(); + (sidebar.querySelector(".tree-leaf") as HTMLElement).click(); + expect(shell.classList.contains("drawer-open")).toBe(false); + }); + + it("closes when a spawn is requested", () => { + const { shell } = mount(); + document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT)); + expect(shell.classList.contains("drawer-open")).toBe(false); + }); + + it("closes when spawn similar is requested", () => { + const { shell } = mount(); + document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} })); + expect(shell.classList.contains("drawer-open")).toBe(false); + }); + + it("leaves the backdrop click closing it", () => { + const { shell } = mount(); + (shell.lastElementChild as HTMLElement).click(); + expect(shell.classList.contains("drawer-open")).toBe(false); + }); +}); diff --git a/web/src/drawer.ts b/web/src/drawer.ts new file mode 100644 index 0000000..dc65976 --- /dev/null +++ b/web/src/drawer.ts @@ -0,0 +1,74 @@ +// Mobile off-canvas sidebar (issue #35); on desktop the `drawer-open` class is +// inert (CSS gates the drawer styling). +// +// 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. "Spawn +// similar" sits inside a session row but stops propagation, so it never reaches +// the row's own close — both spawn paths arrive here via document events. + +import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js"; + +export interface Drawer { + open(): void; + close(): void; + toggle(): void; + isOpen(): boolean; +} + +export function installDrawer( + shellEl: HTMLElement, + sidebarEl: HTMLElement, + backdropEl: HTMLElement, +): Drawer { + const drawer: Drawer = { + open: () => shellEl.classList.add("drawer-open"), + close: () => shellEl.classList.remove("drawer-open"), + toggle: () => shellEl.classList.toggle("drawer-open"), + isOpen: () => shellEl.classList.contains("drawer-open"), + }; + + backdropEl.addEventListener("click", () => drawer.close()); + sidebarEl.addEventListener("click", (e) => { + if ((e.target as HTMLElement).closest(".tree-leaf")) drawer.close(); + }); + document.addEventListener(OPEN_SPAWN_EVENT, () => drawer.close()); + document.addEventListener(SPAWN_SIMILAR_EVENT, () => drawer.close()); + + return drawer; +} + +/** 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. */ +export function installDrawerSwipe(drawer: Drawer): void { + const mobileMq = window.matchMedia("(max-width: 859px)"); + let sx = 0; + let sy = 0; + let tracking = false; + document.addEventListener( + "touchstart", + (e) => { + tracking = mobileMq.matches && e.touches.length === 1; + if (tracking) { + sx = e.touches[0].clientX; + sy = e.touches[0].clientY; + } + }, + { passive: true }, + ); + document.addEventListener( + "touchend", + (e) => { + 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; + if (dx > 0 && !drawer.isOpen()) drawer.open(); + else if (dx < 0 && drawer.isOpen()) drawer.close(); + }, + { passive: true }, + ); +} diff --git a/web/src/main.ts b/web/src/main.ts index e224950..6ff41be 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -4,6 +4,7 @@ import "@fontsource/jetbrains-mono/600.css"; import "@fontsource/jetbrains-mono/700.css"; import { renderSidebar, renderTopbar } from "./catalog.js"; import { ConnectionPool } from "./connections.js"; +import { installDrawer, installDrawerSwipe } from "./drawer.js"; import { installHudKeybind } from "./debug.js"; import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js"; import { CatalogStore } from "./events.js"; @@ -25,7 +26,7 @@ import { mountHud, type Hud } from "./hud.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; import { loadLayout, saveLayout } from "./persistence.js"; -import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT } from "./catalog.js"; +import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js"; import { initTheme } from "./theme.js"; import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js"; import { installViewportSizing } from "./viewport.js"; @@ -92,60 +93,13 @@ const content = document.createElement("section"); content.className = "content"; // Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in -// the header; a backdrop covers the content while it's open, and tapping a -// session closes it. On desktop these are inert (CSS gates the drawer styling). +// the header. `installDrawer` owns the close rules (session tap, Spawn, Spawn +// similar, backdrop); on desktop the class it toggles is inert. const backdrop = document.createElement("div"); backdrop.className = "mv-backdrop"; -function closeDrawer(): void { - shellEl.classList.remove("drawer-open"); -} -function openDrawer(): void { - shellEl.classList.add("drawer-open"); -} -function toggleDrawer(): void { - shellEl.classList.toggle("drawer-open"); -} -backdrop.addEventListener("click", closeDrawer); -sidebar.el.addEventListener("click", (e) => { - if ((e.target as HTMLElement).closest(".tree-leaf")) closeDrawer(); -}); - -// Mobile: horizontal swipes drive the drawer (right opens, left closes) rather -// than the browser's back/forward navigation. We only act on a clearly -// horizontal gesture past a threshold, so vertical terminal scrolling (which -// owns its own touch handler) is untouched. -{ - const mobileMq = window.matchMedia("(max-width: 859px)"); - let sx = 0; - let sy = 0; - let tracking = false; - document.addEventListener( - "touchstart", - (e) => { - tracking = mobileMq.matches && e.touches.length === 1; - if (tracking) { - sx = e.touches[0].clientX; - sy = e.touches[0].clientY; - } - }, - { passive: true }, - ); - document.addEventListener( - "touchend", - (e) => { - 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; - const open = shellEl.classList.contains("drawer-open"); - if (dx > 0 && !open) openDrawer(); - else if (dx < 0 && open) closeDrawer(); - }, - { passive: true }, - ); -} +const drawer = installDrawer(shellEl, sidebar.el, backdrop); +installDrawerSwipe(drawer); +const toggleDrawer = () => drawer.toggle(); shellEl.append(sidebar.el, content, backdrop); root.replaceChildren(renderTopbar(store, toggleDrawer), shellEl); @@ -284,7 +238,7 @@ function requestOpenSpawn(): void { mv.openDraft(undefined, { newTab: true }); } document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn()); -document.addEventListener("catalog-spawn-similar", (ev) => { +document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => { const s = (ev as CustomEvent).detail; showMultiView(); mv.openDraft(spawnSimilarSeed(s)); @@ -358,7 +312,7 @@ window.addEventListener("keydown", (e) => { if (sidebar.hasListFocus()) { pool.focusTerminal(); } else { - openDrawer(); // no-op on desktop; on mobile the list is off-canvas + drawer.open(); // no-op on desktop; on mobile the list is off-canvas sidebar.focusList(); } });