Skip to content

Commit 0a359aa

Browse files
authored
👐 a11y: Accessible Conversation Menu Options (danny-avila#3864)
* fix: type issues * feat: Fix document title setting in Conversation component * style: new chat theme * fix: No keyboard access to chat menus in the chat history danny-avila#3788 * fix: Menu button in the chat history area does not indicate its state danny-avila#3823 * refactor: use ariakit for DropdownPopup * style: update sticky z-index in NewChat component * style: update ConvoOptions menu button styling
1 parent 2ce4f66 commit 0a359aa

File tree

6 files changed

+121
-130
lines changed

6 files changed

+121
-130
lines changed

client/src/components/Chat/ExportAndShareMenu.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { useState } from 'react';
2-
import { Upload, Share2 } from 'lucide-react';
1+
import { useState, useId } from 'react';
32
import { useRecoilValue } from 'recoil';
3+
import * as Ariakit from '@ariakit/react';
4+
import { Upload, Share2 } from 'lucide-react';
45
import { ShareButton } from '~/components/Conversations/ConvoOptions';
5-
import { Button, DropdownPopup } from '~/components/ui';
66
import { useMediaQuery, useLocalize } from '~/hooks';
7+
import { DropdownPopup } from '~/components/ui';
78
import { ExportModal } from '../Nav';
89
import store from '~/store';
910

@@ -13,11 +14,13 @@ export default function ExportAndShareMenu({
1314
isSharedButtonEnabled: boolean;
1415
}) {
1516
const localize = useLocalize();
16-
const conversation = useRecoilValue(store.conversationByIndex(0));
17-
const [isPopoverActive, setIsPopoverActive] = useState(false);
1817
const [showExports, setShowExports] = useState(false);
18+
const [isPopoverActive, setIsPopoverActive] = useState(false);
1919
const [showShareDialog, setShowShareDialog] = useState(false);
20+
21+
const menuId = useId();
2022
const isSmallScreen = useMediaQuery('(max-width: 768px)');
23+
const conversation = useRecoilValue(store.conversationByIndex(0));
2124

2225
const exportable =
2326
conversation &&
@@ -60,20 +63,19 @@ export default function ExportAndShareMenu({
6063
return (
6164
<>
6265
<DropdownPopup
66+
menuId={menuId}
6367
isOpen={isPopoverActive}
6468
setIsOpen={setIsPopoverActive}
6569
trigger={
66-
<Button
70+
<Ariakit.MenuButton
6771
id="export-menu-button"
6872
aria-label="Export options"
69-
variant="outline"
70-
className="mr-4 h-10 w-10 p-0 transition-all duration-300 ease-in-out"
73+
className="mr-4 inline-flex h-10 w-10 items-center justify-center rounded-md border border-border-light bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
7174
>
7275
<Upload className="icon-md dark:text-gray-300" aria-hidden="true" focusable="false" />
73-
</Button>
76+
</Ariakit.MenuButton>
7477
}
7578
items={dropdownItems}
76-
anchor="bottom end"
7779
className={isSmallScreen ? '' : 'absolute right-0 top-0 mt-2'}
7880
/>
7981
{showShareDialog && conversation.conversationId != null && (

client/src/components/Conversations/Convo.tsx

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
import React, { useState, useEffect, useRef, useMemo } from 'react';
12
import { useRecoilValue } from 'recoil';
3+
import { Check, X } from 'lucide-react';
24
import { useParams } from 'react-router-dom';
3-
import React, { useState, useEffect, useRef, useMemo } from 'react';
4-
55
import { Constants } from 'librechat-data-provider';
66
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
7-
import { Check, X } from 'lucide-react';
87
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
8+
import type { TConversation } from 'librechat-data-provider';
99
import { useConversations, useNavigateToConvo, useMediaQuery } from '~/hooks';
1010
import { useUpdateConversationMutation } from '~/data-provider';
1111
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
@@ -17,7 +17,19 @@ import store from '~/store';
1717

1818
type KeyEvent = KeyboardEvent<HTMLInputElement>;
1919

20-
export default function Conversation({ conversation, retainView, toggleNav, isLatestConvo }) {
20+
type ConversationProps = {
21+
conversation: TConversation;
22+
retainView: () => void;
23+
toggleNav: () => void;
24+
isLatestConvo: boolean;
25+
};
26+
27+
export default function Conversation({
28+
conversation,
29+
retainView,
30+
toggleNav,
31+
isLatestConvo,
32+
}: ConversationProps) {
2133
const params = useParams();
2234
const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]);
2335
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
@@ -33,7 +45,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
3345
const [isPopoverActive, setIsPopoverActive] = useState(false);
3446
const isSmallScreen = useMediaQuery('(max-width: 768px)');
3547

36-
const clickHandler = async (event: React.MouseEvent<HTMLAnchorElement>) => {
48+
const clickHandler = async (event: MouseEvent<HTMLAnchorElement>) => {
3749
if (event.button === 0 && (event.ctrlKey || event.metaKey)) {
3850
toggleNav();
3951
return;
@@ -47,12 +59,17 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
4759
toggleNav();
4860

4961
// set document title
50-
document.title = title;
62+
if (typeof title === 'string' && title.length > 0) {
63+
document.title = title;
64+
}
5165
/* Note: Latest Message should not be reset if existing convo */
52-
navigateWithLastTools(conversation, !conversationId || conversationId === Constants.NEW_CONVO);
66+
navigateWithLastTools(
67+
conversation,
68+
!(conversationId ?? '') || conversationId === Constants.NEW_CONVO,
69+
);
5370
};
5471

55-
const renameHandler = (e: MouseEvent<HTMLButtonElement>) => {
72+
const renameHandler: (e: MouseEvent<HTMLButtonElement>) => void = () => {
5673
setIsPopoverActive(false);
5774
setTitleInput(title);
5875
setRenaming(true);
@@ -70,8 +87,12 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
7087
if (titleInput === title) {
7188
return;
7289
}
90+
if (typeof conversationId !== 'string' || conversationId === '') {
91+
return;
92+
}
93+
7394
updateConvoMutation.mutate(
74-
{ conversationId, title: titleInput },
95+
{ conversationId, title: titleInput ?? '' },
7596
{
7697
onSuccess: () => refreshConversations(),
7798
onError: () => {
@@ -101,14 +122,17 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
101122
setRenaming(false);
102123
};
103124

104-
const isActiveConvo =
125+
const isActiveConvo: boolean =
105126
currentConvoId === conversationId ||
106-
(isLatestConvo && currentConvoId === 'new' && activeConvos[0] && activeConvos[0] !== 'new');
127+
(isLatestConvo &&
128+
currentConvoId === 'new' &&
129+
activeConvos[0] != null &&
130+
activeConvos[0] !== 'new');
107131

108132
return (
109133
<div
110134
className={cn(
111-
'group relative mt-2 flex h-9 items-center rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700',
135+
'group relative mt-2 flex h-9 w-full items-center rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700',
112136
isActiveConvo ? 'bg-gray-200 dark:bg-gray-700' : '',
113137
isSmallScreen ? 'h-12' : '',
114138
)}
@@ -119,7 +143,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
119143
ref={inputRef}
120144
type="text"
121145
className="w-full rounded bg-transparent p-0.5 text-sm leading-tight outline-none"
122-
value={titleInput}
146+
value={titleInput ?? ''}
123147
onChange={(e) => setTitleInput(e.target.value)}
124148
onKeyDown={handleKeyDown}
125149
/>
@@ -141,24 +165,17 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
141165
'flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg px-2 py-2',
142166
isActiveConvo ? 'bg-gray-200 dark:bg-gray-700' : '',
143167
)}
144-
title={title}
168+
title={title ?? ''}
145169
>
146170
<EndpointIcon
147171
conversation={conversation}
148172
endpointsConfig={endpointsConfig}
149173
size={20}
150174
context="menu-item"
151175
/>
152-
{!renaming && (
153-
<div className="relative line-clamp-1 flex-1 grow overflow-hidden">{title}</div>
154-
)}
176+
<div className="relative line-clamp-1 flex-1 grow overflow-hidden">{title}</div>
155177
{isActiveConvo ? (
156-
<div
157-
className={cn(
158-
'absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l',
159-
!renaming ? 'from-gray-200 from-40% to-transparent dark:from-gray-700' : '',
160-
)}
161-
/>
178+
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l" />
162179
) : (
163180
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l from-gray-50 from-0% to-transparent group-hover:from-gray-200 group-hover:from-40% dark:from-gray-850 dark:group-hover:from-gray-700" />
164181
)}
@@ -167,7 +184,9 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
167184
<div
168185
className={cn(
169186
'mr-2',
170-
isPopoverActive || isActiveConvo ? 'flex' : 'hidden group-hover:flex',
187+
isPopoverActive || isActiveConvo
188+
? 'flex'
189+
: 'hidden group-focus-within:flex group-hover:flex',
171190
)}
172191
>
173192
<ConvoOptions

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

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import { useState } from 'react';
1+
import { useState, useId } from 'react';
2+
import * as Ariakit from '@ariakit/react';
23
import { Ellipsis, Share2, Archive, Pen, Trash } from 'lucide-react';
34
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
4-
import { Button } from '~/components/ui';
55
import { useArchiveHandler } from './ArchiveButton';
66
import { DropdownPopup } from '~/components/ui';
77
import DeleteButton from './DeleteButton';
88
import ShareButton from './ShareButton';
99
import { useLocalize } from '~/hooks';
10+
import { cn } from '~/utils';
1011

1112
export default function ConvoOptions({
1213
conversation,
@@ -57,27 +58,29 @@ export default function ConvoOptions({
5758
},
5859
];
5960

61+
const menuId = useId();
62+
6063
return (
6164
<>
6265
<DropdownPopup
6366
isOpen={isPopoverActive}
6467
setIsOpen={setIsPopoverActive}
6568
trigger={
66-
<Button
69+
<Ariakit.MenuButton
6770
id="conversation-menu-button"
68-
aria-label="conversation-menu-button"
69-
variant="link"
70-
className="z-10 h-7 w-7 border-none p-0 transition-all duration-200 ease-in-out"
71+
aria-label={localize('com_nav_convo_menu_options')}
72+
className={cn(
73+
'z-30 inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium 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',
74+
isActiveConvo === true
75+
? 'opacity-100'
76+
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
77+
)}
7178
>
7279
<Ellipsis className="icon-md text-text-secondary" />
73-
</Button>
80+
</Ariakit.MenuButton>
7481
}
7582
items={dropdownItems}
76-
className={`${
77-
isActiveConvo === true
78-
? 'opacity-100'
79-
: 'opacity-0 focus:opacity-100 group-hover:opacity-100 data-[open]:opacity-100'
80-
}`}
83+
menuId={menuId}
8184
/>
8285
{showShareDialog && (
8386
<ShareButton

client/src/components/Nav/NewChat.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const NewChatButtonIcon = ({ conversation }: { conversation: TConversation | nul
3838
<ConvoIconURL preset={conversation} endpointIconURL={iconURL} context="nav" />
3939
) : (
4040
<div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
41-
{endpoint && Icon && (
41+
{endpoint && Icon != null && (
4242
<Icon
4343
size={41}
4444
context="nav"
@@ -82,7 +82,7 @@ export default function NewChat({
8282
return (
8383
<TooltipProvider delayDuration={250}>
8484
<Tooltip>
85-
<div className="sticky left-0 right-0 top-0 z-20 bg-surface-primary-alt pt-3.5">
85+
<div className="sticky left-0 right-0 top-0 z-50 bg-surface-primary-alt pt-3.5">
8686
<div className="pb-0.5 last:pb-0" style={{ transform: 'none' }}>
8787
<a
8888
href="/"
@@ -93,7 +93,7 @@ export default function NewChat({
9393
aria-label={localize('com_ui_new_chat')}
9494
>
9595
<NewChatButtonIcon conversation={conversation} />
96-
<div className="text-token-text-primary grow overflow-hidden text-ellipsis whitespace-nowrap text-sm">
96+
<div className="grow overflow-hidden text-ellipsis whitespace-nowrap text-sm text-text-primary">
9797
{localize('com_ui_new_chat')}
9898
</div>
9999
<div className="flex gap-3">
@@ -102,7 +102,7 @@ export default function NewChat({
102102
<button
103103
id="nav-new-chat-btn"
104104
aria-label="nav-new-chat-btn"
105-
className="text-token-text-primary"
105+
className="text-text-primary"
106106
>
107107
<NewChatIcon className="size-5" />
108108
</button>

0 commit comments

Comments
 (0)