mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 18:15:02 +00:00
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:
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"
|
||||
|
||||
Reference in new issue
Block a user