Files
IdeA/frontend/src/shared/ui/FloatingWindow.tsx
Blomios 4cccd40e2a fix(tickets): corrige la perte de focus dans l'édition d'un ticket (#17)
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>
2026-07-07 08:28:08 +02:00

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>
);
}