⌨️ refactor: Favorite Item Selection & Keyboard Navigation/Focus Improvements (#10952)

* refactor: Reuse conversation switching logic from useSelectMention hook for Favorite Items

- Added onSelectEndpoint prop to FavoriteItem for improved endpoint selection handling.
- Refactored conversation initiation logic to utilize the new prop instead of direct navigation.
- Updated FavoritesList to pass onSelectEndpoint to FavoriteItem, streamlining the interaction flow.
- Replaced EndpointIcon with MinimalIcon for a cleaner UI representation of favorite models.

* refactor: Enhance FavoriteItem and FavoritesList for improved accessibility and interaction

- Added onRemoveFocus prop to FavoriteItem for better focus management after item removal.
- Refactored event handling in FavoriteItem to support keyboard interactions for accessibility.
- Updated FavoritesList to utilize the new onRemoveFocus prop, ensuring focus shifts appropriately after removing favorites.
- Enhanced aria-labels and roles for better screen reader support and user experience.

* refactor: Enhance EndpointModelItem for improved accessibility and interaction

- Added useRef and useState hooks to manage active state and focus behavior.
- Implemented MutationObserver to track changes in the data-active-item attribute for better accessibility.
- Refactored favorite button handling to improve interaction and accessibility.
- Updated button tabIndex based on active state to enhance keyboard navigation.

* chore: Update Radix UI dependencies in package-lock and package.json files

- Upgraded @radix-ui/react-alert-dialog and @radix-ui/react-dialog to version 1.1.15 across client and packages/client.
- Updated related dependencies for improved compatibility and performance.
- Removed outdated debug module references from package-lock.json.

* refactor: Improve accessibility and interaction in conversation options

- Added event handling to prevent unintended actions when renaming conversations.
- Updated ConvoOptions to use Ariakit components for better accessibility and interaction.
- Refactored button handlers for sharing and deleting conversations for clarity and consistency.
- Enhanced dialog components with proper aria attributes and improved structure for better screen reader support.

* refactor: Improve nested dialog accessibility for deleting shared link

- Eliminated the setShareDialogOpen prop from both ShareButton and SharedLinkButton components to streamline the code.
- Updated the delete mutation success handler in SharedLinkButton to improve focus management for accessibility.
- Enhanced the OGDialog component in SharedLinkButton with a triggerRef for better interaction.
This commit is contained in:
Danny Avila 2025-12-12 17:18:21 -05:00 committed by GitHub
parent 5b0cce2e2a
commit 4d7e6b4a58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 670 additions and 219 deletions

View file

@ -155,6 +155,9 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
if (renaming) {
return;
}
if (e.target !== e.currentTarget) {
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleNavigation(false);

View file

@ -1,5 +1,5 @@
import { useState, useId, useRef, memo, useCallback, useMemo } from 'react';
import * as Menu from '@ariakit/react/menu';
import * as Ariakit from '@ariakit/react';
import { useParams, useNavigate } from 'react-router-dom';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
@ -49,6 +49,7 @@ function ConvoOptions({
const { conversationId: currentConvoId } = useParams();
const { newConversation } = useNewConvo();
const menuId = useId();
const shareButtonRef = useRef<HTMLButtonElement>(null);
const deleteButtonRef = useRef<HTMLButtonElement>(null);
const [showShareDialog, setShowShareDialog] = useState(false);
@ -106,11 +107,11 @@ function ConvoOptions({
const isArchiveLoading = archiveConvoMutation.isLoading;
const isDeleteLoading = deleteMutation.isLoading;
const handleShareClick = useCallback(() => {
const shareHandler = useCallback(() => {
setShowShareDialog(true);
}, []);
const handleDeleteClick = useCallback(() => {
const deleteHandler = useCallback(() => {
setShowDeleteDialog(true);
}, []);
@ -189,13 +190,15 @@ function ConvoOptions({
() => [
{
label: localize('com_ui_share'),
onClick: handleShareClick,
onClick: shareHandler,
icon: <Share2 className="icon-sm mr-2 text-text-primary" aria-hidden="true" />,
show: startupConfig && startupConfig.sharedLinksEnabled,
hideOnClick: false,
ref: shareButtonRef,
ariaHasPopup: 'dialog' as const,
ariaControls: 'share-conversation-dialog',
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
hideOnClick: false,
ref: shareButtonRef,
render: (props) => <button {...props} />,
},
{
label: localize('com_ui_rename'),
@ -224,29 +227,29 @@ function ConvoOptions({
},
{
label: localize('com_ui_delete'),
onClick: handleDeleteClick,
onClick: deleteHandler,
icon: <Trash className="icon-sm mr-2 text-text-primary" aria-hidden="true" />,
hideOnClick: false,
ref: deleteButtonRef,
ariaHasPopup: 'dialog' as const,
ariaControls: 'delete-conversation-dialog',
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
hideOnClick: false,
ref: deleteButtonRef,
render: (props) => <button {...props} />,
},
],
[
localize,
handleShareClick,
shareHandler,
startupConfig,
renameHandler,
handleDuplicateClick,
deleteHandler,
isArchiveLoading,
isDuplicateLoading,
handleArchiveClick,
isArchiveLoading,
handleDeleteClick,
handleDuplicateClick,
],
);
const menuId = useId();
const buttonClassName = cn(
'inline-flex h-7 w-7 items-center justify-center 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
@ -292,13 +295,13 @@ function ConvoOptions({
</span>
<DropdownPopup
portal={true}
mountByState={true}
menuId={menuId}
focusLoop={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
trigger={
<Menu.MenuButton
<Ariakit.MenuButton
id={`conversation-menu-${conversationId}`}
aria-label={localize('com_nav_convo_menu_options')}
aria-readonly={undefined}
@ -318,10 +321,9 @@ function ConvoOptions({
}}
>
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
</Menu.MenuButton>
</Ariakit.MenuButton>
}
items={dropdownItems}
menuId={menuId}
className="z-30"
/>
{showShareDialog && (

View file

@ -7,6 +7,7 @@ import {
Button,
Spinner,
OGDialog,
OGDialogClose,
OGDialogTitle,
OGDialogHeader,
OGDialogContent,
@ -81,14 +82,14 @@ export function DeleteConversationDialog({
return (
<OGDialogContent
title={localize('com_ui_delete_confirm', { title })}
className="w-11/12 max-w-md"
showCloseButton={false}
aria-describedby="delete-conversation-description"
>
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_delete_conversation')}</OGDialogTitle>
</OGDialogHeader>
<div id="delete-conversation-dialog" className="w-full truncate">
<div id="delete-conversation-description" className="w-full truncate">
<Trans
i18nKey="com_ui_delete_confirm_strong"
values={{ title }}
@ -96,9 +97,11 @@ export function DeleteConversationDialog({
/>
</div>
<div className="flex justify-end gap-4 pt-4">
<Button aria-label="cancel" variant="outline" onClick={() => setShowDeleteDialog(false)}>
{localize('com_ui_cancel')}
</Button>
<OGDialogClose asChild>
<Button aria-label="cancel" variant="outline">
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteMutation.isLoading}>
{deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete')}
</Button>

View file

@ -51,7 +51,6 @@ export default function ShareButton({
share={share}
conversationId={conversationId}
targetMessageId={latestMessage?.messageId}
setShareDialogOpen={onOpenChange}
showQR={showQR}
setShowQR={setShowQR}
setSharedLink={setSharedLink}

View file

@ -1,14 +1,17 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef } from 'react';
import { Trans } from 'react-i18next';
import { QrCode, RotateCw, Trash2 } from 'lucide-react';
import {
Button,
OGDialog,
Spinner,
TooltipAnchor,
Label,
OGDialogTemplate,
Button,
Spinner,
OGDialog,
OGDialogClose,
TooltipAnchor,
OGDialogTitle,
OGDialogHeader,
useToastContext,
OGDialogContent,
} from '@librechat/client';
import type { TSharedLinkGetResponse } from 'librechat-data-provider';
import {
@ -23,7 +26,6 @@ export default function SharedLinkButton({
share,
conversationId,
targetMessageId,
setShareDialogOpen,
showQR,
setShowQR,
setSharedLink,
@ -31,13 +33,13 @@ export default function SharedLinkButton({
share: TSharedLinkGetResponse | undefined;
conversationId: string;
targetMessageId?: string;
setShareDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
showQR: boolean;
setShowQR: (showQR: boolean) => void;
setSharedLink: (sharedLink: string) => void;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const deleteButtonRef = useRef<HTMLButtonElement>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [announcement, setAnnouncement] = useState('');
const shareId = share?.shareId ?? '';
@ -63,9 +65,16 @@ export default function SharedLinkButton({
});
const deleteMutation = useDeleteSharedLinkMutation({
onSuccess: async () => {
onSuccess: () => {
setShowDeleteDialog(false);
setShareDialogOpen(false);
setTimeout(() => {
const dialog = document
.getElementById('share-conversation-dialog')
?.closest('[role="dialog"]');
if (dialog instanceof HTMLElement) {
dialog.focus();
}
}, 0);
},
onError: (error) => {
console.error('Delete error:', error);
@ -175,6 +184,7 @@ export default function SharedLinkButton({
render={(props) => (
<Button
{...props}
ref={deleteButtonRef}
onClick={() => setShowDeleteDialog(true)}
variant="destructive"
aria-label={localize('com_ui_delete')}
@ -185,36 +195,43 @@ export default function SharedLinkButton({
/>
</div>
)}
<OGDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_shared_link_heading')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label
htmlFor="dialog-confirm-delete"
className="text-left text-sm font-medium"
>
<Trans
i18nKey="com_ui_delete_confirm_strong"
values={{ title: shareId }}
components={{ strong: <strong /> }}
/>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: handleDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
<OGDialog
open={showDeleteDialog}
triggerRef={deleteButtonRef}
onOpenChange={setShowDeleteDialog}
>
<OGDialogContent className="max-w-[450px]" showCloseButton={false}>
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_delete_shared_link_heading')}</OGDialogTitle>
</OGDialogHeader>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
<Trans
i18nKey="com_ui_delete_confirm_strong"
values={{ title: shareId }}
components={{ strong: <strong /> }}
/>
</Label>
</div>
</div>
<div className="flex justify-end gap-4 pt-4">
<OGDialogClose asChild>
<Button variant="outline">{localize('com_ui_cancel')}</Button>
</OGDialogClose>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteMutation.isLoading}
>
{deleteMutation.isLoading ? (
<Spinner className="size-4" />
) : (
localize('com_ui_delete')
)}
</Button>
</div>
</OGDialogContent>
</OGDialog>
</div>
</>