Skip to content

Commit 16aa5ed

Browse files
authored
🛠️ fix: Improve Accessibility and Display of Conversation Menu (danny-avila#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
1 parent 000f3a3 commit 16aa5ed

File tree

5 files changed

+153
-113
lines changed

5 files changed

+153
-113
lines changed

client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"homepage": "https://librechat.ai",
3030
"dependencies": {
3131
"@ariakit/react": "^0.4.15",
32-
"@ariakit/react-core": "^0.4.15",
32+
"@ariakit/react-core": "^0.4.17",
3333
"@codesandbox/sandpack-react": "^2.19.10",
3434
"@dicebear/collection": "^9.2.2",
3535
"@dicebear/core": "^9.2.2",

client/src/components/Conversations/Convo.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,12 @@ export default function Conversation({
184184
)}
185185
<div
186186
className={cn(
187-
'mr-2',
187+
'mr-2 flex origin-left',
188188
isPopoverActive || isActiveConvo
189-
? 'flex'
190-
: 'hidden group-focus-within:flex group-hover:flex',
189+
? 'pointer-events-auto max-w-[28px] scale-x-100 opacity-100'
190+
: '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',
191191
)}
192+
aria-hidden={!(isPopoverActive || isActiveConvo)}
192193
>
193194
{!renaming && <ConvoOptions {...convoOptionsProps} />}
194195
</div>

client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx

Lines changed: 14 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -50,35 +50,6 @@ function ConvoOptions({
5050

5151
const archiveConvoMutation = useArchiveConvoMutation();
5252

53-
const archiveHandler = async () => {
54-
const convoId = conversationId ?? '';
55-
56-
if (!convoId) {
57-
return;
58-
}
59-
60-
archiveConvoMutation.mutate(
61-
{ conversationId: convoId, isArchived: true },
62-
{
63-
onSuccess: () => {
64-
if (currentConvoId === convoId || currentConvoId === 'new') {
65-
newConversation();
66-
navigate('/c/new', { replace: true });
67-
}
68-
retainView();
69-
setIsPopoverActive(false);
70-
},
71-
onError: () => {
72-
showToast({
73-
message: localize('com_ui_archive_error'),
74-
severity: NotificationSeverity.ERROR,
75-
showIcon: true,
76-
});
77-
},
78-
},
79-
);
80-
};
81-
8253
const duplicateConversation = useDuplicateConversationMutation({
8354
onSuccess: (data) => {
8455
navigateToConvo(data.conversation);
@@ -220,19 +191,30 @@ function ConvoOptions({
220191
return (
221192
<>
222193
<DropdownPopup
194+
portal={true}
195+
mountByState={true}
196+
unmountOnHide={true}
197+
preserveTabOrder={true}
223198
isOpen={isPopoverActive}
224199
setIsOpen={setIsPopoverActive}
225200
trigger={
226201
<Menu.MenuButton
227202
id={`conversation-menu-${conversationId}`}
228203
aria-label={localize('com_nav_convo_menu_options')}
229204
className={cn(
230-
'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',
231-
isActiveConvo === true
205+
'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',
206+
isActiveConvo === true || isPopoverActive
232207
? 'opacity-100'
233208
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
234209
)}
235-
onClick={(e) => e.stopPropagation()}
210+
onClick={(e: MouseEvent<HTMLButtonElement>) => {
211+
e.stopPropagation();
212+
}}
213+
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => {
214+
if (e.key === 'Enter' || e.key === ' ') {
215+
e.stopPropagation();
216+
}
217+
}}
236218
>
237219
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
238220
</Menu.MenuButton>

client/src/components/ui/DropdownPopup.tsx

Lines changed: 97 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -16,84 +16,119 @@ interface DropdownProps {
1616
anchor?: { x: string; y: string };
1717
gutter?: number;
1818
modal?: boolean;
19+
portal?: boolean;
20+
preserveTabOrder?: boolean;
1921
focusLoop?: boolean;
2022
menuId: string;
23+
mountByState?: boolean;
24+
unmountOnHide?: boolean;
25+
finalFocus?: React.RefObject<HTMLElement>;
2126
}
2227

28+
type MenuProps = Omit<
29+
DropdownProps,
30+
'trigger' | 'isOpen' | 'setIsOpen' | 'focusLoop' | 'mountByState'
31+
>;
32+
2333
const DropdownPopup: React.FC<DropdownProps> = ({
24-
keyPrefix,
2534
trigger,
26-
items,
2735
isOpen,
2836
setIsOpen,
29-
menuId,
30-
modal,
31-
gutter = 8,
32-
sameWidth,
33-
className,
3437
focusLoop,
35-
iconClassName,
36-
itemClassName,
38+
mountByState,
39+
...props
3740
}) => {
3841
const menu = Ariakit.useMenuStore({ open: isOpen, setOpen: setIsOpen, focusLoop });
39-
42+
if (mountByState) {
43+
return (
44+
<Ariakit.MenuProvider store={menu}>
45+
{trigger}
46+
{isOpen && <Menu {...props} />}
47+
</Ariakit.MenuProvider>
48+
);
49+
}
4050
return (
4151
<Ariakit.MenuProvider store={menu}>
4252
{trigger}
43-
<Ariakit.Menu
44-
id={menuId}
45-
className={cn('popover-ui z-50', className)}
46-
gutter={gutter}
47-
modal={modal}
48-
sameWidth={sameWidth}
49-
>
50-
{items
51-
.filter((item) => item.show !== false)
52-
.map((item, index) => {
53-
if (item.separate === true) {
54-
return <Ariakit.MenuSeparator key={index} className="my-1 h-px bg-white/10" />;
55-
}
56-
return (
57-
<Ariakit.MenuItem
58-
key={`${keyPrefix ?? ''}${index}`}
59-
id={item.id}
60-
className={cn(
61-
'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',
62-
itemClassName,
63-
)}
64-
disabled={item.disabled}
65-
render={item.render}
66-
ref={item.ref}
67-
hideOnClick={item.hideOnClick}
68-
onClick={(event) => {
69-
event.preventDefault();
70-
if (item.onClick) {
71-
item.onClick(event);
72-
}
73-
if (item.hideOnClick === false) {
74-
return;
75-
}
76-
menu.hide();
77-
}}
78-
>
79-
{item.icon != null && (
80-
<span className={cn('mr-2 size-4', iconClassName)} aria-hidden="true">
81-
{item.icon}
82-
</span>
83-
)}
84-
{item.label}
85-
{item.kbd != null && (
86-
// eslint-disable-next-line i18next/no-literal-string
87-
<kbd className="ml-auto hidden font-sans text-xs text-black/50 group-hover:inline group-focus:inline dark:text-white/50">
88-
{item.kbd}
89-
</kbd>
90-
)}
91-
</Ariakit.MenuItem>
92-
);
93-
})}
94-
</Ariakit.Menu>
53+
<Menu {...props} />
9554
</Ariakit.MenuProvider>
9655
);
9756
};
9857

58+
const Menu: React.FC<MenuProps> = ({
59+
items,
60+
menuId,
61+
keyPrefix,
62+
className,
63+
iconClassName,
64+
itemClassName,
65+
modal,
66+
portal,
67+
sameWidth,
68+
gutter = 8,
69+
finalFocus,
70+
unmountOnHide,
71+
preserveTabOrder,
72+
}) => {
73+
const menu = Ariakit.useMenuContext();
74+
return (
75+
<Ariakit.Menu
76+
id={menuId}
77+
modal={modal}
78+
gutter={gutter}
79+
portal={portal}
80+
sameWidth={sameWidth}
81+
finalFocus={finalFocus}
82+
unmountOnHide={unmountOnHide}
83+
preserveTabOrder={preserveTabOrder}
84+
className={cn('popover-ui z-50', className)}
85+
>
86+
{items
87+
.filter((item) => item.show !== false)
88+
.map((item, index) => {
89+
if (item.separate === true) {
90+
return <Ariakit.MenuSeparator key={index} className="my-1 h-px bg-white/10" />;
91+
}
92+
return (
93+
<Ariakit.MenuItem
94+
key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}`}
95+
id={item.id}
96+
className={cn(
97+
'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',
98+
itemClassName,
99+
)}
100+
disabled={item.disabled}
101+
render={item.render}
102+
ref={item.ref}
103+
hideOnClick={item.hideOnClick}
104+
onClick={(event) => {
105+
event.preventDefault();
106+
if (item.onClick) {
107+
item.onClick(event);
108+
}
109+
if (item.hideOnClick === false) {
110+
return;
111+
}
112+
menu?.hide();
113+
}}
114+
>
115+
{item.icon != null && (
116+
<span className={cn('mr-2 size-4', iconClassName)} aria-hidden="true">
117+
{item.icon}
118+
</span>
119+
)}
120+
{item.label}
121+
{item.kbd != null && (
122+
// eslint-disable-next-line i18next/no-literal-string
123+
<kbd className="ml-auto hidden font-sans text-xs text-black/50 group-hover:inline group-focus:inline dark:text-white/50">
124+
{item.kbd}
125+
</kbd>
126+
)}
127+
</Ariakit.MenuItem>
128+
);
129+
})}
130+
</Ariakit.Menu>
131+
);
132+
};
133+
99134
export default DropdownPopup;

package-lock.json

Lines changed: 37 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)