🛠️ fix: Improve Accessibility and Display of Conversation Menu (#6913)

* 📦 chore: update @ariakit/react-core to version 0.4.17 in package.json and package-lock.json

* refactor: add additional ariakit menu props and unmount menu if state changes

* fix: accessibility issues and incompatibility issues due to non-portaled menu

* fix: improve visibility and accessibility of conversation options, making sure to expand dynamically when becoming active

* fix: adjust max width for conversation options popover to improve visibility
This commit is contained in:
Danny Avila 2025-04-16 04:28:46 -04:00 committed by GitHub
parent 000f3a3733
commit 16aa5ed466
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 153 additions and 113 deletions

View file

@ -29,7 +29,7 @@
"homepage": "https://librechat.ai", "homepage": "https://librechat.ai",
"dependencies": { "dependencies": {
"@ariakit/react": "^0.4.15", "@ariakit/react": "^0.4.15",
"@ariakit/react-core": "^0.4.15", "@ariakit/react-core": "^0.4.17",
"@codesandbox/sandpack-react": "^2.19.10", "@codesandbox/sandpack-react": "^2.19.10",
"@dicebear/collection": "^9.2.2", "@dicebear/collection": "^9.2.2",
"@dicebear/core": "^9.2.2", "@dicebear/core": "^9.2.2",

View file

@ -184,11 +184,12 @@ export default function Conversation({
)} )}
<div <div
className={cn( className={cn(
'mr-2', 'mr-2 flex origin-left',
isPopoverActive || isActiveConvo isPopoverActive || isActiveConvo
? 'flex' ? 'pointer-events-auto max-w-[28px] scale-x-100 opacity-100'
: 'hidden group-focus-within:flex group-hover:flex', : '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)}
> >
{!renaming && <ConvoOptions {...convoOptionsProps} />} {!renaming && <ConvoOptions {...convoOptionsProps} />}
</div> </div>

View file

@ -50,35 +50,6 @@ function ConvoOptions({
const archiveConvoMutation = useArchiveConvoMutation(); const archiveConvoMutation = useArchiveConvoMutation();
const archiveHandler = async () => {
const convoId = conversationId ?? '';
if (!convoId) {
return;
}
archiveConvoMutation.mutate(
{ conversationId: convoId, isArchived: true },
{
onSuccess: () => {
if (currentConvoId === convoId || currentConvoId === 'new') {
newConversation();
navigate('/c/new', { replace: true });
}
retainView();
setIsPopoverActive(false);
},
onError: () => {
showToast({
message: localize('com_ui_archive_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
},
);
};
const duplicateConversation = useDuplicateConversationMutation({ const duplicateConversation = useDuplicateConversationMutation({
onSuccess: (data) => { onSuccess: (data) => {
navigateToConvo(data.conversation); navigateToConvo(data.conversation);
@ -220,6 +191,10 @@ function ConvoOptions({
return ( return (
<> <>
<DropdownPopup <DropdownPopup
portal={true}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isPopoverActive} isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive} setIsOpen={setIsPopoverActive}
trigger={ trigger={
@ -227,12 +202,19 @@ function ConvoOptions({
id={`conversation-menu-${conversationId}`} id={`conversation-menu-${conversationId}`}
aria-label={localize('com_nav_convo_menu_options')} aria-label={localize('com_nav_convo_menu_options')}
className={cn( className={cn(
'z-30 inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 'z-30 inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50',
isActiveConvo === true isActiveConvo === true || isPopoverActive
? 'opacity-100' ? 'opacity-100'
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100', : 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
)} )}
onClick={(e) => e.stopPropagation()} onClick={(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
}}
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
}
}}
> >
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} /> <Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
</Menu.MenuButton> </Menu.MenuButton>

View file

@ -16,36 +16,72 @@ interface DropdownProps {
anchor?: { x: string; y: string }; anchor?: { x: string; y: string };
gutter?: number; gutter?: number;
modal?: boolean; modal?: boolean;
portal?: boolean;
preserveTabOrder?: boolean;
focusLoop?: boolean; focusLoop?: boolean;
menuId: string; menuId: string;
mountByState?: boolean;
unmountOnHide?: boolean;
finalFocus?: React.RefObject<HTMLElement>;
} }
type MenuProps = Omit<
DropdownProps,
'trigger' | 'isOpen' | 'setIsOpen' | 'focusLoop' | 'mountByState'
>;
const DropdownPopup: React.FC<DropdownProps> = ({ const DropdownPopup: React.FC<DropdownProps> = ({
keyPrefix,
trigger, trigger,
items,
isOpen, isOpen,
setIsOpen, setIsOpen,
menuId,
modal,
gutter = 8,
sameWidth,
className,
focusLoop, focusLoop,
iconClassName, mountByState,
itemClassName, ...props
}) => { }) => {
const menu = Ariakit.useMenuStore({ open: isOpen, setOpen: setIsOpen, focusLoop }); const menu = Ariakit.useMenuStore({ open: isOpen, setOpen: setIsOpen, focusLoop });
if (mountByState) {
return ( return (
<Ariakit.MenuProvider store={menu}> <Ariakit.MenuProvider store={menu}>
{trigger} {trigger}
{isOpen && <Menu {...props} />}
</Ariakit.MenuProvider>
);
}
return (
<Ariakit.MenuProvider store={menu}>
{trigger}
<Menu {...props} />
</Ariakit.MenuProvider>
);
};
const Menu: React.FC<MenuProps> = ({
items,
menuId,
keyPrefix,
className,
iconClassName,
itemClassName,
modal,
portal,
sameWidth,
gutter = 8,
finalFocus,
unmountOnHide,
preserveTabOrder,
}) => {
const menu = Ariakit.useMenuContext();
return (
<Ariakit.Menu <Ariakit.Menu
id={menuId} id={menuId}
className={cn('popover-ui z-50', className)}
gutter={gutter}
modal={modal} modal={modal}
gutter={gutter}
portal={portal}
sameWidth={sameWidth} sameWidth={sameWidth}
finalFocus={finalFocus}
unmountOnHide={unmountOnHide}
preserveTabOrder={preserveTabOrder}
className={cn('popover-ui z-50', className)}
> >
{items {items
.filter((item) => item.show !== false) .filter((item) => item.show !== false)
@ -55,7 +91,7 @@ const DropdownPopup: React.FC<DropdownProps> = ({
} }
return ( return (
<Ariakit.MenuItem <Ariakit.MenuItem
key={`${keyPrefix ?? ''}${index}`} key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}`}
id={item.id} id={item.id}
className={cn( className={cn(
'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2', 'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
@ -73,7 +109,7 @@ const DropdownPopup: React.FC<DropdownProps> = ({
if (item.hideOnClick === false) { if (item.hideOnClick === false) {
return; return;
} }
menu.hide(); menu?.hide();
}} }}
> >
{item.icon != null && ( {item.icon != null && (
@ -92,7 +128,6 @@ const DropdownPopup: React.FC<DropdownProps> = ({
); );
})} })}
</Ariakit.Menu> </Ariakit.Menu>
</Ariakit.MenuProvider>
); );
}; };

52
package-lock.json generated
View file

@ -1165,7 +1165,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@ariakit/react": "^0.4.15", "@ariakit/react": "^0.4.15",
"@ariakit/react-core": "^0.4.15", "@ariakit/react-core": "^0.4.17",
"@codesandbox/sandpack-react": "^2.19.10", "@codesandbox/sandpack-react": "^2.19.10",
"@dicebear/collection": "^9.2.2", "@dicebear/collection": "^9.2.2",
"@dicebear/core": "^9.2.2", "@dicebear/core": "^9.2.2",
@ -1299,6 +1299,42 @@
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"client/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==",
"license": "MIT",
"dependencies": {
"@ariakit/core": "0.4.15",
"@floating-ui/dom": "^1.0.0",
"use-sync-external-store": "^1.2.0"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"client/node_modules/@ariakit/react-core/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==",
"license": "MIT"
},
"client/node_modules/@ariakit/react/node_modules/@ariakit/react-core": {
"version": "0.4.15",
"resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.15.tgz",
"integrity": "sha512-Up8+U97nAPJdyUh9E8BCEhJYTA+eVztWpHoo1R9zZfHd4cnBWAg5RHxEmMH+MamlvuRxBQA71hFKY/735fDg+A==",
"license": "MIT",
"dependencies": {
"@ariakit/core": "0.4.14",
"@floating-ui/dom": "^1.0.0",
"use-sync-external-store": "^1.2.0"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"client/node_modules/@babel/compat-data": { "client/node_modules/@babel/compat-data": {
"version": "7.26.8", "version": "7.26.8",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz",
@ -3417,20 +3453,6 @@
"resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.14.tgz", "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.14.tgz",
"integrity": "sha512-hpzZvyYzGhP09S9jW1XGsU/FD5K3BKsH1eG/QJ8rfgEeUdPS7BvHPt5lHbOeJ2cMrRzBEvsEzLi1ivfDifHsVA==" "integrity": "sha512-hpzZvyYzGhP09S9jW1XGsU/FD5K3BKsH1eG/QJ8rfgEeUdPS7BvHPt5lHbOeJ2cMrRzBEvsEzLi1ivfDifHsVA=="
}, },
"node_modules/@ariakit/react-core": {
"version": "0.4.15",
"resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.15.tgz",
"integrity": "sha512-Up8+U97nAPJdyUh9E8BCEhJYTA+eVztWpHoo1R9zZfHd4cnBWAg5RHxEmMH+MamlvuRxBQA71hFKY/735fDg+A==",
"dependencies": {
"@ariakit/core": "0.4.14",
"@floating-ui/dom": "^1.0.0",
"use-sync-external-store": "^1.2.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/@aws-crypto/crc32": { "node_modules/@aws-crypto/crc32": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz",