mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
🛠️ 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:
parent
000f3a3733
commit
16aa5ed466
5 changed files with 153 additions and 113 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -16,84 +16,119 @@ 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 (
|
||||||
|
<Ariakit.MenuProvider store={menu}>
|
||||||
|
{trigger}
|
||||||
|
{isOpen && <Menu {...props} />}
|
||||||
|
</Ariakit.MenuProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Ariakit.MenuProvider store={menu}>
|
<Ariakit.MenuProvider store={menu}>
|
||||||
{trigger}
|
{trigger}
|
||||||
<Ariakit.Menu
|
<Menu {...props} />
|
||||||
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>
|
|
||||||
</Ariakit.MenuProvider>
|
</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;
|
export default DropdownPopup;
|
||||||
|
|
|
||||||
52
package-lock.json
generated
52
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue