mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +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
9e9ab1bad7
commit
6b5847d6b7
6 changed files with 174 additions and 43 deletions
|
|
@ -28,9 +28,11 @@ import store from '~/store';
|
||||||
const EditPresetDialog = ({
|
const EditPresetDialog = ({
|
||||||
exportPreset,
|
exportPreset,
|
||||||
submitPreset,
|
submitPreset,
|
||||||
|
triggerRef,
|
||||||
}: {
|
}: {
|
||||||
exportPreset: () => void;
|
exportPreset: () => void;
|
||||||
submitPreset: () => void;
|
submitPreset: () => void;
|
||||||
|
triggerRef?: React.RefObject<HTMLDivElement>;
|
||||||
}) => {
|
}) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -129,7 +131,7 @@ const EditPresetDialog = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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]">
|
<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>
|
<OGDialogTitle>
|
||||||
{localize('com_ui_edit_preset_title', { title: preset?.title })}
|
{localize('com_ui_edit_preset_title', { title: preset?.title })}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
EditIcon,
|
EditIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
|
TooltipAnchor,
|
||||||
DialogTemplate,
|
DialogTemplate,
|
||||||
} from '@librechat/client';
|
} from '@librechat/client';
|
||||||
import type { TPreset } from 'librechat-data-provider';
|
import type { TPreset } from 'librechat-data-provider';
|
||||||
|
|
@ -159,41 +160,88 @@ const PresetItems: FC<{
|
||||||
data-testid={`preset-item-${preset}`}
|
data-testid={`preset-item-${preset}`}
|
||||||
>
|
>
|
||||||
<div className="flex h-full items-center justify-end gap-1">
|
<div className="flex h-full items-center justify-end gap-1">
|
||||||
<button
|
<TooltipAnchor
|
||||||
className={cn(
|
description={
|
||||||
'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',
|
|
||||||
defaultPreset?.presetId === presetId
|
defaultPreset?.presetId === presetId
|
||||||
? ''
|
? localize('com_ui_unpin')
|
||||||
: 'sm:invisible sm:group-hover:visible',
|
: localize('com_ui_pin')
|
||||||
)}
|
}
|
||||||
onClick={(e) => {
|
aria-label={
|
||||||
e.preventDefault();
|
defaultPreset?.presetId === presetId
|
||||||
e.stopPropagation();
|
? localize('com_ui_unpin')
|
||||||
onSetDefaultPreset(preset, defaultPreset?.presetId === presetId);
|
: localize('com_ui_pin')
|
||||||
}}
|
}
|
||||||
>
|
render={
|
||||||
<PinIcon unpin={defaultPreset?.presetId === presetId} />
|
<button
|
||||||
</button>
|
className={cn(
|
||||||
<button
|
'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',
|
||||||
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"
|
defaultPreset?.presetId === presetId
|
||||||
onClick={(e) => {
|
? ''
|
||||||
e.preventDefault();
|
: 'sm:invisible sm:group-focus-within:visible sm:group-hover:visible',
|
||||||
e.stopPropagation();
|
)}
|
||||||
onChangePreset(preset);
|
onClick={(e) => {
|
||||||
}}
|
e.preventDefault();
|
||||||
>
|
e.stopPropagation();
|
||||||
<EditIcon />
|
onSetDefaultPreset(preset, defaultPreset?.presetId === presetId);
|
||||||
</button>
|
}}
|
||||||
<button
|
onKeyDown={(e) => {
|
||||||
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"
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
onClick={(e) => {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
onSetDefaultPreset(preset, defaultPreset?.presetId === presetId);
|
||||||
onDeletePreset(preset);
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TrashIcon />
|
<PinIcon unpin={defaultPreset?.presetId === presetId} />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Flipped>
|
</Flipped>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,22 @@
|
||||||
import type { FC } from 'react';
|
import { useRef } from 'react';
|
||||||
import { BookCopy } from 'lucide-react';
|
import { BookCopy } from 'lucide-react';
|
||||||
import { TooltipAnchor } from '@librechat/client';
|
|
||||||
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
|
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 { EditPresetDialog, PresetItems } from './Presets';
|
||||||
import { useLocalize, usePresets } from '~/hooks';
|
import { useLocalize, usePresets } from '~/hooks';
|
||||||
import { useChatContext } from '~/Providers';
|
import { useChatContext } from '~/Providers';
|
||||||
|
|
||||||
const PresetsMenu: FC = () => {
|
const PresetsMenu: FC = () => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
const presetsMenuTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
const {
|
const {
|
||||||
presetsQuery,
|
presetsQuery,
|
||||||
onSetDefaultPreset,
|
onSetDefaultPreset,
|
||||||
|
|
@ -18,12 +27,27 @@ const PresetsMenu: FC = () => {
|
||||||
onDeletePreset,
|
onDeletePreset,
|
||||||
submitPreset,
|
submitPreset,
|
||||||
exportPreset,
|
exportPreset,
|
||||||
|
showDeleteDialog,
|
||||||
|
setShowDeleteDialog,
|
||||||
|
presetToDelete,
|
||||||
|
confirmDeletePreset,
|
||||||
} = usePresets();
|
} = usePresets();
|
||||||
const { preset } = useChatContext();
|
const { preset } = useChatContext();
|
||||||
|
|
||||||
|
const handleDeleteDialogChange = (open: boolean) => {
|
||||||
|
setShowDeleteDialog(open);
|
||||||
|
if (!open && presetsMenuTriggerRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
presetsMenuTriggerRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Root>
|
<Root>
|
||||||
<Trigger asChild>
|
<Trigger asChild>
|
||||||
<TooltipAnchor
|
<TooltipAnchor
|
||||||
|
ref={presetsMenuTriggerRef}
|
||||||
id="presets-button"
|
id="presets-button"
|
||||||
aria-label={localize('com_endpoint_examples')}
|
aria-label={localize('com_endpoint_examples')}
|
||||||
description={localize('com_endpoint_examples')}
|
description={localize('com_endpoint_examples')}
|
||||||
|
|
@ -63,7 +87,41 @@ const PresetsMenu: FC = () => {
|
||||||
</Content>
|
</Content>
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
</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>
|
</Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import filenamify from 'filenamify';
|
||||||
import exportFromJSON from 'export-from-json';
|
import exportFromJSON from 'export-from-json';
|
||||||
import { useToastContext } from '@librechat/client';
|
import { useToastContext } from '@librechat/client';
|
||||||
import { QueryKeys } from 'librechat-data-provider';
|
import { QueryKeys } from 'librechat-data-provider';
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useRecoilState, useSetRecoilState, useRecoilValue } from 'recoil';
|
import { useRecoilState, useSetRecoilState, useRecoilValue } from 'recoil';
|
||||||
import { useCreatePresetMutation, useGetModelsQuery } from 'librechat-data-provider/react-query';
|
import { useCreatePresetMutation, useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||||
import type { TPreset, TEndpointsConfig } from 'librechat-data-provider';
|
import type { TPreset, TEndpointsConfig } from 'librechat-data-provider';
|
||||||
|
|
@ -27,6 +27,8 @@ export default function usePresets() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { showToast } = useToastContext();
|
const { showToast } = useToastContext();
|
||||||
const { user, isAuthenticated } = useAuthContext();
|
const { user, isAuthenticated } = useAuthContext();
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [presetToDelete, setPresetToDelete] = useState<TPreset | null>(null);
|
||||||
|
|
||||||
const modularChat = useRecoilValue(store.modularChat);
|
const modularChat = useRecoilValue(store.modularChat);
|
||||||
const availableTools = useRecoilValue(store.availableTools);
|
const availableTools = useRecoilValue(store.availableTools);
|
||||||
|
|
@ -86,6 +88,11 @@ export default function usePresets() {
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries([QueryKeys.presets]);
|
queryClient.invalidateQueries([QueryKeys.presets]);
|
||||||
|
showToast({
|
||||||
|
message: localize('com_endpoint_preset_delete_success'),
|
||||||
|
severity: NotificationSeverity.SUCCESS,
|
||||||
|
showIcon: true,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
queryClient.invalidateQueries([QueryKeys.presets]);
|
queryClient.invalidateQueries([QueryKeys.presets]);
|
||||||
|
|
@ -93,6 +100,7 @@ export default function usePresets() {
|
||||||
showToast({
|
showToast({
|
||||||
message: localize('com_endpoint_preset_delete_error'),
|
message: localize('com_endpoint_preset_delete_error'),
|
||||||
severity: NotificationSeverity.ERROR,
|
severity: NotificationSeverity.ERROR,
|
||||||
|
showIcon: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -224,10 +232,17 @@ export default function usePresets() {
|
||||||
const clearAllPresets = () => deletePresetsMutation.mutate(undefined);
|
const clearAllPresets = () => deletePresetsMutation.mutate(undefined);
|
||||||
|
|
||||||
const onDeletePreset = (preset: TPreset) => {
|
const onDeletePreset = (preset: TPreset) => {
|
||||||
if (!confirm(localize('com_endpoint_preset_delete_confirm'))) {
|
setPresetToDelete(preset);
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeletePreset = () => {
|
||||||
|
if (!presetToDelete) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deletePresetsMutation.mutate(preset);
|
deletePresetsMutation.mutate(presetToDelete);
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setPresetToDelete(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitPreset = () => {
|
const submitPreset = () => {
|
||||||
|
|
@ -264,5 +279,9 @@ export default function usePresets() {
|
||||||
onDeletePreset,
|
onDeletePreset,
|
||||||
submitPreset,
|
submitPreset,
|
||||||
exportPreset,
|
exportPreset,
|
||||||
|
showDeleteDialog,
|
||||||
|
setShowDeleteDialog,
|
||||||
|
presetToDelete,
|
||||||
|
confirmDeletePreset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,7 @@
|
||||||
"com_endpoint_preset_default_removed": "is no longer the default preset.",
|
"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_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_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": "Preset Imported!",
|
||||||
"com_endpoint_preset_import_error": "There was an error importing your preset. Please try again.",
|
"com_endpoint_preset_import_error": "There was an error importing your preset. Please try again.",
|
||||||
"com_endpoint_preset_name": "Preset Name",
|
"com_endpoint_preset_name": "Preset Name",
|
||||||
|
|
@ -865,6 +866,7 @@
|
||||||
"com_ui_delete_mcp_success": "MCP server deleted successfully",
|
"com_ui_delete_mcp_success": "MCP server deleted successfully",
|
||||||
"com_ui_delete_memory": "Delete Memory",
|
"com_ui_delete_memory": "Delete Memory",
|
||||||
"com_ui_delete_not_allowed": "Delete operation is not allowed",
|
"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_prompt": "Delete Prompt?",
|
||||||
"com_ui_delete_shared_link": "Delete Shared Link - {{title}}",
|
"com_ui_delete_shared_link": "Delete Shared Link - {{title}}",
|
||||||
"com_ui_delete_prompt_name": "Delete Prompt - {{name}}",
|
"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_load": "Failed to load permissions. Please try again.",
|
||||||
"com_ui_permissions_failed_update": "Failed to update 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_permissions_updated_success": "Permissions updated successfully",
|
||||||
|
"com_ui_pin": "Pin",
|
||||||
"com_ui_preferences_updated": "Preferences updated successfully",
|
"com_ui_preferences_updated": "Preferences updated successfully",
|
||||||
"com_ui_prev": "Prev",
|
"com_ui_prev": "Prev",
|
||||||
"com_ui_preview": "Preview",
|
"com_ui_preview": "Preview",
|
||||||
|
|
@ -1299,6 +1302,7 @@
|
||||||
"com_ui_unavailable": "Unavailable",
|
"com_ui_unavailable": "Unavailable",
|
||||||
"com_ui_unknown": "Unknown",
|
"com_ui_unknown": "Unknown",
|
||||||
"com_ui_unset": "Unset",
|
"com_ui_unset": "Unset",
|
||||||
|
"com_ui_unpin": "Unpin",
|
||||||
"com_ui_untitled": "Untitled",
|
"com_ui_untitled": "Untitled",
|
||||||
"com_ui_update": "Update",
|
"com_ui_update": "Update",
|
||||||
"com_ui_update_mcp_error": "There was an error creating or updating the MCP.",
|
"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';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
interface OGDialogProps extends DialogPrimitive.DialogProps {
|
interface OGDialogProps extends DialogPrimitive.DialogProps {
|
||||||
triggerRef?: React.RefObject<HTMLButtonElement | HTMLInputElement | null>;
|
triggerRef?: React.RefObject<HTMLButtonElement | HTMLInputElement | HTMLDivElement | null>;
|
||||||
triggerRefs?: React.RefObject<HTMLButtonElement | HTMLInputElement | null>[];
|
triggerRefs?: React.RefObject<HTMLButtonElement | HTMLInputElement | HTMLDivElement | null>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Dialog = React.forwardRef<HTMLDivElement, OGDialogProps>(
|
const Dialog = React.forwardRef<HTMLDivElement, OGDialogProps>(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue