fix(ui): the issue composer opens on its title, not on the Edit switch

A form dialog opens on the body's first control, and the composer's first
control is the Edit / Preview switch above the form: the focus landed there,
at open and when a retried load brought the form, and typing went nowhere. A
form can now mark the field to open on (data-initial-focus); every other
dialog keeps its first control. The Modal's comment on the re-armed watch now
says where the focus goes when the focused control is removed but others stay.
This commit is contained in:
2026-10-04 04:56:57 +02:00
parent 40b27f2982
commit 4efe799eda
5 changed files with 51 additions and 13 deletions
+13 -11
View File
@@ -14,16 +14,17 @@ import { X } from "lucide-react";
import { useBackToClose } from "../../lib/history";
import { anyBlockingLayerAbove, pushLayer, removeLayer, takeEscape } from "../../lib/layerStack";
import { useFullscreenPortalHost } from "../../lib/fullscreen";
import { focusablesIn, trapTab, useDialogChrome } from "./dialogFocus";
import { firstFieldIn, focusablesIn, trapTab, useDialogChrome } from "./dialogFocus";
import { IconButton } from "./IconButton";
import Toaster from "./Toaster";
import { useOverlayOrder } from "./overlayOrder";
import { SURFACE } from "./surfaces";
/** Where a dialog opens focused (C4, by purpose — WAI-ARIA APG): `field` = the body's first control
* (a form), `close` = the header's ✕ (anything to read or browse), `action` = the child that takes
* `autoFocus` itself (a confirm's least destructive button; the ✕ if none does). Required, so every
* dialog states its purpose instead of inheriting "the first focusable", which is always the ✕. */
/** Where a dialog opens focused (C4, by purpose — WAI-ARIA APG): `field` = the body's first control,
* or the one marked `data-initial-focus` (a form), `close` = the header's ✕ (anything to read or
* browse), `action` = the child that takes `autoFocus` itself (a confirm's least destructive button;
* the ✕ if none does). Required, so every dialog states its purpose instead of inheriting "the first
* focusable", which is always the ✕. */
export type ModalFocus = "field" | "close" | "action";
// The footer slot of the open dialog. `ModalFooter` portals into it from any depth, so a body that
@@ -100,18 +101,19 @@ export default function Modal({
// Only a rendered target (the trap's own `offsetParent` test); none falls back to the hook's
// first-focusable rule. `action` lands on the ✕ here and its child's autoFocus then takes over.
initial: () =>
(initialFocus === "field" && bodyRef.current ? focusablesIn(bodyRef.current)[0] : null) ??
(initialFocus === "field" && bodyRef.current ? firstFieldIn(bodyRef.current) : null) ??
(closeRef.current?.offsetParent != null ? closeRef.current : null),
});
// 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.
// to take the focus at open, so it lands on the ✕. When the form arrives the focus moves to its
// first field — but only while it still rests on the ✕: a user who has moved it keeps it there.
// The form takes it ONCE: arrived while the focus was elsewhere, it disarms all the same, or a later
// mutation would pull the focus off a ✕ the user went back to (E32 review round 2). The watch
// re-arms only when the focus is taken away, not moved: the body has no control again, or the
// focused one went (a failed load's Retry giving way to the spinner, or straight to the form) and
// the focus fell to <body> — it then waits on the ✕ for the form (E32 review). Only <body>: focus
// in a dialog stacked on top is not this one's to take.
// the focus fell to <body>. It then goes to the ✕, and on to the first field as soon as the body
// has one — at once, when other controls stayed beside the one that went (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;
@@ -121,7 +123,7 @@ export default function Modal({
closeRef.current.focus();
armed = true;
}
const first = focusablesIn(body)[0];
const first = firstFieldIn(body);
if (!first) {
armed = true;
return;