diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx
index 9534b61b9..3a9d3f39f 100644
--- a/components/email/__tests__/composer-a11y.test.tsx
+++ b/components/email/__tests__/composer-a11y.test.tsx
@@ -138,8 +138,9 @@ vi.mock('@/stores/template-store', () => {
return { useTemplateStore: hook };
});
+const toastError = vi.hoisted(() => vi.fn());
vi.mock('@/stores/toast-store', () => ({
- toast: { info: () => {}, error: () => {}, success: () => {} },
+ toast: { info: () => {}, error: toastError, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
@@ -394,14 +395,17 @@ describe('attachment uploads (SR-16)', () => {
expect(bar).toHaveAttribute('aria-valuetext', 'attachment_progress:45');
});
- it('says in words that an upload failed and announces it', async () => {
+ // The error toast is a live region of its own; the composer saying it
+ // too would read the failure twice.
+ it('says in words that an upload failed and reports it once', async () => {
uploadClient(() => Promise.reject(new Error('boom')));
render();
await attach(new File(['%PDF'], 'report.pdf', { type: 'application/pdf' }));
await waitFor(() => expect(screen.getByText('attachment_upload_failed')).toBeInTheDocument());
expect(screen.queryByRole('progressbar')).toBeNull();
- expect(liveRegion()).toHaveTextContent('upload_failed:report.pdf');
+ expect(toastError).toHaveBeenCalledWith('upload_failed:report.pdf');
+ expect(liveRegion()).not.toHaveTextContent('upload_failed');
});
});
diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx
index e934e97ee..1ce1de0e3 100644
--- a/components/email/__tests__/composer-close-guard.test.tsx
+++ b/components/email/__tests__/composer-close-guard.test.tsx
@@ -3,6 +3,7 @@ import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { useAuthStore } from '@/stores/auth-store';
+import { LIVE_REGION_IDS } from '@/lib/announce';
// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
@@ -322,6 +323,28 @@ describe('composer close guard with a follow-up', () => {
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
});
+ it('tells screen readers the draft was saved when the user saves, not on autosave', async () => {
+ const createDraft = vi.fn().mockResolvedValue('draft-9');
+ useAuthStore.setState({
+ client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
+ });
+ const polite = () => document.getElementById(LIVE_REGION_IDS.polite);
+ try {
+ const { requestCloseRef, afterClose } = renderWithCloseRef();
+ await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
+ await new Promise((r) => setTimeout(r, 200));
+ expect(polite()?.textContent ?? '').not.toContain('draft_saved');
+
+ fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } });
+ act(() => requestCloseRef.current!(afterClose));
+ fireEvent.click(within(await screen.findByRole('alertdialog')).getByText('save'));
+
+ await waitFor(() => expect(polite()?.textContent).toContain('draft_saved'));
+ } finally {
+ useAuthStore.setState({ client: null });
+ }
+ });
+
it('drops the follow-up when the save fails and the rescue takes over', async () => {
// When the server refuses the draft, the composer text survives only via
// the unmount stash into the continue-draft slot (#702). That rescue
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 0e8749ec0..6d0f7e011 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -141,6 +141,7 @@ import { isValidEmail } from "@/lib/validation";
import { composeTextStyle, composeTextStyleCss } from "@/components/email/font-size";
import { buildMentionCandidates, type MentionCandidate } from "@/lib/recipient-mentions";
import { describeRecipientChanges, recipientDisplayName, type RecipientChange, type RecipientFields } from "@/lib/recipient-announcements";
+import { announce as announceStatus } from "@/lib/announce";
import { normalizeCid } from "@/lib/attachment-visibility";
import { RichTextEditor } from "@/components/email/rich-text-editor";
import { isRecipientMentionActive } from "@/components/email/recipient-mention";
@@ -2277,8 +2278,8 @@ export function EmailComposer({
} catch (error) {
if (controller?.signal.aborted) continue;
debug.error(`Failed to upload ${file.name}:`, error);
+ // The error toast reads itself out (components/ui/toast.tsx).
toast.error(t('upload_failed', { filename: file.name }));
- announce(t('upload_failed', { filename: file.name }));
setAttachments(prev =>
prev.map(att =>
@@ -2289,7 +2290,7 @@ export function EmailComposer({
);
}
}
- }, [client, t, plainTextMode, announce]);
+ }, [client, t, plainTextMode]);
const handleImageUpload = useCallback(async (
file: File,
@@ -2745,13 +2746,17 @@ export function EmailComposer({
// racing two `Email/set { destroy, create }` requests against the same
// draftId, which left orphan drafts and (when EmailSubmission failed)
// looked like "send didn't happen" (#303).
- const saveDraft = (opts: { auto?: boolean } = {}): Promise => {
+ const saveDraft = (opts: { auto?: boolean; announce?: boolean } = {}): Promise => {
const previous = inflightSaveRef.current;
const promise = (async (): Promise => {
if (previous) {
try { await previous; } catch { /* prior failure already reported */ }
}
- return saveDraftOnce(!!opts.auto);
+ const id = await saveDraftOnce(!!opts.auto);
+ // Only the save the user asked for is read out: autosave would say it
+ // every minute, and the save before sending is followed by "Sent".
+ if (opts.announce && id && !saveFailedRef.current) announceStatus(t('draft_saved'));
+ return id;
})();
inflightSaveRef.current = promise;
promise.finally(() => {
@@ -3777,7 +3782,7 @@ export function EmailComposer({
// close attempt just re-opened this dialog - Discard was the only way out
// (#702). Requests carry a deadline now, so a broken save resolves.
try {
- await saveDraft();
+ await saveDraft({ announce: true });
if (saveFailedRef.current) {
// The server never took the draft. Clearing stateRef here would drop the
// message on the floor with nothing but a toast, so leave the text in