🎛️ 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 69200623c2
commit 5fac4ffd1c
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>
);
};