mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🎛️ 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:
parent
69200623c2
commit
5fac4ffd1c
6 changed files with 174 additions and 43 deletions
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue