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>
This commit is contained in:
2026-07-07 08:28:08 +02:00
parent f580edc93a
commit 4cccd40e2a
2 changed files with 47 additions and 2 deletions

View File

@ -80,6 +80,15 @@ function FloatingWindowBody({
}: 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;
@ -96,7 +105,7 @@ function FloatingWindowBody({
if (!isTopmost()) return;
if (e.key === "Escape") {
e.preventDefault();
onClose();
onCloseRef.current();
return;
}
if (e.key !== "Tab" || !node) return;
@ -128,7 +137,10 @@ function FloatingWindowBody({
if (i >= 0) windowStack.splice(i, 1);
previouslyFocused?.focus?.();
};
}, [onClose]);
// 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