Le focus-trap de FloatingWindow avait un useEffect dépendant de `[onClose]`. `onClose` (ex. TicketDetail.requestClose) est une closure recréée à chaque render : chaque frappe ré-armait l'effet et renvoyait le focus au premier élément focusable, ne laissant saisir qu'une lettre à la fois dans le formulaire d'édition. Correctif : conserver le dernier `onClose` dans un `onCloseRef` et passer l'effet focus-trap en mount-only (`[]`). Le handler Escape lit la valeur courante via la ref. Ajout d'un test de régression prouvé rouge-sans / vert-avec le fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
182 lines
6.1 KiB
TypeScript
182 lines
6.1 KiB
TypeScript
/**
|
|
* FloatingWindow — the shared modal-overlay primitive (ticket #16, gate G1).
|
|
*
|
|
* Consolidates the `fixed inset-0 role="dialog" aria-modal` pattern that was
|
|
* hand-rolled in ~7 features. Mounted at the **chrome level** (ProjectsView/App),
|
|
* never inside LayoutGrid/LeafView (G5). It provides:
|
|
* - a focus-trap (mandatory: xterm captures keyboard focus, so a window opened
|
|
* over a terminal must steal *and* trap focus, then restore it on close),
|
|
* - Escape-to-close and backdrop-click-to-close,
|
|
* - the frozen `floatingWindow` z-index token (50).
|
|
*
|
|
* Purely presentational: the caller owns `open`/`onClose` state and the body.
|
|
* Rendered as `null` while closed so the body (and its hooks) only run when the
|
|
* window is actually open.
|
|
*/
|
|
|
|
import { useEffect, useRef, type ReactNode } from "react";
|
|
|
|
import { cn } from "../lib/cn";
|
|
import { Button } from "./Button";
|
|
import { zIndex } from "./zIndex";
|
|
|
|
/** Width presets; height is capped at 80vh with an internal scroll region. */
|
|
export type FloatingWindowSize = "sm" | "md" | "lg" | "xl";
|
|
|
|
const SIZE_CLASS: Record<FloatingWindowSize, string> = {
|
|
sm: "max-w-sm",
|
|
md: "max-w-lg",
|
|
lg: "max-w-2xl",
|
|
xl: "max-w-4xl",
|
|
};
|
|
|
|
/** Tabbable-element selector used by the focus-trap. */
|
|
const FOCUSABLE =
|
|
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
|
|
/**
|
|
* Stack of currently-mounted windows (most-recent last). Only the topmost window
|
|
* reacts to Escape and traps Tab, so a window opened over another (e.g. a ticket
|
|
* detail over the tickets list) doesn't close/steal from the one beneath it.
|
|
*/
|
|
const windowStack: object[] = [];
|
|
|
|
export interface FloatingWindowProps {
|
|
/** Whether the window is shown. Rendered as `null` when closed. */
|
|
open: boolean;
|
|
/** Accessible dialog title, shown in the header. */
|
|
title: string;
|
|
/** Called on Escape, backdrop click, or the Close button. */
|
|
onClose: () => void;
|
|
/** Width preset (default "md"). */
|
|
size?: FloatingWindowSize;
|
|
children: ReactNode;
|
|
}
|
|
|
|
export function FloatingWindow({
|
|
open,
|
|
title,
|
|
onClose,
|
|
size = "md",
|
|
children,
|
|
}: FloatingWindowProps) {
|
|
if (!open) return null;
|
|
return (
|
|
<FloatingWindowBody title={title} onClose={onClose} size={size}>
|
|
{children}
|
|
</FloatingWindowBody>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The mounted window. Split out so the focus-trap effect and the body only run
|
|
* while `open` — mounting/unmounting on `open` gives clean focus save/restore.
|
|
*/
|
|
function FloatingWindowBody({
|
|
title,
|
|
onClose,
|
|
size,
|
|
children,
|
|
}: Omit<FloatingWindowProps, "open">) {
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Keep the latest `onClose` in a ref so the mount-only effect below never
|
|
// re-runs when the caller passes an unstable `onClose` closure (a new
|
|
// identity on each parent render). Re-running the effect would move focus
|
|
// back to the first focusable element on every keystroke, so a controlled
|
|
// input inside the window could only accept one character before losing
|
|
// focus. The Escape handler reads the current value via this ref.
|
|
const onCloseRef = useRef(onClose);
|
|
onCloseRef.current = onClose;
|
|
|
|
useEffect(() => {
|
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
const node = dialogRef.current;
|
|
// Register on the window stack so only the topmost handles keys.
|
|
const token = {};
|
|
windowStack.push(token);
|
|
const isTopmost = () => windowStack[windowStack.length - 1] === token;
|
|
// Move focus into the window (first focusable, else the dialog itself).
|
|
const first = node?.querySelector<HTMLElement>(FOCUSABLE);
|
|
(first ?? node)?.focus();
|
|
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
// Ignore keys while a later window is stacked on top of this one.
|
|
if (!isTopmost()) return;
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
onCloseRef.current();
|
|
return;
|
|
}
|
|
if (e.key !== "Tab" || !node) return;
|
|
const focusables = Array.from(
|
|
node.querySelectorAll<HTMLElement>(FOCUSABLE),
|
|
);
|
|
if (focusables.length === 0) {
|
|
// Nothing focusable inside: keep focus on the dialog container.
|
|
e.preventDefault();
|
|
node.focus();
|
|
return;
|
|
}
|
|
const firstEl = focusables[0];
|
|
const lastEl = focusables[focusables.length - 1];
|
|
const active = document.activeElement;
|
|
if (e.shiftKey && active === firstEl) {
|
|
e.preventDefault();
|
|
lastEl.focus();
|
|
} else if (!e.shiftKey && active === lastEl) {
|
|
e.preventDefault();
|
|
firstEl.focus();
|
|
}
|
|
}
|
|
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => {
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
const i = windowStack.indexOf(token);
|
|
if (i >= 0) windowStack.splice(i, 1);
|
|
previouslyFocused?.focus?.();
|
|
};
|
|
// Mount-only: focus save/restore and listener wiring must not re-run when
|
|
// `onClose` changes identity (see `onCloseRef` above).
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 flex items-center justify-center bg-black/55 p-4"
|
|
style={{ zIndex: zIndex.floatingWindow }}
|
|
onMouseDown={(e) => {
|
|
// Backdrop click closes; clicks inside the panel don't reach here.
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
>
|
|
<div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={title}
|
|
tabIndex={-1}
|
|
className={cn(
|
|
"flex max-h-[80vh] w-full flex-col overflow-hidden rounded-lg",
|
|
"border border-border bg-surface shadow-xl outline-none",
|
|
SIZE_CLASS[size ?? "md"],
|
|
)}
|
|
>
|
|
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
|
|
<span className="text-sm font-medium text-content">{title}</span>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
aria-label={`close ${title}`}
|
|
onClick={onClose}
|
|
>
|
|
Close
|
|
</Button>
|
|
</header>
|
|
<div className="min-h-0 flex-1 overflow-auto p-4">{children}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|