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 [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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user