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:
@@ -70,7 +70,7 @@ export default function Modal({
|
||||
// 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.
|
||||
// nothing, holds no focus, and blocks no native file drop.
|
||||
const paneVisible = usePaneVisible();
|
||||
const paneVisibleRef = useRef(paneVisible);
|
||||
paneVisibleRef.current = paneVisible;
|
||||
@@ -85,11 +85,19 @@ export default function Modal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Move focus inside — on mount, and again whenever the pane comes back.
|
||||
// Move focus inside — on mount, and again whenever the pane comes back. And
|
||||
// move it *out* when the pane steps aside: the backdrop goes `display:none`
|
||||
// with the keyboard focus still inside it, and nothing else relocates it, so
|
||||
// the user lands on the new tab with focus held by a dialog they cannot see.
|
||||
// Blurring puts it on `<body>`, which is where a fresh Tab starts.
|
||||
useEffect(() => {
|
||||
if (!paneVisible) return;
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
if (!paneVisible) {
|
||||
const active = panel.ownerDocument.activeElement as HTMLElement | null;
|
||||
if (active && panel.contains(active)) active.blur?.();
|
||||
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?.());
|
||||
@@ -151,10 +159,11 @@ 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"
|
||||
/* This dialog swallows native file drops for as long as it is on screen.
|
||||
Dropped while the owning pane is hidden, so a dialog parked on another
|
||||
tab does not keep refusing drops here — `lib/dropTarget.ts` also
|
||||
filters `[hidden]`, and these two must not disagree. */
|
||||
data-blocks-drop={paneVisible ? "true" : undefined}
|
||||
hidden={!paneVisible}
|
||||
aria-hidden={paneVisible ? undefined : true}
|
||||
/* `hidden` is a base-layer rule and `flex` is a utility-layer one, so the
|
||||
|
||||
Reference in New Issue
Block a user