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
+26 -5
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,8 +290,13 @@ export const ComposeModal = () => {
Drop images to inline · other files attach Drop images to inline · other files attach
</div> </div>
)} )}
{/* A real <form>: Enter in To/Cc/Bcc/Subject sends, matching every other single-line field.
`contents` keeps it out of the flex layout — the fields around it don't know it's there. */}
<form onSubmit={send} className="contents">
<div className="flex items-center border-b px-4 py-2.5 pr-10"> <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> <DialogTitle className="text-sm font-semibold">
{/^re:/i.test(subject) ? 'Reply' : 'New message'}
</DialogTitle>
</div> </div>
<div className="flex items-center border-b"> <div className="flex items-center border-b">
@@ -297,12 +306,20 @@ export const ComposeModal = () => {
</div> </div>
<div className="flex items-center gap-2 px-3"> <div className="flex items-center gap-2 px-3">
{!showCc && ( {!showCc && (
<button onClick={() => setShowCc(true)} className="text-xs opacity-50 hover:opacity-100 cursor-pointer"> <button
type="button"
onClick={() => setShowCc(true)}
className="text-xs opacity-50 hover:opacity-100 cursor-pointer"
>
Cc Cc
</button> </button>
)} )}
{!showBcc && ( {!showBcc && (
<button onClick={() => setShowBcc(true)} className="text-xs opacity-50 hover:opacity-100 cursor-pointer"> <button
type="button"
onClick={() => setShowBcc(true)}
className="text-xs opacity-50 hover:opacity-100 cursor-pointer"
>
Bcc Bcc
</button> </button>
)} )}
@@ -351,6 +368,7 @@ export const ComposeModal = () => {
<span className="max-w-[16rem] truncate">{f.name}</span> <span className="max-w-[16rem] truncate">{f.name}</span>
<span className="opacity-50">{fmtSize(f.size)}</span> <span className="opacity-50">{fmtSize(f.size)}</span>
<button <button
type="button"
onClick={() => setFiles((prev) => prev.filter((_, idx) => idx !== i))} onClick={() => setFiles((prev) => prev.filter((_, idx) => idx !== i))}
className="opacity-50 hover:opacity-100 cursor-pointer" className="opacity-50 hover:opacity-100 cursor-pointer"
title="Remove" title="Remove"
@@ -374,6 +392,7 @@ export const ComposeModal = () => {
}} }}
/> />
<button <button
type="button"
onClick={() => fileInput.current?.click()} 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" 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" title="Attach files"
@@ -381,13 +400,14 @@ export const ComposeModal = () => {
<Paperclip className="h-4 w-4" /> <Paperclip className="h-4 w-4" />
</button> </button>
<button <button
type="button"
onClick={close} onClick={close}
className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer" className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={send} type="submit"
disabled={sending} 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" 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"
> >
@@ -395,6 +415,7 @@ export const ComposeModal = () => {
Send Send
</button> </button>
</div> </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 */}