👐 a11y: Bump @ariakit/react, Improve a11y of Token Usage, Archived Chats, Reduce Table Layout Shifts (#13874)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Publish `@librechat/client` to NPM / pack (push) Waiting to run
Publish `@librechat/client` to NPM / publish-npm (push) Blocked by required conditions
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
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run

* chore: Update `@ariakit/react` and `@ariakit/react-core` dependencies to v0.4.29 and v0.4.26 respectively, and add new `@ariakit/components`, `@ariakit/react-components`, `@ariakit/react-store`, and `@ariakit/react-utils` packages to package-lock.json and package.json files.

* fix: restore keyboard navigation for Tools dropdown submenus

Compose the Artifacts and MCP submenu triggers as a `MenuButton` that
receives the parent `MenuItem`'s props/ref directly, instead of nesting a
`MenuItem` inside the submenu's own provider and placing the ref on a
wrapper div. This registers the focusable trigger with the parent menu
store so arrow-key navigation reaches the items, which fully broke under
Ariakit 0.4.29.

* fix: Improve keyboard navigation for TokenUsageIndicator popover

Refactor the TokenUsageIndicator component to enhance keyboard accessibility. The popover now maintains focus on the gauge trigger, ensuring that the Escape key closes the popover without shifting focus to the non-interactive panel. Additionally, the autoFocusOnShow property is set to false to prevent unwanted focus behavior when the popover is displayed.

* fix: Stabilize focus and layout shift in Archived Chats dialog

Anchor dialog focus to the content element so rapid tabbing during the
virtualized table's loading state no longer escapes to the page's top
focus guard, and stabilize the columns memo to keep the focus trap intact.
Reserve a fixed height and stable scrollbar gutter, and drop the redundant
nested scroll wrapper in the shared DataTable to eliminate load-time
layout shift.

* fix: Add stable scrollbar gutter to SharedLinks DataTable

Enhance the layout stability of the SharedLinks component by adding a "scrollbar-gutter-stable" class to the DataTable. This change aims to prevent layout shifts during loading, improving the overall user experience.

* fix: Enhance keyboard accessibility and focus management in TokenUsageIndicator

Refactor the TokenUsageIndicator component to improve keyboard navigation and focus behavior. Introduced a useRef hook for the disclosure button to ensure focus remains on the gauge trigger when the popover is opened. Updated the popover's finalFocus property to return focus to the trigger on close, enhancing the overall user experience for keyboard users.
This commit is contained in:
Danny Avila 2026-06-21 12:53:24 -04:00 committed by GitHub
parent 1505fd5262
commit 465cb6e394
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 184 additions and 86 deletions

View file

@ -29,8 +29,8 @@
},
"homepage": "https://librechat.ai",
"dependencies": {
"@ariakit/react": "^0.4.15",
"@ariakit/react-core": "^0.4.17",
"@ariakit/react": "^0.4.29",
"@ariakit/react-core": "^0.4.26",
"@codesandbox/sandpack-react": "^2.19.10",
"@dicebear/collection": "^9.4.1",
"@dicebear/core": "^9.4.1",

View file

@ -1,12 +1,12 @@
import React from 'react';
import * as Ariakit from '@ariakit/react';
import { PinIcon } from '@librechat/client';
import { ChevronRight, WandSparkles } from 'lucide-react';
import { ArtifactModes } from 'librechat-data-provider';
import { ChevronRight, WandSparkles } from 'lucide-react';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface ArtifactsSubMenuProps {
interface ArtifactsSubMenuProps extends React.HTMLAttributes<HTMLButtonElement> {
isArtifactsPinned: boolean;
setIsArtifactsPinned: (value: boolean) => void;
artifactsMode: string;
@ -15,7 +15,7 @@ interface ArtifactsSubMenuProps {
handleCustomToggle: () => void;
}
const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>(
const ArtifactsSubMenu = React.forwardRef<HTMLButtonElement, ArtifactsSubMenuProps>(
(
{
isArtifactsPinned,
@ -24,6 +24,7 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
handleArtifactsToggle,
handleShadcnToggle,
handleCustomToggle,
className,
...props
},
ref,
@ -41,25 +42,24 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
const isCustomEnabled = artifactsMode === ArtifactModes.CUSTOM;
return (
<div ref={ref}>
<>
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
<Ariakit.MenuButton
ref={ref}
{...props}
hideOnClick={false}
render={
<Ariakit.MenuButton
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
handleArtifactsToggle();
}}
onMouseEnter={() => {
if (isEnabled) {
menuStore.show();
}
}}
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover"
/>
}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
handleArtifactsToggle();
}}
onMouseEnter={() => {
if (isEnabled) {
menuStore.show();
}
}}
className={cn(
'flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover',
className,
)}
>
<div className="flex items-center gap-2">
<WandSparkles className="icon-md" aria-hidden="true" />
@ -83,7 +83,7 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
<PinIcon unpin={isArtifactsPinned} />
</div>
</button>
</Ariakit.MenuItem>
</Ariakit.MenuButton>
{isEnabled && (
<Ariakit.Menu
@ -144,7 +144,7 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
</Ariakit.Menu>
)}
</Ariakit.MenuProvider>
</div>
</>
);
},
);

View file

@ -8,12 +8,12 @@ import { useBadgeRowContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface MCPSubMenuProps {
interface MCPSubMenuProps extends React.HTMLAttributes<HTMLButtonElement> {
placeholder?: string;
}
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
({ placeholder, ...props }, ref) => {
const MCPSubMenu = React.forwardRef<HTMLButtonElement, MCPSubMenuProps>(
({ placeholder, className, ...props }, ref) => {
const localize = useLocalize();
const context = useBadgeRowContext();
const { storageContextKey, mcpServerManager } = context ?? {};
@ -48,20 +48,19 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
const configDialogProps = getConfigDialogProps();
return (
<div ref={ref}>
<>
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
<Ariakit.MenuButton
ref={ref}
{...props}
hideOnClick={false}
render={
<Ariakit.MenuButton
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
menuStore.toggle();
}}
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover"
/>
}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
menuStore.toggle();
}}
className={cn(
'flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover',
className,
)}
>
<div className="flex items-center gap-2">
<MCPIcon className="h-5 w-5 flex-shrink-0 text-text-primary" aria-hidden="true" />
@ -85,7 +84,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
<PinIcon unpin={isPinned} />
</div>
</button>
</Ariakit.MenuItem>
</Ariakit.MenuButton>
<Ariakit.Menu
portal={true}
@ -114,7 +113,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
{configDialogProps && (
<MCPConfigDialog {...configDialogProps} storageContextKey={storageContextKey} />
)}
</div>
</>
);
},
);

View file

@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, useRef } from 'react';
import * as Ariakit from '@ariakit/react';
import { TooltipAnchor } from '@librechat/client';
import type { TConversation } from 'librechat-data-provider';
@ -29,6 +29,7 @@ function TokenUsageIndicator({
const localize = useLocalize();
const view = useTokenUsage({ index, conversation, isSubmitting });
const popover = Ariakit.usePopoverStore({ placement: 'top' });
const disclosureRef = useRef<HTMLButtonElement>(null);
/** Hide until the branch has data keeps a fresh, message-less chat clean and
* lets the indicator animate into view once the first tokens land. */
@ -64,6 +65,7 @@ function TokenUsageIndicator({
side="top"
render={
<Ariakit.PopoverDisclosure
ref={disclosureRef}
store={popover}
type="button"
data-testid="token-usage"
@ -88,11 +90,17 @@ function TokenUsageIndicator({
</Ariakit.PopoverDisclosure>
}
/>
{/* Focus the labelled dialog on open so screen readers enter and announce
the breakdown, and so focus stays contained instead of falling back to
the body (which the composer's global focus logic would steal). The
visible ring is suppressed via focus:outline-none, and finalFocus
returns focus to the gauge trigger on close. */}
<Ariakit.Popover
store={popover}
gutter={8}
portal
unmountOnHide
finalFocus={disclosureRef}
aria-label={localize('com_ui_context_usage')}
className="z-[200] rounded-xl border border-border-medium bg-surface-secondary p-3 shadow-lg focus:outline-none"
>

View file

@ -332,6 +332,7 @@ export default function SharedLinks() {
<DataTable
columns={columns}
data={allLinks}
className="scrollbar-gutter-stable"
onDelete={handleDelete}
filterColumn="title"
hasNextPage={hasNextPage}

View file

@ -1,3 +1,4 @@
import { useRef } from 'react';
import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle } from '@librechat/client';
import type { RefObject } from 'react';
import ArchivedChatsTable from './ArchivedChatsTable';
@ -13,12 +14,24 @@ export function ArchivedChatsModal({
triggerRef?: RefObject<HTMLButtonElement | HTMLDivElement | null>;
}) {
const localize = useLocalize();
const contentRef = useRef<HTMLDivElement>(null);
/** The virtualized table has no stable focusable on mount, so Radix's default
* autofocus lands on a row that the virtualizer tears out, dropping focus to
* the page's top focus guard; anchor focus to the dialog content instead. */
const handleOpenAutoFocus = (event: Event) => {
event.preventDefault();
contentRef.current?.focus();
};
return (
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
<OGDialogContent
ref={contentRef}
tabIndex={-1}
onOpenAutoFocus={handleOpenAutoFocus}
title={localize('com_nav_archived_chats')}
className="w-11/12 max-w-[1000px] bg-background text-text-primary shadow-2xl"
className="w-11/12 max-w-[1000px] bg-background text-text-primary shadow-2xl focus:outline-none"
>
<OGDialogHeader>
<OGDialogTitle>{localize('com_nav_archived_chats')}</OGDialogTitle>

View file

@ -109,7 +109,7 @@ export default function ArchivedChatsTable({
},
});
const unarchiveMutation = useArchiveConvoMutation({
const { mutate: unarchiveConversation, isLoading: isUnarchiving } = useArchiveConvoMutation({
onSuccess: async () => {
await refetch();
},
@ -253,20 +253,16 @@ export default function ArchivedChatsTable({
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() =>
unarchiveMutation.mutate({
unarchiveConversation({
conversationId: conversation.conversationId,
isArchived: false,
})
}
title={localize('com_ui_unarchive_conversation')}
aria-label={localize('com_ui_unarchive_conversation')}
disabled={unarchiveMutation.isLoading}
disabled={isUnarchiving}
>
{unarchiveMutation.isLoading ? (
<Spinner />
) : (
<ArchiveRestore className="size-4" />
)}
{isUnarchiving ? <Spinner /> : <ArchiveRestore className="size-4" />}
</Button>
}
/>
@ -296,24 +292,29 @@ export default function ArchivedChatsTable({
},
},
],
[isSmallScreen, localize, unarchiveMutation],
[isSmallScreen, localize, unarchiveConversation, isUnarchiving],
);
return (
<>
<DataTable
columns={columns}
data={allConversations}
filterColumn="title"
onFilterChange={debouncedFilterChange}
filterValue={queryParams.search}
fetchNextPage={handleFetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={isLoading}
showCheckboxes={false}
enableSearch={searchState.enabled === true}
/>
{/* Fixed height keeps the loading (skeleton) and loaded states the same
size, so the virtualized table can't reflow the dialog on load. */}
<div className="h-[60vh]">
<DataTable
columns={columns}
data={allConversations}
className="scrollbar-gutter-stable"
filterColumn="title"
onFilterChange={debouncedFilterChange}
filterValue={queryParams.search}
fetchNextPage={handleFetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={isLoading}
showCheckboxes={false}
enableSearch={searchState.enabled === true}
/>
</div>
<OGDialog open={isDeleteOpen} onOpenChange={onOpenChange}>
<OGDialogContent

109
package-lock.json generated
View file

@ -413,8 +413,8 @@
"version": "v0.8.7-rc1",
"license": "ISC",
"dependencies": {
"@ariakit/react": "^0.4.15",
"@ariakit/react-core": "^0.4.17",
"@ariakit/react": "^0.4.29",
"@ariakit/react-core": "^0.4.26",
"@codesandbox/sandpack-react": "^2.19.10",
"@dicebear/collection": "^9.4.1",
"@dicebear/core": "^9.4.1",
@ -762,19 +762,30 @@
"ajv": ">=8"
}
},
"node_modules/@ariakit/components": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@ariakit/components/-/components-0.1.2.tgz",
"integrity": "sha512-tvh2P0x1cJnoPXnmDEJwdRk3z7x6cTB8ArctcZdAUXlRg9tuwW/rJoBFJMzD5qMI9CDDlQ3Zctx58HvENw4BYw==",
"license": "MIT",
"dependencies": {
"@ariakit/store": "0.1.2",
"@ariakit/utils": "0.1.2"
}
},
"node_modules/@ariakit/core": {
"version": "0.4.15",
"resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.15.tgz",
"integrity": "sha512-vvxmZvkNhiisKM+Y1TbGMUfVVchV/sWu9F0xw0RYADXcimWPK31dd9JnIZs/OQ5pwAryAHmERHwuGQVESkSjwQ==",
"version": "0.4.20",
"resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.20.tgz",
"integrity": "sha512-DJbUnui0fM+2ZgiWLOMuFOmlWSJDNV3f6tqghIYRTWEm51TN/LoU6uM8og6/g7Nrwl4Uo5l8AoQT9Kkr/i/uRg==",
"deprecated": "This package has been split into smaller packages. Use @ariakit/components, @ariakit/store, or @ariakit/utils depending on the APIs you need.",
"license": "MIT"
},
"node_modules/@ariakit/react": {
"version": "0.4.17",
"resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.17.tgz",
"integrity": "sha512-HQaIboE2axtlncJz1hRTaiQfJ1GGjhdtNcAnPwdjvl2RybfmlHowIB+HTVBp36LzroKPs/M4hPCxk7XTaqRZGg==",
"version": "0.4.29",
"resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.29.tgz",
"integrity": "sha512-SLXlsddWHSwfUol4Yi0zULlalNWjzWjpS3zg7B7aaPd64saONQ5ktWf9KMxqBklcpjMLeF2dB9BAHAvpPVdCIQ==",
"license": "MIT",
"dependencies": {
"@ariakit/react-core": "0.4.17"
"@ariakit/react-components": "0.1.2"
},
"funding": {
"type": "opencollective",
@ -785,21 +796,83 @@
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@ariakit/react-core": {
"version": "0.4.17",
"resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.17.tgz",
"integrity": "sha512-kFF6n+gC/5CRQIyaMTFoBPio2xUe0k9rZhMNdUobWRmc/twfeLVkODx+8UVYaNyKilTge8G0JFqwvFKku/jKEw==",
"node_modules/@ariakit/react-components": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@ariakit/react-components/-/react-components-0.1.2.tgz",
"integrity": "sha512-SM+SPMAVlOZmGAfWNBza+0k9y4mkA5/dJhDoOyhE96cbNARy665uLdwowSJl1JGuFfcZzuzAwGon7f/rYeyfkQ==",
"license": "MIT",
"dependencies": {
"@ariakit/core": "0.4.15",
"@floating-ui/dom": "^1.0.0",
"use-sync-external-store": "^1.2.0"
"@ariakit/components": "0.1.2",
"@ariakit/react-store": "0.1.2",
"@ariakit/react-utils": "0.1.2",
"@ariakit/store": "0.1.2",
"@ariakit/utils": "0.1.2",
"@floating-ui/dom": "^1.0.0"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@ariakit/react-core": {
"version": "0.4.26",
"resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.26.tgz",
"integrity": "sha512-/Peh1KiVpjj79nCJIa6lEdzSTT9P9FZoy+CxByIFKL3YKdlXmDIIhS1E/tAqKbDq4ODVdynnqmrIDxE5wCoZYw==",
"deprecated": "This package has been split into smaller packages. Use @ariakit/react-components or @ariakit/react-utils depending on the APIs you need.",
"license": "MIT",
"dependencies": {
"@ariakit/core": "0.4.20",
"@floating-ui/dom": "^1.0.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@ariakit/react-store": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@ariakit/react-store/-/react-store-0.1.2.tgz",
"integrity": "sha512-1r1Gn0tqhnOS0LFvHNGzn5/8C5aOANO5vb0Gxh94oR/be4zwCSE2zfQjOjRfpL+BBDhOcProME2+G6UslEJxbg==",
"license": "MIT",
"dependencies": {
"@ariakit/react-utils": "0.1.2",
"@ariakit/store": "0.1.2",
"@ariakit/utils": "0.1.2",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@ariakit/react-utils": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@ariakit/react-utils/-/react-utils-0.1.2.tgz",
"integrity": "sha512-Rnl6D1542Mqu80xK++oUv1JXS0PtNmKXd9nkdud5nyvySiBDTrmPqRW44/D+5GbuZrboreQuY3tPYwKL7a7onQ==",
"license": "MIT",
"dependencies": {
"@ariakit/store": "0.1.2",
"@ariakit/utils": "0.1.2"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@ariakit/store": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@ariakit/store/-/store-0.1.2.tgz",
"integrity": "sha512-SS7bV4+a+1q9M9i0WV6DD4P/ypRKlCvII8soo2UMe1yuaxZA/Fc0htHe+EZwjJ6TMLjHfHh2TDSnXyrjC7QImA==",
"license": "MIT",
"dependencies": {
"@ariakit/utils": "0.1.2"
}
},
"node_modules/@ariakit/utils": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@ariakit/utils/-/utils-0.1.2.tgz",
"integrity": "sha512-lBJhtBWpKjIck/9i7G8cahvaUgLsyGklI/Pjv+VtY9KTzyuzX5GpRbbLKMS/e1qLnFPS4C3CybYB70b1bVcAkw==",
"license": "MIT"
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
@ -43076,8 +43149,8 @@
"typescript": "^5.9.3"
},
"peerDependencies": {
"@ariakit/react": "^0.4.16",
"@ariakit/react-core": "^0.4.17",
"@ariakit/react": "^0.4.29",
"@ariakit/react-core": "^0.4.26",
"@dicebear/collection": "^9.4.1",
"@dicebear/core": "^9.4.1",
"@headlessui/react": "^2.1.2",

View file

@ -36,8 +36,8 @@
"dev": "tsdown --watch"
},
"peerDependencies": {
"@ariakit/react": "^0.4.16",
"@ariakit/react-core": "^0.4.17",
"@ariakit/react": "^0.4.29",
"@ariakit/react-core": "^0.4.26",
"@dicebear/collection": "^9.4.1",
"@dicebear/core": "^9.4.1",
"@headlessui/react": "^2.1.2",

View file

@ -435,7 +435,10 @@ export default function DataTable<TData, TValue>({
className,
)}
>
<Table className="w-full min-w-[300px] table-fixed border-separate border-spacing-0">
<Table
unwrapped
className="w-full min-w-[300px] table-fixed border-separate border-spacing-0"
>
<TableHeader className="sticky top-0 z-50 bg-surface-secondary">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-b border-border-light">