mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 01:40: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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue