🛠️ 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",
"dependencies": {
"@ariakit/react": "^0.4.15",
"@ariakit/react-core": "^0.4.15",
"@ariakit/react-core": "^0.4.17",
"@codesandbox/sandpack-react": "^2.19.10",
"@dicebear/collection": "^9.2.2",
"@dicebear/core": "^9.2.2",

View file

@ -184,11 +184,12 @@ export default function Conversation({
)}
<div
className={cn(
'mr-2',
'mr-2 flex origin-left',
isPopoverActive || isActiveConvo
? 'flex'
: 'hidden group-focus-within:flex group-hover:flex',
? '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)}
>
{!renaming && <ConvoOptions {...convoOptionsProps} />}
</div>

View file

@ -50,35 +50,6 @@ function ConvoOptions({
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({
onSuccess: (data) => {
navigateToConvo(data.conversation);
@ -220,6 +191,10 @@ function ConvoOptions({
return (
<>
<DropdownPopup
portal={true}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
trigger={
@ -227,12 +202,19 @@ function ConvoOptions({
id={`conversation-menu-${conversationId}`}
aria-label={localize('com_nav_convo_menu_options')}
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',
isActiveConvo === true
'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 || isPopoverActive
? '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} />
</Menu.MenuButton>

View file

@ -16,84 +16,119 @@ interface DropdownProps {
anchor?: { x: string; y: string };
gutter?: number;
modal?: boolean;
portal?: boolean;
preserveTabOrder?: boolean;
focusLoop?: boolean;
menuId: string;
mountByState?: boolean;
unmountOnHide?: boolean;
finalFocus?: React.RefObject<HTMLElement>;
}
type MenuProps = Omit<
DropdownProps,
'trigger' | 'isOpen' | 'setIsOpen' | 'focusLoop' | 'mountByState'
>;
const DropdownPopup: React.FC<DropdownProps> = ({
keyPrefix,
trigger,
items,
isOpen,
setIsOpen,
menuId,
modal,
gutter = 8,
sameWidth,
className,
focusLoop,
iconClassName,
itemClassName,
mountByState,
...props
}) => {
const menu = Ariakit.useMenuStore({ open: isOpen, setOpen: setIsOpen, focusLoop });
if (mountByState) {
return (
<Ariakit.MenuProvider store={menu}>
{trigger}
{isOpen && <Menu {...props} />}
</Ariakit.MenuProvider>
);
}
return (
<Ariakit.MenuProvider store={menu}>
{trigger}
<Ariakit.Menu
id={menuId}
className={cn('popover-ui z-50', className)}
gutter={gutter}
modal={modal}
sameWidth={sameWidth}
>
{items
.filter((item) => item.show !== false)
.map((item, index) => {
if (item.separate === true) {
return <Ariakit.MenuSeparator key={index} className="my-1 h-px bg-white/10" />;
}
return (
<Ariakit.MenuItem
key={`${keyPrefix ?? ''}${index}`}
id={item.id}
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',
itemClassName,
)}
disabled={item.disabled}
render={item.render}
ref={item.ref}
hideOnClick={item.hideOnClick}
onClick={(event) => {
event.preventDefault();
if (item.onClick) {
item.onClick(event);
}
if (item.hideOnClick === false) {
return;
}
menu.hide();
}}
>
{item.icon != null && (
<span className={cn('mr-2 size-4', iconClassName)} aria-hidden="true">
{item.icon}
</span>
)}
{item.label}
{item.kbd != null && (
// eslint-disable-next-line i18next/no-literal-string
<kbd className="ml-auto hidden font-sans text-xs text-black/50 group-hover:inline group-focus:inline dark:text-white/50">
{item.kbd}
</kbd>
)}
</Ariakit.MenuItem>
);
})}
</Ariakit.Menu>
<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
id={menuId}
modal={modal}
gutter={gutter}
portal={portal}
sameWidth={sameWidth}
finalFocus={finalFocus}
unmountOnHide={unmountOnHide}
preserveTabOrder={preserveTabOrder}
className={cn('popover-ui z-50', className)}
>
{items
.filter((item) => item.show !== false)
.map((item, index) => {
if (item.separate === true) {
return <Ariakit.MenuSeparator key={index} className="my-1 h-px bg-white/10" />;
}
return (
<Ariakit.MenuItem
key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}`}
id={item.id}
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',
itemClassName,
)}
disabled={item.disabled}
render={item.render}
ref={item.ref}
hideOnClick={item.hideOnClick}
onClick={(event) => {
event.preventDefault();
if (item.onClick) {
item.onClick(event);
}
if (item.hideOnClick === false) {
return;
}
menu?.hide();
}}
>
{item.icon != null && (
<span className={cn('mr-2 size-4', iconClassName)} aria-hidden="true">
{item.icon}
</span>
)}
{item.label}
{item.kbd != null && (
// eslint-disable-next-line i18next/no-literal-string
<kbd className="ml-auto hidden font-sans text-xs text-black/50 group-hover:inline group-focus:inline dark:text-white/50">
{item.kbd}
</kbd>
)}
</Ariakit.MenuItem>
);
})}
</Ariakit.Menu>
);
};
export default DropdownPopup;