Merge branch 'r2/front' into integration/round-1
This commit is contained in:
@@ -394,15 +394,83 @@ describe("FilesTab host drag-and-drop", () => {
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("divides the payload position by devicePixelRatio", async () => {
|
||||
// The native payload is in physical pixels; the rect is in CSS pixels.
|
||||
// At dpr 2 a physical (900, 900) is a CSS (450, 450) — inside an 800x600 pane.
|
||||
const original = window.devicePixelRatio;
|
||||
it("divides the payload position by devicePixelRatio on Windows only", async () => {
|
||||
// Only wry's WebView2 backend hands over *physical* pixels; the macOS and
|
||||
// GTK ones deliver logical points and `tauri-runtime-wry` does not rescale
|
||||
// them. At dpr 2 a physical (900, 900) is a CSS (450, 450) — inside the
|
||||
// 800x600 pane — but the same payload on a HiDPI Mac or Linux box really
|
||||
// is (900, 900) and belongs to nobody.
|
||||
const originalDpr = window.devicePixelRatio;
|
||||
const originalUa = window.navigator.userAgent;
|
||||
Object.defineProperty(window, "devicePixelRatio", { value: 2, configurable: true });
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
configurable: true,
|
||||
});
|
||||
await renderTab();
|
||||
await drop(["/host/a.png"], { x: 900, y: 900 });
|
||||
expect(uploadFileToContainer).toHaveBeenCalled();
|
||||
Object.defineProperty(window, "devicePixelRatio", { value: original, configurable: true });
|
||||
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
|
||||
configurable: true,
|
||||
});
|
||||
vi.mocked(uploadFileToContainer).mockClear();
|
||||
await drop(["/host/a.png"], { x: 900, y: 900 });
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
// …and the *unhalved* point still lands, which is the half a HiDPI Mac
|
||||
// user was losing.
|
||||
await drop(["/host/a.png"], { x: 400, y: 300 });
|
||||
expect(uploadFileToContainer).toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(window, "devicePixelRatio", {
|
||||
value: originalDpr,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
value: originalUa,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a drop that lands on a toast floating over the pane", async () => {
|
||||
// `ToastHost` is `fixed bottom-4 right-4 z-[60]` and 24rem wide, and its
|
||||
// error cards stay until dismissed — so a z-order gate asking "is what is
|
||||
// painted here part of my pane?" made the bottom-right corner of this pane
|
||||
// refuse drops for as long as one error was on screen. jsdom has no
|
||||
// `elementFromPoint`, so that branch only runs when a test supplies one.
|
||||
await renderTab();
|
||||
const toastCard = document.createElement("div");
|
||||
document.body.appendChild(toastCard);
|
||||
Object.defineProperty(document, "elementFromPoint", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: () => toastCard,
|
||||
});
|
||||
|
||||
await drop(["/host/a.png"], { x: 700, y: 550 });
|
||||
expect(uploadFileToContainer).toHaveBeenCalled();
|
||||
|
||||
delete (document as Partial<Document>).elementFromPoint;
|
||||
toastCard.remove();
|
||||
});
|
||||
|
||||
it("refuses a drop that lands on a dialog painted over the pane", async () => {
|
||||
await renderTab();
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.setAttribute("data-blocks-drop", "true");
|
||||
document.body.appendChild(backdrop);
|
||||
Object.defineProperty(document, "elementFromPoint", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: () => backdrop,
|
||||
});
|
||||
|
||||
await drop(["/host/a.png"], { x: 400, y: 300 });
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
|
||||
delete (document as Partial<Document>).elementFromPoint;
|
||||
backdrop.remove();
|
||||
});
|
||||
|
||||
it("highlights the pane while a drag hovers it, and drops the highlight on leave", async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { startDrag } from "@crabnebula/tauri-plugin-drag";
|
||||
import type { FileEntry, Project } from "../../../lib/types";
|
||||
import { useFileManager } from "../../../hooks/useFileManager";
|
||||
import { isDropTarget } from "../../../lib/dropTarget";
|
||||
import { classifyDrop, isDropTarget } from "../../../lib/dropTarget";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import Button from "../../ui/Button";
|
||||
import FileViewerModal from "./FileViewerModal";
|
||||
@@ -413,8 +413,11 @@ export default function FilesTab({ project }: Props) {
|
||||
// reason `TerminalView` uses it: `dragDropEnabled` is on (the terminal needs
|
||||
// it), which blocks HTML5 drag inside the webview on Windows, and only the
|
||||
// native payload carries real file *paths*. The listener is window-wide, so
|
||||
// routing is `isDropTarget` — the rect hit test, in CSS pixels, *plus* the
|
||||
// z-order and "is anything modal on screen" questions a rect cannot answer.
|
||||
// routing is `classifyDrop` — the rect hit test *plus* the z-order question
|
||||
// a rect cannot answer: is a modal or a blocking overlay painted at that
|
||||
// point? (Not "is that element mine": chrome painted over a pane — a toast,
|
||||
// a button — is not something that swallows a drop, and treating it as such
|
||||
// made permanent dead zones.)
|
||||
//
|
||||
// Two further filters sit in front of it, both about our own drag-out:
|
||||
// `dragOutInFlight`, and the staged-path check, which is exact because
|
||||
@@ -440,7 +443,23 @@ export default function FilesTab({ project }: Props) {
|
||||
if (payload.type !== "drop") return;
|
||||
setDragOver(false);
|
||||
if (dragOutInFlight.current) return;
|
||||
if (!isDropTarget(paneRef.current, payload.position)) return;
|
||||
const verdict = classifyDrop(paneRef.current, payload.position);
|
||||
// Aimed at this pane and refused anyway: say so. Nothing else would —
|
||||
// the file just never appears in the listing.
|
||||
if (verdict === "blocked") {
|
||||
console.warn(
|
||||
"[drop] refused: an overlay is covering the drop point",
|
||||
payload.position,
|
||||
);
|
||||
useAppState.getState().pushToast({
|
||||
kind: "info",
|
||||
message: "File drop ignored",
|
||||
detail:
|
||||
"A dialog or full-window overlay is covering that point. Close it and drop the file again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (verdict !== "accept") return;
|
||||
// Anything we staged for a drag-out is our own copy of a file that is
|
||||
// already in the container; re-importing it would overwrite the
|
||||
// original with a snapshot.
|
||||
|
||||
@@ -943,6 +943,65 @@ describe("DiskSettings", () => {
|
||||
expect(within(dialog).getByRole("alert")).toHaveTextContent(/no space left on device/);
|
||||
});
|
||||
|
||||
it("keeps the semi-safe confirmation open, in the backend's words, when it is refused", async () => {
|
||||
// A refusal comes back inside `Ok` — the command succeeded at declining —
|
||||
// so it never reaches `error`, and reading only "did it throw" closed the
|
||||
// dialog, dropped the tick list, and left the explanation in the outcome
|
||||
// panel several screens above the row that was clicked. The sentence shown
|
||||
// is the backend's own: it is the only side that knows which blocker is
|
||||
// actually holding the project.
|
||||
listReclaimable.mockResolvedValue(
|
||||
plan({
|
||||
items: [
|
||||
item({
|
||||
target: { kind: "compact_snapshot", project_id: "p-whp" },
|
||||
safety: "semi_safe",
|
||||
label: "Compact whp's snapshot",
|
||||
bytes: 5_100_000_000,
|
||||
bytes_are_exact: false,
|
||||
bytes_floor: 0,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
reclaim.mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
target: { kind: "compact_snapshot", project_id: "p-whp" },
|
||||
destroyed: null,
|
||||
ok: false,
|
||||
freed_bytes: 0,
|
||||
projected_bytes: null,
|
||||
message: "Cannot compact whp: a terminal session is still attached.",
|
||||
} as ReclaimResult,
|
||||
],
|
||||
total_freed_bytes: 0,
|
||||
});
|
||||
|
||||
await renderAndScan();
|
||||
const semi = await screen.findByTestId("disk-semi-bucket");
|
||||
await act(async () => {
|
||||
fireEvent.click(within(semi).getByRole("button", { name: "Run…" }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(screen.getByRole("dialog")).getByRole("button", { name: "Run it" }),
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole("alert")).toHaveTextContent(
|
||||
/a terminal session is still attached/,
|
||||
);
|
||||
// Nothing was removed, so the row that offered the action is still there.
|
||||
expect(
|
||||
within(await screen.findByTestId("disk-semi-bucket")).getByRole("button", {
|
||||
name: "Run…",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the confirmation once the action succeeds", async () => {
|
||||
listReclaimable.mockResolvedValue(
|
||||
plan({
|
||||
|
||||
@@ -158,6 +158,18 @@ export default function DiskSettings() {
|
||||
// five targets can come back with two failures and a real byte total.
|
||||
const failedCount = outcome?.results.filter((r) => !r.ok).length ?? 0;
|
||||
|
||||
// What the backend said about the parts it refused, rendered **verbatim**.
|
||||
// A refusal arrives inside `Ok` — the command succeeded at declining — so it
|
||||
// never reaches `error`, and the dialog that asked for the work has nothing
|
||||
// else to show. Not a sentence of our own: the backend is the only side that
|
||||
// knows which blocker is actually holding the project, and one written here
|
||||
// would go stale the day that answer improves.
|
||||
const refusalText =
|
||||
outcome?.results
|
||||
.filter((r) => !r.ok)
|
||||
.map((r) => r.message)
|
||||
.join(" ") ?? "";
|
||||
|
||||
const tone: StatusTone = scanning ? "unknown" : report ? "ok" : "off";
|
||||
const statusLabel = scanning
|
||||
? "Scanning"
|
||||
@@ -706,6 +718,9 @@ export default function DiskSettings() {
|
||||
// minutes, and the dialog reporting it beats it vanishing —
|
||||
// and if it fails, the dialog is the only place the user is
|
||||
// still looking, so it stays open and reports it here.
|
||||
// `false` covers both a throw and a refusal that came back
|
||||
// inside `Ok`; either way the work did not happen, so the
|
||||
// dialog stays put and reports it where the user is looking.
|
||||
const ok = await runReclaim([confirming.target]);
|
||||
setActionFailed(!ok);
|
||||
if (ok) setConfirming(null);
|
||||
@@ -721,7 +736,7 @@ export default function DiskSettings() {
|
||||
line, which this dialog is covering. */}
|
||||
{actionFailed && (
|
||||
<p role="alert" className="text-[var(--error)]">
|
||||
{error ?? "That did not run. Nothing was changed."}
|
||||
{error ?? (refusalText || "That did not run. Nothing was changed.")}
|
||||
</p>
|
||||
)}
|
||||
<p>{confirming.detail}</p>
|
||||
@@ -787,7 +802,11 @@ export default function DiskSettings() {
|
||||
// A failure here has to land inside the dialog. The panel's own
|
||||
// error line is at the top of several screens of scroll, and this
|
||||
// dialog was reached from a project row far below it.
|
||||
error={actionFailed ? (error ?? "That did not run. Nothing was deleted.") : null}
|
||||
error={
|
||||
actionFailed
|
||||
? (error ?? (refusalText || "That did not run. Nothing was deleted."))
|
||||
: null
|
||||
}
|
||||
onCancel={closeDestroying}
|
||||
onConfirm={async (typed) => {
|
||||
// The modal stays mounted until the call settles, so its `busy`
|
||||
|
||||
@@ -266,12 +266,17 @@ describe("supersedes — who owns the prompt slot", () => {
|
||||
describe("TerminalView — where a dropped file lands", () => {
|
||||
/** Mount, let the async drag-drop registration settle, and give the pane a
|
||||
* rect — jsdom has no layout, so every element is 0×0 and would be rejected
|
||||
* as a hidden pane. */
|
||||
* as a hidden pane.
|
||||
*
|
||||
* The rect goes on the *pane wrapper*, which is what the hit test asks
|
||||
* about: it is what the user sees as the terminal (gutter included), and
|
||||
* the chrome painted over it — the Following toggle, the URL toast — are
|
||||
* its children rather than the xterm host's. */
|
||||
async function mountWithLayout() {
|
||||
const view = mountSession("bash");
|
||||
await act(async () => {});
|
||||
const pane = view.container.querySelector(".xterm")?.parentElement;
|
||||
if (!pane) throw new Error("terminal host element not found");
|
||||
const pane = view.container.firstElementChild as HTMLElement | null;
|
||||
if (!pane) throw new Error("terminal pane not found");
|
||||
pane.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
@@ -287,6 +292,17 @@ describe("TerminalView — where a dropped file lands", () => {
|
||||
return view;
|
||||
}
|
||||
|
||||
/** jsdom has no `elementFromPoint`, so the z-order branch is unreachable
|
||||
* unless a test supplies one — which is exactly how a gate that refused
|
||||
* every drop under the Following toggle shipped through this file green. */
|
||||
function stubElementFromPoint(top: Element | null) {
|
||||
Object.defineProperty(document, "elementFromPoint", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: () => top,
|
||||
});
|
||||
}
|
||||
|
||||
async function drop(x: number, y: number) {
|
||||
if (!dragDrop.handler) throw new Error("no drag-drop listener registered");
|
||||
await act(async () => {
|
||||
@@ -333,6 +349,47 @@ describe("TerminalView — where a dropped file lands", () => {
|
||||
await drop(400, 300);
|
||||
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uploads a file dropped onto the always-present Following toggle", async () => {
|
||||
// The regression this file could not see. The toggle is `absolute top-2
|
||||
// right-4 z-50` and is rendered unconditionally, so `elementFromPoint`
|
||||
// returns *it* for the terminal's top-right corner — and a gate asking
|
||||
// "is what is painted here inside the xterm host?" answered no, forever,
|
||||
// with no message and no log line. jsdom never ran that branch.
|
||||
const view = await mountWithLayout();
|
||||
const toggle = view.getByTitle(/Auto-scroll/i);
|
||||
stubElementFromPoint(toggle);
|
||||
|
||||
await drop(780, 10);
|
||||
|
||||
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledWith(
|
||||
"s1",
|
||||
"/host/dropped.txt",
|
||||
);
|
||||
delete (document as Partial<Document>).elementFromPoint;
|
||||
});
|
||||
|
||||
it("refuses — and says so — when a dialog is painted over the drop point", async () => {
|
||||
await mountWithLayout();
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.setAttribute("data-blocks-drop", "true");
|
||||
const panel = document.createElement("div");
|
||||
panel.setAttribute("aria-modal", "true");
|
||||
backdrop.appendChild(panel);
|
||||
document.body.appendChild(backdrop);
|
||||
stubElementFromPoint(backdrop);
|
||||
|
||||
await drop(400, 300);
|
||||
|
||||
expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled();
|
||||
// A refused drop is otherwise indistinguishable from a broken one.
|
||||
expect(
|
||||
useAppState.getState().toasts.some((t) => t.message === "File drop ignored"),
|
||||
).toBe(true);
|
||||
|
||||
backdrop.remove();
|
||||
delete (document as Partial<Document>).elementFromPoint;
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalView — reaching the URL prompt without a mouse", () => {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
parseUrlRelayOsc,
|
||||
sanitizeRelayUrl,
|
||||
} from "../../lib/urlRelay";
|
||||
import { isDropTarget } from "../../lib/dropTarget";
|
||||
import { classifyDrop } from "../../lib/dropTarget";
|
||||
import UrlToast, {
|
||||
URL_TOAST_PRIMARY_SELECTOR,
|
||||
URL_TOAST_SELECTOR,
|
||||
@@ -237,14 +237,22 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
//
|
||||
// The listener is window-wide, so every pane decides for itself whether a
|
||||
// drop was meant for it. `isDropTarget` is that decision, shared with the
|
||||
// Files pane: the physical-pixel position ÷ `devicePixelRatio` against this
|
||||
// pane's rect (a hidden pane is `display:none`, so its zero-size rect is what
|
||||
// stops two panes both claiming the drop), plus z-order — which a rect alone
|
||||
// cannot see. An open `Modal` is a `fixed inset-0` portal painted *over* the
|
||||
// window and the pane underneath still has its rect, so the geometric test
|
||||
// that used to live here uploaded files into the directory a dialog was
|
||||
// covering. Same for the shutdown overlay, which is on screen precisely while
|
||||
// nothing should be accepting work at all.
|
||||
// Files pane: the payload position against this pane's rect (a hidden pane is
|
||||
// `display:none`, so its zero-size rect is what stops two panes both claiming
|
||||
// the drop), plus z-order — which a rect alone cannot see. An open `Modal` is
|
||||
// a `fixed inset-0` portal painted *over* the window and the pane underneath
|
||||
// still has its rect, so the geometric test that used to live here uploaded
|
||||
// files into the directory a dialog was covering. Same for the shutdown
|
||||
// overlay, which is on screen precisely while nothing should be accepting
|
||||
// work at all.
|
||||
//
|
||||
// The rect asked about is the **pane wrapper**, not the xterm host inside it:
|
||||
// the pane is what the user sees as "the terminal", gutter included, and the
|
||||
// chrome painted over it (the Following toggle, the URL toast) is a sibling
|
||||
// of the host rather than a child. `classifyDrop` answers "is a *blocking
|
||||
// overlay* here?" rather than "is this element mine?" for the same reason —
|
||||
// asking the second question turned every pixel under that chrome into a
|
||||
// permanent dead zone.
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
@@ -257,7 +265,29 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
(async () => {
|
||||
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
if (event.payload.type !== "drop") return;
|
||||
if (!isDropTarget(containerRef.current, event.payload.position)) return;
|
||||
const verdict = classifyDrop(
|
||||
terminalContainerRef.current,
|
||||
event.payload.position,
|
||||
);
|
||||
// A refused drop is invisible — the file simply does not arrive — so
|
||||
// the one case where the user aimed at us and we said no gets both a
|
||||
// log line and something on screen. The toast, not `imagePasteMsg`:
|
||||
// whatever refused this is painted over the terminal, and `ToastHost`
|
||||
// sits above it.
|
||||
if (verdict === "blocked") {
|
||||
console.warn(
|
||||
"[drop] refused: an overlay is covering the drop point",
|
||||
event.payload.position,
|
||||
);
|
||||
useAppState.getState().pushToast({
|
||||
kind: "info",
|
||||
message: "File drop ignored",
|
||||
detail:
|
||||
"A dialog or full-window overlay is covering that point. Close it and drop the file again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (verdict !== "accept") return;
|
||||
|
||||
const paths = event.payload.paths ?? [];
|
||||
if (paths.length === 0) return;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import Modal from "./Modal";
|
||||
import { PaneVisibilityProvider } from "./PaneVisibility";
|
||||
import { dropIsBlocked } from "../../lib/dropTarget";
|
||||
|
||||
/**
|
||||
* Modal focuses asynchronously via rAF so the panel is laid out first; jsdom
|
||||
@@ -102,6 +104,64 @@ describe("Modal", () => {
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Stepping aside with the pane that owns it
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("marks its backdrop as swallowing native file drops", async () => {
|
||||
// The backdrop, not the panel, is what `elementFromPoint` returns for a
|
||||
// drop released beside the dialog — so it is the element that has to carry
|
||||
// the marker `lib/dropTarget` looks for.
|
||||
render(
|
||||
<Modal title="Reset" onClose={vi.fn()}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
const backdrop = document.querySelector(".fixed.inset-0");
|
||||
expect(backdrop).toHaveAttribute("data-blocks-drop", "true");
|
||||
expect(dropIsBlocked()).toBe(true);
|
||||
});
|
||||
|
||||
it("paints nothing, traps nothing and blocks no drop while its pane is hidden", async () => {
|
||||
// A dialog portals to `document.body`, where the `hidden` class its pane
|
||||
// uses to step aside for another tab cannot reach it. Left to itself it
|
||||
// stayed on screen over the tab the user switched to, kept its Escape
|
||||
// binding, and refused every native file drop in the window.
|
||||
const onClose = vi.fn();
|
||||
const { rerender } = render(
|
||||
<PaneVisibilityProvider visible={false}>
|
||||
<Modal title="Reset" onClose={onClose}>
|
||||
<button>Confirm</button>
|
||||
</Modal>
|
||||
</PaneVisibilityProvider>,
|
||||
);
|
||||
await flushFocus();
|
||||
|
||||
const backdrop = document.querySelector(".fixed.inset-0") as HTMLElement;
|
||||
expect(backdrop.hidden).toBe(true);
|
||||
expect(backdrop.style.display).toBe("none");
|
||||
expect(dropIsBlocked()).toBe(false);
|
||||
expect(backdrop.contains(document.activeElement)).toBe(false);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
// Back on screen: the same dialog, still mounted, resumes everything.
|
||||
rerender(
|
||||
<PaneVisibilityProvider visible={true}>
|
||||
<Modal title="Reset" onClose={onClose}>
|
||||
<button>Confirm</button>
|
||||
</Modal>
|
||||
</PaneVisibilityProvider>,
|
||||
);
|
||||
await flushFocus();
|
||||
expect(backdrop.hidden).toBe(false);
|
||||
expect(dropIsBlocked()).toBe(true);
|
||||
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores Escape and overlay clicks when not dismissible", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePaneVisible } from "./PaneVisibility";
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
"a[href]",
|
||||
@@ -66,26 +67,43 @@ export default function Modal({
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
const descId = useId();
|
||||
// A dialog portals to `document.body`, so the `hidden` class its pane uses to
|
||||
// step aside for another tab cannot reach it. `PaneVisibility` is how it
|
||||
// finds out, and while it is false this dialog paints nothing, traps
|
||||
// nothing, and — via `[hidden]` — blocks no native file drop.
|
||||
const paneVisible = usePaneVisible();
|
||||
const paneVisibleRef = useRef(paneVisible);
|
||||
paneVisibleRef.current = paneVisible;
|
||||
|
||||
// Remember what had focus, move focus inside, restore on unmount.
|
||||
// Remember what had focus, and restore it on unmount — but not if the pane
|
||||
// is hidden by then: a dialog closed while the user is on another tab would
|
||||
// otherwise yank focus back to a control they cannot see.
|
||||
useEffect(() => {
|
||||
restoreFocusRef.current = document.activeElement as HTMLElement | null;
|
||||
const panel = panelRef.current;
|
||||
if (panel) {
|
||||
const target =
|
||||
initialFocusRef?.current ?? focusableWithin(panel)[0] ?? panel;
|
||||
// Defer so the panel is laid out (offsetParent) before we query it.
|
||||
requestAnimationFrame(() => target.focus?.());
|
||||
}
|
||||
return () => {
|
||||
restoreFocusRef.current?.focus?.();
|
||||
if (paneVisibleRef.current) restoreFocusRef.current?.focus?.();
|
||||
};
|
||||
// Mount/unmount only — re-running would steal focus mid-interaction.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Escape closes; Tab is trapped inside the panel.
|
||||
// Move focus inside — on mount, and again whenever the pane comes back.
|
||||
useEffect(() => {
|
||||
if (!paneVisible) return;
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const target = initialFocusRef?.current ?? focusableWithin(panel)[0] ?? panel;
|
||||
// Defer so the panel is laid out (offsetParent) before we query it.
|
||||
const frame = requestAnimationFrame(() => target.focus?.());
|
||||
return () => cancelAnimationFrame(frame);
|
||||
// `initialFocusRef` is a ref object; re-running on its identity would steal
|
||||
// focus mid-interaction.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paneVisible]);
|
||||
|
||||
// Escape closes; Tab is trapped inside the panel. Neither applies while the
|
||||
// pane is hidden — those keystrokes belong to whatever the user is looking
|
||||
// at instead.
|
||||
useEffect(() => {
|
||||
if (!paneVisible) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && dismissible) {
|
||||
e.stopPropagation();
|
||||
@@ -119,7 +137,7 @@ export default function Modal({
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
return () => document.removeEventListener("keydown", onKeyDown, true);
|
||||
}, [dismissible, onClose]);
|
||||
}, [dismissible, onClose, paneVisible]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -133,6 +151,15 @@ export default function Modal({
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
|
||||
/* The backdrop, not the panel, is what `elementFromPoint` returns for a
|
||||
drop released beside the dialog — so it is the element that has to say
|
||||
"I swallow drops". See `lib/dropTarget.ts`. */
|
||||
data-blocks-drop="true"
|
||||
hidden={!paneVisible}
|
||||
aria-hidden={paneVisible ? undefined : true}
|
||||
/* `hidden` is a base-layer rule and `flex` is a utility-layer one, so the
|
||||
attribute alone loses. Inline wins over both. */
|
||||
style={paneVisible ? undefined : { display: "none" }}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* "Is the pane I belong to the one on screen?"
|
||||
*
|
||||
* The main area keeps every tab *mounted* and hides the inactive ones with a
|
||||
* `hidden` class, so their state survives a tab switch. A `ui/Modal` opened
|
||||
* inside one of those panes does not go quiet when its pane does: it portals
|
||||
* to `document.body`, where an ancestor's `display:none` cannot reach it. So a
|
||||
* dialog opened in project A stayed painted over project B after a tab switch,
|
||||
* kept its focus trap and its Escape binding, and — because it is a blocking
|
||||
* overlay — refused every native file drop in the window.
|
||||
*
|
||||
* `App` publishes the answer around each pane it mounts — it is what decides
|
||||
* which one is on screen — and `Modal` reads it. Nothing else needs to:
|
||||
* dialogs are the only thing in the app that escapes its pane's subtree.
|
||||
*
|
||||
* Default `true`, so a dialog with no pane above it — host settings, the
|
||||
* Docker install prompt — behaves exactly as it always has.
|
||||
*/
|
||||
const PaneVisibilityContext = createContext(true);
|
||||
|
||||
export function PaneVisibilityProvider({
|
||||
visible,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PaneVisibilityContext.Provider value={visible}>
|
||||
{children}
|
||||
</PaneVisibilityContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** True unless an ancestor pane says it is currently hidden. */
|
||||
export function usePaneVisible(): boolean {
|
||||
return useContext(PaneVisibilityContext);
|
||||
}
|
||||
Reference in New Issue
Block a user