From caee90facc4c96ad0da163f0f62e06f6a7ba0715 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:38:34 +0100 Subject: [PATCH] fix: name the message body and tie its error to it for screen readers --- .../email/__tests__/composer-a11y.test.tsx | 21 ++++++++ .../__tests__/rich-text-editor-a11y.test.tsx | 51 +++++++++++++++++++ components/email/email-composer.tsx | 14 +++++ components/email/rich-text-editor.tsx | 37 ++++++++++++-- locales/ar/common.json | 3 +- locales/ca/common.json | 3 +- locales/cs/common.json | 3 +- locales/da/common.json | 3 +- locales/de/common.json | 3 +- locales/en/common.json | 3 +- locales/es/common.json | 3 +- locales/et/common.json | 3 +- locales/fa/common.json | 3 +- locales/fr/common.json | 3 +- locales/he/common.json | 3 +- locales/hu/common.json | 3 +- locales/it/common.json | 3 +- locales/ja/common.json | 3 +- locales/ko/common.json | 3 +- locales/lv/common.json | 3 +- locales/mn/common.json | 3 +- locales/nb/common.json | 3 +- locales/nl/common.json | 3 +- locales/pl/common.json | 3 +- locales/pt/common.json | 3 +- locales/ro/common.json | 3 +- locales/ru/common.json | 3 +- locales/sk/common.json | 3 +- locales/th/common.json | 3 +- locales/tr/common.json | 3 +- locales/uk/common.json | 3 +- locales/zh-TW/common.json | 3 +- locales/zh/common.json | 3 +- 33 files changed, 178 insertions(+), 32 deletions(-) create mode 100644 components/email/__tests__/rich-text-editor-a11y.test.tsx diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx index e8a87819b..54a96693d 100644 --- a/components/email/__tests__/composer-a11y.test.tsx +++ b/components/email/__tests__/composer-a11y.test.tsx @@ -325,3 +325,24 @@ describe('moving a recipient by keyboard (MO-07)', () => { expect(await screen.findByTestId('recipient-move-bcc')).toBeInTheDocument(); }); }); + +describe('message body (SR-07)', () => { + it('labels the plain-text body', () => { + settings.plainTextMode = true; + render(); + expect(screen.getByLabelText('body_label').tagName).toBe('TEXTAREA'); + }); + + it('marks an empty body invalid and links the error text', async () => { + render(); + const editor = screen.getByTestId('rich-text-editor'); + expect(editor).not.toHaveAttribute('data-invalid'); + + fireEvent.keyDown(screen.getByTestId('composer-subject'), { key: 'Enter', ctrlKey: true }); + await act(async () => {}); + + expect(editor).toHaveAttribute('data-invalid', 'true'); + const error = document.getElementById(editor.getAttribute('data-describedby')!); + expect(error).toHaveTextContent('validation.body_required'); + }); +}); diff --git a/components/email/__tests__/rich-text-editor-a11y.test.tsx b/components/email/__tests__/rich-text-editor-a11y.test.tsx new file mode 100644 index 000000000..2334fbc4e --- /dev/null +++ b/components/email/__tests__/rich-text-editor-a11y.test.tsx @@ -0,0 +1,51 @@ +import { render, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import React from 'react'; +import type { Editor } from '@tiptap/core'; + +import { RichTextEditor } from '../rich-text-editor'; + +type Props = Partial>; + +async function setup(props: Props = {}) { + let editor: Editor | null = null; + const view = render( + {}} + onEditorReady={(ed) => { editor = ed as unknown as Editor; }} + {...props} + /> + ); + await waitFor(() => expect(editor).not.toBeNull()); + const rerender = (next: Props) => view.rerender( + {}} {...props} {...next} /> + ); + return { ...view, editor: editor as unknown as Editor, rerender }; +} + +describe('RichTextEditor editing area (SR-07)', () => { + it('is a named multi-line textbox', async () => { + const { editor } = await setup({ ariaLabel: 'Message' }); + const dom = editor.view.dom; + expect(dom).toHaveAttribute('role', 'textbox'); + expect(dom).toHaveAttribute('aria-multiline', 'true'); + expect(dom).toHaveAttribute('aria-label', 'Message'); + expect(dom).not.toHaveAttribute('aria-invalid'); + }); + + it('marks the body invalid and points at the error once validation fails', async () => { + const { editor, rerender } = await setup({ ariaLabel: 'Message', ariaDescribedBy: 'body-error' }); + expect(editor.view.dom).not.toHaveAttribute('aria-describedby'); + + rerender({ ariaInvalid: true }); + await waitFor(() => expect(editor.view.dom).toHaveAttribute('aria-invalid', 'true')); + expect(editor.view.dom).toHaveAttribute('aria-describedby', 'body-error'); + // Still the editor's own class and name. + expect(editor.view.dom).toHaveClass('tiptap'); + expect(editor.view.dom).toHaveAttribute('aria-label', 'Message'); + + rerender({ ariaInvalid: false }); + await waitFor(() => expect(editor.view.dom).not.toHaveAttribute('aria-invalid')); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 2761df12a..23962448e 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -4394,7 +4394,10 @@ export function EmailComposer({ )} {/* Body */} {plainTextMode ? ( + <> +