fix(ui): a reviewed log takes the focus back once, and a dialog's focus survives a Retry

E32 review round 1 (F1, F2).

- Issue composer: the review's focus-return token became state in S4 and was never spent, so every
  Preview → Edit round trip remounted the attachments and moved the focus from the toggle to the
  log's Review button. The token is cleared once it has been used, as the ref was.
- Modal (initialFocus "field"): the late-form watch moved the focus to the first control once and
  stopped. A failed load's Retry then gave way to the spinner, its focus fell to <body> inside the
  aria-modal dialog, and the form's first field never got it. The watch now re-arms when the focus
  falls to <body>: it waits on the ✕ and moves to the first field when the form arrives. Focus in a
  dialog stacked on top is left alone.
This commit is contained in:
2026-10-04 02:29:03 +02:00
parent 38943fa93e
commit e09d613b04
4 changed files with 50 additions and 6 deletions
+11 -3
View File
@@ -106,14 +106,22 @@ export default function Modal({
// A form that loads after the dialog opens (the issue composer shows a spinner first) has no field
// to take the focus at open, so it lands on the ✕. When the first control arrives the focus moves
// there — but only while it still rests on the ✕: a user who has moved it keeps it where it is.
// The watch re-arms when the focused control is taken away (a failed load's Retry giving way to
// the spinner again): the focus fell to <body>, so it waits on the ✕ for the form (E32 review).
// Only <body>: focus in a dialog stacked on top is not this one's to take.
useEffect(() => {
const body = bodyRef.current;
if (initialFocus !== "field" || !body || focusablesIn(body).length > 0) return;
let armed = true;
const watch = new MutationObserver(() => {
if (document.activeElement === document.body && closeRef.current) {
closeRef.current.focus();
armed = true;
}
const first = focusablesIn(body)[0];
if (!first) return;
watch.disconnect();
if (document.activeElement === closeRef.current) first.focus();
if (!armed || !first || document.activeElement !== closeRef.current) return;
armed = false;
first.focus();
});
watch.observe(body, { childList: true, subtree: true });
return () => watch.disconnect();