️ fix: Accessibility, UI consistency, dialog & avatar refactors (#9975)

* 🔧 refactor: Improve accessibility and styling in ChatGroupItem and FilterPrompts components

* 🔧 fix: Add button type and keyboard accessibility to dropdown menu trigger in ChatGroupItem

* 🔧 fix(757): Enhance accessibility by updating aria-labels and adding localization for prompt groups

* 🔧 fix(618): Update version to 0.3.1 and enhance accessibility in InfoHoverCard component

* 🔧 fix(618): Update aria-label in InfoHoverCard to use dynamic text prop for improved accessibility

* 🔧 fix: Enhance accessibility by updating aria-labels and roles in Conversations components

* 🔧 fix(620): Enhance accessibility by adding tabIndex to Tabs.Content components in ArtifactTabs, Settings, and Speech components

* refactor: remove RevokeKeysButton component and update related components for accessibility

- Deleted RevokeKeysButton component.
- Updated SharedLinks and General components to use Label for accessibility.
- Enhanced Personalization component with aria-labelledby and aria-describedby attributes.
- Refactored ConversationModeSwitch to use ToggleSwitch for better state management.
- Improved AutoSendTextSelector with local state management and accessibility attributes.
- Replaced Switch components with ToggleSwitch in various Speech and TTS components for consistency.
- Added aria-labelledby attributes to Dropdown components for better accessibility.
- Updated translation.json to include new localization keys and improved existing ones.
- Enhanced Slider component to support aria attributes for better accessibility.

* 🔧 fix: Enhance user feedback for API key operations with success and error messages

* 🔧 fix: Update aria-labels in Avatar component for improved localization and accessibility

* 🔧 fix: Refactor handleFile and handleDrop functions for improved readability and maintainability
This commit is contained in:
Marco Beretta 2025-10-07 20:12:49 +02:00 committed by GitHub
parent bcd97aad2f
commit a5189052ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1158 additions and 857 deletions

View file

@ -31,12 +31,12 @@ export const ClearChats = () => {
return (
<div className="flex items-center justify-between">
<Label className="font-light">{localize('com_nav_clear_all_chats')}</Label>
<Label id="clear-all-chats-label">{localize('com_nav_clear_all_chats')}</Label>
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<Button
aria-labelledby="clear-all-chats-label"
variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setOpen(true)}
>
{localize('com_ui_delete')}
@ -47,7 +47,7 @@ export const ClearChats = () => {
title={localize('com_nav_confirm_clear')}
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
<Label className="break-words">
{localize('com_nav_clear_conversation_confirm_message')}
</Label>
}

View file

@ -1,7 +1,7 @@
import React, { useState, useRef } from 'react';
import { useOnClickOutside } from '@librechat/client';
import ImportConversations from './ImportConversations';
import { RevokeAllKeys } from './RevokeAllKeys';
import { RevokeKeys } from './RevokeKeys';
import { DeleteCache } from './DeleteCache';
import { ClearChats } from './ClearChats';
import SharedLinks from './SharedLinks';
@ -20,7 +20,7 @@ function Data() {
<SharedLinks />
</div>
<div className="pb-3">
<RevokeAllKeys />
<RevokeKeys />
</div>
<div className="pb-3">
<DeleteCache />

View file

@ -38,14 +38,14 @@ export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => {
return (
<div className="flex items-center justify-between">
<Label className="font-light">{localize('com_nav_delete_cache_storage')}</Label>
<Label id="delete-cache-label">{localize('com_nav_delete_cache_storage')}</Label>
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<Button
variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setOpen(true)}
disabled={disabled || isCacheEmpty}
aria-labelledby="delete-cache-label"
>
{localize('com_ui_delete')}
</Button>

View file

@ -1,96 +1,114 @@
import { useState, useRef } from 'react';
import { useState, useRef, useCallback } from 'react';
import { Import } from 'lucide-react';
import { Spinner, useToastContext } from '@librechat/client';
import type { TError } from 'librechat-data-provider';
import { Spinner, useToastContext, Label, Button } from '@librechat/client';
import { useUploadConversationsMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import { cn, logger } from '~/utils';
function ImportConversations() {
const localize = useLocalize();
const fileInputRef = useRef<HTMLInputElement>(null);
const { showToast } = useToastContext();
const [, setErrors] = useState<string[]>([]);
const [allowImport, setAllowImport] = useState(true);
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
const [isUploading, setIsUploading] = useState(false);
const handleSuccess = useCallback(() => {
showToast({
message: localize('com_ui_import_conversation_success'),
status: NotificationSeverity.SUCCESS,
});
setIsUploading(false);
}, [localize, showToast]);
const handleError = useCallback(
(error: unknown) => {
logger.error('Import error:', error);
setIsUploading(false);
const isUnsupportedType = error?.toString().includes('Unsupported import type');
showToast({
message: localize(
isUnsupportedType
? 'com_ui_import_conversation_file_type_error'
: 'com_ui_import_conversation_error',
),
status: NotificationSeverity.ERROR,
});
},
[localize, showToast],
);
const uploadFile = useUploadConversationsMutation({
onSuccess: () => {
showToast({ message: localize('com_ui_import_conversation_success') });
setAllowImport(true);
},
onError: (error) => {
console.error('Error: ', error);
setAllowImport(true);
setError(
(error as TError).response?.data?.message ?? 'An error occurred while uploading the file.',
);
if (error?.toString().includes('Unsupported import type') === true) {
showToast({
message: localize('com_ui_import_conversation_file_type_error'),
status: 'error',
});
} else {
showToast({ message: localize('com_ui_import_conversation_error'), status: 'error' });
}
},
onMutate: () => {
setAllowImport(false);
},
onSuccess: handleSuccess,
onError: handleError,
onMutate: () => setIsUploading(true),
});
const startUpload = async (file: File) => {
const formData = new FormData();
formData.append('file', file, encodeURIComponent(file.name || 'File'));
const handleFileUpload = useCallback(
async (file: File) => {
try {
const formData = new FormData();
formData.append('file', file, encodeURIComponent(file.name || 'File'));
uploadFile.mutate(formData);
} catch (error) {
logger.error('File processing error:', error);
setIsUploading(false);
showToast({
message: localize('com_ui_import_conversation_upload_error'),
status: NotificationSeverity.ERROR,
});
}
},
[uploadFile, showToast, localize],
);
uploadFile.mutate(formData);
};
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFileUpload(file);
}
event.target.value = '';
},
[handleFileUpload],
);
const handleFiles = async (_file: File) => {
try {
await startUpload(_file);
} catch (error) {
console.log('file handling error', error);
setError('An error occurred while processing the file.');
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFiles(file);
}
};
const handleImportClick = () => {
const handleImportClick = useCallback(() => {
fileInputRef.current?.click();
};
}, []);
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleImportClick();
}
};
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleImportClick();
}
},
[handleImportClick],
);
const isImportDisabled = isUploading;
return (
<div className="flex items-center justify-between">
<div>{localize('com_ui_import_conversation_info')}</div>
<button
<Label id="import-conversation-label">{localize('com_ui_import_conversation_info')}</Label>
<Button
variant="outline"
onClick={handleImportClick}
onKeyDown={handleKeyDown}
disabled={!allowImport}
disabled={isImportDisabled}
aria-label={localize('com_ui_import')}
className="btn btn-neutral relative"
aria-labelledby="import-conversation-label"
>
{allowImport ? (
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
) : (
{isUploading ? (
<Spinner className="mr-1 w-4" />
) : (
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
)}
<span>{localize('com_ui_import')}</span>
</button>
</Button>
<input
ref={fileInputRef}
type="file"

View file

@ -1,15 +0,0 @@
import React from 'react';
import { Label } from '@librechat/client';
import { RevokeKeysButton } from './RevokeKeysButton';
import { useLocalize } from '~/hooks';
export const RevokeAllKeys = () => {
const localize = useLocalize();
return (
<div className="flex items-center justify-between">
<Label className="font-light">{localize('com_ui_revoke_info')}</Label>
<RevokeKeysButton all={true} />
</div>
);
};

View file

@ -0,0 +1,72 @@
import React, { useState } from 'react';
import { useRevokeAllUserKeysMutation } from 'librechat-data-provider/react-query';
import {
OGDialogTemplate,
Button,
Label,
OGDialog,
OGDialogTrigger,
Spinner,
} from '@librechat/client';
import { useLocalize } from '~/hooks';
export const RevokeKeys = ({
disabled = false,
setDialogOpen,
}: {
disabled?: boolean;
setDialogOpen?: (open: boolean) => void;
}) => {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const revokeKeysMutation = useRevokeAllUserKeysMutation();
const handleSuccess = () => {
if (!setDialogOpen) {
return;
}
setDialogOpen(false);
};
const onClick = () => {
revokeKeysMutation.mutate({}, { onSuccess: handleSuccess });
};
const isLoading = revokeKeysMutation.isLoading;
return (
<div className="flex items-center justify-between">
<Label id="revoke-info-label">{localize('com_ui_revoke_info')}</Label>
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<Button
variant="destructive"
onClick={() => setOpen(true)}
disabled={disabled}
aria-labelledby="revoke-info-label"
>
{localize('com_ui_revoke')}
</Button>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_revoke_keys')}
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_revoke_keys_confirm')}
</Label>
}
selection={{
selectHandler: onClick,
selectClasses:
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
selectText: isLoading ? <Spinner /> : localize('com_ui_revoke'),
}}
/>
</OGDialog>
</div>
);
};

View file

@ -1,84 +0,0 @@
import React, { useState } from 'react';
import {
useRevokeAllUserKeysMutation,
useRevokeUserKeyMutation,
} from 'librechat-data-provider/react-query';
import {
OGDialogTemplate,
Button,
Label,
OGDialog,
OGDialogTrigger,
Spinner,
} from '@librechat/client';
import { useLocalize } from '~/hooks';
export const RevokeKeysButton = ({
endpoint = '',
all = false,
disabled = false,
setDialogOpen,
}: {
endpoint?: string;
all?: boolean;
disabled?: boolean;
setDialogOpen?: (open: boolean) => void;
}) => {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const revokeKeyMutation = useRevokeUserKeyMutation(endpoint);
const revokeKeysMutation = useRevokeAllUserKeysMutation();
const handleSuccess = () => {
if (!setDialogOpen) {
return;
}
setDialogOpen(false);
};
const onClick = () => {
if (all) {
revokeKeysMutation.mutate({});
} else {
revokeKeyMutation.mutate({}, { onSuccess: handleSuccess });
}
};
const dialogTitle = all
? localize('com_ui_revoke_keys')
: localize('com_ui_revoke_key_endpoint', { 0: endpoint });
const dialogMessage = all
? localize('com_ui_revoke_keys_confirm')
: localize('com_ui_revoke_key_confirm');
const isLoading = revokeKeyMutation.isLoading || revokeKeysMutation.isLoading;
return (
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<Button
variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setOpen(true)}
disabled={disabled}
>
{localize('com_ui_revoke')}
</Button>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={dialogTitle}
className="max-w-[450px]"
main={<Label className="text-left text-sm font-medium">{dialogMessage}</Label>}
selection={{
selectHandler: onClick,
selectClasses:
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
selectText: isLoading ? <Spinner /> : localize('com_ui_revoke'),
}}
/>
</OGDialog>
);
};

View file

@ -286,11 +286,13 @@ export default function SharedLinks() {
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_shared_links')}</div>
<Label id="shared-links-label">{localize('com_nav_shared_links')}</Label>
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
<Button variant="outline">{localize('com_ui_manage')}</Button>
<Button aria-labelledby="shared-links-label" variant="outline">
{localize('com_ui_manage')}
</Button>
</OGDialogTrigger>
<OGDialogContent