Files
Triple-C/app/src/components/ui/Modal.tsx
T
shadow-testandClaude Opus 5 5926a52ff6 Stop the terminal and Files panes refusing drops onto their own chrome
The z-order gate added last round asked `el.contains(elementFromPoint(x, y))`
— "is the thing painted here mine?" — and was handed `TerminalView`'s inner
xterm host while every overlay in that pane is a *sibling* of it. So any point
under the pane's own chrome answered "not mine" and the drop was refused, with
no message and no log line. The "▼ Following / ▽ Paused" toggle is rendered
unconditionally at `absolute top-2 right-4`, and `ToastHost` is `fixed
bottom-4 right-4` 24rem wide with error cards that never time out: two corners
of the terminal, and one of the Files pane, that could not accept a file for
as long as the app was running.

It shipped green because jsdom has no `elementFromPoint`, so not one of the 81
drop tests entered that branch. The tests here install one.

The question the gate asks is now "is a *blocking overlay* painted here?".
Chrome the pane paints over itself is not one; a dialog backdrop is, and
`ui/Modal` marks its own backdrop so the element `elementFromPoint` actually
returns is the one carrying the marker. `classifyDrop` also separates "aimed at
me and swallowed" from "not my drop", so the first gets a toast and a log line
and the second stays silent.

Three defects around it:

- **A dialog now refuses only the points it covers.** `dropIsBlocked` is
  document-wide and `ui/Modal` portals to `document.body`, so any open dialog
  refused every drop in the window. The deeper half of that is that a dialog
  opened in project A really was still on screen after a tab switch — the pane
  hides itself with a `hidden` class, which a portal does not inherit — so
  `PaneVisibility` lets `App` tell a `Modal` its pane stepped aside, and a
  hidden one paints nothing, traps no focus, answers no Escape and blocks no
  drop while staying mounted with its state intact.

- **`devicePixelRatio` is applied on Windows only.** 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. Halving those was
  survivable while the test was a bare rect and is a refused drop once z-order
  joins in. Read from the wry/tauri sources, not verified on a HiDPI Mac or
  GTK box.

- **`isFileExistsError` can no longer be forged by a filename.** It matched
  `fileexists` anywhere in a normalised error, so uploading a host file called
  `file-exists.txt` turned *any* failure into a collision — and Replace
  re-invoked the upload with `overwrite: true`. The marker now has to stand
  alone in the backend's canonical form, or be a whole discriminant value.

- **A refused compaction or cache-clear keeps its dialog.** `reclaim` reports
  refusals inside `Ok`, so "did it throw" read one as success: the dialog
  closed, the tick list was dropped, and the explanation appeared in the
  outcome panel several screens above the row that was clicked. The dialog now
  stays put and renders the backend's own sentence verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 13:03:57 -07:00

211 lines
7.7 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, and — via `[hidden]` — 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.
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();
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"
/* 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}
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,
);
}