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:
@@ -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";
|
||||
}
|
||||
|
||||
@@ -48,8 +48,12 @@ 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) {
|
||||
if (!autoFocusOnMount.current) return;
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
@@ -60,8 +64,7 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
}, [autoFocus]);
|
||||
}, []);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user