Merge pull request #10 from LINUXexpert-org/fix-subject-focus

Stop the composer stealing focus while the subject is typed
This commit is contained in:
LINUXexpert.org
2026-08-23 14:17:24 -07:00
committed by GitHub
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 d = draft;
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 onHtml = useCallback((html: string) => update(key, { html }), [update, key]);
@@ -138,7 +143,7 @@ export function Composer({ draft }: { draft: Draft }) {
)}
<div className="composer-field">
<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">
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</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">
<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.requestReceipt && <span className="tag" style={{ background: "var(--accent)" }} title="Read receipt requested"><CheckCheck size={12} /></span>}
</div>
</div>
{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} />
)}
@@ -230,3 +235,12 @@ export function Composer({ draft }: { draft: Draft }) {
</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]);
// 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(() => {
if (autoFocus) {
const el = elRef.current;
if (!el) return;
el.focus();
// caret at start
const sel = window.getSelection();
const range = document.createRange();
range.setStart(el, 0);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
}
}, [autoFocus]);
if (!autoFocusOnMount.current) return;
const el = elRef.current;
if (!el) return;
el.focus();
// caret at start
const sel = window.getSelection();
const range = document.createRange();
range.setStart(el, 0);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
}, []);
const emit = useCallback(() => {
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();
});
});