Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0edbc09d2 |
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 "{name}"?</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,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user