🎛️ feat: Better Preset Menu Accessibility (#10734)

* 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
This commit is contained in:
Dustin Healy 2025-12-01 10:08:00 -08:00 committed by Danny Avila
parent 9e9ab1bad7
commit 6b5847d6b7
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
6 changed files with 174 additions and 43 deletions

View file

@ -28,9 +28,11 @@ import store from '~/store';
const EditPresetDialog = ({
exportPreset,
submitPreset,
triggerRef,
}: {
exportPreset: () => void;
submitPreset: () => void;
triggerRef?: React.RefObject<HTMLDivElement>;
}) => {
const localize = useLocalize();
const queryClient = useQueryClient();
@ -129,7 +131,7 @@ const EditPresetDialog = ({
}
return (
<OGDialog open={presetModalVisible} onOpenChange={handleOpenChange}>
<OGDialog open={presetModalVisible} onOpenChange={handleOpenChange} triggerRef={triggerRef}>
<OGDialogContent className="h-[100dvh] max-h-[100dvh] w-full max-w-full overflow-y-auto bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300 md:h-auto md:max-h-[90vh] md:max-w-[75vw] md:rounded-lg lg:max-w-[950px]">
<OGDialogTitle>
{localize('com_ui_edit_preset_title', { title: preset?.title })}

View file

@ -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}`}
>
<div className="flex h-full items-center justify-end gap-1">
<button
className={cn(
'm-0 h-full rounded-md bg-transparent p-2 text-gray-400 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
<TooltipAnchor
description={
defaultPreset?.presetId === presetId
? ''
: 'sm:invisible sm:group-hover:visible',
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onSetDefaultPreset(preset, defaultPreset?.presetId === presetId);
}}
>
<PinIcon unpin={defaultPreset?.presetId === presetId} />
</button>
<button
className="m-0 h-full rounded-md p-2 text-gray-400 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:group-hover:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChangePreset(preset);
}}
>
<EditIcon />
</button>
<button
className="m-0 h-full rounded-md p-2 text-gray-400 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:group-hover:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDeletePreset(preset);
}}
>
<TrashIcon />
</button>
? localize('com_ui_unpin')
: localize('com_ui_pin')
}
aria-label={
defaultPreset?.presetId === presetId
? localize('com_ui_unpin')
: localize('com_ui_pin')
}
render={
<button
className={cn(
'm-0 h-full rounded-md bg-transparent p-2 text-gray-400 hover:text-gray-700 focus:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 dark:focus:text-gray-200',
defaultPreset?.presetId === presetId
? ''
: 'sm:invisible sm:group-focus-within:visible sm:group-hover:visible',
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onSetDefaultPreset(preset, defaultPreset?.presetId === presetId);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onSetDefaultPreset(preset, defaultPreset?.presetId === presetId);
}
}}
>
<PinIcon unpin={defaultPreset?.presetId === presetId} />
</button>
}
/>
<TooltipAnchor
description={localize('com_ui_edit')}
aria-label={localize('com_ui_edit')}
render={
<button
className="m-0 h-full rounded-md p-2 text-gray-400 hover:text-gray-700 focus:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 dark:focus:text-gray-200 sm:invisible sm:group-focus-within:visible sm:group-hover:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChangePreset(preset);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onChangePreset(preset);
}
}}
>
<EditIcon />
</button>
}
/>
<TooltipAnchor
description={localize('com_ui_delete')}
aria-label={localize('com_ui_delete')}
render={
<button
className="m-0 h-full rounded-md p-2 text-gray-400 hover:text-gray-600 focus:text-gray-600 dark:text-gray-400 dark:hover:text-gray-200 dark:focus:text-gray-200 sm:invisible sm:group-focus-within:visible sm:group-hover:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDeletePreset(preset);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onDeletePreset(preset);
}
}}
>
<TrashIcon />
</button>
}
/>
</div>
</MenuItem>
</Flipped>

View file

@ -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<HTMLDivElement>(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 (
<Root>
<Trigger asChild>
<TooltipAnchor
ref={presetsMenuTriggerRef}
id="presets-button"
aria-label={localize('com_endpoint_examples')}
description={localize('com_endpoint_examples')}
@ -63,7 +87,41 @@ const PresetsMenu: FC = () => {
</Content>
</div>
</Portal>
{preset && <EditPresetDialog submitPreset={submitPreset} exportPreset={exportPreset} />}
{preset && (
<EditPresetDialog
submitPreset={submitPreset}
exportPreset={exportPreset}
triggerRef={presetsMenuTriggerRef}
/>
)}
{presetToDelete && (
<OGDialog open={showDeleteDialog} onOpenChange={handleDeleteDialogChange}>
<OGDialogContent
title={localize('com_endpoint_preset_delete_confirm')}
className="w-11/12 max-w-md"
showCloseButton={false}
>
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_delete_preset')}</OGDialogTitle>
</OGDialogHeader>
<div className="w-full truncate">
{localize('com_ui_delete_confirm')} <strong>{presetToDelete.title}</strong>?
</div>
<div className="flex justify-end gap-4 pt-4">
<Button
aria-label="cancel"
variant="outline"
onClick={() => handleDeleteDialogChange(false)}
>
{localize('com_ui_cancel')}
</Button>
<Button variant="destructive" onClick={confirmDeletePreset}>
{localize('com_ui_delete')}
</Button>
</div>
</OGDialogContent>
</OGDialog>
)}
</Root>
);
};

View file

@ -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<TPreset | null>(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,
};
}

View file

@ -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.",

View file

@ -4,8 +4,8 @@ import { X } from 'lucide-react';
import { cn } from '~/utils';
interface OGDialogProps extends DialogPrimitive.DialogProps {
triggerRef?: React.RefObject<HTMLButtonElement | HTMLInputElement | null>;
triggerRefs?: React.RefObject<HTMLButtonElement | HTMLInputElement | null>[];
triggerRef?: React.RefObject<HTMLButtonElement | HTMLInputElement | HTMLDivElement | null>;
triggerRefs?: React.RefObject<HTMLButtonElement | HTMLInputElement | HTMLDivElement | null>[];
}
const Dialog = React.forwardRef<HTMLDivElement, OGDialogProps>(