️ a11y: improve keyboard operability, focus retention, and accessible naming (#14600)

* fix: improve accessibility with semantic HTML and keyboard support

* fix: preserve focus on attachments and stop CSS leaking into label text

Passing `Wrapper` to FileRow as an inline arrow made it a new component type on
every render, so React remounted the whole file row. A keyboard user who tabbed
to an attachment thumbnail lost focus to <body> the moment the upload settled.
Hoist the wrappers to module scope so their identity is stable.

BlinkAnimation rendered a <style> tag into the DOM; stylesheet text becomes part
of the ancestor's textContent and leaks raw CSS into label readouts. Move the
keyframes into the tailwind config, named logo-blink to avoid colliding with the
existing `blink` keyframes in style.css, and honour prefers-reduced-motion.

* fix: make preset row actions reachable by keyboard

The pin, edit and delete buttons on a preset row were hidden with `invisible`,
which sets visibility: hidden and removes them from the tab order entirely. The
`group-focus-within` variant meant to reveal them never fired, because nothing
inside the row ever receives DOM focus during keyboard navigation. Verified in a
browser: arrowing and tabbing through the presets menu skipped the row and the
buttons reported focusable: false, while hovering made them focusable.

Hide them with opacity instead, which keeps them in the tab order, and reveal on
focus as well as hover. At rest they still compute to opacity 0, so there is no
visual change.

* fix: harden a11y heading, Space activation, and preset hit targets

Gate the page heading on a title that matches the routed conversation so
stale Recoil state is not announced during navigation. Ignore key-repeat
on role=button TooltipAnchor activation while still blocking Space scroll.
Disable pointer events on transparent preset actions until hover or focus.

* fix: address a11y review follow-ups and eslint formatting

Use the shared layout test harness for ChatView heading tests, default
role=button TooltipAnchors into the tab order, ship spinner keyframes in
package CSS, and let native preset buttons handle activation once.
This commit is contained in:
Marco Beretta 2026-08-05 19:03:30 +02:00 committed by GitHub
parent 22642df40c
commit 26ba2c2954
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 510 additions and 51 deletions

View file

@ -5,25 +5,15 @@ export const BlinkAnimation = ({
active: boolean;
children: React.ReactNode;
}) => {
const style = `
@keyframes blink-animation {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}`;
if (!active) {
return <>{children}</>;
}
return (
<>
<style>{style}</style>
<div style={{ animation: 'blink-animation 3s infinite' }}>{children}</div>
</>
);
/**
* Animation comes from the `blink` keyframes in the Tailwind config rather than an
* inline `<style>` tag: stylesheet text rendered into the DOM becomes part of the
* ancestor's `textContent` and leaks raw CSS into label readouts. `motion-reduce`
* honours a user's reduced-motion preference.
*/
return <div className="animate-logo-blink motion-reduce:animate-none">{children}</div>;
};

View file

@ -0,0 +1,42 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { BlinkAnimation } from '../BlinkAnimation';
const CHILD_TEXT = 'logo';
const renderBlink = (active: boolean) =>
render(
<BlinkAnimation active={active}>
<span>{CHILD_TEXT}</span>
</BlinkAnimation>,
);
describe('BlinkAnimation', () => {
test('renders children untouched when inactive', () => {
const { container } = renderBlink(false);
expect(screen.getByText(CHILD_TEXT)).toBeInTheDocument();
expect(container.querySelector('[class*="logo-blink"]')).not.toBeInTheDocument();
});
test('applies the blink utility when active', () => {
const { container } = renderBlink(true);
expect(container.querySelector('.animate-logo-blink')).toBeInTheDocument();
});
test('honours reduced-motion preferences', () => {
const { container } = renderBlink(true);
expect(container.querySelector('.motion-reduce\\:animate-none')).toBeInTheDocument();
});
/** A <style> tag inside the tree leaks its CSS into the ancestor's textContent. */
test('never injects a style tag', () => {
const { container } = renderBlink(true);
expect(container.querySelector('style')).not.toBeInTheDocument();
expect(container.textContent).toBe(CHILD_TEXT);
});
});

View file

@ -109,12 +109,22 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
? localize('com_ui_new_chat_in_project', { name: project.name })
: undefined;
// Recoil conversation can lag the route during navigation; only announce a
// title that belongs to the conversation currently in the URL.
const conversationTitle =
chatHelpers.conversation?.conversationId === conversationId
? chatHelpers.conversation?.title?.trim()
: undefined;
const pageHeading =
isLandingPage || !conversationTitle ? localize('com_ui_new_chat') : conversationTitle;
return (
<ChatFormProvider {...methods}>
<ChatContext.Provider value={chatHelpers}>
<AddedChatContext.Provider value={addedChatHelpers}>
<Presentation>
<div className="relative flex h-full w-full flex-col">
<h1 className="sr-only">{pageHeading}</h1>
<Header />
<>
<div

View file

@ -6,6 +6,15 @@ import { useFileHandlingNoChatContext } from '~/hooks';
import FileRow from './FileRow';
import store from '~/store';
/**
* Declared at module scope so its identity is stable across renders. An inline
* wrapper is a new component type on every render, which remounts the whole file
* row and silently drops keyboard focus mid-upload.
*/
const ChatFileRowWrapper = ({ children }: { children: React.ReactNode }) => (
<div className="mx-2 mt-2 flex flex-wrap gap-2">{children}</div>
);
function FileFormChat({
conversation,
files,
@ -36,7 +45,7 @@ function FileFormChat({
abortUpload={abortUpload}
setFilesLoading={setFilesLoading}
isRTL={isRTL}
Wrapper={({ children }) => <div className="mx-2 mt-2 flex flex-wrap gap-2">{children}</div>}
Wrapper={ChatFileRowWrapper}
/>
</>
);

View file

@ -9,6 +9,15 @@ import FileContainer from './FileContainer';
import { useLocalize } from '~/hooks';
import Image from './Image';
/**
* Shared wrapper with a stable module-scope identity. Passing an inline arrow as
* `Wrapper` makes it a new component type on every render, so React remounts the
* whole row and any focused control inside it loses focus.
*/
export const FileRowWrapper = ({ children }: { children: React.ReactNode }) => (
<div className="flex flex-wrap gap-2">{children}</div>
);
export default function FileRow({
files: _files,
setFiles,

View file

@ -31,6 +31,7 @@ const ImagePreview = ({
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
@ -108,6 +109,9 @@ const ImagePreview = ({
transition: 'stroke-dashoffset 0.3s linear',
};
/** Keyboard users need the same expand affordance the pointer gets on hover. */
const showExpandAffordance = isHovered || isFocused;
return (
<>
<button
@ -128,6 +132,8 @@ const ImagePreview = ({
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
>
{progress < 1 ? (
<ProgressCircle
@ -140,14 +146,14 @@ const ImagePreview = ({
<div
className={cn(
'absolute inset-0 flex transform-gpu cursor-pointer items-center justify-center rounded-xl transition-opacity duration-200 ease-in-out',
isHovered ? 'bg-black/20 opacity-100' : 'opacity-0',
showExpandAffordance ? 'bg-black/20 opacity-100' : 'opacity-0',
)}
aria-hidden="true"
>
<Maximize2
className={cn(
'size-5 transform-gpu text-white drop-shadow-lg transition-all duration-200',
isHovered ? 'scale-110' : '',
showExpandAffordance ? 'scale-110' : '',
)}
/>
</div>

View file

@ -0,0 +1,62 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, fireEvent } from '@testing-library/react';
import ImagePreview from '../ImagePreview';
/**
* The expand affordance is decorative (aria-hidden), so it is asserted through the
* opacity utilities that actually drive its visibility.
*/
const getAffordance = (container: HTMLElement) =>
container.querySelector('[aria-hidden="true"]') as HTMLElement;
describe('ImagePreview expand affordance', () => {
const trigger = () => screen.getByRole('button', { name: 'View Preview image in full size' });
test('is hidden at rest', () => {
const { container } = render(<ImagePreview url="/img.png" />);
expect(getAffordance(container)).toHaveClass('opacity-0');
});
test('appears on hover', () => {
const { container } = render(<ImagePreview url="/img.png" />);
fireEvent.mouseEnter(trigger());
expect(getAffordance(container)).toHaveClass('opacity-100');
});
test('appears on keyboard focus', () => {
const { container } = render(<ImagePreview url="/img.png" />);
fireEvent.focus(trigger());
expect(getAffordance(container)).toHaveClass('opacity-100');
});
test('hides again on blur', () => {
const { container } = render(<ImagePreview url="/img.png" />);
fireEvent.focus(trigger());
fireEvent.blur(trigger());
expect(getAffordance(container)).toHaveClass('opacity-0');
});
test('stays visible while focused after the pointer leaves', () => {
const { container } = render(<ImagePreview url="/img.png" />);
fireEvent.focus(trigger());
fireEvent.mouseEnter(trigger());
fireEvent.mouseLeave(trigger());
expect(getAffordance(container)).toHaveClass('opacity-100');
});
test('keeps a visible focus ring on the trigger', () => {
render(<ImagePreview url="/img.png" />);
expect(trigger()).toHaveClass('focus-visible:ring-2');
});
});

View file

@ -177,18 +177,20 @@ const PresetItems: FC<{
'm-0 h-full rounded-md bg-transparent p-2 text-gray-400 hover:text-gray-700 focus:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 dark:focus:text-gray-200',
defaultPreset?.presetId === presetId
? ''
: 'sm:invisible sm:group-focus-within:visible sm:group-hover:visible',
: // opacity keeps buttons in the tab order; pointer-events-none
// while transparent so touch/pointer cannot hit invisible controls
'sm:pointer-events-none sm:opacity-0 sm:transition-opacity sm:focus:pointer-events-auto sm:focus:opacity-100 sm:group-focus-within:pointer-events-auto sm:group-focus-within:opacity-100 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100',
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onSetDefaultPreset(preset, defaultPreset?.presetId === presetId);
}}
// Native <button> already activates once on Enter/Space; only
// stop propagation so the parent MenuItem does not also fire.
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onSetDefaultPreset(preset, defaultPreset?.presetId === presetId);
}
}}
>
@ -201,7 +203,7 @@ const PresetItems: FC<{
aria-label={localize('com_ui_edit')}
render={
<button
className="m-0 h-full rounded-md p-2 text-gray-400 hover:text-gray-700 focus:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 dark:focus:text-gray-200 sm:invisible sm:group-focus-within:visible sm:group-hover:visible"
className="m-0 h-full rounded-md p-2 text-gray-400 hover:text-gray-700 focus:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 dark:focus:text-gray-200 sm:pointer-events-none sm:opacity-0 sm:transition-opacity sm:focus:pointer-events-auto sm:focus:opacity-100 sm:group-focus-within:pointer-events-auto sm:group-focus-within:opacity-100 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -209,9 +211,7 @@ const PresetItems: FC<{
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onChangePreset(preset);
}
}}
>
@ -224,7 +224,7 @@ const PresetItems: FC<{
aria-label={localize('com_ui_delete')}
render={
<button
className="m-0 h-full rounded-md p-2 text-gray-400 hover:text-gray-600 focus:text-gray-600 dark:text-gray-400 dark:hover:text-gray-200 dark:focus:text-gray-200 sm:invisible sm:group-focus-within:visible sm:group-hover:visible"
className="m-0 h-full rounded-md p-2 text-gray-400 hover:text-gray-600 focus:text-gray-600 dark:text-gray-400 dark:hover:text-gray-200 dark:focus:text-gray-200 sm:pointer-events-none sm:opacity-0 sm:transition-opacity sm:focus:pointer-events-auto sm:focus:opacity-100 sm:group-focus-within:pointer-events-auto sm:group-focus-within:opacity-100 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -232,9 +232,7 @@ const PresetItems: FC<{
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onDeletePreset(preset);
}
}}
>

View file

@ -0,0 +1,112 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from 'test/layout-test-utils';
import ChatView from '../ChatView';
const mockParams = jest.fn();
const mockConversation = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => mockParams(),
}));
/** Auth is out of scope for heading selection; keep the shared harness wrappers. */
jest.mock('~/hooks/AuthContext', () => ({
AuthContextProvider: ({ children }: { children: React.ReactNode }) => children,
useAuthContext: () => ({ isAuthenticated: false, user: null, roles: {} }),
}));
jest.mock('~/data-provider', () => ({
useGetMessagesByConvoId: () => ({ data: null, isLoading: false, isFetching: false }),
}));
/**
* Heading selection only needs route params + conversation state. Stub the chat
* helper surface so the suite can inject matching vs stale IDs without standing
* up SSE, message trees, or full ChatRoute synchronization.
*/
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => (key === 'com_ui_new_chat' ? 'New chat' : key),
useChatHelpers: () => ({ conversation: mockConversation() }),
useAddedResponse: () => ({}),
useAdaptiveSSE: jest.fn(),
useResumeOnLoad: jest.fn(),
useQueueDrain: jest.fn(),
}));
jest.mock('../Presentation', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
jest.mock('../Header', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Footer', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Landing', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../ProjectLandingChip', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Messages/MessagesView', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Input/ChatForm', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Input/ConversationStarters', () => ({ __esModule: true, default: () => <div /> }));
describe('ChatView page heading', () => {
beforeEach(() => {
mockParams.mockReturnValue({});
mockConversation.mockReturnValue(null);
});
test('exposes a single h1 to assistive technology', () => {
render(<ChatView />);
const headings = screen.getAllByRole('heading', { level: 1 });
expect(headings).toHaveLength(1);
});
test('keeps the heading visually hidden', () => {
render(<ChatView />);
expect(screen.getByRole('heading', { level: 1 })).toHaveClass('sr-only');
});
test('announces a localized new chat heading on the landing page', () => {
render(<ChatView />);
expect(screen.getByRole('heading', { level: 1, name: 'New chat' })).toBeInTheDocument();
});
test('uses the conversation title once a conversation is open', () => {
mockParams.mockReturnValue({ conversationId: 'convo-1' });
mockConversation.mockReturnValue({ conversationId: 'convo-1', title: 'Deploy checklist' });
render(<ChatView />);
expect(screen.getByRole('heading', { level: 1, name: 'Deploy checklist' })).toBeInTheDocument();
});
test('falls back to the localized heading when a title is blank', () => {
mockParams.mockReturnValue({ conversationId: 'convo-1' });
mockConversation.mockReturnValue({ conversationId: 'convo-1', title: ' ' });
render(<ChatView />);
expect(screen.getByRole('heading', { level: 1, name: 'New chat' })).toBeInTheDocument();
});
test('prefers the localized heading over a stale title on the landing page', () => {
mockConversation.mockReturnValue({ conversationId: 'new', title: 'New Chat' });
render(<ChatView />);
expect(screen.getByRole('heading', { level: 1, name: 'New chat' })).toBeInTheDocument();
});
test('ignores a Recoil title that belongs to a different conversation than the route', () => {
mockParams.mockReturnValue({ conversationId: 'convo-2' });
mockConversation.mockReturnValue({ conversationId: 'convo-1', title: 'Previous chat' });
render(<ChatView />);
expect(screen.getByRole('heading', { level: 1, name: 'New chat' })).toBeInTheDocument();
expect(
screen.queryByRole('heading', { level: 1, name: 'Previous chat' }),
).not.toBeInTheDocument();
});
});

View file

@ -131,6 +131,7 @@ export default function AgentFooter({
type="submit"
disabled={isSaving}
aria-busy={isSaving}
aria-label={saveLabel}
>
{renderSaveButton()}
</button>

View file

@ -6,11 +6,11 @@ import { SharePointIcon, DropdownPopup } from '@librechat/client';
import { EModelEndpoint, EToolResources, AgentCapabilities } from 'librechat-data-provider';
import type { ExtendedFile, AgentForm } from '~/common';
import { useSharePointFileHandlingNoChatContext } from '~/hooks/Files/useSharePointFileHandling';
import FileRow, { FileRowWrapper } from '~/components/Chat/Input/Files/FileRow';
import { useFileHandlingNoChatContext } from '~/hooks/Files/useFileHandling';
import { useAgentFileConfig, useLocalize, useLazyEffect } from '~/hooks';
import DropzoneContent, { dropzoneClassName } from './UploadDropzone';
import { SharePointPickerDialog } from '~/components/SharePoint';
import FileRow from '~/components/Chat/Input/Files/FileRow';
import { useGetStartupConfig } from '~/data-provider';
import SectionHeader from './SectionHeader';
import { isEphemeralAgent } from '~/common';
@ -142,7 +142,7 @@ function FileSearch({
setFiles={setFiles}
agent_id={agent_id}
tool_resource={EToolResources.file_search}
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
Wrapper={FileRowWrapper}
/>
<div>
{sharePointEnabled ? (

View file

@ -335,6 +335,7 @@ describe('AgentFooter', () => {
});
render(<AgentFooter {...defaultProps} />);
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument();
expect(screen.getByText('Create')).toBeInTheDocument();
expect(screen.queryByTestId('version-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('delete-button')).not.toBeInTheDocument();

View file

@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react';
import { EToolResources, mergeFileConfig, getEndpointFileConfig } from 'librechat-data-provider';
import type { AssistantsEndpoint } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import FileRow from '~/components/Chat/Input/Files/FileRow';
import FileRow, { FileRowWrapper } from '~/components/Chat/Input/Files/FileRow';
import { useGetFileConfig } from '~/data-provider';
import { useFileHandling } from '~/hooks/Files';
import { useChatContext } from '~/Providers';
@ -69,7 +69,7 @@ export default function CodeFiles({
assistant_id={assistant_id}
tool_resource={tool_resource}
setFilesLoading={setFilesLoading}
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
Wrapper={FileRowWrapper}
/>
<div>
<button

View file

@ -52,6 +52,11 @@ module.exports = {
'25%': { transform: 'translateX(-3px)' },
'75%': { transform: 'translateX(3px)' },
},
/** Named distinctly: `blink` is already taken by keyframes in style.css. */
'logo-blink': {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0' },
},
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out forwards',
@ -62,6 +67,7 @@ module.exports = {
'slide-out-left': 'slide-out-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
'slide-out-right': 'slide-out-right 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
'shortcut-shake': 'shortcut-shake 0.25s ease-in-out',
'logo-blink': 'logo-blink 3s infinite',
},
colors: {
gray: {

View file

@ -0,0 +1,172 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, fireEvent } from '@testing-library/react';
import { TooltipAnchor } from './Tooltip';
describe('TooltipAnchor', () => {
describe('role="button" keyboard activation', () => {
/** Renders a non-native element, so Enter and Space must both be handled (WCAG 2.1.1). */
const renderButtonAnchor = (onClick: jest.Mock) => {
render(
<TooltipAnchor
role="button"
tabIndex={0}
description="Do the thing"
aria-label="Do the thing"
onClick={onClick}
>
<span>icon</span>
</TooltipAnchor>,
);
return screen.getByLabelText('Do the thing');
};
test('activates on Enter', () => {
const onClick = jest.fn();
const anchor = renderButtonAnchor(onClick);
fireEvent.keyDown(anchor, { key: 'Enter' });
expect(onClick).toHaveBeenCalledTimes(1);
});
test('activates on Space', () => {
const onClick = jest.fn();
const anchor = renderButtonAnchor(onClick);
fireEvent.keyDown(anchor, { key: ' ' });
expect(onClick).toHaveBeenCalledTimes(1);
});
test('ignores repeated Space keydowns from a held key', () => {
const onClick = jest.fn();
const anchor = renderButtonAnchor(onClick);
fireEvent.keyDown(anchor, { key: ' ' });
fireEvent.keyDown(anchor, { key: ' ', repeat: true });
fireEvent.keyDown(anchor, { key: ' ', repeat: true });
expect(onClick).toHaveBeenCalledTimes(1);
});
test('ignores repeated Enter keydowns from a held key', () => {
const onClick = jest.fn();
const anchor = renderButtonAnchor(onClick);
fireEvent.keyDown(anchor, { key: 'Enter' });
fireEvent.keyDown(anchor, { key: 'Enter', repeat: true });
expect(onClick).toHaveBeenCalledTimes(1);
});
test('prevents default on Space so the page does not scroll', () => {
const onClick = jest.fn();
const anchor = renderButtonAnchor(onClick);
const notCancelled = fireEvent.keyDown(anchor, { key: ' ', cancelable: true });
expect(notCancelled).toBe(false);
});
test('still prevents default on repeated Space keydowns', () => {
const onClick = jest.fn();
const anchor = renderButtonAnchor(onClick);
fireEvent.keyDown(anchor, { key: ' ' });
const notCancelled = fireEvent.keyDown(anchor, {
key: ' ',
repeat: true,
cancelable: true,
});
expect(notCancelled).toBe(false);
expect(onClick).toHaveBeenCalledTimes(1);
});
test('ignores unrelated keys', () => {
const onClick = jest.fn();
const anchor = renderButtonAnchor(onClick);
fireEvent.keyDown(anchor, { key: 'a' });
fireEvent.keyDown(anchor, { key: 'Escape' });
expect(onClick).not.toHaveBeenCalled();
});
test('does not synthesize activation without role="button"', () => {
const onClick = jest.fn();
render(
<TooltipAnchor description="Plain" aria-label="Plain" onClick={onClick}>
<span>icon</span>
</TooltipAnchor>,
);
fireEvent.keyDown(screen.getByLabelText('Plain'), { key: 'Enter' });
fireEvent.keyDown(screen.getByLabelText('Plain'), { key: ' ' });
expect(onClick).not.toHaveBeenCalled();
});
test('defaults tabIndex to 0 when role is button', () => {
render(
<TooltipAnchor role="button" description="Focusable" aria-label="Focusable">
<span>icon</span>
</TooltipAnchor>,
);
expect(screen.getByLabelText('Focusable')).toHaveAttribute('tabindex', '0');
});
test('preserves an explicit tabIndex for role-button anchors', () => {
render(
<TooltipAnchor role="button" tabIndex={-1} description="Deferred" aria-label="Deferred">
<span>icon</span>
</TooltipAnchor>,
);
expect(screen.getByLabelText('Deferred')).toHaveAttribute('tabindex', '-1');
});
});
describe('consumer onKeyDown', () => {
test('is invoked rather than silently overridden', () => {
const onKeyDown = jest.fn();
render(
<TooltipAnchor
role="button"
tabIndex={0}
description="Chained"
aria-label="Chained"
onKeyDown={onKeyDown}
>
<span>icon</span>
</TooltipAnchor>,
);
fireEvent.keyDown(screen.getByLabelText('Chained'), { key: 'Enter' });
expect(onKeyDown).toHaveBeenCalledTimes(1);
});
test('can suppress the built-in activation via preventDefault', () => {
const onClick = jest.fn();
render(
<TooltipAnchor
role="button"
tabIndex={0}
description="Suppressed"
aria-label="Suppressed"
onClick={onClick}
onKeyDown={(event) => event.preventDefault()}
>
<span>icon</span>
</TooltipAnchor>,
);
fireEvent.keyDown(screen.getByLabelText('Suppressed'), { key: 'Enter' });
expect(onClick).not.toHaveBeenCalled();
});
});
});

View file

@ -121,19 +121,38 @@ const TooltipPopup = memo(function TooltipPopup({
export const TooltipAnchor: ForwardRefExoticComponent<
Omit<TooltipAnchorProps, 'ref'> & RefAttributes<HTMLDivElement>
> = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor(
{ description, side = 'top', className, role, enableHTML = false, ...props },
{ description, side = 'top', className, role, enableHTML = false, onKeyDown, tabIndex, ...props },
ref,
) {
const tooltip = Ariakit.useTooltipStore({ placement: side });
/**
* `role="button"` renders a plain element with no native activation, so Enter and
* Space must both be handled to match a real button (WCAG 2.1.1). Space is always
* preventDefault'd (including key-repeat) to suppress page scroll. Activation
* ignores event.repeat so a held Space does not fire click() repeatedly.
*
* Default tabIndex to 0 for role="button" so keyboard users can reach consumers
* that forget an explicit tabIndex (e.g. MCP card actions). Explicit values win.
*/
const resolvedTabIndex = role === 'button' ? (tabIndex ?? 0) : tabIndex;
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (role === 'button' && event.key === 'Enter') {
event.preventDefault();
(event.target as HTMLDivElement).click();
onKeyDown?.(event);
if (role !== 'button' || event.defaultPrevented) {
return;
}
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
if (event.repeat) {
return;
}
event.currentTarget.click();
},
[role],
[role, onKeyDown],
);
return (
@ -142,6 +161,7 @@ export const TooltipAnchor: ForwardRefExoticComponent<
{...props}
ref={ref}
role={role}
tabIndex={resolvedTabIndex}
onKeyDown={handleKeyDown}
className={cn('cursor-pointer', className)}
/>

View file

@ -0,0 +1,23 @@
/**
* Package-local spinner animation. Ships in @librechat/client/style.css so
* consumers do not need the host app's Tailwind `animate-spin` utility.
* Keyframes live in this file (not an embedded <style> tag) so CSS text never
* leaks into ancestor textContent / accessible names.
*/
@keyframes librechat-spinner-rotate {
to {
transform: rotate(360deg);
}
}
.spinner {
transform-origin: center;
overflow: visible;
animation: librechat-spinner-rotate var(--spinner-speed, 0.75s) linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
}
}

View file

@ -1,5 +1,6 @@
import { JSX } from 'react/jsx-runtime';
import { cn } from '~/utils/';
import './Spinner.css';
interface SpinnerProps {
className?: string;
@ -9,6 +10,14 @@ interface SpinnerProps {
speed?: number;
}
/**
* Accessible loading spinner.
*
* Animation is defined in Spinner.css (extracted into the package style bundle),
* never an embedded <style> tag: stylesheet text inside the SVG becomes part of
* the ancestor's textContent, leaking raw CSS into label readouts of any control
* that wraps a spinner.
*/
export default function Spinner({
className = 'm-auto',
size = 20,
@ -29,20 +38,9 @@ export default function Spinner({
xmlns="http://www.w3.org/2000/svg"
style={cssVars}
aria-hidden="true"
focusable="false"
role="presentation"
>
<defs>
<style type="text/css">{`
.spinner {
transform-origin: center;
overflow: visible;
animation: spinner-rotate var(--spinner-speed) linear infinite;
}
@keyframes spinner-rotate {
to { transform: rotate(360deg); }
}
`}</style>
</defs>
<circle
cx="20"
cy="20"