mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-29 05:20:49 +00:00
♿️ 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:
parent
22642df40c
commit
26ba2c2954
18 changed files with 510 additions and 51 deletions
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
|||
42
client/src/components/Auth/__tests__/BlinkAnimation.spec.tsx
Normal file
42
client/src/components/Auth/__tests__/BlinkAnimation.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
112
client/src/components/Chat/__tests__/ChatView.spec.tsx
Normal file
112
client/src/components/Chat/__tests__/ChatView.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -131,6 +131,7 @@ export default function AgentFooter({
|
|||
type="submit"
|
||||
disabled={isSaving}
|
||||
aria-busy={isSaving}
|
||||
aria-label={saveLabel}
|
||||
>
|
||||
{renderSaveButton()}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
172
packages/client/src/components/Tooltip.spec.tsx
Normal file
172
packages/client/src/components/Tooltip.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
23
packages/client/src/svgs/Spinner.css
Normal file
23
packages/client/src/svgs/Spinner.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue