🛗 fix: Address Accessibility Issues - Axe Rating: Serious (#10521)

* feat: add light/dark differentiation on text color for login footer links for more accessible contrast in light mode

* feat: add darker color focus ring on ThemeSelector in light mode for more accessible contrast

* feat: increase contrast on text color for rendered error messages in light and dark mode so that they pass the 4.5:1 accessibility contrast threshold against their backgrounds

* feat: add more accessible color vars to style.css for better contrast against light/dark backgrounds

* feat: un-nest DropdownMenu from ListCard and make them siblings instead for better accessibility

* feat: tweak --border-heavy in light mode so that it uses --gray-410 rather than --gray-400 so that the contrast ratio threshold is hit for accessibility

* feat: switch email and password input border to border-heavy for more accessible contrast on Login page

* fix: add proper focus ring for Action menu button in Prompts Sidenav

* fix: align light and dark focus rings with surrounding elements on preview/edit menu dropdown button in Prompt Card

* fix: remove aria-hidden on parent div with focusable child element according to accessibility guidelines

* fix: add missing aria-readonly false property that should have been in previous accessibility PR

* feat: add horizontal padding on rowRenderer's CellMeasurer div so that focus ring on rows doesnt clip behind virtualized table borders side-to-side

(still need to figure out vertical clipping on final row / a better solution to be able to get overflows to work properly within the virtualized table)

* feat: remove render prop override so that Share and Delete Buttons in Conversation dropdown can be pressed with Enter keystroke

* fix: undo additional colors and changes to --surface-hover

the initial changes came from a misunderstanding of contrast threshold requirements for hover effect accessibility

* feat: better layout for non-nested prompt card / action menu combination

* fix: add proper focus restoration behavior for Preview modal on close

* fix: undo change to --border-heavy in light mode

* fix: set borders for login input boxes back to light

* feat: add announcement for state change when link copied to clipboard in conversation share modal

* feat: add announcement to Refresh Link button

* feat: add announcement for archiving chats

* feat: make date sections in conversation history list <h2> rather than generic <div> for improved screen reader support

* feat: ensure Share Link modal is accessible at high zoom percentage and low viewport width / height requirements by adding max height and overflow attributes to allow scrolling

* feat: bold toast text so that it hits font size accessibility threshold (above 14 px when bolded - change makes text 16 px bold) so that the more disruptive contrast change of the toast background color is no longer necessary.

The background color would need to achieve a 4.5:1 contrast ratio, which would significantly affect the established aesthetic of the current toast system if achieved.

* fix: do not render side nav when it is hidden to avoid keyboard navigation with screen reader

* fix: add side nav button state change announcements and don't render components that were previosuly reachable via keyboard navigation while in the side nav

* feat: add tooltip anchor for Model Select

* fix: only hide the model selector, export, and temp chat buttons when in mobile view and the sidenav is expanded

* feat: add aria-haspopup support for MenuItems and add aria-haspopup: 'dialog' for Share and Delete buttons in ConvoOptions

* feat: add label for DataTable search so that it does not rely on placeholder attribute for function identification

* feat: make X buttons on dialogs 24x24px to achieve AA compliance

* feat: add announcements for the search bar for model selector

* feat: persistent label for DataTable

* feat: make filter files text contrast compliant

* feat: add non-color visual indicator to AudioRecorder listening state

* feat: add aria-expanded attribute to tool call dropdown for screen reader

* feat: add high contrast and rounded outlines for focus indicators on Run Code and Copy Code buttons for code blocks

* fix: change Button to anchor tag in Shared Links component when linking to original conversation

* fix: allow overflow in datatable cells so that focus indicators dont get cut off

* feat: round out focus outline for link name in SharedLinks modal

* feat: add aria-controls and aria-haspopup: "dialog" to SharedLinks delete button and modal

* feat: add aria-controls for dropdown menu items on ConvoOptions for share and delete modals

* feat: add trigger ref to 2FA button and modal in settings menu so focus returns to button on modal close

* feat: add refs so that open sidebar and close sidebar buttons transfer focus to one another

* chore: formatting

* feat: make sure settings modal is accessible at 200% zoom for screen size 1366x768 viewport

* feat: round out focus outline for link names in archived chats modal

* feat: add result announcements for screen reader in DataTable search

* feat: simplify layout for checkbox / api key components for better accessibility

* feat: return focus to chat input on prompt variables modal close

* feat: add persistent labels to TextareaAutosize Inputs in Variable form

* feat: tighten max width so side scrolling not necessary at 400% zoom for VariableForm modal

* feat: add persistent labels to prompt management page

* feat: announce results found for search bars in prompts page and improve them in datatable

* feat: de-nest DashGroupItem buttons in Prompts page to allow better navigation and comply with accessibility standard

* feat: add heading for new prompt creation page for screen readers

* feat: remove non-compliant description truncation for small screen sizes by making labels static on small enough viewport width

* feat: add mobile view sidebar for prompts page

* feat: add bolded text on select for AdvancedSwitch so that there is a visual indicator of selection and it does not rely solely on color as an indication of state

* feat: add persistent labels to ModelSelector search inputs

* feat: align aria-label with visual label for speech recognition users

* feat: make MemoryCreateDialog accessible at 400% zoom (introduce max viewport height attr and make scrollable)

* feat: add persistent label to Filter input for DataTable in file attach sidebar menu

* feat: add persistent label for bookmark filter input in bookmarks sidebar menu

* feat: add alert for screen readers for invalid inputs when editting bookmarks

* feat: bold font in BookmarkForm error readout to pass contrast compliance thresholds for 14pt text

* feat: align aria-label with visual label for BookmarkForm Ttile input

* feat: add 400% zoom support for ALL modals utilizing OriginalDialog to prevent clipping

* feat: remove state change on aria label and give consistent labelling for button, offload state change notification to the announcement div and make more assertive

* feat: add aria-labels which convey that the buttons are sortable (divergence from visual text because iconography is used to signify sort functionality)

* feat: add supplemental visuals to indicate link is clickable other than color in SharedLinks

* feat: increase saturation to hit contrast threshold minimums on Link color in SharedLinks

* feat: stop DataTable from disappearing at 400% zoom in SharedLinks

* feat: increase contrast to hit contrast threshold minimums on Animated Search Input visual indicators

* feat: add aria-label for AnimatedSearchInput (doesn't require explicit labelling because of Search icon)

* fix: stop long example variable declaration from clipping at high zoom in variables info

* feat: add aria-label to bettter describe sort button functionality for vision impaired users

* chore: remove unused translation key

* chore: address ESLint comments

* fix: modify test to account for new alert on theme toggle switch for login page

* chore: interpolate translation key
This commit is contained in:
Dustin Healy 2025-11-26 06:12:04 -08:00 committed by Danny Avila
parent ac68e629e6
commit 2989ebd649
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
62 changed files with 935 additions and 414 deletions

View file

@ -16,6 +16,17 @@ export interface MenuItemProps {
separate?: boolean;
hideOnClick?: boolean;
dialog?: React.ReactElement;
ariaHasPopup?:
| boolean
| 'dialog'
| 'menu'
| 'true'
| 'false'
| 'listbox'
| 'tree'
| 'grid'
| undefined;
ariaControls?: string;
ref?: React.Ref<any>;
render?:
| RenderProp<React.HTMLAttributes<any> & { ref?: React.Ref<any> | undefined }>

View file

@ -1,5 +1,6 @@
import React from 'react';
import { Search } from 'lucide-react';
import { TranslationKeys, useLocalize } from '~/hooks';
import { cn } from '~/utils';
const AnimatedSearchInput = ({
@ -15,6 +16,7 @@ const AnimatedSearchInput = ({
}) => {
const isSearching = searching === true;
const hasValue = value != null && value.length > 0;
const localize = useLocalize();
return (
<div className="relative w-full">
@ -25,7 +27,7 @@ const AnimatedSearchInput = ({
<Search
className={cn(
`h-4 w-4 transition-all duration-500 ease-in-out`,
isSearching && hasValue ? 'text-blue-400' : 'text-gray-400',
isSearching && hasValue ? 'text-blue-400' : 'text-gray-500',
)}
/>
</div>
@ -36,7 +38,8 @@ const AnimatedSearchInput = ({
value={value}
onChange={onChange}
placeholder={placeholder}
className={`peer relative z-20 w-full rounded-lg bg-surface-secondary px-10 py-2 outline-none backdrop-blur-sm transition-all duration-500 ease-in-out placeholder:text-gray-400 focus:ring-ring`}
aria-label={localize('com_ui_search')}
className={`peer relative z-20 w-full rounded-lg bg-surface-secondary py-2 pl-10 outline-none backdrop-blur-sm transition-all duration-500 ease-in-out placeholder:text-gray-500 focus:ring-ring`}
/>
{/* Gradient overlay */}

View file

@ -14,8 +14,8 @@ import {
} from '@tanstack/react-table';
import type { Table as TTable } from '@tanstack/react-table';
import { Table, TableRow, TableBody, TableCell, TableHead, TableHeader } from './Table';
import { useMediaQuery, useLocalize, TranslationKeys } from '~/hooks';
import AnimatedSearchInput from './AnimatedSearchInput';
import { useMediaQuery, useLocalize } from '~/hooks';
import { TrashIcon, Spinner } from '~/svgs';
import { Skeleton } from './Skeleton';
import { Checkbox } from './Checkbox';
@ -129,7 +129,7 @@ const TableRowComponent = <TData, TValue>({
)}
scope="row"
>
<div className="overflow-hidden text-ellipsis">
<div className="overflow-visible text-ellipsis">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</TableHead>
@ -139,13 +139,13 @@ const TableRowComponent = <TData, TValue>({
return (
<TableCell
key={cell.id}
className="w-0 max-w-0 px-2 py-1 align-middle text-xs transition-all duration-300 sm:px-4 sm:py-2 sm:text-sm"
className="w-0 max-w-0 overflow-visible px-2 py-1 align-middle text-xs transition-all duration-300 sm:px-4 sm:py-2 sm:text-sm"
style={getColumnStyle(
cell.column.columnDef as TableColumn<TData, TValue>,
isSmallScreen,
)}
>
<div className="overflow-hidden text-ellipsis">
<div className="overflow-visible text-ellipsis">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</TableCell>
@ -234,6 +234,7 @@ export default function DataTable<TData, TValue>({
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [searchTerm, setSearchTerm] = useState(filterValue ?? '');
const [isSearching, setIsSearching] = useState(false);
const [searchResultsAnnouncement, setSearchResultsAnnouncement] = useState('');
const tableColumns = useMemo(() => {
if (!enableRowSelection || !showCheckboxes) {
@ -331,6 +332,29 @@ export default function DataTable<TData, TValue>({
return () => clearTimeout(timeout);
}, [searchTerm, onFilterChange]);
useEffect(() => {
if (!searchTerm.trim() || isSearching) {
setSearchResultsAnnouncement('');
return;
}
const resultCount = rows.length;
const announcement =
resultCount === 1
? localize('com_ui_result_found' as TranslationKeys, {
count: resultCount,
})
: localize('com_ui_results_found' as TranslationKeys, {
count: resultCount,
});
const timeout = setTimeout(() => {
setSearchResultsAnnouncement(announcement);
}, 300);
return () => clearTimeout(timeout);
}, [rows.length, searchTerm, isSearching, localize]);
const handleDelete = useCallback(async () => {
if (!onDelete) {
return;
@ -373,6 +397,10 @@ export default function DataTable<TData, TValue>({
return (
<div className={cn('flex h-full flex-col gap-4', className)}>
<div aria-live="assertive" aria-atomic="true" className="sr-only">
{searchResultsAnnouncement}
</div>
{/* Table controls */}
<div className="flex flex-wrap items-center gap-2 sm:gap-4">
{enableRowSelection && showCheckboxes && (
@ -400,7 +428,7 @@ export default function DataTable<TData, TValue>({
<div
ref={tableContainerRef}
className={cn(
'relative h-[calc(100vh-20rem)] max-w-full overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10',
'relative min-h-0 max-w-full flex-1 overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10',
'transition-all duration-300 ease-out',
isSearching && 'bg-surface-secondary/50',
className,

View file

@ -145,6 +145,8 @@ const Menu: React.FC<MenuProps> = ({
render={item.render}
ref={item.ref}
hideOnClick={item.hideOnClick}
aria-haspopup={item.ariaHasPopup}
aria-controls={item.ariaControls}
onClick={(event) => {
event.preventDefault();
if (item.onClick) {

View file

@ -72,7 +72,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'max-w-11/12 fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl bg-background p-6 text-text-primary shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
'max-w-11/12 fixed left-[50%] top-[50%] z-50 grid max-h-[90vh] w-full translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-2xl bg-background p-6 text-text-primary shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
className,
)}
{...props}
@ -80,7 +80,7 @@ const DialogContent = React.forwardRef<
{children}
{showCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-ring-primary ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" aria-hidden="true" />
<X className="h-6 w-6" aria-hidden="true" />
{/* eslint-disable-next-line i18next/no-literal-string */}
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

View file

@ -1,6 +1,7 @@
import { useContext, useCallback, useEffect, useState } from 'react';
import { Sun, Moon, Monitor } from 'lucide-react';
import { ThemeContext, isDark } from '../theme';
import { useLocalize } from '../hooks';
declare global {
interface Window {
@ -11,6 +12,8 @@ declare global {
type ThemeType = 'system' | 'dark' | 'light';
const Theme = ({ theme, onChange }: { theme: string; onChange: (value: string) => void }) => {
const localize = useLocalize();
const themeIcons: Record<ThemeType, JSX.Element> = {
system: <Monitor aria-hidden="true" />,
dark: <Moon color="white" aria-hidden="true" />,
@ -18,7 +21,6 @@ const Theme = ({ theme, onChange }: { theme: string; onChange: (value: string) =
};
const nextTheme = isDark(theme) ? 'light' : 'dark';
const label = `Switch to ${nextTheme} theme`;
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
@ -33,8 +35,8 @@ const Theme = ({ theme, onChange }: { theme: string; onChange: (value: string) =
return (
<button
className="flex items-center gap-2 rounded-lg p-2 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
aria-label={label}
className="flex items-center gap-2 rounded-lg p-2 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 dark:focus-visible:ring-0"
aria-label={localize('com_ui_toggle_theme')}
aria-keyshortcuts="Ctrl+Shift+T"
onClick={(e) => {
e.preventDefault();
@ -55,6 +57,7 @@ const Theme = ({ theme, onChange }: { theme: string; onChange: (value: string) =
const ThemeSelector = ({ returnThemeOnly }: { returnThemeOnly?: boolean }) => {
const { theme, setTheme } = useContext(ThemeContext);
const [announcement, setAnnouncement] = useState('');
const localize = useLocalize();
const changeTheme = useCallback(
(value: string) => {
@ -65,9 +68,13 @@ const ThemeSelector = ({ returnThemeOnly }: { returnThemeOnly?: boolean }) => {
window.lastThemeChange = now;
setTheme(value);
setAnnouncement(isDark(value) ? 'Dark theme enabled' : 'Light theme enabled');
setAnnouncement(
isDark(value)
? localize('com_ui_dark_theme_enabled')
: localize('com_ui_light_theme_enabled'),
);
},
[setTheme],
[setTheme, localize],
);
useEffect(() => {
@ -93,11 +100,9 @@ const ThemeSelector = ({ returnThemeOnly }: { returnThemeOnly?: boolean }) => {
<div className="absolute bottom-0 left-0 m-4">
<Theme theme={theme} onChange={changeTheme} />
</div>
{announcement && (
<div aria-live="polite" className="sr-only">
{announcement}
</div>
)}
<div role="alert" aria-live="assertive" aria-atomic="true" className="sr-only">
{announcement}
</div>
</div>
);
};

View file

@ -23,7 +23,7 @@ export function Toast() {
>
<div className="w-full p-1 text-center md:w-auto md:text-justify">
<div
className={`alert-root pointer-events-auto inline-flex flex-row gap-2 rounded-md border px-3 py-2 text-white ${
className={`alert-root pointer-events-auto inline-flex flex-row gap-2 rounded-md border px-3 py-2 font-bold text-white ${
severityClassName[toast.severity]
}`}
>

View file

@ -3,5 +3,9 @@
"com_ui_no_options": "No options available",
"com_ui_delete_selected_items": "Delete selected items",
"com_ui_filter_by": "Filter by {{title}}",
"com_ui_cancel_dialog": "Cancel dialog"
"com_ui_cancel_dialog": "Cancel dialog",
"com_ui_toggle_theme": "Toggle theme",
"com_ui_dark_theme_enabled": "Dark theme enabled",
"com_ui_light_theme_enabled": "Light theme enabled",
"com_ui_search": "Search..."
}