Files
Triple-C/app/src/components/ui/Modal.tsx
T
shadowdaoandClaude Opus 5 f7db4323be 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
2026-08-23 15:31:13 -07:00

220 lines
8.2 KiB
TypeScript

import { useCallback, useEffect, useId, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { usePaneVisible } from "./PaneVisibility";
const FOCUSABLE_SELECTOR = [
"a[href]",
"area[href]",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"button:not([disabled])",
"iframe",
"object",
"embed",
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(",");
function focusableWithin(root: HTMLElement): HTMLElement[] {
// Deliberately no `offsetParent` check: everything a dialog renders is
// visible, and `offsetParent` is unreliable inside fixed-position overlays.
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => !el.closest("[hidden]") && el.getAttribute("aria-hidden") !== "true",
);
}
export interface ModalProps {
/** Accessible name for the dialog. Rendered as the header unless `hideTitle`. */
title: string;
onClose: () => void;
children: ReactNode;
/** Optional sticky footer row (buttons live here). */
footer?: ReactNode;
/** Optional sub-header description, wired to `aria-describedby`. */
description?: ReactNode;
/** Tailwind width class for the dialog panel. */
widthClassName?: string;
/** When false, Escape / overlay click / the ✕ button do not close. */
dismissible?: boolean;
/** Hide the ✕ in the header (the footer usually carries a Close button). */
hideCloseButton?: boolean;
/** Focused on mount; falls back to the first focusable child. */
initialFocusRef?: React.RefObject<HTMLElement | null>;
/** Applied to the scrollable body wrapper. */
bodyClassName?: string;
}
/**
* The one modal primitive. Every dialog in the app renders through this so
* `role="dialog"`, `aria-modal`, a focus trap, focus restore, Escape and
* click-outside are implemented once instead of twelve times.
*/
export default function Modal({
title,
onClose,
children,
footer,
description,
widthClassName = "w-[32rem]",
dismissible = true,
hideCloseButton = false,
initialFocusRef,
bodyClassName = "",
}: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
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, holds no focus, and blocks no native file drop.
const paneVisible = usePaneVisible();
const paneVisibleRef = useRef(paneVisible);
paneVisibleRef.current = paneVisible;
// 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;
return () => {
if (paneVisibleRef.current) restoreFocusRef.current?.focus?.();
};
}, []);
// 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(() => {
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?.());
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();
onClose();
return;
}
if (e.key !== "Tab") return;
const panel = panelRef.current;
if (!panel) return;
const items = focusableWithin(panel);
if (items.length === 0) {
e.preventDefault();
panel.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement as HTMLElement | null;
if (!active || !panel.contains(active)) {
e.preventDefault();
first.focus();
return;
}
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [dismissible, onClose, paneVisible]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (dismissible && e.target === overlayRef.current) onClose();
},
[dismissible, onClose],
);
return createPortal(
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
/* 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
attribute alone loses. Inline wins over both. */
style={paneVisible ? undefined : { display: "none" }}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descId : undefined}
tabIndex={-1}
className={`flex flex-col max-h-[85vh] ${widthClassName} max-w-full bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)]`}
style={{ boxShadow: "var(--shadow-overlay)" }}
>
<div className="flex items-start justify-between gap-4 px-5 py-3 border-b border-[var(--border-color)] flex-shrink-0">
<div className="min-w-0">
<h2 id={titleId} className="text-sm font-semibold text-[var(--text-primary)]">
{title}
</h2>
{description && (
<p id={descId} className="mt-0.5 text-xs text-[var(--text-secondary)]">
{description}
</p>
)}
</div>
{!hideCloseButton && dismissible && (
<button
type="button"
onClick={onClose}
aria-label="Close dialog"
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true"></span>
</button>
)}
</div>
<div className={`flex-1 min-h-0 overflow-y-auto px-5 py-4 ${bodyClassName}`}>
{children}
</div>
{footer && (
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--border-color)] flex-shrink-0">
{footer}
</div>
)}
</div>
</div>,
document.body,
);
}