🛂 fix: Address Accessibility Issues - Axe Rating: Serious (#10607)

* feat: wrap main content of page in <main> tag for screen reader landmarks (439)

* feat: add italic on active convo when selected so that selection state does not rely on bg contrast ratio (562)

* feat: add border ring around SearchBar so that it passes focus contrast minimums (577)

* fix: hide decorative SVGs from screen readers (578)

* fix: stop clipping of focus outlines in My Files modal (593)

* feat: programmatically declare state of Temporary Chat toggle for screen readers (606)

* feat: add sr-only components to warn screen readers that footer links open in new tab (611)

* feat: add aria-labels to archived chat table buttons

* feat: add screen reader heading for prompt edit page (776)

* feat: increase contrast to threshold minimum for production tag in prompts advanced view (773)

* feat: increase contrast to thehold minimums for production tag and version card border highlights (770)

* fix: h2 now reads as 'control bar' to screen readers in edit prompt page (768)

* feat: add selected state tracking for simple / advanced toggle for screen readers (765)

* feat: add left padding to theme selector in prompts side nav panel so that focus outline doesnt clip

* feat: darken orange bg for warning toasts to hit 3:1 contrast minimum with white text (725)

* fix: return focus to triggering element on modal close for image preview in attach files panel (717)

* fix: hide SVG for AddMultiConvo button from screen readers (708)

* feat: add persistent label to Filter Memories... input in memory side panel
This commit is contained in:
Dustin Healy 2025-11-25 11:35:59 -08:00 committed by Danny Avila
parent 1143f73f59
commit 39cecc97bd
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
22 changed files with 81 additions and 67 deletions

View file

@ -20,6 +20,9 @@ export default function Footer({ className }: { className?: string }) {
rel="noreferrer"
>
{localize('com_ui_privacy_policy')}
{privacyPolicy.openNewTab === true && (
<span className="sr-only">{' ' + localize('com_ui_opens_new_tab')}</span>
)}
</a>
);
@ -31,6 +34,9 @@ export default function Footer({ className }: { className?: string }) {
rel="noreferrer"
>
{localize('com_ui_terms_of_service')}
{termsOfService.openNewTab === true && (
<span className="sr-only">{' ' + localize('com_ui_opens_new_tab')}</span>
)}
</a>
);
@ -66,6 +72,7 @@ export default function Footer({ className }: { className?: string }) {
{...otherProps}
>
{children}
<span className="sr-only">{' ' + localize('com_ui_opens_new_tab')}</span>
</a>
);
},

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Maximize2 } from 'lucide-react';
import { FileSources } from 'librechat-data-provider';
import { OGDialog, OGDialogContent } from '@librechat/client';
@ -13,11 +13,6 @@ type styleProps = {
backgroundRepeat?: string;
};
interface CloseModalEvent {
stopPropagation: () => void;
preventDefault: () => void;
}
const ImagePreview = ({
imageBase64,
url,
@ -35,53 +30,32 @@ const ImagePreview = ({
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [previousActiveElement, setPreviousActiveElement] = useState<Element | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const openModal = useCallback(() => {
setPreviousActiveElement(document.activeElement);
setIsModalOpen(true);
}, []);
const closeModal = useCallback(
(e: CloseModalEvent): void => {
setIsModalOpen(false);
e.stopPropagation();
e.preventDefault();
if (
previousActiveElement instanceof HTMLElement &&
!previousActiveElement.closest('[data-skip-refocus="true"]')
) {
previousActiveElement.focus();
}
},
[previousActiveElement],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeModal(e);
}
},
[closeModal],
);
const handleOpenChange = useCallback((open: boolean) => {
setIsModalOpen(open);
if (!open && triggerRef.current) {
requestAnimationFrame(() => {
triggerRef.current?.focus({ preventScroll: true });
});
}
}, []);
useEffect(() => {
if (isModalOpen) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
const closeButton = document.querySelector('[aria-label="Close full view"]') as HTMLElement;
if (closeButton) {
setTimeout(() => closeButton.focus(), 0);
}
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'unset';
};
}, [isModalOpen, handleKeyDown]);
}, [isModalOpen]);
const baseStyle: styleProps = {
backgroundSize: 'cover',
@ -117,6 +91,7 @@ const ImagePreview = ({
onMouseLeave={() => setIsHovered(false)}
>
<button
ref={triggerRef}
type="button"
className="size-full overflow-hidden rounded-xl"
style={style}
@ -158,7 +133,7 @@ const ImagePreview = ({
<SourceIcon source={source} aria-label={source ? `Source: ${source}` : undefined} />
</div>
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<OGDialog open={isModalOpen} onOpenChange={handleOpenChange}>
<OGDialogContent
showCloseButton={false}
className="w-11/12 overflow-x-auto bg-transparent p-0 sm:w-auto"

View file

@ -79,7 +79,7 @@ export const columns: ColumnDef<TFile>[] = [
<div className="flex gap-2">
<ImagePreview
url={file.filepath}
className="relative h-10 w-10 shrink-0 overflow-hidden rounded-md"
className="relative h-10 w-10 shrink-0 overflow-visible rounded-md"
source={file.source}
/>
<span className="self-center truncate">{file.filename}</span>

View file

@ -158,8 +158,8 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
{headerGroup.headers.map((header, index) => {
const style: Style = {};
if (index === 0 && header.id === 'select') {
style.width = '35px';
style.minWidth = '35px';
style.width = '36px';
style.minWidth = '36px';
} else if (header.id === 'filename') {
style.width = isSmallScreen ? '60%' : '40%';
} else {
@ -204,7 +204,10 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
return (
<TableCell
key={cell.id}
className="align-start overflow-x-auto px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm [tr[data-disabled=true]_&]:opacity-50"
className={cn(
'align-start px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm [tr[data-disabled=true]_&]:opacity-50',
cell.column.id === 'select' ? 'overflow-visible' : 'overflow-x-auto',
)}
style={style}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}

View file

@ -30,7 +30,7 @@ export default function OpenSidebar({
})
}
>
<Sidebar />
<Sidebar aria-hidden="true" />
</Button>
}
/>

View file

@ -42,6 +42,7 @@ export function TemporaryChat() {
<button
onClick={handleBadgeToggle}
aria-label={localize(temporaryBadge.label)}
aria-pressed={isTemporary}
className={cn(
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out hover:bg-surface-tertiary',
isTemporary