Make the drop gate a state question, not a geometry one
The gate that decides whether a native file drop is accepted has been wrong twice in opposite directions, both times because it tried to be precise about *which points* a dialog covers: - Round 1 asked `el.contains(elementFromPoint(x, y))` and was handed the inner xterm host while the overlays are siblings, so the always-rendered Following/Paused button made the terminal's top-right corner permanently refuse drops. - Round 2 replaced that with "is a blocking overlay painted here?" and deleted the document-wide gate. `elementFromPoint` returns the *topmost* element, and ToastHost is z-[60] against the Modal backdrop's z-50 in the same stacking context — so a refused drop pushed a toast, the toast covered the dialog, and the next drop released on it was reported clear and landed in the directory the dialog was covering. The gate armed its own hole. Split the two questions instead of merging them: - Geometry answers *whose* drop it is (rect hit test, unchanged), so exactly one listener speaks for a drop and a hidden pane's zero-size rect still keeps TerminalView and FilesTab from both firing. - `dropIsBlocked` answers whether the app should take a drop at all — document-wide, no z-index in it. While a modal or blocking overlay is on screen anywhere, every drop is refused. There is no `elementFromPoint` call left, so no future overlay can become a drop hole by being painted high enough and no chrome can become a dead zone by being painted at all. The cost is over-refusal while a dialog is open, in a state the user entered deliberately, announced, writing nothing. Also: - `[aria-hidden="true"]` no longer disqualifies a blocker. It is not a visibility statement (it sits on visible decorative content), so a blocker nested in such a wrapper would have silently stopped blocking. - Modal drops `data-blocks-drop` when its pane hides, and moves focus out of itself rather than leaving it inside a `display:none` panel. - The refusal notice stays `kind: "info"` (an expected refusal is not an error, and an error card never auto-dismisses) and carries a `dedupeKey`, so repeated refusals replace rather than stack. Tests: mutation-checked against the previous implementation — four in dropTarget.test.ts, two in each of TerminalView/FilesTab, two in Modal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -381,11 +381,13 @@ describe("FilesTab host drag-and-drop", () => {
|
||||
});
|
||||
|
||||
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.
|
||||
// Round 1. `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 ran when a test supplied one;
|
||||
// the gate no longer asks, and this pins that nothing painted over a pane
|
||||
// can refuse a drop on its own account.
|
||||
await renderTab();
|
||||
const toastCard = document.createElement("div");
|
||||
document.body.appendChild(toastCard);
|
||||
@@ -402,21 +404,37 @@ describe("FilesTab host drag-and-drop", () => {
|
||||
toastCard.remove();
|
||||
});
|
||||
|
||||
it("refuses a drop that lands on a dialog painted over the pane", async () => {
|
||||
it("refuses a drop while a dialog is open, toast painted over it or not", async () => {
|
||||
// Round 2, which is the reason this file exists in its current shape. The
|
||||
// refusal pushes a toast; `ToastHost` is `z-[60]` and the `Modal` backdrop
|
||||
// is `z-50` in the same stacking context, so the *toast* becomes the
|
||||
// topmost element over a covered pane. A gate that asked `elementFromPoint`
|
||||
// "is a blocker painted here?" then answered no and uploaded into the
|
||||
// directory the dialog was covering — one refused drop was all it took to
|
||||
// open the hole. Both stubs below therefore have to be refused.
|
||||
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,
|
||||
});
|
||||
const toastCard = document.createElement("div"); // z-[60], above the backdrop
|
||||
document.body.appendChild(toastCard);
|
||||
const stub = (top: Element) =>
|
||||
Object.defineProperty(document, "elementFromPoint", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: () => top,
|
||||
});
|
||||
|
||||
stub(backdrop);
|
||||
await drop(["/host/a.png"], { x: 400, y: 300 });
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
|
||||
stub(toastCard);
|
||||
await drop(["/host/a.png"], { x: 700, y: 550 });
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
|
||||
delete (document as Partial<Document>).elementFromPoint;
|
||||
toastCard.remove();
|
||||
backdrop.remove();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user