fix: tell screen readers when a draft is saved and read a failed upload out once

This commit is contained in:
Linus Rath
2026-10-29 17:53:50 +01:00
parent 23813be9b9
commit 4c1f24947e
3 changed files with 40 additions and 8 deletions
@@ -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(<EmailComposer initialData={DRAFT} />);
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');
});
});
@@ -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
+10 -5
View File
@@ -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<string | null> => {
const saveDraft = (opts: { auto?: boolean; announce?: boolean } = {}): Promise<string | null> => {
const previous = inflightSaveRef.current;
const promise = (async (): Promise<string | null> => {
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