Name the file in a failed chip's tooltip and announce failures

Follow-ups to moving the failure reason into a tooltip:

- The tooltip now reads "<file>: <reason>". The name is truncated on the
  chip, and two long names can look the same.
- A polite role="status" region, always mounted and screen-reader only,
  lists the failed files and their reasons. The red line it replaces was
  role="alert", so failures were announced; a hover tooltip is not.
- The reason now describes the remove button (aria-describedby on a
  hidden span), the chip's only focusable control. Before, it was sr-only
  text on a chip that can't take focus.
This commit is contained in:
arc53-machine committed 2026-09-28 10:37:09 +01:00
1 parent b8a62cedf9
commit 7e990d4384
2 files changed
+51 -11

No files matched your search

@@ -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.',
);
});
@@ -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 = (
<div
key={attachment.id}
@@ -119,7 +123,9 @@ export default function AttachmentChipList({
{attachment.fileName}
</span>
{failureReason && (
<span className="sr-only">{failureReason}</span>
<span id={reasonId} hidden>
{failureReason}
</span>
)}
<IconButton
@@ -128,6 +134,7 @@ export default function AttachmentChipList({
size="icon-xs"
shape="pill"
className="ml-1.5"
aria-describedby={failureReason ? reasonId : undefined}
onClick={() => {
onRemove(attachment.id);
}}
@@ -140,12 +147,26 @@ export default function AttachmentChipList({
return (
<Tooltip key={attachment.id}>
<TooltipTrigger asChild>{chip}</TooltipTrigger>
<TooltipContent>{failureReason}</TooltipContent>
<TooltipContent>
{attachment.fileName}: {failureReason}
</TooltipContent>
</Tooltip>
);
})}
</div>
{/* Always mounted, so a chip turning failed is announced: the reason
itself is only on hover, which a screen reader never sees. */}
<div className="sr-only" role="status" aria-live="polite">
{attachments
.filter((attachment) => attachment.status === 'failed')
.map(
(attachment) =>
`${attachment.fileName}: ${failureReasonOf(attachment)}`,
)
.join(' ')}
</div>
{unreadable.length > 0 && (
<div
className="text-warning flex flex-col gap-0.5 px-2 pb-1 text-xs sm:px-3"