Files
ihasmail-inbuxa/web/src/views/compose/__tests__/focus.test.tsx
T
jcoffey-dev 8faf9002c2 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.
2026-08-23 14:15:28 -07:00

63 lines
2.0 KiB
TypeScript

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();
});
});