diff --git a/frontend/src/components/message-input/AttachmentChipList.test.tsx b/frontend/src/components/message-input/AttachmentChipList.test.tsx index a2f7b799..b117f6bc 100644 --- a/frontend/src/components/message-input/AttachmentChipList.test.tsx +++ b/frontend/src/components/message-input/AttachmentChipList.test.tsx @@ -78,16 +78,35 @@ describe('AttachmentChipList failed chip', () => { vi.advanceTimersByTime(500); }); + // The full name, since two long names can truncate to the same text. expect(tooltip()?.textContent).toContain( - 'Upload failed. The file could not be read.', + 'scan.pdf: Upload failed. The file could not be read.', ); }); - it('keeps the reason available to screen readers', async () => { + it('announces failures in a persistent polite live region', async () => { + await render([att({ status: 'uploading', errorMessage: undefined })]); + const region = container.querySelector('[role="status"]'); + expect(region?.textContent).toBe(''); + await render([att()]); - const srOnly = container.querySelector('.sr-only'); - expect(srOnly?.textContent).toBe( + // Same node, updated in place, so screen readers announce the change. + expect(container.querySelector('[role="status"]')).toBe(region); + expect(region?.textContent).toBe( + 'scan.pdf: Upload failed. The file could not be read.', + ); + }); + + it('describes the focusable remove button with the reason', async () => { + await render([att()]); + + const remove = container.querySelector( + 'button[aria-label="conversation.attachments.remove"]', + )!; + const describedBy = remove.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + expect(document.getElementById(describedBy!)?.textContent).toBe( 'Upload failed. The file could not be read.', ); }); diff --git a/frontend/src/components/message-input/AttachmentChipList.tsx b/frontend/src/components/message-input/AttachmentChipList.tsx index bda4e01a..f6d6cd48 100644 --- a/frontend/src/components/message-input/AttachmentChipList.tsx +++ b/frontend/src/components/message-input/AttachmentChipList.tsx @@ -1,4 +1,5 @@ import { Paperclip, TriangleAlert, X } from 'lucide-react'; +import { useId } from 'react'; import { useTranslation } from 'react-i18next'; import type { Attachment } from '../../upload/uploadSlice'; @@ -30,6 +31,9 @@ export default function AttachmentChipList({ modelName, }: AttachmentChipListProps) { const { t } = useTranslation(); + const reasonIdPrefix = useId(); + const failureReasonOf = (attachment: Attachment) => + attachment.errorMessage ?? t('conversation.attachments.failed'); // Not a failure: the file is kept and still sends. It only warns that the // model picked right now would receive nothing it can read. @@ -43,11 +47,11 @@ export default function AttachmentChipList({ {attachments.map((attachment) => { const failed = attachment.status === 'failed'; // A failed file never blocks the send (it is dropped then), so its - // reason stays out of the way: the chip's warning icon marks it, and - // the reason is a hover tooltip plus screen-reader text. - const failureReason = failed - ? (attachment.errorMessage ?? t('conversation.attachments.failed')) - : null; + // reason stays out of the way: the chip's warning icon marks it, the + // reason is a hover tooltip, and it describes the remove button (the + // chip's one focusable control) for keyboard and screen readers. + const failureReason = failed ? failureReasonOf(attachment) : null; + const reasonId = `${reasonIdPrefix}-${attachment.id}`; const chip = (
{failureReason && ( - {failureReason} + )} { onRemove(attachment.id); }} @@ -140,12 +147,26 @@ export default function AttachmentChipList({ return ( {chip} - {failureReason} + + {attachment.fileName}: {failureReason} + ); })}
+ {/* Always mounted, so a chip turning failed is announced: the reason + itself is only on hover, which a screen reader never sees. */} +
+ {attachments + .filter((attachment) => attachment.status === 'failed') + .map( + (attachment) => + `${attachment.fileName}: ${failureReasonOf(attachment)}`, + ) + .join(' ')} +
+ {unreadable.length > 0 && (