️ 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

@ -44,6 +44,7 @@ export default function ArtifactTabs({
value="code"
id="artifacts-code"
className={cn('flex-grow overflow-auto')}
tabIndex={-1}
>
{isMermaid ? (
<MermaidMarkdown content={content} isSubmitting={isSubmitting} />
@ -58,7 +59,7 @@ export default function ArtifactTabs({
/>
)}
</Tabs.Content>
<Tabs.Content value="preview" className="flex-grow overflow-auto">
<Tabs.Content value="preview" className="flex-grow overflow-auto" tabIndex={-1}>
<ArtifactPreview
files={files}
fileKey={fileKey}

View file

@ -19,9 +19,11 @@ export function BrowserVoiceDropdown() {
}
};
const labelId = 'browser-voice-dropdown-label';
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_voice_select')}</div>
<div id={labelId}>{localize('com_nav_voice_select')}</div>
<Dropdown
key={`browser-voice-dropdown-${voices.length}`}
value={voice ?? ''}
@ -30,6 +32,7 @@ export function BrowserVoiceDropdown() {
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
testId="BrowserVoiceDropdown"
className="z-50"
aria-labelledby={labelId}
/>
</div>
);
@ -48,9 +51,11 @@ export function ExternalVoiceDropdown() {
}
};
const labelId = 'external-voice-dropdown-label';
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_voice_select')}</div>
<div id={labelId}>{localize('com_nav_voice_select')}</div>
<Dropdown
key={`external-voice-dropdown-${voices.length}`}
value={voice ?? ''}
@ -59,6 +64,7 @@ export function ExternalVoiceDropdown() {
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
testId="ExternalVoiceDropdown"
className="z-50"
aria-labelledby={labelId}
/>
</div>
);

View file

@ -28,6 +28,8 @@ const LoadingSpinner = memo(() => {
);
});
LoadingSpinner.displayName = 'LoadingSpinner';
const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
const localize = useLocalize();
return (
@ -74,6 +76,7 @@ const Conversations: FC<ConversationsProps> = ({
isLoading,
isSearchLoading,
}) => {
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const convoHeight = isSmallScreen ? 44 : 34;
@ -181,7 +184,7 @@ const Conversations: FC<ConversationsProps> = ({
{isSearchLoading ? (
<div className="flex flex-1 items-center justify-center">
<Spinner className="text-text-primary" />
<span className="ml-2 text-text-primary">Loading...</span>
<span className="ml-2 text-text-primary">{localize('com_ui_loading')}</span>
</div>
) : (
<div className="flex-1">

View file

@ -135,8 +135,9 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9',
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
)}
role="listitem"
tabIndex={0}
role="button"
tabIndex={renaming ? -1 : 0}
aria-label={`${title || localize('com_ui_untitled')} conversation`}
onClick={(e) => {
if (renaming) {
return;
@ -149,7 +150,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
if (renaming) {
return;
}
if (e.key === 'Enter') {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleNavigation(false);
}
}}

View file

@ -40,8 +40,7 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
e.stopPropagation();
onRename();
}}
role="button"
aria-label={isSmallScreen ? undefined : title || localize('com_ui_untitled')}
aria-label={title || localize('com_ui_untitled')}
>
{title || localize('com_ui_untitled')}
</div>

View file

@ -201,6 +201,7 @@ function ConvoOptions({
<Menu.MenuButton
id={`conversation-menu-${conversationId}`}
aria-label={localize('com_nav_convo_menu_options')}
aria-readonly={undefined}
className={cn(
'inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50',
isActiveConvo === true || isPopoverActive

View file

@ -1,16 +1,33 @@
import React, { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { OGDialogTemplate, OGDialog, Dropdown, useToastContext } from '@librechat/client';
import {
OGDialog,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
OGDialogFooter,
Dropdown,
useToastContext,
Button,
Label,
OGDialogTrigger,
Spinner,
} from '@librechat/client';
import { EModelEndpoint, alternateName, isAssistantsEndpoint } from 'librechat-data-provider';
import {
useRevokeAllUserKeysMutation,
useRevokeUserKeyMutation,
} from 'librechat-data-provider/react-query';
import type { TDialogProps } from '~/common';
import { useGetEndpointsQuery } from '~/data-provider';
import { RevokeKeysButton } from '~/components/Nav';
import { useUserKey, useLocalize } from '~/hooks';
import { NotificationSeverity } from '~/common';
import CustomConfig from './CustomEndpoint';
import GoogleConfig from './GoogleConfig';
import OpenAIConfig from './OpenAIConfig';
import OtherConfig from './OtherConfig';
import HelpText from './HelpText';
import { logger } from '~/utils';
const endpointComponents = {
[EModelEndpoint.google]: GoogleConfig,
@ -42,6 +59,94 @@ const EXPIRY = {
NEVER: { label: 'never', value: 0 },
};
const RevokeKeysButton = ({
endpoint,
disabled,
setDialogOpen,
}: {
endpoint: string;
disabled: boolean;
setDialogOpen: (open: boolean) => void;
}) => {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const { showToast } = useToastContext();
const revokeKeyMutation = useRevokeUserKeyMutation(endpoint);
const revokeKeysMutation = useRevokeAllUserKeysMutation();
const handleSuccess = () => {
showToast({
message: localize('com_ui_revoke_key_success'),
status: NotificationSeverity.SUCCESS,
});
if (!setDialogOpen) {
return;
}
setDialogOpen(false);
};
const handleError = () => {
showToast({
message: localize('com_ui_revoke_key_error'),
status: NotificationSeverity.ERROR,
});
};
const onClick = () => {
revokeKeyMutation.mutate(
{},
{
onSuccess: handleSuccess,
onError: handleError,
},
);
};
const isLoading = revokeKeyMutation.isLoading || revokeKeysMutation.isLoading;
return (
<div className="flex items-center justify-between">
<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>
<OGDialogContent className="max-w-[450px]">
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_revoke_key_endpoint', { 0: endpoint })}</OGDialogTitle>
</OGDialogHeader>
<div className="py-4">
<Label className="text-left text-sm font-medium">
{localize('com_ui_revoke_key_confirm')}
</Label>
</div>
<OGDialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{localize('com_ui_cancel')}
</Button>
<Button
variant="destructive"
onClick={onClick}
disabled={isLoading}
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
>
{isLoading ? <Spinner /> : localize('com_ui_revoke')}
</Button>
</OGDialogFooter>
</OGDialogContent>
</OGDialog>
</div>
);
};
const SetKeyDialog = ({
open,
onOpenChange,
@ -83,7 +188,7 @@ const SetKeyDialog = ({
const submit = () => {
const selectedOption = expirationOptions.find((option) => option.label === expiresAtLabel);
let expiresAt;
let expiresAt: number | null;
if (selectedOption?.value === 0) {
expiresAt = null;
@ -92,8 +197,20 @@ const SetKeyDialog = ({
}
const saveKey = (key: string) => {
saveUserKey(key, expiresAt);
onOpenChange(false);
try {
saveUserKey(key, expiresAt);
showToast({
message: localize('com_ui_save_key_success'),
status: NotificationSeverity.SUCCESS,
});
onOpenChange(false);
} catch (error) {
logger.error('Error saving user key:', error);
showToast({
message: localize('com_ui_save_key_error'),
status: NotificationSeverity.ERROR,
});
}
};
if (formSet.has(endpoint) || formSet.has(endpointType ?? '')) {
@ -148,6 +265,14 @@ const SetKeyDialog = ({
return;
}
if (!userKey.trim()) {
showToast({
message: localize('com_ui_key_required'),
status: NotificationSeverity.ERROR,
});
return;
}
saveKey(userKey);
setUserKey('');
};
@ -159,56 +284,54 @@ const SetKeyDialog = ({
return (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogTemplate
title={`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`}
className="w-11/12 max-w-2xl"
showCancelButton={false}
main={
<div className="grid w-full items-center gap-2">
<small className="text-red-600">
{expiryTime === 'never'
? localize('com_endpoint_config_key_never_expires')
: `${localize('com_endpoint_config_key_encryption')} ${new Date(
expiryTime ?? 0,
).toLocaleString()}`}
</small>
<Dropdown
label="Expires "
value={expiresAtLabel}
onChange={handleExpirationChange}
options={expirationOptions.map((option) => option.label)}
sizeClasses="w-[185px]"
portal={false}
<OGDialogContent className="w-11/12 max-w-2xl">
<OGDialogHeader>
<OGDialogTitle>
{`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`}
</OGDialogTitle>
</OGDialogHeader>
<div className="grid w-full items-center gap-2 py-4">
<small className="text-red-600">
{expiryTime === 'never'
? localize('com_endpoint_config_key_never_expires')
: `${localize('com_endpoint_config_key_encryption')} ${new Date(
expiryTime ?? 0,
).toLocaleString()}`}
</small>
<Dropdown
label="Expires "
value={expiresAtLabel}
onChange={handleExpirationChange}
options={expirationOptions.map((option) => option.label)}
sizeClasses="w-[185px]"
portal={false}
/>
<div className="mt-2" />
<FormProvider {...methods}>
<EndpointComponent
userKey={userKey}
setUserKey={setUserKey}
endpoint={
endpoint === EModelEndpoint.gptPlugins && (config?.azure ?? false)
? EModelEndpoint.azureOpenAI
: endpoint
}
userProvideURL={userProvideURL}
/>
<div className="mt-2" />
<FormProvider {...methods}>
<EndpointComponent
userKey={userKey}
setUserKey={setUserKey}
endpoint={
endpoint === EModelEndpoint.gptPlugins && (config?.azure ?? false)
? EModelEndpoint.azureOpenAI
: endpoint
}
userProvideURL={userProvideURL}
/>
</FormProvider>
<HelpText endpoint={endpoint} />
</div>
}
selection={{
selectHandler: submit,
selectClasses: 'btn btn-primary',
selectText: localize('com_ui_submit'),
}}
leftButtons={
</FormProvider>
<HelpText endpoint={endpoint} />
</div>
<OGDialogFooter>
<RevokeKeysButton
endpoint={endpoint}
disabled={!(expiryTime ?? '')}
setDialogOpen={onOpenChange}
/>
}
/>
<Button variant="submit" onClick={submit}>
{localize('com_ui_submit')}
</Button>
</OGDialogFooter>
</OGDialogContent>
</OGDialog>
);
};

View file

@ -182,7 +182,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<line x1="18" x2="6" y1="6" y2="18"></line>
<line x1="6" x2="18" y1="6" y2="18"></line>
</svg>
<span className="sr-only">{localize('com_ui_close')}</span>
<span className="sr-only">{localize('com_ui_close_settings')}</span>
</button>
</DialogTitle>
<div className="max-h-[550px] overflow-auto px-6 md:max-h-[400px] md:min-h-[400px] md:w-[680px]">
@ -220,35 +220,35 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
))}
</Tabs.List>
<div className="overflow-auto sm:w-full sm:max-w-none md:pr-0.5 md:pt-0.5">
<Tabs.Content value={SettingsTabValues.GENERAL}>
<Tabs.Content value={SettingsTabValues.GENERAL} tabIndex={-1}>
<General />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.CHAT}>
<Tabs.Content value={SettingsTabValues.CHAT} tabIndex={-1}>
<Chat />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.COMMANDS}>
<Tabs.Content value={SettingsTabValues.COMMANDS} tabIndex={-1}>
<Commands />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.SPEECH}>
<Tabs.Content value={SettingsTabValues.SPEECH} tabIndex={-1}>
<Speech />
</Tabs.Content>
{hasAnyPersonalizationFeature && (
<Tabs.Content value={SettingsTabValues.PERSONALIZATION}>
<Tabs.Content value={SettingsTabValues.PERSONALIZATION} tabIndex={-1}>
<Personalization
hasMemoryOptOut={hasMemoryOptOut}
hasAnyPersonalizationFeature={hasAnyPersonalizationFeature}
/>
</Tabs.Content>
)}
<Tabs.Content value={SettingsTabValues.DATA}>
<Tabs.Content value={SettingsTabValues.DATA} tabIndex={-1}>
<Data />
</Tabs.Content>
{startupConfig?.balance?.enabled && (
<Tabs.Content value={SettingsTabValues.BALANCE}>
<Tabs.Content value={SettingsTabValues.BALANCE} tabIndex={-1}>
<Balance />
</Tabs.Content>
)}
<Tabs.Content value={SettingsTabValues.ACCOUNT}>
<Tabs.Content value={SettingsTabValues.ACCOUNT} tabIndex={-1}>
<Account />
</Tabs.Content>
</div>

View file

@ -1,9 +1,11 @@
import React, { useState, useRef, useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
// @ts-ignore - no type definitions available
import AvatarEditor from 'react-avatar-editor';
import { FileImage, RotateCw, Upload } from 'lucide-react';
import { FileImage, RotateCw, Upload, ZoomIn, ZoomOut, Move, X } from 'lucide-react';
import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider';
import {
Label,
Slider,
Button,
Spinner,
@ -25,14 +27,20 @@ interface AvatarEditorRef {
getImage: () => HTMLImageElement;
}
interface Position {
x: number;
y: number;
}
function Avatar() {
const setUser = useSetRecoilState(store.user);
const [scale, setScale] = useState<number>(1);
const [rotation, setRotation] = useState<number>(0);
const [position, setPosition] = useState<Position>({ x: 0.5, y: 0.5 });
const [isDragging, setIsDragging] = useState<boolean>(false);
const editorRef = useRef<AvatarEditorRef | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const openButtonRef = useRef<HTMLButtonElement>(null);
const [image, setImage] = useState<string | File | null>(null);
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
@ -48,7 +56,6 @@ function Avatar() {
onSuccess: (data) => {
showToast({ message: localize('com_ui_upload_success') });
setUser((prev) => ({ ...prev, avatar: data.url }) as TUser);
openButtonRef.current?.click();
},
onError: (error) => {
console.error('Error:', error);
@ -61,29 +68,45 @@ function Avatar() {
handleFile(file);
};
const handleFile = (file: File | undefined) => {
if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) {
setImage(file);
setScale(1);
setRotation(0);
} else {
const megabytes =
fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2;
showToast({
message: localize('com_ui_upload_invalid_var', { 0: megabytes + '' }),
status: 'error',
});
}
};
const handleFile = useCallback(
(file: File | undefined) => {
if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) {
setImage(file);
setScale(1);
setRotation(0);
setPosition({ x: 0.5, y: 0.5 });
} else {
const megabytes =
fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2;
showToast({
message: localize('com_ui_upload_invalid_var', { 0: megabytes + '' }),
status: 'error',
});
}
},
[fileConfig.avatarSizeLimit, localize, showToast],
);
const handleScaleChange = (value: number[]) => {
setScale(value[0]);
};
const handleZoomIn = () => {
setScale((prev) => Math.min(prev + 0.2, 5));
};
const handleZoomOut = () => {
setScale((prev) => Math.max(prev - 0.2, 1));
};
const handleRotate = () => {
setRotation((prev) => (prev + 90) % 360);
};
const handlePositionChange = (position: Position) => {
setPosition(position);
};
const handleUpload = () => {
if (editorRef.current) {
const canvas = editorRef.current.getImageScaledToCanvas();
@ -98,11 +121,14 @@ function Avatar() {
}
};
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
handleFile(file);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
handleFile(file);
},
[handleFile],
);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
@ -116,8 +142,15 @@ function Avatar() {
setImage(null);
setScale(1);
setRotation(0);
setPosition({ x: 0.5, y: 0.5 });
}, []);
const handleReset = () => {
setScale(1);
setRotation(0);
setPosition({ x: 0.5, y: 0.5 });
};
return (
<OGDialog
open={isDialogOpen}
@ -125,90 +158,190 @@ function Avatar() {
setDialogOpen(open);
if (!open) {
resetImage();
setTimeout(() => {
openButtonRef.current?.focus();
}, 0);
}
}}
>
<div className="flex items-center justify-between">
<span>{localize('com_nav_profile_picture')}</span>
<OGDialogTrigger ref={openButtonRef}>
<OGDialogTrigger asChild>
<Button variant="outline">
<FileImage className="mr-2 flex w-[22px] items-center stroke-1" />
<FileImage className="mr-2 flex w-[22px] items-center" />
<span>{localize('com_nav_change_picture')}</span>
</Button>
</OGDialogTrigger>
</div>
<OGDialogContent className="w-11/12 max-w-sm" style={{ borderRadius: '12px' }}>
<OGDialogContent showCloseButton={false} className="w-11/12 max-w-md">
<OGDialogHeader>
<OGDialogTitle className="text-lg font-medium leading-6 text-text-primary">
{image != null ? localize('com_ui_preview') : localize('com_ui_upload_image')}
</OGDialogTitle>
</OGDialogHeader>
<div className="flex flex-col items-center justify-center">
<div className="flex flex-col items-center justify-center p-2">
{image != null ? (
<>
<div className="relative overflow-hidden rounded-full">
<div
className={cn(
'relative overflow-hidden rounded-full ring-4 ring-gray-200 transition-all dark:ring-gray-700',
isDragging && 'cursor-move ring-blue-500 dark:ring-blue-400',
)}
onMouseDown={() => setIsDragging(true)}
onMouseUp={() => setIsDragging(false)}
onMouseLeave={() => setIsDragging(false)}
>
<AvatarEditor
ref={editorRef}
image={image}
width={250}
height={250}
width={280}
height={280}
border={0}
borderRadius={125}
borderRadius={140}
color={[255, 255, 255, 0.6]}
scale={scale}
rotate={rotation}
position={position}
onPositionChange={handlePositionChange}
className="cursor-move"
/>
{!isDragging && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 transition-opacity hover:opacity-100">
<div className="rounded-full bg-black/50 p-2">
<Move className="h-6 w-6 text-white" />
</div>
</div>
)}
</div>
<div className="mt-4 flex w-full flex-col items-center space-y-4">
<div className="flex w-full items-center justify-center space-x-4">
<span className="text-sm">{localize('com_ui_zoom')}</span>
<Slider
value={[scale]}
min={1}
max={5}
step={0.001}
onValueChange={handleScaleChange}
className="w-2/3 max-w-xs"
/>
<div className="mt-6 w-full space-y-6">
{/* Zoom Controls */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="zoom-slider" className="text-sm font-medium">
{localize('com_ui_zoom')}
</Label>
<span className="text-sm text-text-secondary">{Math.round(scale * 100)}%</span>
</div>
<div className="flex items-center space-x-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleZoomOut}
disabled={scale <= 1}
aria-label={localize('com_ui_zoom_out')}
className="shrink-0"
>
<ZoomOut className="h-4 w-4" />
</Button>
<Slider
id="zoom-slider"
value={[scale]}
min={1}
max={5}
step={0.1}
onValueChange={handleScaleChange}
className="flex-1"
aria-label={localize('com_ui_zoom_level')}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleZoomIn}
disabled={scale >= 5}
aria-label={localize('com_ui_zoom_in')}
className="shrink-0"
>
<ZoomIn className="h-4 w-4" />
</Button>
</div>
</div>
<button
onClick={handleRotate}
className="rounded-full bg-gray-200 p-2 transition-colors hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500"
>
<RotateCw className="h-5 w-5" />
</button>
<div className="flex items-center justify-center space-x-3">
<Button
type="button"
variant="outline"
onClick={handleRotate}
className="flex items-center space-x-2"
aria-label={localize('com_ui_rotate_90')}
>
<RotateCw className="h-4 w-4" />
<span className="text-sm">{localize('com_ui_rotate')}</span>
</Button>
<Button
type="button"
variant="outline"
onClick={handleReset}
className="flex items-center space-x-2"
aria-label={localize('com_ui_reset_adjustments')}
>
<X className="h-4 w-4" />
<span className="text-sm">{localize('com_ui_reset')}</span>
</Button>
</div>
{/* Helper Text */}
<p className="text-center text-xs text-gray-500 dark:text-gray-400">
{localize('com_ui_editor_instructions')}
</p>
</div>
{/* Action Buttons */}
<div className="mt-6 flex w-full space-x-3">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={resetImage}
disabled={isUploading}
>
{localize('com_ui_cancel')}
</Button>
<Button
variant="submit"
type="button"
className={cn('w-full', isUploading ? 'cursor-not-allowed opacity-90' : '')}
onClick={handleUpload}
disabled={isUploading}
>
{isUploading ? (
<Spinner className="icon-sm mr-2" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
{localize('com_ui_upload')}
</Button>
</div>
<Button
className={cn(
'btn btn-primary mt-4 flex w-full hover:bg-green-600',
isUploading ? 'cursor-not-allowed opacity-90' : '',
)}
onClick={handleUpload}
disabled={isUploading}
>
{isUploading ? (
<Spinner className="icon-sm mr-2" />
) : (
<Upload className="mr-2 h-5 w-5" />
)}
{localize('com_ui_upload')}
</Button>
</>
) : (
<div
className="flex h-64 w-11/12 flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-transparent dark:border-gray-600"
className="flex h-72 w-full flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-transparent transition-colors hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500"
onDrop={handleDrop}
onDragOver={handleDragOver}
role="button"
tabIndex={0}
onClick={openFileDialog}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openFileDialog();
}
}}
aria-label={localize('com_ui_upload_avatar_label')}
>
<FileImage className="mb-4 size-12 text-gray-400" />
<p className="mb-2 text-center text-sm text-gray-500 dark:text-gray-400">
<FileImage className="mb-4 size-16 text-gray-400" />
<p className="mb-2 text-center text-sm font-medium text-text-primary">
{localize('com_ui_drag_drop')}
</p>
<Button variant="secondary" onClick={openFileDialog}>
<p className="mb-4 text-center text-xs text-text-secondary">
{localize('com_ui_max_file_size', {
0:
fileConfig.avatarSizeLimit != null
? formatBytes(fileConfig.avatarSizeLimit)
: '2MB',
})}
</p>
<Button type="button" variant="secondary" onClick={openFileDialog}>
{localize('com_ui_select_file')}
</Button>
<input
@ -217,6 +350,7 @@ function Avatar() {
className="hidden"
accept=".png, .jpg, .jpeg"
onChange={handleFileChange}
aria-label={localize('com_ui_file_input_avatar_label')}
/>
</div>
)}

View file

@ -1,6 +1,7 @@
import { LockIcon, Trash } from 'lucide-react';
import React, { useState, useCallback } from 'react';
import {
Label,
Input,
Button,
Spinner,
@ -45,11 +46,11 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
<>
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
<div className="flex items-center justify-between">
<span>{localize('com_nav_delete_account')}</span>
<Label id="delete-account-label">{localize('com_nav_delete_account')}</Label>
<OGDialogTrigger asChild>
<Button
aria-labelledby="delete-account-label"
variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setDialogOpen(true)}
disabled={disabled}
>

View file

@ -20,7 +20,7 @@ export const DisableTwoFactorToggle: React.FC<DisableTwoFactorToggleProps> = ({
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Label className="font-light"> {localize('com_nav_2fa')}</Label>
<Label> {localize('com_nav_2fa')}</Label>
</div>
<div className="flex items-center gap-3">
<Button

View file

@ -15,7 +15,7 @@ export default function DisplayUsernameMessages() {
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Label className="font-light">{localize('com_nav_user_name_display')}</Label>
<Label id="user-name-display-label">{localize('com_nav_user_name_display')}</Label>
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_user_name_display')} />
</div>
<Switch
@ -24,6 +24,7 @@ export default function DisplayUsernameMessages() {
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="UsernameDisplay"
aria-labelledby="user-name-display-label"
/>
</div>
);

View file

@ -19,16 +19,16 @@ const ChatDirection = () => {
</div>
<Button
variant="outline"
aria-label="Toggle chat direction"
aria-label={`${localize('com_nav_chat_direction')}: ${localize('com_ui_x_selected', {
0:
direction === 'LTR'
? localize('chat_direction_left_to_right')
: localize('chat_direction_right_to_left'),
})}`}
onClick={toggleChatDirection}
data-testid="chatDirection"
>
<span aria-hidden="true">{direction.toLowerCase()}</span>
<span id="chat-direction-status" className="sr-only">
{direction === 'LTR'
? localize('chat_direction_left_to_right')
: localize('chat_direction_right_to_left')}
</span>
{direction.toLowerCase()}
</Button>
</div>
);

View file

@ -20,9 +20,11 @@ export default function FontSizeSelector() {
{ value: 'text-xl', label: localize('com_nav_font_size_xl') },
];
const labelId = 'font-size-selector-label';
return (
<div className="flex w-full items-center justify-between">
<div>{localize('com_nav_font_size')}</div>
<div id={labelId}>{localize('com_nav_font_size')}</div>
<Dropdown
value={fontSize}
options={options}
@ -30,6 +32,7 @@ export default function FontSizeSelector() {
testId="font-size-selector"
sizeClasses="w-[150px]"
className="z-50"
aria-labelledby={labelId}
/>
</div>
);

View file

@ -20,13 +20,14 @@ export const ForkSettings = () => {
<>
<div className="pb-3">
<div className="flex items-center justify-between">
<div> {localize('com_ui_fork_default')} </div>
<div id="remember-default-fork-label"> {localize('com_ui_fork_default')} </div>
<Switch
id="rememberDefaultFork"
checked={remember}
onCheckedChange={setRemember}
className="ml-4"
data-testid="rememberDefaultFork"
aria-labelledby="remember-default-fork-label"
/>
</div>
</div>
@ -34,7 +35,7 @@ export const ForkSettings = () => {
<div className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_ui_fork_change_default')}</div>
<div id="fork-change-default-label">{localize('com_ui_fork_change_default')}</div>
<InfoHoverCard
side={ESide.Bottom}
text={localize('com_nav_info_fork_change_default')}
@ -47,6 +48,7 @@ export const ForkSettings = () => {
sizeClasses="w-[200px]"
testId="fork-setting-dropdown"
className="z-[50]"
aria-labelledby="fork-change-default-label"
/>
</div>
</div>
@ -54,7 +56,7 @@ export const ForkSettings = () => {
<div className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_ui_fork_split_target_setting')}</div>
<div id="split-at-target-label">{localize('com_ui_fork_split_target_setting')}</div>
<InfoHoverCard
side={ESide.Bottom}
text={localize('com_nav_info_fork_split_target_setting')}
@ -66,6 +68,7 @@ export const ForkSettings = () => {
onCheckedChange={setSplitAtTarget}
className="ml-4"
data-testid="splitAtTarget"
aria-labelledby="split-at-target-label"
/>
</div>
</div>

View file

@ -1,26 +0,0 @@
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function AtCommandSwitch() {
const [atCommand, setAtCommand] = useRecoilState<boolean>(store.atCommand);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setAtCommand(value);
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_at_command_description')}</div>
<Switch
id="atCommand"
checked={atCommand}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="atCommand"
/>
</div>
);
}

View file

@ -1,10 +1,33 @@
import { memo } from 'react';
import { InfoHoverCard, ESide } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import SlashCommandSwitch from './SlashCommandSwitch';
import { useLocalize, useHasAccess } from '~/hooks';
import PlusCommandSwitch from './PlusCommandSwitch';
import AtCommandSwitch from './AtCommandSwitch';
import ToggleSwitch from '../ToggleSwitch';
import store from '~/store';
const commandSwitchConfigs = [
{
stateAtom: store.atCommand,
localizationKey: 'com_nav_at_command_description' as const,
switchId: 'atCommand',
key: 'atCommand',
permissionType: undefined,
},
{
stateAtom: store.plusCommand,
localizationKey: 'com_nav_plus_command_description' as const,
switchId: 'plusCommand',
key: 'plusCommand',
permissionType: PermissionTypes.MULTI_CONVO,
},
{
stateAtom: store.slashCommand,
localizationKey: 'com_nav_slash_command_description' as const,
switchId: 'slashCommand',
key: 'slashCommand',
permissionType: PermissionTypes.PROMPTS,
},
] as const;
function Commands() {
const localize = useLocalize();
@ -19,6 +42,19 @@ function Commands() {
permission: Permissions.USE,
});
const getShowSwitch = (permissionType?: PermissionTypes) => {
if (!permissionType) {
return true;
}
if (permissionType === PermissionTypes.MULTI_CONVO) {
return hasAccessToMultiConvo === true;
}
if (permissionType === PermissionTypes.PROMPTS) {
return hasAccessToPrompts === true;
}
return true;
};
return (
<div className="space-y-4 p-1">
<div className="flex items-center gap-2">
@ -28,19 +64,16 @@ function Commands() {
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_chat_commands_info')} />
</div>
<div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="pb-3">
<AtCommandSwitch />
</div>
{hasAccessToMultiConvo === true && (
<div className="pb-3">
<PlusCommandSwitch />
{commandSwitchConfigs.map((config) => (
<div key={config.key} className="pb-3">
<ToggleSwitch
stateAtom={config.stateAtom}
localizationKey={config.localizationKey}
switchId={config.switchId}
showSwitch={getShowSwitch(config.permissionType)}
/>
</div>
)}
{hasAccessToPrompts === true && (
<div className="pb-3">
<SlashCommandSwitch />
</div>
)}
))}
</div>
</div>
);

View file

@ -1,26 +0,0 @@
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function PlusCommandSwitch() {
const [plusCommand, setPlusCommand] = useRecoilState<boolean>(store.plusCommand);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setPlusCommand(value);
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_plus_command_description')}</div>
<Switch
id="plusCommand"
checked={plusCommand}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="plusCommand"
/>
</div>
);
}

View file

@ -1,25 +0,0 @@
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function SlashCommandSwitch() {
const [slashCommand, setSlashCommand] = useRecoilState<boolean>(store.slashCommand);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setSlashCommand(value);
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_slash_command_description')}</div>
<Switch
id="slashCommand"
checked={slashCommand}
onCheckedChange={handleCheckedChange}
data-testid="slashCommand"
/>
</div>
);
}

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

View file

@ -46,9 +46,11 @@ export const ThemeSelector = ({
{ value: 'light', label: localize('com_nav_theme_light') },
];
const labelId = 'theme-selector-label';
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_theme')}</div>
<div id={labelId}>{localize('com_nav_theme')}</div>
<Dropdown
value={theme}
@ -57,6 +59,7 @@ export const ThemeSelector = ({
sizeClasses="w-[180px]"
testId="theme-selector"
className="z-50"
aria-labelledby={labelId}
/>
</div>
);
@ -112,9 +115,11 @@ export const LangSelector = ({
{ value: 'uk-UA', label: localize('com_nav_lang_ukrainian') },
];
const labelId = 'language-selector-label';
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_language')}</div>
<div id={labelId}>{localize('com_nav_language')}</div>
<Dropdown
value={langcode}
@ -122,6 +127,7 @@ export const LangSelector = ({
sizeClasses="[--anchor-max-height:256px]"
options={languageOptions}
className="z-50"
aria-labelledby={labelId}
/>
</div>
);

View file

@ -65,10 +65,13 @@ export default function Personalization({
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<div id="reference-saved-memories-label" className="flex items-center gap-2">
{localize('com_ui_reference_saved_memories')}
</div>
<div className="mt-1 text-xs text-text-secondary">
<div
id="reference-saved-memories-description"
className="mt-1 text-xs text-text-secondary"
>
{localize('com_ui_reference_saved_memories_description')}
</div>
</div>
@ -76,7 +79,8 @@ export default function Personalization({
checked={referenceSavedMemories}
onCheckedChange={handleMemoryToggle}
disabled={updateMemoryPreferencesMutation.isLoading}
aria-label={localize('com_ui_reference_saved_memories')}
aria-labelledby="reference-saved-memories-label"
aria-describedby="reference-saved-memories-description"
/>
</div>
</>

View file

@ -1,6 +1,5 @@
import { Switch } from '@librechat/client';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useLocalize } from '~/hooks';
import ToggleSwitch from '../ToggleSwitch';
import store from '~/store';
export default function ConversationModeSwitch({
@ -8,8 +7,6 @@ export default function ConversationModeSwitch({
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const localize = useLocalize();
const [conversationMode, setConversationMode] = useRecoilState<boolean>(store.conversationMode);
const speechToText = useRecoilValue(store.speechToText);
const textToSpeech = useRecoilValue(store.textToSpeech);
const [, setAutoSendText] = useRecoilState(store.autoSendText);
@ -20,27 +17,19 @@ export default function ConversationModeSwitch({
setAutoTranscribeAudio(value);
setAutoSendText(3);
setDecibelValue(-45);
setConversationMode(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div>
<strong>{localize('com_nav_conversation_mode')}</strong>
</div>
<div className="flex items-center justify-between">
<Switch
id="ConversationMode"
checked={conversationMode}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="ConversationMode"
disabled={!textToSpeech || !speechToText}
/>
</div>
</div>
<ToggleSwitch
stateAtom={store.conversationMode}
localizationKey={'com_nav_conversation_mode' as const}
switchId="ConversationMode"
onCheckedChange={handleCheckedChange}
disabled={!textToSpeech || !speechToText}
strongLabel={true}
/>
);
}

View file

@ -1,6 +1,6 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Slider, InputNumber } from '@librechat/client';
import { Slider, InputNumber, Switch } from '@librechat/client';
import { cn, defaultTextProps, optionText } from '~/utils/';
import { useLocalize } from '~/hooks';
import store from '~/store';
@ -11,40 +11,104 @@ export default function AutoSendTextSelector() {
const speechToText = useRecoilValue(store.speechToText);
const [autoSendText, setAutoSendText] = useRecoilState(store.autoSendText);
// Local state for enabled/disabled toggle
const [isEnabled, setIsEnabled] = useState(autoSendText !== -1);
const [delayValue, setDelayValue] = useState(autoSendText === -1 ? 3 : autoSendText);
// Sync local state when autoSendText changes externally
useEffect(() => {
setIsEnabled(autoSendText !== -1);
if (autoSendText !== -1) {
setDelayValue(autoSendText);
}
}, [autoSendText]);
const handleToggle = (enabled: boolean) => {
setIsEnabled(enabled);
if (enabled) {
setAutoSendText(delayValue);
} else {
setAutoSendText(-1);
}
};
const handleSliderChange = (value: number[]) => {
const newValue = value[0];
setDelayValue(newValue);
if (isEnabled) {
setAutoSendText(newValue);
}
};
const handleInputChange = (value: number[] | null) => {
const newValue = value ? value[0] : 3;
setDelayValue(newValue);
if (isEnabled) {
setAutoSendText(newValue);
}
};
const labelId = 'auto-send-text-label';
return (
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div>{localize('com_nav_auto_send_text')}</div>
<div className="w-2" />
<small className="opacity-40">({localize('com_nav_auto_send_text_disabled')})</small>
</div>
<div className="flex items-center justify-between">
<Slider
value={[autoSendText ?? -1]}
onValueChange={(value) => setAutoSendText(value[0])}
onDoubleClick={() => setAutoSendText(-1)}
min={-1}
max={60}
step={1}
className="ml-4 flex h-4 w-24"
<div className="flex items-center space-x-2">
<div id={labelId}>{localize('com_nav_auto_send_text')}</div>
</div>
<Switch
id="autoSendTextToggle"
checked={isEnabled}
onCheckedChange={handleToggle}
className="ml-4"
data-testid="autoSendTextToggle"
aria-labelledby={labelId}
disabled={!speechToText}
/>
<div className="w-2" />
<InputNumber
value={`${autoSendText} s`}
disabled={!speechToText}
onChange={(value) => setAutoSendText(value ? value[0] : 0)}
min={-1}
max={60}
className={cn(
defaultTextProps,
cn(
optionText,
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
),
)}
/>
</div>
{isEnabled && (
<div className="mt-2 flex items-center justify-between">
<div className="flex items-center justify-between">
<div id="auto-send-delay-label" className="text-sm text-text-secondary">
{localize('com_nav_setting_delay')}
</div>
</div>
<div className="flex items-center justify-between">
<Slider
value={[delayValue]}
onValueChange={handleSliderChange}
onDoubleClick={() => {
setDelayValue(3);
if (isEnabled) {
setAutoSendText(3);
}
}}
min={0}
max={60}
step={1}
className="ml-4 flex h-4 w-24"
disabled={!speechToText || !isEnabled}
aria-labelledby="auto-send-delay-label"
/>
<div className="w-2" />
<InputNumber
value={`${delayValue} s`}
disabled={!speechToText || !isEnabled}
onChange={handleInputChange}
min={0}
max={60}
aria-labelledby="auto-send-delay-label"
className={cn(
defaultTextProps,
cn(
optionText,
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
),
)}
/>
</div>
</div>
)}
</div>
);
}

View file

@ -1,6 +1,5 @@
import { Switch } from '@librechat/client';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useLocalize } from '~/hooks';
import { useRecoilValue } from 'recoil';
import ToggleSwitch from '../../ToggleSwitch';
import store from '~/store';
export default function AutoTranscribeAudioSwitch({
@ -8,30 +7,15 @@ export default function AutoTranscribeAudioSwitch({
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const localize = useLocalize();
const [autoTranscribeAudio, setAutoTranscribeAudio] = useRecoilState<boolean>(
store.autoTranscribeAudio,
);
const speechToText = useRecoilValue(store.speechToText);
const handleCheckedChange = (value: boolean) => {
setAutoTranscribeAudio(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_auto_transcribe_audio')}</div>
<Switch
id="AutoTranscribeAudio"
checked={autoTranscribeAudio}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="AutoTranscribeAudio"
disabled={!speechToText}
/>
</div>
<ToggleSwitch
stateAtom={store.autoTranscribeAudio}
localizationKey={'com_nav_auto_transcribe_audio' as const}
switchId="AutoTranscribeAudio"
onCheckedChange={onCheckedChange}
disabled={!speechToText}
/>
);
}

View file

@ -13,7 +13,7 @@ export default function DecibelSelector() {
return (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between">
<div>{localize('com_nav_db_sensitivity')}</div>
<div id="decibel-selector-label">{localize('com_nav_db_sensitivity')}</div>
<div className="w-2" />
<small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '-45' })})
@ -29,6 +29,7 @@ export default function DecibelSelector() {
step={1}
className="ml-4 flex h-4 w-24"
disabled={!speechToText}
aria-labelledby="decibel-selector-label"
/>
<div className="w-2" />
<InputNumber
@ -37,6 +38,7 @@ export default function DecibelSelector() {
onChange={(value) => setDecibelValue(value ? value[0] : 0)}
min={-100}
max={-30}
aria-labelledby="decibel-selector-label"
className={cn(
defaultTextProps,
cn(

View file

@ -23,9 +23,11 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
setEngineSTT(value);
};
const labelId = 'engine-stt-dropdown-label';
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_engine')}</div>
<div id={labelId}>{localize('com_nav_engine')}</div>
<Dropdown
value={engineSTT}
onChange={handleSelect}
@ -33,6 +35,7 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
sizeClasses="w-[180px]"
testId="EngineSTTDropdown"
className="z-50"
aria-labelledby={labelId}
/>
</div>
);

View file

@ -94,9 +94,11 @@ export default function LanguageSTTDropdown() {
setLanguageSTT(value);
};
const labelId = 'language-stt-dropdown-label';
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_language')}</div>
<div id={labelId}>{localize('com_nav_language')}</div>
<Dropdown
value={languageSTT}
onChange={handleSelect}
@ -104,6 +106,7 @@ export default function LanguageSTTDropdown() {
sizeClasses="[--anchor-max-height:256px]"
testId="LanguageSTTDropdown"
className="z-50"
aria-labelledby={labelId}
/>
</div>
);

View file

@ -1,6 +1,4 @@
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import ToggleSwitch from '../../ToggleSwitch';
import store from '~/store';
export default function SpeechToTextSwitch({
@ -8,28 +6,13 @@ export default function SpeechToTextSwitch({
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const localize = useLocalize();
const [speechToText, setSpeechToText] = useRecoilState<boolean>(store.speechToText);
const handleCheckedChange = (value: boolean) => {
setSpeechToText(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div>
<strong>{localize('com_nav_speech_to_text')}</strong>
</div>
<Switch
id="SpeechToText"
checked={speechToText}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="SpeechToText"
/>
</div>
<ToggleSwitch
stateAtom={store.speechToText}
localizationKey={'com_nav_speech_to_text' as const}
switchId="SpeechToText"
onCheckedChange={onCheckedChange}
strongLabel={true}
/>
);
}

View file

@ -23,7 +23,7 @@ import {
} from './STT';
import ConversationModeSwitch from './ConversationModeSwitch';
import { useLocalize } from '~/hooks';
import { cn, logger } from '~/utils';
import { cn } from '~/utils';
import store from '~/store';
function Speech() {
@ -186,7 +186,7 @@ function Speech() {
</Tabs.List>
</div>
<Tabs.Content value={'simple'}>
<Tabs.Content value={'simple'} tabIndex={-1}>
<div className="flex flex-col gap-3 text-sm text-text-primary">
<SpeechToTextSwitch />
<EngineSTTDropdown external={sttExternal} />
@ -198,7 +198,7 @@ function Speech() {
</div>
</Tabs.Content>
<Tabs.Content value={'advanced'}>
<Tabs.Content value={'advanced'} tabIndex={-1}>
<div className="flex flex-col gap-3 text-sm text-text-primary">
<ConversationModeSwitch />
<div className="mt-2 h-px bg-border-medium" role="none" />

View file

@ -1,6 +1,4 @@
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import ToggleSwitch from '../../ToggleSwitch';
import store from '~/store';
export default function AutomaticPlaybackSwitch({
@ -8,26 +6,12 @@ export default function AutomaticPlaybackSwitch({
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const localize = useLocalize();
const [automaticPlayback, setAutomaticPlayback] = useRecoilState(store.automaticPlayback);
const handleCheckedChange = (value: boolean) => {
setAutomaticPlayback(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_automatic_playback')}</div>
<Switch
id="AutomaticPlayback"
checked={automaticPlayback}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="AutomaticPlayback"
/>
</div>
<ToggleSwitch
stateAtom={store.automaticPlayback}
localizationKey={'com_nav_automatic_playback' as const}
switchId="AutomaticPlayback"
onCheckedChange={onCheckedChange}
/>
);
}

View file

@ -1,6 +1,5 @@
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { useRecoilValue } from 'recoil';
import ToggleSwitch from '../../ToggleSwitch';
import store from '~/store';
export default function CacheTTSSwitch({
@ -8,28 +7,15 @@ export default function CacheTTSSwitch({
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const localize = useLocalize();
const [cacheTTS, setCacheTTS] = useRecoilState<boolean>(store.cacheTTS);
const [textToSpeech] = useRecoilState<boolean>(store.textToSpeech);
const handleCheckedChange = (value: boolean) => {
setCacheTTS(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
const textToSpeech = useRecoilValue(store.textToSpeech);
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_enable_cache_tts')}</div>
<Switch
id="CacheTTS"
checked={cacheTTS}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="CacheTTS"
disabled={!textToSpeech}
/>
</div>
<ToggleSwitch
stateAtom={store.cacheTTS}
localizationKey={'com_nav_enable_cache_tts' as const}
switchId="CacheTTS"
onCheckedChange={onCheckedChange}
disabled={!textToSpeech}
/>
);
}

View file

@ -1,6 +1,5 @@
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { useRecoilValue } from 'recoil';
import ToggleSwitch from '../../ToggleSwitch';
import store from '~/store';
export default function CloudBrowserVoicesSwitch({
@ -8,30 +7,15 @@ export default function CloudBrowserVoicesSwitch({
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const localize = useLocalize();
const [cloudBrowserVoices, setCloudBrowserVoices] = useRecoilState<boolean>(
store.cloudBrowserVoices,
);
const [textToSpeech] = useRecoilState<boolean>(store.textToSpeech);
const handleCheckedChange = (value: boolean) => {
setCloudBrowserVoices(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
const textToSpeech = useRecoilValue(store.textToSpeech);
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_enable_cloud_browser_voice')}</div>
<Switch
id="CloudBrowserVoices"
checked={cloudBrowserVoices}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="CloudBrowserVoices"
disabled={!textToSpeech}
/>
</div>
<ToggleSwitch
stateAtom={store.cloudBrowserVoices}
localizationKey={'com_nav_enable_cloud_browser_voice' as const}
switchId="CloudBrowserVoices"
onCheckedChange={onCheckedChange}
disabled={!textToSpeech}
/>
);
}

View file

@ -23,9 +23,11 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
setEngineTTS(value);
};
const labelId = 'engine-tts-dropdown-label';
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_engine')}</div>
<div id={labelId}>{localize('com_nav_engine')}</div>
<Dropdown
value={engineTTS}
onChange={handleSelect}
@ -33,6 +35,7 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
sizeClasses="w-[180px]"
testId="EngineTTSDropdown"
className="z-50"
aria-labelledby={labelId}
/>
</div>
);

View file

@ -13,7 +13,7 @@ export default function DecibelSelector() {
return (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between">
<div>{localize('com_nav_playback_rate')}</div>
<div id="playback-rate-label">{localize('com_nav_playback_rate')}</div>
<div className="w-2" />
<small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '1' })})
@ -29,6 +29,7 @@ export default function DecibelSelector() {
step={0.1}
className="ml-4 flex h-4 w-24"
disabled={!textToSpeech}
aria-labelledby="playback-rate-label"
/>
<div className="w-2" />
<InputNumber
@ -37,6 +38,7 @@ export default function DecibelSelector() {
onChange={(value) => setPlaybackRate(value ? value[0] : 0)}
min={0.1}
max={2}
aria-labelledby="playback-rate-label"
className={cn(
defaultTextProps,
cn(

View file

@ -1,6 +1,4 @@
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import ToggleSwitch from '../../ToggleSwitch';
import store from '~/store';
export default function TextToSpeechSwitch({
@ -8,28 +6,13 @@ export default function TextToSpeechSwitch({
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const localize = useLocalize();
const [TextToSpeech, setTextToSpeech] = useRecoilState<boolean>(store.textToSpeech);
const handleCheckedChange = (value: boolean) => {
setTextToSpeech(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div>
<strong>{localize('com_nav_text_to_speech')}</strong>
</div>
<Switch
id="TextToSpeech"
checked={TextToSpeech}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="TextToSpeech"
/>
</div>
<ToggleSwitch
stateAtom={store.textToSpeech}
localizationKey={'com_nav_text_to_speech' as const}
switchId="TextToSpeech"
onCheckedChange={onCheckedChange}
strongLabel={true}
/>
);
}

View file

@ -11,6 +11,9 @@ interface ToggleSwitchProps {
hoverCardText?: LocalizeKey;
switchId: string;
onCheckedChange?: (value: boolean) => void;
showSwitch?: boolean;
disabled?: boolean;
strongLabel?: boolean;
}
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
@ -19,6 +22,9 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
hoverCardText,
switchId,
onCheckedChange,
showSwitch = true,
disabled = false,
strongLabel = false,
}) => {
const [switchState, setSwitchState] = useRecoilState(stateAtom);
const localize = useLocalize();
@ -28,10 +34,18 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
onCheckedChange?.(value);
};
const labelId = `${switchId}-label`;
if (!showSwitch) {
return null;
}
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize(localizationKey)}</div>
<div id={labelId}>
{strongLabel ? <strong>{localize(localizationKey)}</strong> : localize(localizationKey)}
</div>
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
</div>
<Switch
@ -40,6 +54,8 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid={switchId}
aria-labelledby={labelId}
disabled={disabled}
/>
</div>
);

View file

@ -1,9 +1,8 @@
export { default as General } from './General/General';
export { default as Chat } from './Chat/Chat';
export { default as Data } from './Data/Data';
export { default as Commands } from './Commands/Commands';
export { RevokeKeysButton } from './Data/RevokeKeysButton';
export { default as Account } from './Account/Account';
export { default as Balance } from './Balance/Balance';
export { default as Speech } from './Speech/Speech';
export { default as Balance } from './Balance/Balance';
export { default as General } from './General/General';
export { default as Account } from './Account/Account';
export { default as Commands } from './Commands/Commands';
export { default as Personalization } from './Personalization';

View file

@ -71,10 +71,12 @@ function ChatGroupItem({
<DropdownMenuTrigger asChild>
<button
id={`prompt-actions-${group._id}`}
aria-label={`${group.name} - Actions Menu`}
aria-expanded="false"
aria-controls={`prompt-menu-${group._id}`}
aria-haspopup="menu"
type="button"
aria-label={
localize('com_ui_sr_actions_menu', { 0: group.name }) +
' ' +
localize('com_ui_prompt')
}
onClick={(e) => {
e.stopPropagation();
}}
@ -86,11 +88,6 @@ function ChatGroupItem({
className="z-50 inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out hover:border-border-heavy hover:bg-surface-hover focus:border-border-heavy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
<MenuIcon className="icon-md text-text-secondary" aria-hidden="true" />
<span className="sr-only">
{localize('com_ui_sr_actions_menu', { 0: group.name }) +
' ' +
localize('com_ui_prompt')}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
@ -98,30 +95,29 @@ function ChatGroupItem({
aria-label={`Available actions for ${group.name}`}
className="z-50 w-fit rounded-xl"
collisionPadding={2}
align="end"
align="start"
>
<DropdownMenuItem
role="menuitem"
onClick={(e) => {
e.stopPropagation();
setPreviewDialogOpen(true);
}}
className="w-full cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
>
<TextSearch className="mr-2 h-4 w-4" aria-hidden="true" />
<TextSearch className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
<span>{localize('com_ui_preview')}</span>
</DropdownMenuItem>
{canEdit && (
<DropdownMenuGroup>
<DropdownMenuItem
disabled={!canEdit}
className="cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
className="cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
onClick={(e) => {
e.stopPropagation();
onEditClick(e);
}}
>
<EditIcon className="mr-2 h-4 w-4" aria-hidden="true" />
<EditIcon className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
<span>{localize('com_ui_edit')}</span>
</DropdownMenuItem>
</DropdownMenuGroup>

View file

@ -89,7 +89,7 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
aria-label={`${group.name} prompt group`}
aria-label={`${group.name} Prompt, ${localize('com_ui_category')}: ${group.category ?? ''}`}
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2 truncate pr-2">

View file

@ -87,7 +87,7 @@ export default function FilterPrompts({ className = '' }: { className?: string }
value={categoryFilter || SystemCategories.ALL}
onChange={onSelect}
options={filterOptions}
className="bg-transparent"
className="rounded-lg bg-transparent"
icon={<ListFilter className="h-4 w-4" />}
label="Filter: "
ariaLabel={localize('com_ui_filter_prompts')}

View file

@ -40,7 +40,7 @@ export default function List({
</Button>
</div>
)}
<div className="flex-grow overflow-y-auto">
<div className="flex-grow overflow-y-auto" aria-label={localize('com_ui_prompt_groups')}>
<div className="overflow-y-auto overflow-x-hidden">
{isLoading && isChatRoute && (
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />

View file

@ -14,7 +14,7 @@ const PreviewPrompt = ({
return (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent className="max-h-[90vh] w-11/12 max-w-full overflow-y-auto md:max-w-[60vw]">
<div className="p-2">
<div>
<PromptDetails group={group} />
</div>
</OGDialogContent>

View file

@ -1,6 +1,6 @@
{
"chat_direction_left_to_right": "something needs to go here. was empty",
"chat_direction_right_to_left": "something needs to go here. was empty",
"chat_direction_left_to_right": "Left to Right",
"chat_direction_right_to_left": "Right to Left",
"com_a11y_ai_composing": "The AI is still composing.",
"com_a11y_end": "The AI has finished their reply.",
"com_a11y_start": "The AI has started their reply.",
@ -408,7 +408,6 @@
"com_nav_auto_scroll": "Auto-Scroll to latest message on chat open",
"com_nav_auto_send_prompts": "Auto-send Prompts",
"com_nav_auto_send_text": "Auto send text",
"com_nav_auto_send_text_disabled": "set -1 to disable",
"com_nav_auto_transcribe_audio": "Auto transcribe audio",
"com_nav_automatic_playback": "Autoplay Latest Message",
"com_nav_balance": "Balance",
@ -573,6 +572,7 @@
"com_nav_slash_command_description": "Toggle command \"/\" for selecting a prompt via keyboard",
"com_nav_speech_to_text": "Speech to Text",
"com_nav_stop_generating": "Stop generating",
"com_nav_setting_delay": "Delay (s)",
"com_nav_text_to_speech": "Text to Speech",
"com_nav_theme": "Theme",
"com_nav_theme_dark": "Dark",
@ -761,6 +761,7 @@
"com_ui_close": "Close",
"com_ui_close_menu": "Close Menu",
"com_ui_close_window": "Close Window",
"com_ui_close_settings": "Close Settings",
"com_ui_code": "Code",
"com_ui_collapse_chat": "Collapse Chat",
"com_ui_command_placeholder": "Optional: Enter a command for the prompt or name will be used",
@ -950,8 +951,9 @@
"com_ui_image_edited": "Image edited",
"com_ui_image_gen": "Image Gen",
"com_ui_import": "Import",
"com_ui_import_conversation_error": "There was an error importing your conversations",
"com_ui_import_conversation_file_type_error": "Unsupported import type",
"com_ui_import_conversation_error": "There was an error while importing your conversations",
"com_ui_import_conversation_file_type_error": "Error with file type. Please select a valid JSON file.",
"com_ui_import_conversation_upload_error": "Error uploading file. Please try again.",
"com_ui_import_conversation_info": "Import conversations from a JSON file",
"com_ui_import_conversation_success": "Conversations imported successfully",
"com_ui_include_shadcnui": "Include shadcn/ui components instructions",
@ -1077,6 +1079,7 @@
"com_ui_prompts_allow_create": "Allow creating Prompts",
"com_ui_prompts_allow_share": "Allow sharing Prompts",
"com_ui_prompts_allow_use": "Allow using Prompts",
"com_ui_prompt_groups": "Prompt Groups List",
"com_ui_provider": "Provider",
"com_ui_quality": "Quality",
"com_ui_read_aloud": "Read aloud",
@ -1279,5 +1282,21 @@
"com_ui_x_selected": "{{0}} selected",
"com_ui_yes": "Yes",
"com_ui_zoom": "Zoom",
"com_user_message": "You"
"com_user_message": "You",
"com_ui_rotate": "Rotate",
"com_ui_reset": "Reset",
"com_ui_zoom_in": "Zoom in",
"com_ui_zoom_out": "Zoom out",
"com_ui_zoom_level": "Zoom level",
"com_ui_rotate_90": "Rotate 90 degrees",
"com_ui_reset_adjustments": "Reset adjustments",
"com_ui_editor_instructions": "Drag the image to reposition • Use zoom slider or buttons to adjust size",
"com_ui_save_key_success": "API key saved successfully",
"com_ui_save_key_error": "Failed to save API key. Please try again.",
"com_ui_revoke_key_success": "API key revoked successfully",
"com_ui_revoke_key_error": "Failed to revoke API key. Please try again.",
"com_ui_key_required": "API key is required",
"com_ui_max_file_size": "PNG, JPG or JPEG (max {{0}})",
"com_ui_upload_avatar_label": "Upload avatar image",
"com_ui_file_input_avatar_label": "File input for avatar"
}

View file

@ -16,6 +16,7 @@ interface DropdownProps {
iconOnly?: boolean;
renderValue?: (option: Option) => React.ReactNode;
ariaLabel?: string;
'aria-labelledby'?: string;
portal?: boolean;
}
@ -37,6 +38,7 @@ const Dropdown: React.FC<DropdownProps> = ({
iconOnly = false,
renderValue,
ariaLabel,
'aria-labelledby': ariaLabelledBy,
portal = true,
}) => {
const handleChange = (value: string) => {
@ -77,6 +79,7 @@ const Dropdown: React.FC<DropdownProps> = ({
)}
data-testid={testId}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
>
<div className="flex w-full items-center gap-2">
{icon}

View file

@ -1,191 +1,225 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '~/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'text-popover-foreground max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border border-border-light bg-surface-secondary p-1 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className = '', inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-gray-100 data-[state=open]:bg-gray-100 dark:focus:bg-gray-900 dark:data-[state=open]:bg-gray-900',
inset ? 'pl-8' : '',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className = '', ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-100 bg-white p-1 text-gray-700 shadow-md animate-in slide-in-from-left-1 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className = '', sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-100 bg-white p-1 text-gray-700 shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400',
"data-[variant=destructive]:*:[svg]:!text-destructive outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className = '', inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-900',
inset ? 'pl-8' : '',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className = '', children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-900',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className = '', children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
className,
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800',
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className = '', inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold text-gray-900 dark:text-gray-300',
inset ? 'pl-8' : '',
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className = '', ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border-medium', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className = '',
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn('ml-auto text-xs tracking-widest text-gray-500', className)} {...props} />
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('-mx-1 my-1 h-px bg-surface-hover', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
'outline-hidden flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'text-popover-foreground origin-(--radix-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border border-border-medium bg-surface-secondary p-1 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
DropdownMenuSubContent,
};

View file

@ -1,3 +1,4 @@
import { useState } from 'react';
import { CircleHelpIcon } from 'lucide-react';
import { HoverCard, HoverCardTrigger, HoverCardPortal, HoverCardContent } from './HoverCard';
import { ESide } from '~/common';
@ -8,15 +9,23 @@ type InfoHoverCardProps = {
};
const InfoHoverCard = ({ side, text }: InfoHoverCardProps) => {
const [isOpen, setIsOpen] = useState(false);
return (
<HoverCard openDelay={50}>
<HoverCardTrigger className="cursor-help">
<CircleHelpIcon className="h-5 w-5 text-text-tertiary" />{' '}
<HoverCard openDelay={50} open={isOpen} onOpenChange={setIsOpen}>
<HoverCardTrigger
tabIndex={0}
className="inline-flex cursor-help items-center justify-center rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary focus-visible:ring-offset-2"
onFocus={() => setIsOpen(true)}
onBlur={() => setIsOpen(false)}
aria-label={text}
>
<CircleHelpIcon className="h-5 w-5 text-text-tertiary" aria-hidden="true" />
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side={side} className="z-[999] w-80">
<div className="space-y-2">
<p className="text-sm text-text-secondary">{text}</p>
<span className="text-sm text-text-secondary">{text}</span>
</div>
</HoverCardContent>
</HoverCardPortal>

View file

@ -13,7 +13,7 @@ const Label = React.forwardRef<
{...props}
{...{
className: cn(
'block w-full break-all text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-200',
'block w-full break-all text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-200',
className,
),
}}

View file

@ -2,37 +2,56 @@ import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '~/utils';
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
className?: string;
onDoubleClick?: () => void;
}
>(({ className, onDoubleClick, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
{...props}
{...{
className: cn(
'relative flex w-full cursor-pointer touch-none select-none items-center',
className,
),
type SliderProps = React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
className?: string;
onDoubleClick?: () => void;
'aria-describedby'?: string;
} & (
| { 'aria-label': string; 'aria-labelledby'?: never }
| { 'aria-labelledby': string; 'aria-label'?: never }
| { 'aria-label': string; 'aria-labelledby': string }
);
const Slider = React.forwardRef<React.ElementRef<typeof SliderPrimitive.Root>, SliderProps>(
(
{
className,
onDoubleClick,
}}
>
<SliderPrimitive.Track
{...{ className: 'relative h-2 w-full grow overflow-hidden rounded-full bg-secondary' }}
>
<SliderPrimitive.Range {...{ className: 'absolute h-full bg-primary' }} />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
'aria-labelledby': ariaLabelledBy,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
...props
},
ref,
) => (
<SliderPrimitive.Root
ref={ref}
{...props}
{...{
className:
'block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
className: cn(
'relative flex w-full cursor-pointer touch-none select-none items-center',
className,
),
onDoubleClick,
}}
/>
</SliderPrimitive.Root>
));
>
<SliderPrimitive.Track
{...{ className: 'relative h-2 w-full grow overflow-hidden rounded-full bg-secondary' }}
>
<SliderPrimitive.Range {...{ className: 'absolute h-full bg-primary' }} />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
{...{
className:
'block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'aria-labelledby': ariaLabelledBy,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
}}
/>
</SliderPrimitive.Root>
),
);
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };