From 5fac4ffd1cae6089409de7aadbc322174da57436 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:08:00 -0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=9B=EF=B8=8F=20feat:=20Better=20Preset?= =?UTF-8?q?=20Menu=20Accessibility=20(#10734)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: allow keyboard nav in presetItems (previously edit / pin / delete buttons would only render on hover, so when the element was focused with keybaord navigation, those buttons wouldn't render and couldn't be focused or actuated) * feat: add aria-labels and TooltipAnchors to buttons in PresetItems * fix: stop keypresses from triggering parent menuitem instead of buttons * feat: better focus management on modal close with trigger refs * feat: use OGDialog modal for preset deletion * feat: add toast for successful preset deletion * chore: address copilot comments * chore: address comments * chore: import order --- .../Chat/Menus/Presets/EditPresetDialog.tsx | 4 +- .../Chat/Menus/Presets/PresetItems.tsx | 116 +++++++++++++----- .../src/components/Chat/Menus/PresetsMenu.tsx | 64 +++++++++- client/src/hooks/Conversations/usePresets.ts | 25 +++- client/src/locales/en/translation.json | 4 + .../client/src/components/OriginalDialog.tsx | 4 +- 6 files changed, 174 insertions(+), 43 deletions(-) diff --git a/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx b/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx index 886bf1e63d..8df89013b0 100644 --- a/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx +++ b/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx @@ -28,9 +28,11 @@ import store from '~/store'; const EditPresetDialog = ({ exportPreset, submitPreset, + triggerRef, }: { exportPreset: () => void; submitPreset: () => void; + triggerRef?: React.RefObject; }) => { const localize = useLocalize(); const queryClient = useQueryClient(); @@ -129,7 +131,7 @@ const EditPresetDialog = ({ } return ( - + {localize('com_ui_edit_preset_title', { title: preset?.title })} diff --git a/client/src/components/Chat/Menus/Presets/PresetItems.tsx b/client/src/components/Chat/Menus/Presets/PresetItems.tsx index a242184870..0b8708b1e9 100644 --- a/client/src/components/Chat/Menus/Presets/PresetItems.tsx +++ b/client/src/components/Chat/Menus/Presets/PresetItems.tsx @@ -9,6 +9,7 @@ import { EditIcon, TrashIcon, DialogTrigger, + TooltipAnchor, DialogTemplate, } from '@librechat/client'; import type { TPreset } from 'librechat-data-provider'; @@ -159,41 +160,88 @@ const PresetItems: FC<{ data-testid={`preset-item-${preset}`} >
- - - + ? localize('com_ui_unpin') + : localize('com_ui_pin') + } + aria-label={ + defaultPreset?.presetId === presetId + ? localize('com_ui_unpin') + : localize('com_ui_pin') + } + render={ + + } + /> + { + e.preventDefault(); + e.stopPropagation(); + onChangePreset(preset); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + onChangePreset(preset); + } + }} + > + + + } + /> + { + e.preventDefault(); + e.stopPropagation(); + onDeletePreset(preset); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + onDeletePreset(preset); + } + }} + > + + + } + />
diff --git a/client/src/components/Chat/Menus/PresetsMenu.tsx b/client/src/components/Chat/Menus/PresetsMenu.tsx index ea1fc3aa4b..26b0be4268 100644 --- a/client/src/components/Chat/Menus/PresetsMenu.tsx +++ b/client/src/components/Chat/Menus/PresetsMenu.tsx @@ -1,13 +1,22 @@ -import type { FC } from 'react'; +import { useRef } from 'react'; import { BookCopy } from 'lucide-react'; -import { TooltipAnchor } from '@librechat/client'; import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover'; +import { + Button, + OGDialog, + TooltipAnchor, + OGDialogTitle, + OGDialogHeader, + OGDialogContent, +} from '@librechat/client'; +import type { FC } from 'react'; import { EditPresetDialog, PresetItems } from './Presets'; import { useLocalize, usePresets } from '~/hooks'; import { useChatContext } from '~/Providers'; const PresetsMenu: FC = () => { const localize = useLocalize(); + const presetsMenuTriggerRef = useRef(null); const { presetsQuery, onSetDefaultPreset, @@ -18,12 +27,27 @@ const PresetsMenu: FC = () => { onDeletePreset, submitPreset, exportPreset, + showDeleteDialog, + setShowDeleteDialog, + presetToDelete, + confirmDeletePreset, } = usePresets(); const { preset } = useChatContext(); + + const handleDeleteDialogChange = (open: boolean) => { + setShowDeleteDialog(open); + if (!open && presetsMenuTriggerRef.current) { + setTimeout(() => { + presetsMenuTriggerRef.current?.focus(); + }, 0); + } + }; + return ( { - {preset && } + {preset && ( + + )} + {presetToDelete && ( + + + + {localize('com_ui_delete_preset')} + +
+ {localize('com_ui_delete_confirm')} {presetToDelete.title}? +
+
+ + +
+
+
+ )}
); }; diff --git a/client/src/hooks/Conversations/usePresets.ts b/client/src/hooks/Conversations/usePresets.ts index b2bb156638..90ca5ab132 100644 --- a/client/src/hooks/Conversations/usePresets.ts +++ b/client/src/hooks/Conversations/usePresets.ts @@ -2,8 +2,8 @@ import filenamify from 'filenamify'; import exportFromJSON from 'export-from-json'; import { useToastContext } from '@librechat/client'; import { QueryKeys } from 'librechat-data-provider'; -import { useCallback, useEffect, useRef } from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useRecoilState, useSetRecoilState, useRecoilValue } from 'recoil'; import { useCreatePresetMutation, useGetModelsQuery } from 'librechat-data-provider/react-query'; import type { TPreset, TEndpointsConfig } from 'librechat-data-provider'; @@ -27,6 +27,8 @@ export default function usePresets() { const queryClient = useQueryClient(); const { showToast } = useToastContext(); const { user, isAuthenticated } = useAuthContext(); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [presetToDelete, setPresetToDelete] = useState(null); const modularChat = useRecoilValue(store.modularChat); const availableTools = useRecoilValue(store.availableTools); @@ -86,6 +88,11 @@ export default function usePresets() { }, onSuccess: () => { queryClient.invalidateQueries([QueryKeys.presets]); + showToast({ + message: localize('com_endpoint_preset_delete_success'), + severity: NotificationSeverity.SUCCESS, + showIcon: true, + }); }, onError: (error) => { queryClient.invalidateQueries([QueryKeys.presets]); @@ -93,6 +100,7 @@ export default function usePresets() { showToast({ message: localize('com_endpoint_preset_delete_error'), severity: NotificationSeverity.ERROR, + showIcon: true, }); }, }); @@ -224,10 +232,17 @@ export default function usePresets() { const clearAllPresets = () => deletePresetsMutation.mutate(undefined); const onDeletePreset = (preset: TPreset) => { - if (!confirm(localize('com_endpoint_preset_delete_confirm'))) { + setPresetToDelete(preset); + setShowDeleteDialog(true); + }; + + const confirmDeletePreset = () => { + if (!presetToDelete) { return; } - deletePresetsMutation.mutate(preset); + deletePresetsMutation.mutate(presetToDelete); + setShowDeleteDialog(false); + setPresetToDelete(null); }; const submitPreset = () => { @@ -264,5 +279,9 @@ export default function usePresets() { onDeletePreset, submitPreset, exportPreset, + showDeleteDialog, + setShowDeleteDialog, + presetToDelete, + confirmDeletePreset, }; } diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 4e5221f47c..3aa64da011 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -315,6 +315,7 @@ "com_endpoint_preset_default_removed": "is no longer the default preset.", "com_endpoint_preset_delete_confirm": "Are you sure you want to delete this preset?", "com_endpoint_preset_delete_error": "There was an error deleting your preset. Please try again.", + "com_endpoint_preset_delete_success": "Preset deleted successfully", "com_endpoint_preset_import": "Preset Imported!", "com_endpoint_preset_import_error": "There was an error importing your preset. Please try again.", "com_endpoint_preset_name": "Preset Name", @@ -865,6 +866,7 @@ "com_ui_delete_mcp_success": "MCP server deleted successfully", "com_ui_delete_memory": "Delete Memory", "com_ui_delete_not_allowed": "Delete operation is not allowed", + "com_ui_delete_preset": "Delete Preset?", "com_ui_delete_prompt": "Delete Prompt?", "com_ui_delete_shared_link": "Delete Shared Link - {{title}}", "com_ui_delete_prompt_name": "Delete Prompt - {{name}}", @@ -1119,6 +1121,7 @@ "com_ui_permissions_failed_load": "Failed to load permissions. Please try again.", "com_ui_permissions_failed_update": "Failed to update permissions. Please try again.", "com_ui_permissions_updated_success": "Permissions updated successfully", + "com_ui_pin": "Pin", "com_ui_preferences_updated": "Preferences updated successfully", "com_ui_prev": "Prev", "com_ui_preview": "Preview", @@ -1299,6 +1302,7 @@ "com_ui_unavailable": "Unavailable", "com_ui_unknown": "Unknown", "com_ui_unset": "Unset", + "com_ui_unpin": "Unpin", "com_ui_untitled": "Untitled", "com_ui_update": "Update", "com_ui_update_mcp_error": "There was an error creating or updating the MCP.", diff --git a/packages/client/src/components/OriginalDialog.tsx b/packages/client/src/components/OriginalDialog.tsx index f067b8cd53..08f6ef6bba 100644 --- a/packages/client/src/components/OriginalDialog.tsx +++ b/packages/client/src/components/OriginalDialog.tsx @@ -4,8 +4,8 @@ import { X } from 'lucide-react'; import { cn } from '~/utils'; interface OGDialogProps extends DialogPrimitive.DialogProps { - triggerRef?: React.RefObject; - triggerRefs?: React.RefObject[]; + triggerRef?: React.RefObject; + triggerRefs?: React.RefObject[]; } const Dialog = React.forwardRef(