👐 a11y: Accessible Conversation Menu Options (#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 #3788

* fix: Menu button in the chat history area does not indicate its state #3823

* refactor: use ariakit for DropdownPopup

* style: update sticky z-index in NewChat component

* style: update ConvoOptions menu button styling
This commit is contained in:
Danny Avila 2024-08-30 13:39:30 -04:00 committed by GitHub
parent 2ce4f66218
commit 0a359aa705
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 122 additions and 131 deletions

View file

@ -1,9 +1,10 @@
import { useState } from 'react'; import { useState, useId } from 'react';
import { Upload, Share2 } from 'lucide-react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import * as Ariakit from '@ariakit/react';
import { Upload, Share2 } from 'lucide-react';
import { ShareButton } from '~/components/Conversations/ConvoOptions'; import { ShareButton } from '~/components/Conversations/ConvoOptions';
import { Button, DropdownPopup } from '~/components/ui';
import { useMediaQuery, useLocalize } from '~/hooks'; import { useMediaQuery, useLocalize } from '~/hooks';
import { DropdownPopup } from '~/components/ui';
import { ExportModal } from '../Nav'; import { ExportModal } from '../Nav';
import store from '~/store'; import store from '~/store';
@ -13,11 +14,13 @@ export default function ExportAndShareMenu({
isSharedButtonEnabled: boolean; isSharedButtonEnabled: boolean;
}) { }) {
const localize = useLocalize(); const localize = useLocalize();
const conversation = useRecoilValue(store.conversationByIndex(0));
const [isPopoverActive, setIsPopoverActive] = useState(false);
const [showExports, setShowExports] = useState(false); const [showExports, setShowExports] = useState(false);
const [isPopoverActive, setIsPopoverActive] = useState(false);
const [showShareDialog, setShowShareDialog] = useState(false); const [showShareDialog, setShowShareDialog] = useState(false);
const menuId = useId();
const isSmallScreen = useMediaQuery('(max-width: 768px)'); const isSmallScreen = useMediaQuery('(max-width: 768px)');
const conversation = useRecoilValue(store.conversationByIndex(0));
const exportable = const exportable =
conversation && conversation &&
@ -60,20 +63,19 @@ export default function ExportAndShareMenu({
return ( return (
<> <>
<DropdownPopup <DropdownPopup
menuId={menuId}
isOpen={isPopoverActive} isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive} setIsOpen={setIsPopoverActive}
trigger={ trigger={
<Button <Ariakit.MenuButton
id="export-menu-button" id="export-menu-button"
aria-label="Export options" aria-label="Export options"
variant="outline" 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"
className="mr-4 h-10 w-10 p-0 transition-all duration-300 ease-in-out"
> >
<Upload className="icon-md dark:text-gray-300" aria-hidden="true" focusable="false" /> <Upload className="icon-md dark:text-gray-300" aria-hidden="true" focusable="false" />
</Button> </Ariakit.MenuButton>
} }
items={dropdownItems} items={dropdownItems}
anchor="bottom end"
className={isSmallScreen ? '' : 'absolute right-0 top-0 mt-2'} className={isSmallScreen ? '' : 'absolute right-0 top-0 mt-2'}
/> />
{showShareDialog && conversation.conversationId != null && ( {showShareDialog && conversation.conversationId != null && (

View file

@ -1,11 +1,11 @@
import { useRecoilValue } from 'recoil';
import { useParams } from 'react-router-dom';
import React, { useState, useEffect, useRef, useMemo } from 'react'; import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { Check, X } from 'lucide-react';
import { useParams } from 'react-router-dom';
import { Constants } from 'librechat-data-provider'; import { Constants } from 'librechat-data-provider';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query'; import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import { Check, X } from 'lucide-react';
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react'; import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
import type { TConversation } from 'librechat-data-provider';
import { useConversations, useNavigateToConvo, useMediaQuery } from '~/hooks'; import { useConversations, useNavigateToConvo, useMediaQuery } from '~/hooks';
import { useUpdateConversationMutation } from '~/data-provider'; import { useUpdateConversationMutation } from '~/data-provider';
import EndpointIcon from '~/components/Endpoints/EndpointIcon'; import EndpointIcon from '~/components/Endpoints/EndpointIcon';
@ -17,7 +17,19 @@ import store from '~/store';
type KeyEvent = KeyboardEvent<HTMLInputElement>; type KeyEvent = KeyboardEvent<HTMLInputElement>;
export default function Conversation({ conversation, retainView, toggleNav, isLatestConvo }) { type ConversationProps = {
conversation: TConversation;
retainView: () => void;
toggleNav: () => void;
isLatestConvo: boolean;
};
export default function Conversation({
conversation,
retainView,
toggleNav,
isLatestConvo,
}: ConversationProps) {
const params = useParams(); const params = useParams();
const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]); const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]);
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? ''); const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
@ -33,7 +45,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
const [isPopoverActive, setIsPopoverActive] = useState(false); const [isPopoverActive, setIsPopoverActive] = useState(false);
const isSmallScreen = useMediaQuery('(max-width: 768px)'); const isSmallScreen = useMediaQuery('(max-width: 768px)');
const clickHandler = async (event: React.MouseEvent<HTMLAnchorElement>) => { const clickHandler = async (event: MouseEvent<HTMLAnchorElement>) => {
if (event.button === 0 && (event.ctrlKey || event.metaKey)) { if (event.button === 0 && (event.ctrlKey || event.metaKey)) {
toggleNav(); toggleNav();
return; return;
@ -47,12 +59,17 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
toggleNav(); toggleNav();
// set document title // set document title
document.title = title; if (typeof title === 'string' && title.length > 0) {
document.title = title;
}
/* Note: Latest Message should not be reset if existing convo */ /* Note: Latest Message should not be reset if existing convo */
navigateWithLastTools(conversation, !conversationId || conversationId === Constants.NEW_CONVO); navigateWithLastTools(
conversation,
!(conversationId ?? '') || conversationId === Constants.NEW_CONVO,
);
}; };
const renameHandler = (e: MouseEvent<HTMLButtonElement>) => { const renameHandler: (e: MouseEvent<HTMLButtonElement>) => void = () => {
setIsPopoverActive(false); setIsPopoverActive(false);
setTitleInput(title); setTitleInput(title);
setRenaming(true); setRenaming(true);
@ -70,8 +87,12 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
if (titleInput === title) { if (titleInput === title) {
return; return;
} }
if (typeof conversationId !== 'string' || conversationId === '') {
return;
}
updateConvoMutation.mutate( updateConvoMutation.mutate(
{ conversationId, title: titleInput }, { conversationId, title: titleInput ?? '' },
{ {
onSuccess: () => refreshConversations(), onSuccess: () => refreshConversations(),
onError: () => { onError: () => {
@ -101,14 +122,17 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
setRenaming(false); setRenaming(false);
}; };
const isActiveConvo = const isActiveConvo: boolean =
currentConvoId === conversationId || currentConvoId === conversationId ||
(isLatestConvo && currentConvoId === 'new' && activeConvos[0] && activeConvos[0] !== 'new'); (isLatestConvo &&
currentConvoId === 'new' &&
activeConvos[0] != null &&
activeConvos[0] !== 'new');
return ( return (
<div <div
className={cn( className={cn(
'group relative mt-2 flex h-9 items-center rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700', 'group relative mt-2 flex h-9 w-full items-center rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700',
isActiveConvo ? 'bg-gray-200 dark:bg-gray-700' : '', isActiveConvo ? 'bg-gray-200 dark:bg-gray-700' : '',
isSmallScreen ? 'h-12' : '', isSmallScreen ? 'h-12' : '',
)} )}
@ -119,7 +143,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
ref={inputRef} ref={inputRef}
type="text" type="text"
className="w-full rounded bg-transparent p-0.5 text-sm leading-tight outline-none" className="w-full rounded bg-transparent p-0.5 text-sm leading-tight outline-none"
value={titleInput} value={titleInput ?? ''}
onChange={(e) => setTitleInput(e.target.value)} onChange={(e) => setTitleInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
/> />
@ -141,7 +165,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
'flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg px-2 py-2', 'flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg px-2 py-2',
isActiveConvo ? 'bg-gray-200 dark:bg-gray-700' : '', isActiveConvo ? 'bg-gray-200 dark:bg-gray-700' : '',
)} )}
title={title} title={title ?? ''}
> >
<EndpointIcon <EndpointIcon
conversation={conversation} conversation={conversation}
@ -149,16 +173,9 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
size={20} size={20}
context="menu-item" context="menu-item"
/> />
{!renaming && ( <div className="relative line-clamp-1 flex-1 grow overflow-hidden">{title}</div>
<div className="relative line-clamp-1 flex-1 grow overflow-hidden">{title}</div>
)}
{isActiveConvo ? ( {isActiveConvo ? (
<div <div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l" />
className={cn(
'absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l',
!renaming ? 'from-gray-200 from-40% to-transparent dark:from-gray-700' : '',
)}
/>
) : ( ) : (
<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" /> <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" />
)} )}
@ -167,7 +184,9 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
<div <div
className={cn( className={cn(
'mr-2', 'mr-2',
isPopoverActive || isActiveConvo ? 'flex' : 'hidden group-hover:flex', isPopoverActive || isActiveConvo
? 'flex'
: 'hidden group-focus-within:flex group-hover:flex',
)} )}
> >
<ConvoOptions <ConvoOptions

View file

@ -1,12 +1,13 @@
import { useState } from 'react'; import { useState, useId } from 'react';
import * as Ariakit from '@ariakit/react';
import { Ellipsis, Share2, Archive, Pen, Trash } from 'lucide-react'; import { Ellipsis, Share2, Archive, Pen, Trash } from 'lucide-react';
import { useGetStartupConfig } from 'librechat-data-provider/react-query'; import { useGetStartupConfig } from 'librechat-data-provider/react-query';
import { Button } from '~/components/ui';
import { useArchiveHandler } from './ArchiveButton'; import { useArchiveHandler } from './ArchiveButton';
import { DropdownPopup } from '~/components/ui'; import { DropdownPopup } from '~/components/ui';
import DeleteButton from './DeleteButton'; import DeleteButton from './DeleteButton';
import ShareButton from './ShareButton'; import ShareButton from './ShareButton';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function ConvoOptions({ export default function ConvoOptions({
conversation, conversation,
@ -57,27 +58,29 @@ export default function ConvoOptions({
}, },
]; ];
const menuId = useId();
return ( return (
<> <>
<DropdownPopup <DropdownPopup
isOpen={isPopoverActive} isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive} setIsOpen={setIsPopoverActive}
trigger={ trigger={
<Button <Ariakit.MenuButton
id="conversation-menu-button" id="conversation-menu-button"
aria-label="conversation-menu-button" aria-label={localize('com_nav_convo_menu_options')}
variant="link" className={cn(
className="z-10 h-7 w-7 border-none p-0 transition-all duration-200 ease-in-out" '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',
isActiveConvo === true
? 'opacity-100'
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
)}
> >
<Ellipsis className="icon-md text-text-secondary" /> <Ellipsis className="icon-md text-text-secondary" />
</Button> </Ariakit.MenuButton>
} }
items={dropdownItems} items={dropdownItems}
className={`${ menuId={menuId}
isActiveConvo === true
? 'opacity-100'
: 'opacity-0 focus:opacity-100 group-hover:opacity-100 data-[open]:opacity-100'
}`}
/> />
{showShareDialog && ( {showShareDialog && (
<ShareButton <ShareButton

View file

@ -38,7 +38,7 @@ const NewChatButtonIcon = ({ conversation }: { conversation: TConversation | nul
<ConvoIconURL preset={conversation} endpointIconURL={iconURL} context="nav" /> <ConvoIconURL preset={conversation} endpointIconURL={iconURL} context="nav" />
) : ( ) : (
<div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black"> <div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
{endpoint && Icon && ( {endpoint && Icon != null && (
<Icon <Icon
size={41} size={41}
context="nav" context="nav"
@ -82,7 +82,7 @@ export default function NewChat({
return ( return (
<TooltipProvider delayDuration={250}> <TooltipProvider delayDuration={250}>
<Tooltip> <Tooltip>
<div className="sticky left-0 right-0 top-0 z-20 bg-surface-primary-alt pt-3.5"> <div className="sticky left-0 right-0 top-0 z-50 bg-surface-primary-alt pt-3.5">
<div className="pb-0.5 last:pb-0" style={{ transform: 'none' }}> <div className="pb-0.5 last:pb-0" style={{ transform: 'none' }}>
<a <a
href="/" href="/"
@ -93,7 +93,7 @@ export default function NewChat({
aria-label={localize('com_ui_new_chat')} aria-label={localize('com_ui_new_chat')}
> >
<NewChatButtonIcon conversation={conversation} /> <NewChatButtonIcon conversation={conversation} />
<div className="text-token-text-primary grow overflow-hidden text-ellipsis whitespace-nowrap text-sm"> <div className="grow overflow-hidden text-ellipsis whitespace-nowrap text-sm text-text-primary">
{localize('com_ui_new_chat')} {localize('com_ui_new_chat')}
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@ -102,7 +102,7 @@ export default function NewChat({
<button <button
id="nav-new-chat-btn" id="nav-new-chat-btn"
aria-label="nav-new-chat-btn" aria-label="nav-new-chat-btn"
className="text-token-text-primary" className="text-text-primary"
> >
<NewChatIcon className="size-5" /> <NewChatIcon className="size-5" />
</button> </button>

View file

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'; import * as Ariakit from '@ariakit/react';
interface DropdownProps { interface DropdownProps {
trigger: React.ReactNode; trigger: React.ReactNode;
@ -15,89 +15,55 @@ interface DropdownProps {
isOpen: boolean; isOpen: boolean;
setIsOpen: (isOpen: boolean) => void; setIsOpen: (isOpen: boolean) => void;
className?: string; className?: string;
anchor?: string; anchor?: { x: string; y: string };
menuId: string;
} }
const DropdownPopup: React.FC<DropdownProps> = ({ const DropdownPopup: React.FC<DropdownProps> = ({ trigger, items, isOpen, setIsOpen, menuId }) => {
trigger, const menu = Ariakit.useMenuStore({ open: isOpen, setOpen: setIsOpen });
items,
isOpen,
setIsOpen,
className,
anchor = { x: 'bottom', y: 'start' },
}) => {
const handleButtonClick = () => {
setIsOpen(!isOpen);
};
return ( return (
<Menu> <Ariakit.MenuProvider store={menu}>
{({ open }) => ( {trigger}
<> <Ariakit.Menu
<MenuButton id={menuId}
onClick={handleButtonClick} className="z-50 mt-2 overflow-hidden rounded-lg bg-header-primary p-1.5 shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
className={`inline-flex items-center gap-2 rounded-md ${className}`} gutter={8}
/** This is set as `div` since triggers themselves are buttons; >
* prevents React Warning: validateDOMNesting(...): <button> cannot appear as a descendant of <button>. {items
*/ .filter((item) => item.show !== false)
as="div" .map((item, index) =>
> item.separate === true ? (
{trigger} <Ariakit.MenuSeparator key={index} className="my-1 h-px bg-white/10" />
</MenuButton> ) : (
<Transition <Ariakit.MenuItem
show={open} key={index}
enter="transition-opacity duration-150" className="group flex w-full cursor-pointer items-center gap-2 rounded-lg p-2.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover"
enterFrom="opacity-0" disabled={item.disabled}
enterTo="opacity-100" onClick={(event) => {
leave="transition-opacity duration-150" event.preventDefault();
leaveFrom="opacity-100" if (item.onClick) {
leaveTo="opacity-0" item.onClick();
afterLeave={() => setIsOpen(false)} }
> menu.hide();
<div className={`${isOpen ? 'visible' : 'invisible'}`}> }}
{open && ( >
<MenuItems {item.icon != null && (
static <span className="mr-2 h-5 w-5" aria-hidden="true">
// @ts-ignore {item.icon}
anchor={anchor} </span>
className="mt-2 overflow-hidden rounded-lg bg-header-primary p-1.5 shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring-primary" )}
> {item.label}
<div> {item.kbd != null && (
{items <kbd className="ml-auto hidden font-sans text-xs text-black/50 group-hover:inline group-focus:inline dark:text-white/50">
.filter((item) => item.show !== false) {item.kbd}
.map((item, index) => </kbd>
item.separate ? ( )}
<div key={index} className="my-1 h-px bg-white/10" /> </Ariakit.MenuItem>
) : ( ),
<MenuItem key={index}> )}
<button </Ariakit.Menu>
onClick={item.onClick} </Ariakit.MenuProvider>
className="group flex w-full gap-2 rounded-lg p-2.5 text-sm text-text-primary transition-colors duration-200 data-[focus]:bg-surface-hover"
disabled={item.disabled}
>
{item.icon && (
<span className="mr-2 h-5 w-5" aria-hidden="true">
{item.icon}
</span>
)}
{item.label}
{item.kbd && (
<kbd className="ml-auto hidden font-sans text-xs text-black/50 group-data-[focus]:inline dark:text-white/50">
{item.kbd}
</kbd>
)}
</button>
</MenuItem>
),
)}
</div>
</MenuItems>
)}
</div>
</Transition>
</>
)}
</Menu>
); );
}; };

View file

@ -3,6 +3,7 @@
// file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets present in this file // file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets present in this file
export default { export default {
com_nav_convo_menu_options: 'Conversation Menu Options',
com_ui_artifacts: 'Artifacts', com_ui_artifacts: 'Artifacts',
com_ui_artifacts_toggle: 'Toggle Artifacts UI', com_ui_artifacts_toggle: 'Toggle Artifacts UI',
com_nav_info_code_artifacts: com_nav_info_code_artifacts: