🔗 feat: More Accessible Link Behaviors and Minor UI Improvements (#11549)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

* fix: accessibility issues with links and link descriptions + minor ui tweaks

* fix: link accessibility in archived chats table

* fix: remove open in new tab behavior for other footer links

* chore: remove unused translation string

* style: formatting

* refactor: rename searchState to searchStore for clarity

* chore: Reorganize imports and state variables in SharedLinks

* chore: re-organize imports/hooks

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Dustin Healy 2026-01-28 09:15:43 -08:00 committed by GitHub
parent 95a234fb83
commit 13cea97c9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 129 additions and 105 deletions

View file

@ -13,30 +13,14 @@ export default function Footer({ className }: { className?: string }) {
const termsOfService = config?.interface?.termsOfService;
const privacyPolicyRender = privacyPolicy?.externalUrl != null && (
<a
className="text-text-secondary underline"
href={privacyPolicy.externalUrl}
target={privacyPolicy.openNewTab === true ? '_blank' : undefined}
rel="noreferrer"
>
<a className="text-text-secondary underline" href={privacyPolicy.externalUrl} rel="noreferrer">
{localize('com_ui_privacy_policy')}
{privacyPolicy.openNewTab === true && (
<span className="sr-only">{' ' + localize('com_ui_opens_new_tab')}</span>
)}
</a>
);
const termsOfServiceRender = termsOfService?.externalUrl != null && (
<a
className="text-text-secondary underline"
href={termsOfService.externalUrl}
target={termsOfService.openNewTab === true ? '_blank' : undefined}
rel="noreferrer"
>
<a className="text-text-secondary underline" href={termsOfService.externalUrl} rel="noreferrer">
{localize('com_ui_terms_of_service')}
{termsOfService.openNewTab === true && (
<span className="sr-only">{' ' + localize('com_ui_opens_new_tab')}</span>
)}
</a>
);
@ -67,12 +51,10 @@ export default function Footer({ className }: { className?: string }) {
<a
className="text-text-secondary underline"
href={href}
target="_blank"
rel="noreferrer"
{...otherProps}
>
{children}
<span className="sr-only">{' ' + localize('com_ui_opens_new_tab')}</span>
</a>
);
},

View file

@ -4,33 +4,33 @@ import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { Link } from 'react-router-dom';
import {
TrashIcon,
MessageSquare,
ArrowUpDown,
ArrowUp,
TrashIcon,
ArrowDown,
ArrowUpDown,
ExternalLink,
MessageSquare,
} from 'lucide-react';
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';
import {
Label,
Button,
Spinner,
OGDialog,
useToastContext,
OGDialogTemplate,
OGDialogTrigger,
OGDialogContent,
DataTable,
useMediaQuery,
OGDialogHeader,
OGDialogTitle,
TooltipAnchor,
DataTable,
Spinner,
Button,
Label,
OGDialogHeader,
OGDialogTrigger,
OGDialogContent,
useToastContext,
OGDialogTemplate,
} from '@librechat/client';
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
import { formatDate } from '~/utils';
import store from '~/store';
@ -47,12 +47,12 @@ const DEFAULT_PARAMS: SharedLinksListParams = {
export default function SharedLinks() {
const localize = useLocalize();
const { showToast } = useToastContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const isSearchEnabled = useRecoilValue(store.search);
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const searchStore = useRecoilValue(store.search);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
useSharedLinksQuery(queryParams, {
@ -173,17 +173,23 @@ export default function SharedLinks() {
ariaSort = 'ascending';
}
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
aria-sort={ariaSort}
aria-label={localize('com_ui_name_sort')}
aria-current={sortState ? 'true' : 'false'}
>
{localize('com_ui_name')}
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
<TooltipAnchor
description={localize('com_ui_name_sort')}
side="top"
render={
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
aria-sort={ariaSort}
aria-label={localize('com_ui_name_sort')}
aria-current={sortState ? 'true' : 'false'}
>
{localize('com_ui_name')}
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
}
/>
);
},
cell: ({ row }) => {
@ -207,7 +213,7 @@ export default function SharedLinks() {
);
},
meta: {
size: '35%',
size: '32%',
mobileSize: '50%',
},
},
@ -225,17 +231,23 @@ export default function SharedLinks() {
ariaSort = 'ascending';
}
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
aria-sort={ariaSort}
aria-label={localize('com_ui_creation_date_sort' as TranslationKeys)}
aria-current={sortState ? 'true' : 'false'}
>
{localize('com_ui_date')}
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
<TooltipAnchor
description={localize('com_ui_date_sort')}
side="top"
render={
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
aria-sort={ariaSort}
aria-label={localize('com_ui_date_sort')}
aria-current={sortState ? 'true' : 'false'}
>
{localize('com_ui_date')}
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
}
/>
);
},
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
@ -247,7 +259,7 @@ export default function SharedLinks() {
{
accessorKey: 'actions',
header: () => (
<Label className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm">
<Label className="px-2 py-0 text-xs sm:px-2 sm:py-2 sm:text-sm">
{localize('com_assistants_actions')}
</Label>
),
@ -330,7 +342,7 @@ export default function SharedLinks() {
onFilterChange={debouncedFilterChange}
filterValue={queryParams.search}
isLoading={isLoading}
enableSearch={isSearchEnabled}
enableSearch={searchStore.enabled === true}
/>
</OGDialogContent>
</OGDialog>

View file

@ -2,10 +2,18 @@ import { useState, useCallback, useMemo, useEffect } from 'react';
import { Trans } from 'react-i18next';
import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { TrashIcon, ArchiveRestore, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react';
import { Link } from 'react-router-dom';
import {
ArrowUp,
TrashIcon,
ArrowDown,
ArrowUpDown,
ExternalLink,
ArchiveRestore,
} from 'lucide-react';
import {
Button,
Label,
Button,
Spinner,
OGDialog,
DataTable,
@ -17,7 +25,6 @@ import {
OGDialogContent,
} from '@librechat/client';
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';
import {
useConversationsInfiniteQuery,
useDeleteConversationMutation,
@ -42,10 +49,10 @@ export default function ArchivedChatsTable({
onOpenChange: (isOpen: boolean) => void;
}) {
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { showToast } = useToastContext();
const searchState = useRecoilValue(store.search);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [queryParams, setQueryParams] = useState<ConversationListParams>(DEFAULT_PARAMS);
const [deleteConversation, setDeleteConversation] = useState<TConversation | null>(null);
@ -138,35 +145,50 @@ export default function ArchivedChatsTable({
ariaSort = 'ascending';
}
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
aria-sort={ariaSort}
aria-label={localize('com_nav_archive_name_sort' as TranslationKeys)}
aria-current={sortState ? 'true' : 'false'}
>
{localize('com_nav_archive_name')}
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
<TooltipAnchor
description={localize('com_ui_name_sort')}
side="top"
render={
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
aria-sort={ariaSort}
aria-label={localize('com_ui_name_sort')}
aria-current={sortState ? 'true' : 'false'}
>
{localize('com_nav_archive_name')}
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
}
/>
);
},
cell: ({ row }) => {
const { conversationId, title } = row.original;
return (
<button
type="button"
className="flex items-center gap-2 truncate rounded-sm"
onClick={() => window.open(`/c/${conversationId}`, '_blank')}
>
<div className="flex items-center gap-2">
<MinimalIcon
endpoint={row.original.endpoint}
size={28}
isCreatedByUser={false}
iconClassName="size-4"
/>
<span className="underline">{title}</span>
</button>
<Link
to={`/c/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
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}
aria-label={localize('com_ui_open_archived_chat_new_tab_title', { title })}
>
<span className="truncate">{title}</span>
<ExternalLink
className="size-3 flex-shrink-0 opacity-70 group-hover:opacity-100"
aria-hidden="true"
/>
</Link>
</div>
);
},
meta: {
@ -188,17 +210,23 @@ export default function ArchivedChatsTable({
ariaSort = 'ascending';
}
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
aria-sort={ariaSort}
aria-label={localize('com_nav_archive_created_at_sort' as TranslationKeys)}
aria-current={sortState ? 'true' : 'false'}
>
{localize('com_nav_archive_created_at')}
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
<TooltipAnchor
description={localize('com_ui_date_sort')}
side="top"
render={
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
aria-sort={ariaSort}
aria-label={localize('com_ui_date_sort')}
aria-current={sortState ? 'true' : 'false'}
>
{localize('com_nav_archive_created_at')}
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
}
/>
);
},
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
@ -219,7 +247,7 @@ export default function ArchivedChatsTable({
return (
<div className="flex items-center gap-2">
<TooltipAnchor
description={localize('com_ui_unarchive')}
description={localize('com_ui_unarchive_conversation')}
render={
<Button
variant="ghost"
@ -230,8 +258,8 @@ export default function ArchivedChatsTable({
isArchived: false,
})
}
title={localize('com_ui_unarchive')}
aria-label={localize('com_ui_unarchive')}
title={localize('com_ui_unarchive_conversation')}
aria-label={localize('com_ui_unarchive_conversation')}
disabled={unarchiveMutation.isLoading}
>
{unarchiveMutation.isLoading ? (
@ -243,7 +271,7 @@ export default function ArchivedChatsTable({
}
/>
<TooltipAnchor
description={localize('com_ui_delete')}
description={localize('com_ui_delete_conversation_tooltip')}
render={
<Button
variant="ghost"
@ -252,8 +280,8 @@ export default function ArchivedChatsTable({
setDeleteConversation(row.original);
setIsDeleteOpen(true);
}}
title={localize('com_ui_delete')}
aria-label={localize('com_ui_delete')}
title={localize('com_ui_delete_conversation_tooltip')}
aria-label={localize('com_ui_delete_conversation_tooltip')}
>
<TrashIcon className="size-4" />
</Button>

View file

@ -883,6 +883,7 @@
"com_ui_delete_confirm_prompt_version_var": "This will delete the selected version for \"{{0}}.\" If no other versions exist, the prompt will be deleted.",
"com_ui_delete_confirm_strong": "This will delete <strong>{{title}}</strong>",
"com_ui_delete_conversation": "Delete chat?",
"com_ui_delete_conversation_tooltip": "Delete conversation",
"com_ui_delete_memory": "Delete Memory",
"com_ui_delete_not_allowed": "Delete operation is not allowed",
"com_ui_delete_preset": "Delete Preset?",
@ -1176,11 +1177,11 @@
"com_ui_off": "Off",
"com_ui_offline": "Offline",
"com_ui_on": "On",
"com_ui_open_archived_chat_new_tab_title": "{{title}} (opens in new tab)",
"com_ui_open_source_chat_new_tab": "Open Source Chat in New Tab",
"com_ui_open_source_chat_new_tab_title": "Open Source Chat in New Tab - {{title}}",
"com_ui_open_var": "Open {{0}}",
"com_ui_openai": "OpenAI",
"com_ui_opens_new_tab": "(opens in new tab)",
"com_ui_optional": "(optional)",
"com_ui_page": "Page",
"com_ui_people": "people",
@ -1376,6 +1377,7 @@
"com_ui_ui_resource_not_found": "UI Resource not found (index: {{0}})",
"com_ui_ui_resources": "UI Resources",
"com_ui_unarchive": "Unarchive",
"com_ui_unarchive_conversation": "Unarchive conversation",
"com_ui_unarchive_error": "Failed to unarchive conversation",
"com_ui_unavailable": "Unavailable",
"com_ui_unknown": "Unknown",