compose and add-account are real forms, so enter submits them

Compose's To/Cc/Bcc/Subject fields, and each of the three add-account
forms, sat in a plain div — enter did nothing and the button was the
only way in. Wrapped each in a <form onSubmit>, marked every button
that isn't the submit action type="button" (attach, cancel, cc/bcc
toggles, the recipient-suggestion picks), so none of them accidentally
submit now that a form exists around them. The compose body stays a
contenteditable div, which never triggers native enter-to-submit, so
Cmd/Ctrl+Enter wasn't needed to keep newlines working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 10:45:21 +00:00
co-authored by Claude Opus 5
parent bc9934fc55
commit 3db9534614
2 changed files with 143 additions and 113 deletions
+121 -100
View File
@@ -70,6 +70,7 @@ const RecipientInput = ({
{suggestions.map((s) => ( {suggestions.map((s) => (
<button <button
key={s.address} key={s.address}
type="button"
onMouseDown={(ev) => { onMouseDown={(ev) => {
ev.preventDefault(); ev.preventDefault();
pick(s.address); pick(s.address);
@@ -215,7 +216,10 @@ export const ComposeModal = () => {
setDraft(null); setDraft(null);
}; };
const send = async () => { // The body is a contenteditable div, not an <input>/<textarea>, so it never triggers a native
// Enter-to-submit — only the To/Cc/Bcc/Subject fields do, which is the point.
const send = async (ev?: React.FormEvent) => {
ev?.preventDefault();
if (!to.trim()) { if (!to.trim()) {
toast.error('Add at least one recipient'); toast.error('Add at least one recipient');
return; return;
@@ -286,115 +290,132 @@ export const ComposeModal = () => {
Drop images to inline · other files attach Drop images to inline · other files attach
</div> </div>
)} )}
<div className="flex items-center border-b px-4 py-2.5 pr-10"> {/* A real <form>: Enter in To/Cc/Bcc/Subject sends, matching every other single-line field.
<DialogTitle className="text-sm font-semibold">{/^re:/i.test(subject) ? 'Reply' : 'New message'}</DialogTitle> `contents` keeps it out of the flex layout — the fields around it don't know it's there. */}
</div> <form onSubmit={send} className="contents">
<div className="flex items-center border-b px-4 py-2.5 pr-10">
<DialogTitle className="text-sm font-semibold">
{/^re:/i.test(subject) ? 'Reply' : 'New message'}
</DialogTitle>
</div>
<div className="flex items-center border-b">
<span className="pl-4 text-xs opacity-50">To</span>
<div className="flex-1">
<RecipientInput value={to} onChange={setTo} placeholder="Recipients" autoFocus />
</div>
<div className="flex items-center gap-2 px-3">
{!showCc && (
<button onClick={() => setShowCc(true)} className="text-xs opacity-50 hover:opacity-100 cursor-pointer">
Cc
</button>
)}
{!showBcc && (
<button onClick={() => setShowBcc(true)} className="text-xs opacity-50 hover:opacity-100 cursor-pointer">
Bcc
</button>
)}
</div>
</div>
{showCc && (
<div className="flex items-center border-b"> <div className="flex items-center border-b">
<span className="pl-4 text-xs opacity-50">Cc</span> <span className="pl-4 text-xs opacity-50">To</span>
<div className="flex-1"> <div className="flex-1">
<RecipientInput value={cc} onChange={setCc} placeholder="Cc recipients" /> <RecipientInput value={to} onChange={setTo} placeholder="Recipients" autoFocus />
</div> </div>
</div> <div className="flex items-center gap-2 px-3">
)} {!showCc && (
{showBcc && (
<div className="flex items-center border-b">
<span className="pl-4 text-xs opacity-50">Bcc</span>
<div className="flex-1">
<RecipientInput value={bcc} onChange={setBcc} placeholder="Bcc recipients" />
</div>
</div>
)}
<input
value={subject}
onChange={(ev) => setSubject(ev.target.value)}
placeholder="Subject"
className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
/>
<div className="relative flex-1 overflow-hidden">
{bodyEmpty && (
<div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message…</div>
)}
<div
ref={editorRef}
contentEditable
onInput={refreshEmpty}
onPaste={onEditorPaste}
className="h-full overflow-y-auto px-4 py-3 text-sm outline-none [&_img]:my-1 [&_img]:rounded"
/>
</div>
{files.length > 0 && (
<div className="flex flex-wrap gap-1.5 border-t px-4 py-2">
{files.map((f, i) => (
<span key={`${f.name}-${i}`} className="flex items-center gap-1.5 rounded bg-accent px-2 py-1 text-xs">
<Paperclip className="h-3 w-3 opacity-60" />
<span className="max-w-[16rem] truncate">{f.name}</span>
<span className="opacity-50">{fmtSize(f.size)}</span>
<button <button
onClick={() => setFiles((prev) => prev.filter((_, idx) => idx !== i))} type="button"
className="opacity-50 hover:opacity-100 cursor-pointer" onClick={() => setShowCc(true)}
title="Remove" className="text-xs opacity-50 hover:opacity-100 cursor-pointer"
> >
<X className="h-3 w-3" /> Cc
</button> </button>
</span> )}
))} {!showBcc && (
<button
type="button"
onClick={() => setShowBcc(true)}
className="text-xs opacity-50 hover:opacity-100 cursor-pointer"
>
Bcc
</button>
)}
</div>
</div> </div>
)} {showCc && (
<div className="flex items-center border-b">
<div className="flex items-center justify-end gap-2 border-t px-4 py-2.5"> <span className="pl-4 text-xs opacity-50">Cc</span>
<div className="flex-1">
<RecipientInput value={cc} onChange={setCc} placeholder="Cc recipients" />
</div>
</div>
)}
{showBcc && (
<div className="flex items-center border-b">
<span className="pl-4 text-xs opacity-50">Bcc</span>
<div className="flex-1">
<RecipientInput value={bcc} onChange={setBcc} placeholder="Bcc recipients" />
</div>
</div>
)}
<input <input
ref={fileInput} value={subject}
type="file" onChange={(ev) => setSubject(ev.target.value)}
multiple placeholder="Subject"
className="hidden" className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
onChange={(ev) => {
if (ev.target.files?.length) addAttachments(ev.target.files);
ev.target.value = '';
}}
/> />
<button <div className="relative flex-1 overflow-hidden">
onClick={() => fileInput.current?.click()} {bodyEmpty && (
className="mr-auto flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer" <div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message…</div>
title="Attach files" )}
> <div
<Paperclip className="h-4 w-4" /> ref={editorRef}
</button> contentEditable
<button onInput={refreshEmpty}
onClick={close} onPaste={onEditorPaste}
className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer" className="h-full overflow-y-auto px-4 py-3 text-sm outline-none [&_img]:my-1 [&_img]:rounded"
> />
Cancel </div>
</button>
<button {files.length > 0 && (
onClick={send} <div className="flex flex-wrap gap-1.5 border-t px-4 py-2">
disabled={sending} {files.map((f, i) => (
className="flex items-center gap-1.5 rounded-md bg-duck-teal px-4 py-1.5 text-sm font-medium text-white hover:bg-duck-teal/90 disabled:opacity-50 cursor-pointer" <span key={`${f.name}-${i}`} className="flex items-center gap-1.5 rounded bg-accent px-2 py-1 text-xs">
> <Paperclip className="h-3 w-3 opacity-60" />
{sending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />} <span className="max-w-[16rem] truncate">{f.name}</span>
Send <span className="opacity-50">{fmtSize(f.size)}</span>
</button> <button
</div> type="button"
onClick={() => setFiles((prev) => prev.filter((_, idx) => idx !== i))}
className="opacity-50 hover:opacity-100 cursor-pointer"
title="Remove"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-center justify-end gap-2 border-t px-4 py-2.5">
<input
ref={fileInput}
type="file"
multiple
className="hidden"
onChange={(ev) => {
if (ev.target.files?.length) addAttachments(ev.target.files);
ev.target.value = '';
}}
/>
<button
type="button"
onClick={() => fileInput.current?.click()}
className="mr-auto flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
title="Attach files"
>
<Paperclip className="h-4 w-4" />
</button>
<button
type="button"
onClick={close}
className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={sending}
className="flex items-center gap-1.5 rounded-md bg-duck-teal px-4 py-1.5 text-sm font-medium text-white hover:bg-duck-teal/90 disabled:opacity-50 cursor-pointer"
>
{sending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
Send
</button>
</div>
</form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+22 -13
View File
@@ -85,7 +85,8 @@ export const EmailAccounts = () => {
} }
}; };
const handleGmailOAuth = async () => { const handleGmailOAuth = async (ev?: React.FormEvent) => {
ev?.preventDefault();
setSaving(true); setSaving(true);
try { try {
const status = await client.get<{ connected: boolean; email: string | null }>('/integrations/google/status'); const status = await client.get<{ connected: boolean; email: string | null }>('/integrations/google/status');
@@ -116,7 +117,8 @@ export const EmailAccounts = () => {
} }
}; };
const handlePasswordSubmit = async () => { const handlePasswordSubmit = async (ev?: React.FormEvent) => {
ev?.preventDefault();
if (!form.email.trim() || !form.password.trim()) return; if (!form.email.trim() || !form.password.trim()) return;
setSaving(true); setSaving(true);
try { try {
@@ -304,7 +306,10 @@ export const EmailAccounts = () => {
{/* Gmail OAuth form */} {/* Gmail OAuth form */}
{activeForm === 'gmail-oauth' && ( {activeForm === 'gmail-oauth' && (
<div className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4"> <form
onSubmit={handleGmailOAuth}
className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4"
>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add Gmail (OAuth)</p> <p className="text-sm font-medium text-duck-dark dark:text-foreground">Add Gmail (OAuth)</p>
<input <input
type="text" type="text"
@@ -317,16 +322,19 @@ export const EmailAccounts = () => {
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer"> <Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
Cancel Cancel
</Button> </Button>
<Button type="button" onClick={handleGmailOAuth} disabled={saving} className="h-9 flex-1 cursor-pointer"> <Button type="submit" disabled={saving} className="h-9 flex-1 cursor-pointer">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Connect & Add'} {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Connect & Add'}
</Button> </Button>
</div> </div>
</div> </form>
)} )}
{/* Gmail App Password form */} {/* Gmail App Password form */}
{activeForm === 'gmail-password' && ( {activeForm === 'gmail-password' && (
<div className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4"> <form
onSubmit={handlePasswordSubmit}
className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4"
>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add Gmail (App Password)</p> <p className="text-sm font-medium text-duck-dark dark:text-foreground">Add Gmail (App Password)</p>
<div className="text-xs text-duck-dark/50 dark:text-foreground/50 grid gap-1.5"> <div className="text-xs text-duck-dark/50 dark:text-foreground/50 grid gap-1.5">
<p>To create an App Password:</p> <p>To create an App Password:</p>
@@ -373,20 +381,22 @@ export const EmailAccounts = () => {
Cancel Cancel
</Button> </Button>
<Button <Button
type="button" type="submit"
onClick={handlePasswordSubmit}
disabled={!form.email.trim() || !form.password.trim() || saving} disabled={!form.email.trim() || !form.password.trim() || saving}
className="h-9 flex-1 cursor-pointer" className="h-9 flex-1 cursor-pointer"
> >
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Test & Save'} {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Test & Save'}
</Button> </Button>
</div> </div>
</div> </form>
)} )}
{/* Generic IMAP form */} {/* Generic IMAP form */}
{activeForm === 'imap' && ( {activeForm === 'imap' && (
<div className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4"> <form
onSubmit={handlePasswordSubmit}
className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4"
>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add IMAP Account</p> <p className="text-sm font-medium text-duck-dark dark:text-foreground">Add IMAP Account</p>
<input <input
type="email" type="email"
@@ -439,15 +449,14 @@ export const EmailAccounts = () => {
Cancel Cancel
</Button> </Button>
<Button <Button
type="button" type="submit"
onClick={handlePasswordSubmit}
disabled={!form.email.trim() || !form.password.trim() || !form.imapHost.trim() || saving} disabled={!form.email.trim() || !form.password.trim() || !form.imapHost.trim() || saving}
className="h-9 flex-1 cursor-pointer" className="h-9 flex-1 cursor-pointer"
> >
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Test & Save'} {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Test & Save'}
</Button> </Button>
</div> </div>
</div> </form>
)} )}
{/* Add account button */} {/* Add account button */}