Stop the composer stealing focus while the subject is typed

The body editor was told to focus itself with

    autoFocus={d.to.length > 0 && Boolean(d.subject)}

and RichEditor ran that as an effect keyed on the prop. Typing the first
letter of a subject flipped Boolean(d.subject) false -> true, the effect fired,
and the caret jumped from the subject line into the message body.

autoFocus now means what it means on a DOM element: focus on mount. RichEditor
captures the prop in a ref and focuses once, and the composer decides where the
caret starts when it opens - recipients for a blank message, body for a reply
that already has recipients and a subject - instead of deriving it from state
that changes as the user types.

initialFocusTarget is extracted and exported so the rule is stated in one place
and tested. The regression test renders RichEditor and asserts it does not take
focus from a field being typed into; it fails against the previous effect.
This commit is contained in:
2026-08-23 14:15:28 -07:00
parent b4d89e94dc
commit 8faf9002c2
3 changed files with 95 additions and 16 deletions
+17 -3
View File
@@ -38,6 +38,11 @@ export function Composer({ draft }: { draft: Draft }) {
const [showToolbar, setShowToolbar] = useState(true); const [showToolbar, setShowToolbar] = useState(true);
const d = draft; const d = draft;
const key = d.key; const key = d.key;
// Where the caret starts, decided once when the composer opens: a blank
// message starts in the recipients, a reply (already addressed and titled)
// starts in the body. Deriving this from live state would move the caret
// while the user types.
const [initialFocus] = useState(() => initialFocusTarget(draft));
const patch = useCallback((p: Partial<Draft>) => update(key, p), [update, key]); const patch = useCallback((p: Partial<Draft>) => update(key, p), [update, key]);
const onHtml = useCallback((html: string) => update(key, { html }), [update, key]); const onHtml = useCallback((html: string) => update(key, { html }), [update, key]);
@@ -138,7 +143,7 @@ export function Composer({ draft }: { draft: Draft }) {
)} )}
<div className="composer-field"> <div className="composer-field">
<label htmlFor={`${key}-to`}>To</label> <label htmlFor={`${key}-to`}>To</label>
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder="Recipients" autoFocus={!d.to.length} /> <RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder="Recipients" autoFocus={initialFocus === "to"} />
<span className="field-extra"> <span className="field-extra">
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</button>} {!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</button>}
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>} {!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>}
@@ -165,13 +170,13 @@ export function Composer({ draft }: { draft: Draft }) {
)} )}
<div className="composer-field"> <div className="composer-field">
<label htmlFor={`${key}-subj`} className="sr-only">Subject</label> <label htmlFor={`${key}-subj`} className="sr-only">Subject</label>
<input id={`${key}-subj`} className="plain" placeholder="Subject" value={d.subject} onChange={(e) => patch({ subject: e.target.value })} autoFocus={d.to.length > 0 && !d.subject} /> <input id={`${key}-subj`} className="plain" placeholder="Subject" value={d.subject} onChange={(e) => patch({ subject: e.target.value })} autoFocus={initialFocus === "subject"} />
{d.priority !== "normal" && <span className="tag" style={{ background: d.priority === "high" ? "var(--danger)" : "var(--fg-faint)" }}>{d.priority === "high" ? "High priority" : "Low priority"}</span>} {d.priority !== "normal" && <span className="tag" style={{ background: d.priority === "high" ? "var(--danger)" : "var(--fg-faint)" }}>{d.priority === "high" ? "High priority" : "Low priority"}</span>}
{d.requestReceipt && <span className="tag" style={{ background: "var(--accent)" }} title="Read receipt requested"><CheckCheck size={12} /></span>} {d.requestReceipt && <span className="tag" style={{ background: "var(--accent)" }} title="Read receipt requested"><CheckCheck size={12} /></span>}
</div> </div>
</div> </div>
{d.format === "html" ? ( {d.format === "html" ? (
<RichEditor ref={editorRef} html={d.html} onChange={onHtml} placeholder="Write your message…" spellcheck={settings.spellcheck} onFiles={(files) => addFiles(key, files)} showToolbar={showToolbar} autoFocus={d.to.length > 0 && Boolean(d.subject)} /> <RichEditor ref={editorRef} html={d.html} onChange={onHtml} placeholder="Write your message…" spellcheck={settings.spellcheck} onFiles={(files) => addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} />
) : ( ) : (
<textarea className="editor-textarea" value={d.text} onChange={(e) => patch({ text: e.target.value })} placeholder="Write your message…" spellCheck={settings.spellcheck} /> <textarea className="editor-textarea" value={d.text} onChange={(e) => patch({ text: e.target.value })} placeholder="Write your message…" spellCheck={settings.spellcheck} />
)} )}
@@ -230,3 +235,12 @@ export function Composer({ draft }: { draft: Draft }) {
</div> </div>
); );
} }
export type FocusTarget = "to" | "subject" | "body";
/** Which field a freshly opened composer should put the caret in. */
export function initialFocusTarget(d: Pick<Draft, "to" | "subject">): FocusTarget {
if (!d.to.length) return "to";
if (!d.subject) return "subject";
return "body";
}
+16 -13
View File
@@ -48,20 +48,23 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
} }
}, [html]); }, [html]);
// autoFocus means "focus on mount", as it does on a DOM element. Reacting to
// the prop turning true later yanks the caret out of whatever the user is
// typing in — typing the first letter of a subject used to jump to the body.
const autoFocusOnMount = useRef(autoFocus);
useEffect(() => { useEffect(() => {
if (autoFocus) { if (!autoFocusOnMount.current) return;
const el = elRef.current; const el = elRef.current;
if (!el) return; if (!el) return;
el.focus(); el.focus();
// caret at start // caret at start
const sel = window.getSelection(); const sel = window.getSelection();
const range = document.createRange(); const range = document.createRange();
range.setStart(el, 0); range.setStart(el, 0);
range.collapse(true); range.collapse(true);
sel?.removeAllRanges(); sel?.removeAllRanges();
sel?.addRange(range); sel?.addRange(range);
} }, []);
}, [autoFocus]);
const emit = useCallback(() => { const emit = useCallback(() => {
const el = elRef.current; const el = elRef.current;
@@ -0,0 +1,62 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RichEditor } from "../RichEditor";
import { initialFocusTarget } from "../Composer";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("initialFocusTarget", () => {
it("starts a blank message in the recipients", () => {
expect(initialFocusTarget({ to: [], subject: "" })).toBe("to");
});
it("moves on to the subject once there are recipients", () => {
expect(initialFocusTarget({ to: [{ name: null, email: "[email protected]" }], subject: "" })).toBe("subject");
});
it("starts a reply — addressed and titled — in the body", () => {
expect(initialFocusTarget({ to: [{ name: null, email: "[email protected]" }], subject: "Re: hi" })).toBe("body");
});
});
describe("RichEditor autoFocus", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
host.remove();
});
const render = (autoFocus: boolean) =>
act(() => {
root.render(<RichEditor html="" onChange={() => {}} showToolbar={false} autoFocus={autoFocus} />);
});
const editor = () => host.querySelector<HTMLElement>('[contenteditable="true"]');
it("focuses on mount when asked to", () => {
render(true);
expect(document.activeElement).toBe(editor());
});
it("does not steal focus when autoFocus turns true later", () => {
render(false);
expect(document.activeElement).not.toBe(editor());
// Something else holds the caret — the subject field being typed into.
const subject = document.createElement("input");
document.body.appendChild(subject);
subject.focus();
expect(document.activeElement).toBe(subject);
render(true);
expect(document.activeElement, "the editor grabbed focus mid-typing").toBe(subject);
subject.remove();
});
});