LibreChat/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx
Daniel Lew 1143f73f59
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
🔇 fix: Hide Button Icons from Screen Readers (#10776)
If you've got a screen reader that is reading out the whole page,
each icon button (i.e., `<button><SVG></button>`) will have both
the button's aria-label read out as well as the title from the
SVG (which is usually just "image").

Since we are pretty good about setting aria-labels, we should instead
use `aria-hidden="true"` on these images, since they are not useful
to be read out.

I don't consider this a comprehensive review of all icons in the app,
but I knocked out all the low hanging fruit in this commit.
2025-12-11 16:35:17 -05:00

257 lines
7.8 KiB
TypeScript

import { useState, useId, useRef, memo, useCallback, useMemo } from 'react';
import * as Menu from '@ariakit/react/menu';
import { useParams, useNavigate } from 'react-router-dom';
import { DropdownPopup, Spinner, useToastContext } from '@librechat/client';
import { Ellipsis, Share2, CopyPlus, Archive, Pen, Trash } from 'lucide-react';
import type { MouseEvent } from 'react';
import {
useDuplicateConversationMutation,
useGetStartupConfig,
useArchiveConvoMutation,
} from '~/data-provider';
import { useLocalize, useNavigateToConvo, useNewConvo } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { useChatContext } from '~/Providers';
import DeleteButton from './DeleteButton';
import ShareButton from './ShareButton';
import { cn } from '~/utils';
function ConvoOptions({
conversationId,
title,
retainView,
renameHandler,
isPopoverActive,
setIsPopoverActive,
isActiveConvo,
}: {
conversationId: string | null;
title: string | null;
retainView: () => void;
renameHandler: (e: MouseEvent) => void;
isPopoverActive: boolean;
setIsPopoverActive: React.Dispatch<React.SetStateAction<boolean>>;
isActiveConvo: boolean;
}) {
const localize = useLocalize();
const { index } = useChatContext();
const { data: startupConfig } = useGetStartupConfig();
const { navigateToConvo } = useNavigateToConvo(index);
const { showToast } = useToastContext();
const navigate = useNavigate();
const { conversationId: currentConvoId } = useParams();
const { newConversation } = useNewConvo();
const shareButtonRef = useRef<HTMLButtonElement>(null);
const deleteButtonRef = useRef<HTMLButtonElement>(null);
const [showShareDialog, setShowShareDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const archiveConvoMutation = useArchiveConvoMutation();
const duplicateConversation = useDuplicateConversationMutation({
onSuccess: (data) => {
navigateToConvo(data.conversation);
showToast({
message: localize('com_ui_duplication_success'),
status: 'success',
});
setIsPopoverActive(false);
},
onMutate: () => {
showToast({
message: localize('com_ui_duplication_processing'),
status: 'info',
});
},
onError: () => {
showToast({
message: localize('com_ui_duplication_error'),
status: 'error',
});
},
});
const isDuplicateLoading = duplicateConversation.isLoading;
const isArchiveLoading = archiveConvoMutation.isLoading;
const handleShareClick = useCallback(() => {
setShowShareDialog(true);
}, []);
const handleDeleteClick = useCallback(() => {
setShowDeleteDialog(true);
}, []);
const handleArchiveClick = useCallback(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,
});
},
},
);
}, [
conversationId,
currentConvoId,
archiveConvoMutation,
navigate,
newConversation,
retainView,
setIsPopoverActive,
showToast,
localize,
]);
const handleDuplicateClick = useCallback(() => {
duplicateConversation.mutate({
conversationId: conversationId ?? '',
});
}, [conversationId, duplicateConversation]);
const dropdownItems = useMemo(
() => [
{
label: localize('com_ui_share'),
onClick: handleShareClick,
icon: <Share2 className="icon-sm mr-2 text-text-primary" aria-hidden="true" />,
show: startupConfig && startupConfig.sharedLinksEnabled,
hideOnClick: false,
ref: shareButtonRef,
render: (props) => <button {...props} />,
},
{
label: localize('com_ui_rename'),
onClick: renameHandler,
icon: <Pen className="icon-sm mr-2 text-text-primary" aria-hidden="true" />,
},
{
label: localize('com_ui_duplicate'),
onClick: handleDuplicateClick,
hideOnClick: false,
icon: isDuplicateLoading ? (
<Spinner className="size-4" />
) : (
<CopyPlus className="icon-sm mr-2 text-text-primary" aria-hidden="true" />
),
},
{
label: localize('com_ui_archive'),
onClick: handleArchiveClick,
hideOnClick: false,
icon: isArchiveLoading ? (
<Spinner className="size-4" />
) : (
<Archive className="icon-sm mr-2 text-text-primary" aria-hidden="true" />
),
},
{
label: localize('com_ui_delete'),
onClick: handleDeleteClick,
icon: <Trash className="icon-sm mr-2 text-text-primary" aria-hidden="true" />,
hideOnClick: false,
ref: deleteButtonRef,
render: (props) => <button {...props} />,
},
],
[
localize,
handleShareClick,
startupConfig,
renameHandler,
handleDuplicateClick,
isDuplicateLoading,
handleArchiveClick,
isArchiveLoading,
handleDeleteClick,
],
);
const menuId = useId();
return (
<>
<DropdownPopup
portal={true}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
trigger={
<Menu.MenuButton
id={`conversation-menu-${conversationId}`}
aria-label={localize('com_nav_convo_menu_options')}
aria-readonly={undefined}
className={cn(
'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: 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>
}
items={dropdownItems}
menuId={menuId}
className="z-30"
/>
{showShareDialog && (
<ShareButton
conversationId={conversationId ?? ''}
open={showShareDialog}
onOpenChange={setShowShareDialog}
triggerRef={shareButtonRef}
/>
)}
{showDeleteDialog && (
<DeleteButton
title={title ?? ''}
retainView={retainView}
triggerRef={deleteButtonRef}
setMenuOpen={setIsPopoverActive}
showDeleteDialog={showDeleteDialog}
conversationId={conversationId ?? ''}
setShowDeleteDialog={setShowDeleteDialog}
/>
)}
</>
);
}
export default memo(ConvoOptions, (prevProps, nextProps) => {
return (
prevProps.conversationId === nextProps.conversationId &&
prevProps.title === nextProps.title &&
prevProps.isPopoverActive === nextProps.isPopoverActive &&
prevProps.isActiveConvo === nextProps.isActiveConvo
);
});