From 5ed1f2991e7a102b170f7e96d7b9d9805d95bd15 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 26 Nov 2025 06:12:04 -0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=97=20fix:=20Address=20Accessibility?= =?UTF-8?q?=20Issues=20-=20Axe=20Rating:=20Serious=20(#10521)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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

rather than generic
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 --- client/src/common/types.ts | 2 + client/src/components/Auth/Footer.tsx | 4 +- client/src/components/Auth/LoginForm.tsx | 2 +- .../Auth/__tests__/Registration.spec.tsx | 16 +- .../src/components/Bookmarks/BookmarkForm.tsx | 11 +- client/src/components/Chat/Header.tsx | 46 +++-- .../components/Chat/Input/AudioRecorder.tsx | 3 +- .../Chat/Input/Files/Table/DataTable.tsx | 18 +- .../components/Chat/Input/PromptsCommand.tsx | 10 +- .../Chat/Menus/Endpoints/CustomMenu.tsx | 25 ++- .../Chat/Menus/Endpoints/ModelSelector.tsx | 30 ++- .../Endpoints/components/EndpointItem.tsx | 3 +- .../Endpoints/components/SearchResults.tsx | 16 +- .../src/components/Chat/Menus/OpenSidebar.tsx | 43 ++-- .../Chat/Messages/Content/ProgressText.tsx | 1 + .../Conversations/Conversations.tsx | 7 +- client/src/components/Conversations/Convo.tsx | 4 +- .../ConvoOptions/ConvoOptions.tsx | 14 +- .../ConvoOptions/DeleteButton.tsx | 2 +- .../ConvoOptions/ShareButton.tsx | 19 +- .../ConvoOptions/SharedLinkButton.tsx | 36 ++-- .../components/Messages/Content/CodeBlock.tsx | 2 +- .../components/Messages/Content/RunCode.tsx | 2 +- client/src/components/Nav/MobileNav.tsx | 13 +- client/src/components/Nav/Nav.tsx | 60 +++--- client/src/components/Nav/NewChat.tsx | 15 +- client/src/components/Nav/Settings.tsx | 4 +- .../Account/DisableTwoFactorToggle.tsx | 5 + .../Account/TwoFactorAuthentication.tsx | 6 +- .../Nav/SettingsTabs/Data/SharedLinks.tsx | 43 ++-- .../General/ArchivedChatsTable.tsx | 3 +- .../src/components/Prompts/AdvancedSwitch.tsx | 8 +- client/src/components/Prompts/Command.tsx | 42 ++-- client/src/components/Prompts/Description.tsx | 42 ++-- .../Prompts/Groups/ChatGroupItem.tsx | 52 +++-- .../Prompts/Groups/CreatePromptForm.tsx | 15 +- .../Prompts/Groups/DashGroupItem.tsx | 193 ++++++++++-------- .../Prompts/Groups/FilterPrompts.tsx | 30 ++- .../Prompts/Groups/GroupSidePanel.tsx | 48 ++++- .../components/Prompts/Groups/ListCard.tsx | 2 +- .../Prompts/Groups/VariableForm.tsx | 42 ++-- .../src/components/Prompts/PreviewPrompt.tsx | 7 +- .../components/Prompts/PromptVariables.tsx | 2 +- client/src/components/Prompts/PromptsView.tsx | 72 ++++++- .../SidePanel/Agents/Code/Action.tsx | 47 +++-- .../SidePanel/Agents/FileSearchCheckbox.tsx | 34 ++- .../SidePanel/Agents/Search/Action.tsx | 50 +++-- .../SidePanel/Bookmarks/BookmarkTable.tsx | 12 +- .../components/SidePanel/Files/PanelTable.tsx | 12 +- .../SidePanel/Memories/MemoryCreateDialog.tsx | 2 +- .../SidePanel/Memories/MemoryViewer.tsx | 2 +- client/src/locales/en/translation.json | 21 +- client/src/routes/Layouts/DashBreadcrumb.tsx | 32 ++- client/src/routes/Root.tsx | 22 +- packages/client/src/common/menus.ts | 11 + .../src/components/AnimatedSearchInput.tsx | 7 +- packages/client/src/components/DataTable.tsx | 38 +++- .../client/src/components/DropdownPopup.tsx | 2 + .../client/src/components/OriginalDialog.tsx | 4 +- .../client/src/components/ThemeSelector.tsx | 25 ++- packages/client/src/components/Toast.tsx | 2 +- .../client/src/locales/en/translation.json | 6 +- 62 files changed, 935 insertions(+), 414 deletions(-) diff --git a/client/src/common/types.ts b/client/src/common/types.ts index bb3bdcfa6d..9b0a21098a 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -568,6 +568,8 @@ export interface ModelItemProps { export type ContextType = { navVisible: boolean; setNavVisible: React.Dispatch>; + openSidebarRef?: React.RefObject; + closeSidebarRef?: React.RefObject; }; export interface SwitcherProps { diff --git a/client/src/components/Auth/Footer.tsx b/client/src/components/Auth/Footer.tsx index 8d79717683..1387523779 100644 --- a/client/src/components/Auth/Footer.tsx +++ b/client/src/components/Auth/Footer.tsx @@ -11,7 +11,7 @@ function Footer({ startupConfig }: { startupConfig: TStartupConfig | null | unde const privacyPolicyRender = privacyPolicy?.externalUrl && ( = ({ onSubmit, startupConfig, error, const renderError = (fieldName: string) => { const errorMessage = errors[fieldName]?.message; return errorMessage ? ( - + {String(errorMessage)} ) : null; diff --git a/client/src/components/Auth/__tests__/Registration.spec.tsx b/client/src/components/Auth/__tests__/Registration.spec.tsx index 72a21b63b6..a1211ae6be 100644 --- a/client/src/components/Auth/__tests__/Registration.spec.tsx +++ b/client/src/components/Auth/__tests__/Registration.spec.tsx @@ -191,12 +191,16 @@ test('shows validation error messages', async () => { await userEvent.type(getByTestId('password'), 'pass'); await userEvent.type(getByTestId('confirm_password'), 'password1'); const alerts = getAllByRole('alert'); - expect(alerts).toHaveLength(5); - expect(alerts[0]).toHaveTextContent(/Name must be at least 3 characters/i); - expect(alerts[1]).toHaveTextContent(/Username must be at least 2 characters/i); - expect(alerts[2]).toHaveTextContent(/You must enter a valid email address/i); - expect(alerts[3]).toHaveTextContent(/Password must be at least 8 characters/i); - expect(alerts[4]).toHaveTextContent(/Passwords do not match/i); + expect(alerts).toHaveLength(6); + + // This first alert is for the theme toggle, which is empty within this test but still picked up by getAllByRole as an alert + expect(alerts[0]).toHaveTextContent(''); + + expect(alerts[1]).toHaveTextContent(/Name must be at least 3 characters/i); + expect(alerts[2]).toHaveTextContent(/Username must be at least 2 characters/i); + expect(alerts[3]).toHaveTextContent(/You must enter a valid email address/i); + expect(alerts[4]).toHaveTextContent(/Password must be at least 8 characters/i); + expect(alerts[5]).toHaveTextContent(/Passwords do not match/i); }); test('shows error message when registration fails', async () => { diff --git a/client/src/components/Bookmarks/BookmarkForm.tsx b/client/src/components/Bookmarks/BookmarkForm.tsx index bd8ffd2d76..a99b95ab96 100644 --- a/client/src/components/Bookmarks/BookmarkForm.tsx +++ b/client/src/components/Bookmarks/BookmarkForm.tsx @@ -100,9 +100,7 @@ const BookmarkForm = ({ - {errors.tag && {errors.tag.message}} + {errors.tag && ( + + {errors.tag.message} + + )}
diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx index 5025307020..d1e20f8ad8 100644 --- a/client/src/components/Chat/Header.tsx +++ b/client/src/components/Chat/Header.tsx @@ -17,7 +17,8 @@ const defaultInterface = getConfigDefaults().interface; export default function Header() { const { data: startupConfig } = useGetStartupConfig(); - const { navVisible, setNavVisible } = useOutletContext(); + const { navVisible, setNavVisible, openSidebarRef, closeSidebarRef } = + useOutletContext(); const interfaceConfig = useMemo( () => startupConfig?.interface ?? defaultInterface, @@ -50,27 +51,38 @@ export default function Header() { transition={{ duration: 0.2 }} key="header-buttons" > - + )} - -
- - {interfaceConfig.presets === true && interfaceConfig.modelSelect && } - {hasAccessToBookmarks === true && } - {hasAccessToMultiConvo === true && } - {isSmallScreen && ( - <> - - - - )} -
+ {!isSmallScreen && !navVisible && ( +
+ + {interfaceConfig.presets === true && interfaceConfig.modelSelect && } + {hasAccessToBookmarks === true && } + {hasAccessToMultiConvo === true && } + {isSmallScreen && ( + <> + + + + )} +
+ )}
+ {!isSmallScreen && (
{ if (isListening === true) { - return ; + return ; } if (isLoading === true) { return ; diff --git a/client/src/components/Chat/Input/Files/Table/DataTable.tsx b/client/src/components/Chat/Input/Files/Table/DataTable.tsx index db02ff93d1..7cf2909841 100644 --- a/client/src/components/Chat/Input/Files/Table/DataTable.tsx +++ b/client/src/components/Chat/Input/Files/Table/DataTable.tsx @@ -114,12 +114,18 @@ export default function DataTable({ columns, data }: DataTablePro )} {!isSmallScreen && {localize('com_ui_delete')}} - table.getColumn('filename')?.setFilterValue(event.target.value)} - className="flex-1 text-sm" - /> +
+ table.getColumn('filename')?.setFilterValue(event.target.value)} + className="peer w-full text-sm" + aria-label={localize('com_files_filter_input')} + /> + +
+ description={localize('com_ui_select_model')} + render={ + + } + /> ); return ( @@ -84,7 +91,8 @@ function ModelSelectorContent() { }); }} onSearch={(value) => setSearchValue(value)} - combobox={} + combobox={} + comboboxLabel={localize('com_endpoint_search_models')} trigger={trigger} > {searchResults ? ( diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx index dba26ef707..d68bd240a2 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx @@ -127,7 +127,8 @@ export function EndpointItem({ endpoint }: EndpointItemProps) { defaultOpen={endpoint.value === selectedEndpoint} searchValue={searchValue} onSearch={(value) => setEndpointSearchValue(endpoint.value, value)} - combobox={} + combobox={} + comboboxLabel={placeholder} label={
handleSelectEndpoint(endpoint)} diff --git a/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx b/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx index ffefbc44d4..0a3ee7e497 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx @@ -34,14 +34,24 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP } if (!results.length) { return ( -
- {localize('com_files_no_results')} -
+ <> +
+ {localize('com_files_no_results')} +
+
+ {localize('com_files_no_results')} +
+ ); } return ( <> +
+ {results.length === 1 + ? localize('com_files_result_found', { count: results.length }) + : localize('com_files_results_found', { count: results.length })} +
{results.map((item, i) => { if ('name' in item && 'label' in item) { // Render model spec diff --git a/client/src/components/Chat/Menus/OpenSidebar.tsx b/client/src/components/Chat/Menus/OpenSidebar.tsx index 8f25975d6b..5be467bb0c 100644 --- a/client/src/components/Chat/Menus/OpenSidebar.tsx +++ b/client/src/components/Chat/Menus/OpenSidebar.tsx @@ -1,38 +1,53 @@ +import { forwardRef } from 'react'; import { TooltipAnchor, Button, Sidebar } from '@librechat/client'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; -export default function OpenSidebar({ - setNavVisible, - className, -}: { - setNavVisible: React.Dispatch>; - className?: string; -}) { +const OpenSidebar = forwardRef< + HTMLButtonElement, + { + setNavVisible: React.Dispatch>; + className?: string; + closeSidebarRef?: React.RefObject; + } +>(({ setNavVisible, className, closeSidebarRef }, ref) => { const localize = useLocalize(); + + const handleClick = () => { + setNavVisible((prev) => { + localStorage.setItem('navVisible', JSON.stringify(!prev)); + return !prev; + }); + requestAnimationFrame(() => { + closeSidebarRef?.current?.focus(); + }); + }; + return ( - setNavVisible((prev) => { - localStorage.setItem('navVisible', JSON.stringify(!prev)); - return !prev; - }) - } + onClick={handleClick} >

); }); @@ -150,7 +150,7 @@ const Conversations: FC = ({ return ( {({ registerChild }) => ( -
+
{rendering}
)} @@ -199,6 +199,7 @@ const Conversations: FC = ({ rowHeight={getRowHeight} rowRenderer={rowRenderer} overscanRowCount={10} + aria-readonly={false} className="outline-none" style={{ outline: 'none' }} aria-label="Conversations" diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index d44ef58ed8..1a43c23332 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -191,7 +191,9 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co ? 'pointer-events-auto max-w-[28px] scale-x-100 opacity-100' : 'pointer-events-none max-w-0 scale-x-0 opacity-0 group-focus-within:pointer-events-auto group-focus-within:max-w-[28px] group-focus-within:scale-x-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:max-w-[28px] group-hover:scale-x-100 group-hover:opacity-100', )} - aria-hidden={!(isPopoverActive || isActiveConvo)} + // Removing aria-hidden to fix accessibility issue: ARIA hidden element must not be focusable or contain focusable elements + // but not sure what its original purpose was, so leaving the property commented out until it can be cleared safe to delete. + // aria-hidden={!(isPopoverActive || isActiveConvo)} > {!renaming && }
diff --git a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx index 46b1b7d2f7..ace858ff06 100644 --- a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx +++ b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx @@ -47,6 +47,7 @@ function ConvoOptions({ const deleteButtonRef = useRef(null); const [showShareDialog, setShowShareDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [announcement, setAnnouncement] = useState(''); const archiveConvoMutation = useArchiveConvoMutation(); @@ -94,6 +95,10 @@ function ConvoOptions({ { conversationId: convoId, isArchived: true }, { onSuccess: () => { + setAnnouncement(localize('com_ui_convo_archived')); + setTimeout(() => { + setAnnouncement(''); + }, 10000); if (currentConvoId === convoId || currentConvoId === 'new') { newConversation(); navigate('/c/new', { replace: true }); @@ -137,7 +142,8 @@ function ConvoOptions({ show: startupConfig && startupConfig.sharedLinksEnabled, hideOnClick: false, ref: shareButtonRef, - render: (props) => + <> + + {announcement} + + + )} /> diff --git a/client/src/components/Messages/Content/CodeBlock.tsx b/client/src/components/Messages/Content/CodeBlock.tsx index 8ed21790af..9ac31beb0d 100644 --- a/client/src/components/Messages/Content/CodeBlock.tsx +++ b/client/src/components/Messages/Content/CodeBlock.tsx @@ -36,7 +36,7 @@ const CodeBar: React.FC = React.memo( -
+
void; disabled?: boolean; + buttonRef?: React.RefObject; } export const DisableTwoFactorToggle: React.FC = ({ enabled, onChange, disabled, + buttonRef, }) => { const localize = useLocalize(); @@ -24,9 +26,12 @@ export const DisableTwoFactorToggle: React.FC = ({
diff --git a/client/src/components/Nav/SettingsTabs/Account/TwoFactorAuthentication.tsx b/client/src/components/Nav/SettingsTabs/Account/TwoFactorAuthentication.tsx index 21cd6a980b..072a2b790e 100644 --- a/client/src/components/Nav/SettingsTabs/Account/TwoFactorAuthentication.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/TwoFactorAuthentication.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useState, useRef } from 'react'; import { useSetRecoilState } from 'recoil'; import { SmartphoneIcon } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; @@ -35,6 +35,7 @@ const TwoFactorAuthentication: React.FC = () => { const { user } = useAuthContext(); const setUser = useSetRecoilState(store.user); const { showToast } = useToastContext(); + const buttonRef = useRef(null); const [secret, setSecret] = useState(''); const [otpauthUrl, setOtpauthUrl] = useState(''); @@ -197,16 +198,19 @@ const TwoFactorAuthentication: React.FC = () => { resetState(); } }} + triggerRef={buttonRef} > setDialogOpen(true)} disabled={isVerifying || isDisabling || isGenerating} + buttonRef={buttonRef} /> handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc') } + aria-label={localize('com_ui_name_sort')} > {localize('com_ui_name')} {isSorted && sortDirection === 'asc' && ( @@ -189,10 +197,14 @@ export default function SharedLinks() { to={`/share/${shareId}`} target="_blank" rel="noopener noreferrer" - className="block truncate text-blue-500 hover:underline" + className="group flex items-center gap-1 truncate rounded-sm text-blue-600 underline decoration-1 underline-offset-2 hover:decoration-2 focus:outline-none focus:ring-2 focus:ring-ring" title={title} > - {title} + {title} +
); @@ -214,6 +226,7 @@ export default function SharedLinks() { onClick={() => handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc') } + aria-label={localize('com_ui_creation_date_sort')} > {localize('com_ui_date')} {isSorted && sortDirection === 'asc' && ( @@ -245,18 +258,15 @@ export default function SharedLinks() { }, cell: ({ row }) => (
- + @@ -318,7 +330,10 @@ export default function SharedLinks() { className="max-w-[450px]" main={ <> -
+ ); }; diff --git a/client/src/components/Prompts/Groups/ChatGroupItem.tsx b/client/src/components/Prompts/Groups/ChatGroupItem.tsx index 43efdd8c99..e0faa95180 100644 --- a/client/src/components/Prompts/Groups/ChatGroupItem.tsx +++ b/client/src/components/Prompts/Groups/ChatGroupItem.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, memo } from 'react'; +import { useState, useMemo, memo, useRef } from 'react'; import { Menu as MenuIcon, Edit as EditIcon, EarthIcon, TextSearch } from 'lucide-react'; import { DropdownMenu, @@ -37,6 +37,8 @@ function ChatGroupItem({ const { hasPermission } = useResourcePermissions('promptGroup', group._id || ''); const canEdit = hasPermission(PermissionBits.EDIT); + const triggerButtonRef = useRef(null); + const onCardClick: React.MouseEventHandler = () => { const text = group.productionPrompt?.prompt; if (!text?.trim()) { @@ -53,23 +55,28 @@ function ChatGroupItem({ return ( <> - 0 - ? group.oneliner - : (group.productionPrompt?.prompt ?? '') - } - > -
- {groupIsGlobal === true && ( - - )} +
+ 0 + ? group.oneliner + : (group.productionPrompt?.prompt ?? '') + } + > +
+ {groupIsGlobal === true && ( + + )} +
+
+
@@ -127,8 +134,17 @@ function ChatGroupItem({
- - +
+ { + requestAnimationFrame(() => { + triggerButtonRef.current?.focus({ preventScroll: true }); + }); + }} + /> setVariableDialogOpen(false)} diff --git a/client/src/components/Prompts/Groups/CreatePromptForm.tsx b/client/src/components/Prompts/Groups/CreatePromptForm.tsx index 0db5864c7c..3f94932c68 100644 --- a/client/src/components/Prompts/Groups/CreatePromptForm.tsx +++ b/client/src/components/Prompts/Groups/CreatePromptForm.tsx @@ -104,6 +104,7 @@ const CreatePromptForm = ({ return (
+

{localize('com_ui_create_prompt_page')}

( -
+
+
) => { - if (e.key === 'Enter') { + (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/d/prompts/${group._id}`, { replace: true }); } @@ -82,16 +82,26 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps return (
-
+ - - -
- setNameInputValue(e.target.value)} - className="w-full" - aria-label={localize('com_ui_rename_prompt_name', { name: group.name })} - /> -
-
- } - selection={{ - selectHandler: handleSaveRename, - selectClasses: - 'bg-surface-submit hover:bg-surface-submit-hover text-white disabled:hover:bg-surface-submit', - selectText: localize('com_ui_save'), - isLoading, - }} - /> - - )} - - {canDelete && ( - - - - - -
- -
-
- } - selection={{ - selectHandler: triggerDelete, - selectClasses: - 'bg-red-600 dark:bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white', - selectText: localize('com_ui_delete'), - }} - /> - - )}
+ + +
+ {canEdit && ( + + + + + +
+ setNameInputValue(e.target.value)} + className="w-full" + aria-label={localize('com_ui_rename_prompt_name', { name: group.name })} + /> +
+
+ } + selection={{ + selectHandler: handleSaveRename, + selectClasses: + 'bg-surface-submit hover:bg-surface-submit-hover text-white disabled:hover:bg-surface-submit', + selectText: localize('com_ui_save'), + isLoading, + }} + /> + + )} + + {canDelete && ( + + + + + +
+ +
+
+ } + selection={{ + selectHandler: triggerDelete, + selectClasses: + 'bg-red-600 dark:bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white', + selectText: localize('com_ui_delete'), + }} + /> + + )}
); diff --git a/client/src/components/Prompts/Groups/FilterPrompts.tsx b/client/src/components/Prompts/Groups/FilterPrompts.tsx index f74800bdf8..2c3151c0e7 100644 --- a/client/src/components/Prompts/Groups/FilterPrompts.tsx +++ b/client/src/components/Prompts/Groups/FilterPrompts.tsx @@ -4,6 +4,7 @@ import { ListFilter, User, Share2 } from 'lucide-react'; import { SystemCategories } from 'librechat-data-provider'; import { Dropdown, AnimatedSearchInput } from '@librechat/client'; import type { Option } from '~/common'; +import type { TranslationKeys } from '~/hooks'; import { useLocalize, useCategories } from '~/hooks'; import { usePromptGroupsContext } from '~/Providers'; import { cn } from '~/utils'; @@ -11,11 +12,12 @@ import store from '~/store'; export default function FilterPrompts({ className = '' }: { className?: string }) { const localize = useLocalize(); - const { name, setName, hasAccess } = usePromptGroupsContext(); + const { name, setName, hasAccess, promptGroups } = usePromptGroupsContext(); const { categories } = useCategories({ className: 'h-4 w-4', hasAccess }); const [displayName, setDisplayName] = useState(name || ''); const [isSearching, setIsSearching] = useState(false); const [categoryFilter, setCategory] = useRecoilState(store.promptsCategory); + const [searchResultsAnnouncement, setSearchResultsAnnouncement] = useState(''); const filterOptions = useMemo(() => { const baseOptions: Option[] = [ @@ -81,8 +83,34 @@ export default function FilterPrompts({ className = '' }: { className?: string } return () => clearTimeout(timeout); }, [displayName, setName]); + useEffect(() => { + if (!displayName.trim() || isSearching) { + setSearchResultsAnnouncement(''); + return; + } + + const resultCount = promptGroups?.length ?? 0; + 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); + }, [promptGroups?.length, displayName, isSearching, localize]); + return (
+
+ {searchResultsAnnouncement} +
; + onClose?: () => void; }) { const location = useLocation(); - const isSmallerScreen = useMediaQuery('(max-width: 1024px)'); + const localize = useLocalize(); const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]); const { promptGroups, groupsQuery, nextPage, prevPage, hasNextPage, hasPreviousPage } = @@ -25,15 +28,42 @@ export default function GroupSidePanel({ return (
- {children} -
- + {onClose && ( +
+ + + + } + /> +
+ )} +
+ {children} +
+ +
-
+
{fields.map((field, index) => ( -
+
+ <> + + + ); }} /> diff --git a/client/src/components/Prompts/PreviewPrompt.tsx b/client/src/components/Prompts/PreviewPrompt.tsx index 6193bd9e5d..bee4eb09f6 100644 --- a/client/src/components/Prompts/PreviewPrompt.tsx +++ b/client/src/components/Prompts/PreviewPrompt.tsx @@ -6,14 +6,19 @@ const PreviewPrompt = ({ group, open, onOpenChange, + onCloseAutoFocus, }: { group: TPromptGroup; open: boolean; onOpenChange: (open: boolean) => void; + onCloseAutoFocus?: () => void; }) => { return ( - +
diff --git a/client/src/components/Prompts/PromptVariables.tsx b/client/src/components/Prompts/PromptVariables.tsx index c08ae8cd87..fbb956e844 100644 --- a/client/src/components/Prompts/PromptVariables.tsx +++ b/client/src/components/Prompts/PromptVariables.tsx @@ -74,7 +74,7 @@ const PromptVariables = ({ {localize('com_ui_dropdown_variables')} - + {localize('com_ui_dropdown_variables_info')} diff --git a/client/src/components/Prompts/PromptsView.tsx b/client/src/components/Prompts/PromptsView.tsx index acea8da54e..d605038281 100644 --- a/client/src/components/Prompts/PromptsView.tsx +++ b/client/src/components/Prompts/PromptsView.tsx @@ -1,17 +1,24 @@ -import { useMemo, useEffect } from 'react'; +import { useMemo, useEffect, useState, useCallback, useRef } from 'react'; import { Outlet, useParams, useNavigate } from 'react-router-dom'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts'; import DashBreadcrumb from '~/routes/Layouts/DashBreadcrumb'; import GroupSidePanel from './Groups/GroupSidePanel'; +import { useHasAccess, useLocalize } from '~/hooks'; import { PromptGroupsProvider } from '~/Providers'; -import { useHasAccess } from '~/hooks'; +import { useMediaQuery } from '@librechat/client'; import { cn } from '~/utils'; export default function PromptsView() { const params = useParams(); const navigate = useNavigate(); const isDetailView = useMemo(() => !!(params.promptId || params['*'] === 'new'), [params]); + const isSmallerScreen = useMediaQuery('(max-width: 768px)'); + const [panelVisible, setPanelVisible] = useState(!isSmallerScreen); + const openPanelRef = useRef(null); + const closePanelRef = useRef(null); + const localize = useLocalize(); + const hasAccess = useHasAccess({ permissionType: PermissionTypes.PROMPTS, permission: Permissions.USE, @@ -29,6 +36,26 @@ export default function PromptsView() { }; }, [hasAccess, navigate]); + const togglePanel = useCallback(() => { + setPanelVisible((prev) => { + const newValue = !prev; + requestAnimationFrame(() => { + if (newValue) { + closePanelRef?.current?.focus(); + } else { + openPanelRef?.current?.focus(); + } + }); + return newValue; + }); + }, []); + + useEffect(() => { + if (isSmallerScreen && isDetailView) { + setPanelVisible(false); + } + }, [isSmallerScreen, isDetailView]); + if (!hasAccess) { return null; } @@ -36,13 +63,42 @@ export default function PromptsView() { return (
- -
- -
- + +
+ {isSmallerScreen && panelVisible && isDetailView && ( +
+ )} + + {(!isSmallerScreen || !isDetailView || panelVisible) && ( +
+ +
+ +
+
- + )} +
(); - const { control, setValue, getValues } = methods; + const { control, setValue } = methods; + const apiKeyButtonRef = useRef(null); const { onSubmit, isDialogOpen, @@ -27,9 +29,11 @@ export default function Action({ authType = '', isToolAuthenticated = false }) { } = useCodeApiKeyForm({ onSubmit: () => { setValue(AgentCapabilities.execute_code, true, { shouldDirty: true }); + setTimeout(() => apiKeyButtonRef.current?.focus(), 100); }, onRevoke: () => { setValue(AgentCapabilities.execute_code, false, { shouldDirty: true }); + setTimeout(() => apiKeyButtonRef.current?.focus(), 100); }, }); @@ -56,42 +60,44 @@ export default function Action({ authType = '', isToolAuthenticated = false }) { render={({ field }) => ( )} /> - + {localize('com_ui_run_code')} +
- {isUserProvided && (isToolAuthenticated || runCodeIsEnabled) && ( + {isUserProvided && ( )} - - + +
@@ -114,6 +120,7 @@ export default function Action({ authType = '', isToolAuthenticated = false }) { isToolAuthenticated={isToolAuthenticated} handleSubmit={keyFormMethods.handleSubmit} isUserProvided={authType === AuthType.USER_PROVIDED} + triggerRef={apiKeyButtonRef} /> ); diff --git a/client/src/components/SidePanel/Agents/FileSearchCheckbox.tsx b/client/src/components/SidePanel/Agents/FileSearchCheckbox.tsx index 652e918e28..ad025d893a 100644 --- a/client/src/components/SidePanel/Agents/FileSearchCheckbox.tsx +++ b/client/src/components/SidePanel/Agents/FileSearchCheckbox.tsx @@ -16,7 +16,7 @@ import { ESide } from '~/common'; function FileSearchCheckbox() { const localize = useLocalize(); const methods = useFormContext(); - const { control, setValue, getValues } = methods; + const { control } = methods; return ( <> @@ -28,33 +28,31 @@ function FileSearchCheckbox() { render={({ field }) => ( )} /> - + +
diff --git a/client/src/components/SidePanel/Agents/Search/Action.tsx b/client/src/components/SidePanel/Agents/Search/Action.tsx index 30037d4883..d71d0878fa 100644 --- a/client/src/components/SidePanel/Agents/Search/Action.tsx +++ b/client/src/components/SidePanel/Agents/Search/Action.tsx @@ -1,4 +1,5 @@ import { KeyRoundIcon } from 'lucide-react'; +import { useRef } from 'react'; import { AuthType, AgentCapabilities } from 'librechat-data-provider'; import { useFormContext, Controller, useWatch } from 'react-hook-form'; import { @@ -23,7 +24,8 @@ export default function Action({ }) { const localize = useLocalize(); const methods = useFormContext(); - const { control, setValue, getValues } = methods; + const { control, setValue } = methods; + const apiKeyButtonRef = useRef(null); const { onSubmit, isDialogOpen, @@ -33,9 +35,11 @@ export default function Action({ } = useSearchApiKeyForm({ onSubmit: () => { setValue(AgentCapabilities.web_search, true, { shouldDirty: true }); + setTimeout(() => apiKeyButtonRef.current?.focus(), 100); }, onRevoke: () => { setValue(AgentCapabilities.web_search, false, { shouldDirty: true }); + setTimeout(() => apiKeyButtonRef.current?.focus(), 100); }, }); @@ -62,6 +66,7 @@ export default function Action({ render={({ field }) => ( )} /> - + {localize('com_ui_web_search')} +
- {isUserProvided && (isToolAuthenticated || webSearchIsEnabled) && ( - )} - - + +
@@ -116,6 +125,7 @@ export default function Action({ register={keyFormMethods.register} isToolAuthenticated={isToolAuthenticated} handleSubmit={keyFormMethods.handleSubmit} + triggerRef={apiKeyButtonRef} /> ); diff --git a/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx b/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx index 8565e89222..94fb4271a1 100644 --- a/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx +++ b/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx @@ -66,13 +66,21 @@ const BookmarkTable = () => { return (
-
+
setSearchQuery(e.target.value)} aria-label={localize('com_ui_bookmarks_filter')} + className="peer" /> +
diff --git a/client/src/components/SidePanel/Files/PanelTable.tsx b/client/src/components/SidePanel/Files/PanelTable.tsx index abd458d034..a33e8d0e48 100644 --- a/client/src/components/SidePanel/Files/PanelTable.tsx +++ b/client/src/components/SidePanel/Files/PanelTable.tsx @@ -182,13 +182,21 @@ export default function DataTable({ columns, data }: DataTablePro return (
-
+
table.getColumn('filename')?.setFilterValue(event.target.value)} aria-label={localize('com_files_filter')} + className="peer" /> +
diff --git a/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx b/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx index a3bea3769e..b4e34652d6 100644 --- a/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx +++ b/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx @@ -108,7 +108,7 @@ export default function MemoryCreateDialog({
diff --git a/client/src/components/SidePanel/Memories/MemoryViewer.tsx b/client/src/components/SidePanel/Memories/MemoryViewer.tsx index a1d88630f9..2fbe2e0a97 100644 --- a/client/src/components/SidePanel/Memories/MemoryViewer.tsx +++ b/client/src/components/SidePanel/Memories/MemoryViewer.tsx @@ -301,7 +301,7 @@ export default function MemoryViewer() {
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 011a19978a..a873753db7 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -378,9 +378,12 @@ "com_files_downloading": "Downloading Files", "com_files_filter": "Filter files...", "com_files_filter_by": "Filter files by...", + "com_files_filter_input": "Filter listed files by name...", "com_files_no_results": "No results.", "com_files_number_selected": "{{0}} of {{1}} items(s) selected", "com_files_preparing_download": "Preparing download...", + "com_files_result_found": "{{count}} result found", + "com_files_results_found": "{{count}} results found", "com_files_sharepoint_picker_title": "Pick Files", "com_files_table": "something needs to go here. was empty", "com_files_upload_local_machine": "From Local Computer", @@ -392,6 +395,7 @@ "com_nav_account_settings": "Account Settings", "com_nav_always_make_prod": "Always make new versions production", "com_nav_archive_created_at": "Date Archived", + "com_nav_archive_created_at_sort": "Sort by Date Archived", "com_nav_archive_name": "Name", "com_nav_archived_chats": "Archived chats", "com_nav_at_command": "@-Command", @@ -568,6 +572,7 @@ "com_nav_theme_dark": "Dark", "com_nav_theme_light": "Light", "com_nav_theme_system": "System", + "com_nav_toggle_sidebar": "Toggle sidebar", "com_nav_tool_dialog": "Assistant Tools", "com_nav_tool_dialog_agents": "Agent Tools", "com_nav_tool_dialog_description": "Assistant must be saved to persist tool selections.", @@ -619,7 +624,8 @@ "com_ui_action_button": "Action Button", "com_ui_active": "Active", "com_ui_add": "Add", - "com_ui_add_api_key": "Add API Key", + "com_ui_add_code_interpreter_api_key": "Add Code Interpreter API Key", + "com_ui_add_web_search_api_keys": "Add Web Search API Keys", "com_ui_add_mcp": "Add MCP", "com_ui_add_mcp_server": "Add MCP Server", "com_ui_add_model_preset": "Add a model or preset for an additional response", @@ -794,6 +800,7 @@ "com_ui_controls": "Controls", "com_ui_contact_admin_if_issue_persists": "Contact the Admin if the issue persists", "com_ui_conversation_label": "{{title}} conversation", + "com_ui_convo_archived": "Conversation archived", "com_ui_convo_delete_error": "Failed to delete conversation", "com_ui_convo_delete_success": "Conversation successfully deleted", "com_ui_copied": "Copied!", @@ -811,7 +818,9 @@ "com_ui_create_memory": "Create Memory", "com_ui_create_new_agent": "Create New Agent", "com_ui_create_prompt": "Create Prompt", + "com_ui_create_prompt_page": "New Prompt Configuration Page", "com_ui_creating_image": "Creating image. May take a moment", + "com_ui_creation_date_sort": "Sort by Creation Date", "com_ui_current": "Current", "com_ui_currently_production": "Currently in production", "com_ui_custom": "Custom", @@ -1007,6 +1016,8 @@ "com_ui_librechat_code_api_key": "Get your LibreChat Code Interpreter API key", "com_ui_librechat_code_api_subtitle": "Secure. Multi-language. Input/Output Files.", "com_ui_librechat_code_api_title": "Run AI Code", + "com_ui_link_copied": "Link copied", + "com_ui_link_refreshed": "Link refreshed", "com_ui_loading": "Loading...", "com_ui_locked": "Locked", "com_ui_logo": "{{0}} Logo", @@ -1059,6 +1070,7 @@ "com_ui_more_info": "More info", "com_ui_my_prompts": "My Prompts", "com_ui_name": "Name", + "com_ui_name_sort": "Sort by Name", "com_ui_new": "New", "com_ui_new_chat": "New chat", "com_ui_new_conversation_title": "New Conversation Title", @@ -1110,6 +1122,8 @@ "com_ui_privacy_policy": "Privacy policy", "com_ui_privacy_policy_url": "Privacy Policy URL", "com_ui_prompt": "Prompt", + "com_ui_prompt_group_button": "{{name}} prompt, {{category}} category", + "com_ui_prompt_group_button_no_category": "{{name}} prompt", "com_ui_prompt_groups": "Prompt Groups List", "com_ui_prompt_input": "Prompt input", "com_ui_prompt_input_field": "Prompt text input field", @@ -1152,6 +1166,8 @@ "com_ui_resource": "resource", "com_ui_response": "Response", "com_ui_result": "Result", + "com_ui_result_found": "{{count}} result found", + "com_ui_results_found": "{{count}} results found", "com_ui_revoke": "Revoke", "com_ui_revoke_info": "Revoke all user provided credentials", "com_ui_revoke_key_confirm": "Are you sure you want to revoke this key?", @@ -1258,6 +1274,9 @@ "com_ui_opens_new_tab": "(opens in new tab)", "com_ui_thinking": "Thinking...", "com_ui_thoughts": "Thoughts", + "com_ui_toggle_theme": "Toggle theme", + "com_ui_dark_theme_enabled": "Dark theme enabled", + "com_ui_light_theme_enabled": "Light theme enabled", "com_ui_token": "token", "com_ui_token_exchange_method": "Token Exchange Method", "com_ui_token_url": "Token URL", diff --git a/client/src/routes/Layouts/DashBreadcrumb.tsx b/client/src/routes/Layouts/DashBreadcrumb.tsx index c6db3a18bc..f1178ebc7f 100644 --- a/client/src/routes/Layouts/DashBreadcrumb.tsx +++ b/client/src/routes/Layouts/DashBreadcrumb.tsx @@ -1,8 +1,9 @@ import { useMemo, useCallback } from 'react'; +import { useSetRecoilState } from 'recoil'; import { useLocation } from 'react-router-dom'; import { SystemRoles } from 'librechat-data-provider'; -import { useRecoilValue, useSetRecoilState } from 'recoil'; import { ArrowLeft, MessageSquareQuote } from 'lucide-react'; +import { Sidebar } from '@librechat/client'; import { Breadcrumb, BreadcrumbItem, @@ -33,7 +34,15 @@ const getConversationId = (prevLocationPath: string) => { return lastPathnameParts[lastPathnameParts.length - 1]; }; -export default function DashBreadcrumb() { +export default function DashBreadcrumb({ + showToggle = false, + onToggle, + openPanelRef, +}: { + showToggle?: boolean; + onToggle?: () => void; + openPanelRef?: React.RefObject; +}) { const location = useLocation(); const localize = useLocalize(); const { user } = useAuthContext(); @@ -42,7 +51,6 @@ export default function DashBreadcrumb() { const setPromptsName = useSetRecoilState(store.promptsName); const setPromptsCategory = useSetRecoilState(store.promptsCategory); - const editorMode = useRecoilValue(store.promptsEditorMode); const clickCallback = useCallback(() => { setPromptsName(''); @@ -61,6 +69,24 @@ export default function DashBreadcrumb() {
+ {showToggle && onToggle && ( + <> + + + + + + )} (null); + const closeSidebarRef = useRef(null); const { isAuthenticated, logout } = useAuthContext(); @@ -73,10 +75,24 @@ export default function Root() {
-
diff --git a/packages/client/src/common/menus.ts b/packages/client/src/common/menus.ts index c46ad3f8bb..3e81012bbd 100644 --- a/packages/client/src/common/menus.ts +++ b/packages/client/src/common/menus.ts @@ -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; render?: | RenderProp & { ref?: React.Ref | undefined }> diff --git a/packages/client/src/components/AnimatedSearchInput.tsx b/packages/client/src/components/AnimatedSearchInput.tsx index 4351ff58b9..9e3411d1d7 100644 --- a/packages/client/src/components/AnimatedSearchInput.tsx +++ b/packages/client/src/components/AnimatedSearchInput.tsx @@ -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 (
@@ -25,7 +27,7 @@ const AnimatedSearchInput = ({
@@ -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 */} diff --git a/packages/client/src/components/DataTable.tsx b/packages/client/src/components/DataTable.tsx index aa36ce4055..6a26e31d76 100644 --- a/packages/client/src/components/DataTable.tsx +++ b/packages/client/src/components/DataTable.tsx @@ -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 = ({ )} scope="row" > -
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -139,13 +139,13 @@ const TableRowComponent = ({ return ( , isSmallScreen, )} > -
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -234,6 +234,7 @@ export default function DataTable({ const [columnVisibility, setColumnVisibility] = useState({}); 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({ 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({ return (
+
+ {searchResultsAnnouncement} +
+ {/* Table controls */}
{enableRowSelection && showCheckboxes && ( @@ -400,7 +428,7 @@ export default function DataTable({
= ({ render={item.render} ref={item.ref} hideOnClick={item.hideOnClick} + aria-haspopup={item.ariaHasPopup} + aria-controls={item.ariaControls} onClick={(event) => { event.preventDefault(); if (item.onClick) { diff --git a/packages/client/src/components/OriginalDialog.tsx b/packages/client/src/components/OriginalDialog.tsx index bbf375c196..cd8a0a4899 100644 --- a/packages/client/src/components/OriginalDialog.tsx +++ b/packages/client/src/components/OriginalDialog.tsx @@ -72,7 +72,7 @@ const DialogContent = React.forwardRef< - + {/* eslint-disable-next-line i18next/no-literal-string */} Close diff --git a/packages/client/src/components/ThemeSelector.tsx b/packages/client/src/components/ThemeSelector.tsx index e05baab7d5..fd9d93c063 100644 --- a/packages/client/src/components/ThemeSelector.tsx +++ b/packages/client/src/components/ThemeSelector.tsx @@ -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 = { system: , dark: , @@ -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 (
); }; diff --git a/packages/client/src/components/Toast.tsx b/packages/client/src/components/Toast.tsx index 0b815d1de7..993d74bc5d 100644 --- a/packages/client/src/components/Toast.tsx +++ b/packages/client/src/components/Toast.tsx @@ -23,7 +23,7 @@ export function Toast() { >
diff --git a/packages/client/src/locales/en/translation.json b/packages/client/src/locales/en/translation.json index 3d0ef42243..e0078f519e 100644 --- a/packages/client/src/locales/en/translation.json +++ b/packages/client/src/locales/en/translation.json @@ -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..." }