⌨️ a11y(Settings): Improved Keyboard Navigation & Consistent Styling (#3975)

* feat: settings tba accessible

* refactor: cleanup unused code

* refactor: improve accessibility and user experience in ChatDirection component

* style: focus ring primary class

* improve a11y of avatar dialog

* style: a11y improvements for Settings

* style: focus ring primary class in OriginalDialog component

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2024-09-10 10:11:39 -09:00 committed by GitHub
parent 1a1e6850a3
commit d6c0121b19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 507 additions and 513 deletions

View file

@ -70,13 +70,13 @@ export default function ConvoOptions({
id="conversation-menu-button" id="conversation-menu-button"
aria-label={localize('com_nav_convo_menu_options')} aria-label={localize('com_nav_convo_menu_options')}
className={cn( className={cn(
'z-30 inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 'z-30 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:pointer-events-none disabled:opacity-50',
isActiveConvo === true isActiveConvo === true
? 'opacity-100' ? 'opacity-100'
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100', : 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
)} )}
> >
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true}/> <Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
</Ariakit.MenuButton> </Ariakit.MenuButton>
} }
items={dropdownItems} items={dropdownItems}

View file

@ -1,3 +1,4 @@
import * as React from 'react';
import * as Tabs from '@radix-ui/react-tabs'; import * as Tabs from '@radix-ui/react-tabs';
import { MessageSquare, Command } from 'lucide-react'; import { MessageSquare, Command } from 'lucide-react';
import { SettingsTabValues } from 'librechat-data-provider'; import { SettingsTabValues } from 'librechat-data-provider';
@ -11,10 +12,45 @@ import { cn } from '~/utils';
export default function Settings({ open, onOpenChange }: TDialogProps) { export default function Settings({ open, onOpenChange }: TDialogProps) {
const isSmallScreen = useMediaQuery('(max-width: 767px)'); const isSmallScreen = useMediaQuery('(max-width: 767px)');
const localize = useLocalize(); const localize = useLocalize();
const [activeTab, setActiveTab] = React.useState(SettingsTabValues.GENERAL);
const handleKeyDown = (event: React.KeyboardEvent) => {
const tabs = [
SettingsTabValues.GENERAL,
SettingsTabValues.CHAT,
SettingsTabValues.BETA,
SettingsTabValues.COMMANDS,
SettingsTabValues.SPEECH,
SettingsTabValues.DATA,
SettingsTabValues.ACCOUNT,
];
const currentIndex = tabs.indexOf(activeTab);
switch (event.key) {
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault();
setActiveTab(tabs[(currentIndex + 1) % tabs.length]);
break;
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault();
setActiveTab(tabs[(currentIndex - 1 + tabs.length) % tabs.length]);
break;
case 'Home':
event.preventDefault();
setActiveTab(tabs[0]);
break;
case 'End':
event.preventDefault();
setActiveTab(tabs[tabs.length - 1]);
break;
}
};
return ( return (
<Transition appear show={open}> <Transition appear show={open}>
<Dialog as="div" className="relative z-50 focus:outline-none" onClose={onOpenChange}> <Dialog as="div" className="relative z-50" onClose={onOpenChange}>
<TransitionChild <TransitionChild
enter="ease-out duration-200" enter="ease-out duration-200"
enterFrom="opacity-0" enterFrom="opacity-0"
@ -77,127 +113,93 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
</DialogTitle> </DialogTitle>
<div className="max-h-[373px] overflow-auto px-6 md:min-h-[373px] md:w-[680px]"> <div className="max-h-[373px] overflow-auto px-6 md:min-h-[373px] md:w-[680px]">
<Tabs.Root <Tabs.Root
defaultValue={SettingsTabValues.GENERAL} value={activeTab}
onValueChange={(value: string) => setActiveTab(value as SettingsTabValues)}
className="flex flex-col gap-10 md:flex-row" className="flex flex-col gap-10 md:flex-row"
orientation="horizontal" orientation="horizontal"
> >
<Tabs.List <Tabs.List
aria-label="Settings" aria-label="Settings"
role="tablist"
aria-orientation="horizontal"
className={cn( className={cn(
'min-w-auto max-w-auto -ml-[8px] flex flex-shrink-0 flex-col flex-nowrap overflow-auto sm:max-w-none', 'min-w-auto max-w-auto -ml-[8px] flex flex-shrink-0 flex-col flex-nowrap overflow-auto sm:max-w-none',
isSmallScreen ? 'flex-row rounded-lg bg-surface-secondary' : '', isSmallScreen ? 'flex-row rounded-lg bg-surface-secondary' : '',
)} )}
style={{ outline: 'none' }} onKeyDown={handleKeyDown}
> >
<Tabs.Trigger {[
tabIndex={0} {
className={cn( value: SettingsTabValues.GENERAL,
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-active', icon: <GearIcon />,
isSmallScreen label: 'com_nav_setting_general',
? 'flex-1 items-center justify-center text-nowrap p-1 px-3 text-sm text-text-secondary' },
: 'bg-surface-tertiary-alt', {
)} value: SettingsTabValues.CHAT,
value={SettingsTabValues.GENERAL} icon: <MessageSquare className="icon-sm" />,
style={{ userSelect: 'none' }} label: 'com_nav_setting_chat',
> },
<GearIcon /> {
{localize('com_nav_setting_general')} value: SettingsTabValues.BETA,
</Tabs.Trigger> icon: <ExperimentIcon />,
<Tabs.Trigger label: 'com_nav_setting_beta',
tabIndex={0} },
className={cn( {
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-active', value: SettingsTabValues.COMMANDS,
isSmallScreen icon: <Command className="icon-sm" />,
? 'flex-1 items-center justify-center text-nowrap p-1 px-3 text-sm text-text-secondary' label: 'com_nav_commands',
: 'bg-surface-tertiary-alt', },
)} {
value={SettingsTabValues.CHAT} value: SettingsTabValues.SPEECH,
style={{ userSelect: 'none' }} icon: <SpeechIcon className="icon-sm" />,
> label: 'com_nav_setting_speech',
<MessageSquare className="icon-sm" /> },
{localize('com_nav_setting_chat')} {
</Tabs.Trigger> value: SettingsTabValues.DATA,
<Tabs.Trigger icon: <DataIcon />,
tabIndex={0} label: 'com_nav_setting_data',
className={cn( },
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-active', {
isSmallScreen value: SettingsTabValues.ACCOUNT,
? 'flex-1 items-center justify-center text-nowrap p-1 px-3 text-sm text-text-secondary' icon: <UserIcon />,
: 'bg-surface-tertiary-alt', label: 'com_nav_setting_account',
)} },
value={SettingsTabValues.BETA} ].map(({ value, icon, label }) => (
style={{ userSelect: 'none' }} <Tabs.Trigger
> key={value}
<ExperimentIcon /> className={cn(
{localize('com_nav_setting_beta')} 'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-active',
</Tabs.Trigger> isSmallScreen
<Tabs.Trigger ? 'flex-1 items-center justify-center text-nowrap p-1 px-3 text-sm text-text-secondary'
tabIndex={0} : 'bg-surface-tertiary-alt',
className={cn( )}
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-active', value={value}
isSmallScreen >
? 'flex-1 items-center justify-center text-nowrap text-sm text-text-secondary' {icon}
: 'bg-surface-tertiary-alt', {localize(label)}
)} </Tabs.Trigger>
value={SettingsTabValues.COMMANDS} ))}
style={{ userSelect: 'none' }}
>
<Command className="icon-sm" />
{localize('com_nav_commands')}
</Tabs.Trigger>
<Tabs.Trigger
tabIndex={0}
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-active',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap p-1 px-3 text-sm text-text-secondary'
: 'bg-surface-tertiary-alt',
)}
value={SettingsTabValues.SPEECH}
style={{ userSelect: 'none' }}
>
<SpeechIcon className="icon-sm" />
{localize('com_nav_setting_speech')}
</Tabs.Trigger>
<Tabs.Trigger
tabIndex={0}
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-active',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap p-1 px-3 text-sm text-text-secondary'
: 'bg-surface-tertiary-alt',
)}
value={SettingsTabValues.DATA}
style={{ userSelect: 'none' }}
>
<DataIcon />
{localize('com_nav_setting_data')}
</Tabs.Trigger>
<Tabs.Trigger
tabIndex={0}
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-active',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap p-1 px-3 text-sm text-text-secondary'
: 'bg-surface-tertiary-alt',
)}
value={SettingsTabValues.ACCOUNT}
style={{ userSelect: 'none' }}
>
<UserIcon />
{localize('com_nav_setting_account')}
</Tabs.Trigger>
</Tabs.List> </Tabs.List>
<div className="max-h-[373px] overflow-auto sm:w-full sm:max-w-none md:pr-0.5 md:pt-0.5"> <div className="max-h-[373px] overflow-auto sm:w-full sm:max-w-none md:pr-0.5 md:pt-0.5">
<General /> <Tabs.Content value={SettingsTabValues.GENERAL}>
<Chat /> <General />
<Beta /> </Tabs.Content>
<Commands /> <Tabs.Content value={SettingsTabValues.CHAT}>
<Speech /> <Chat />
<Data /> </Tabs.Content>
<Account /> <Tabs.Content value={SettingsTabValues.BETA}>
<Beta />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.COMMANDS}>
<Commands />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.SPEECH}>
<Speech />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.DATA}>
<Data />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.ACCOUNT}>
<Account />
</Tabs.Content>
</div> </div>
</Tabs.Root> </Tabs.Root>
</div> </div>

View file

@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import HoverCardSettings from '../HoverCardSettings'; import HoverCardSettings from '../HoverCardSettings';
import DeleteAccount from './DeleteAccount'; import DeleteAccount from './DeleteAccount';
import { Switch } from '~/components/ui'; import { Switch } from '~/components/ui';
@ -21,33 +19,27 @@ function Account({ onCheckedChange }: { onCheckedChange?: (value: boolean) => vo
}; };
return ( return (
<Tabs.Content <div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
value={SettingsTabValues.ACCOUNT} <div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
role="tabpanel" <Avatar />
className="w-full md:min-h-[271px]"
>
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<Avatar />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<DeleteAccount />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_nav_user_name_display')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_user_name_display" />
</div>
<Switch
id="UsernameDisplay"
checked={UsernameDisplay}
onCheckedChange={handleCheckedChange}
className="ml-4 mt-2"
data-testid="UsernameDisplay"
/>
</div>
</div> </div>
</Tabs.Content> <div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<DeleteAccount />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_nav_user_name_display')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_user_name_display" />
</div>
<Switch
id="UsernameDisplay"
checked={UsernameDisplay}
onCheckedChange={handleCheckedChange}
className="ml-4 mt-2"
data-testid="UsernameDisplay"
/>
</div>
</div>
); );
} }

View file

@ -4,7 +4,14 @@ import { useSetRecoilState } from 'recoil';
import AvatarEditor from 'react-avatar-editor'; import AvatarEditor from 'react-avatar-editor';
import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider'; import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider';
import type { TUser } from 'librechat-data-provider'; import type { TUser } from 'librechat-data-provider';
import { Dialog, DialogContent, DialogHeader, DialogTitle, Slider } from '~/components/ui'; import {
OGDialog,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
OGDialogTrigger,
Slider,
} from '~/components/ui';
import { useUploadAvatarMutation, useGetFileConfig } from '~/data-provider'; import { useUploadAvatarMutation, useGetFileConfig } from '~/data-provider';
import { useToastContext } from '~/Providers'; import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg'; import { Spinner } from '~/components/svg';
@ -20,6 +27,7 @@ function Avatar() {
const [rotation, setRotation] = useState<number>(0); const [rotation, setRotation] = useState<number>(0);
const editorRef = useRef<AvatarEditor | null>(null); const editorRef = useRef<AvatarEditor | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const openButtonRef = useRef<HTMLButtonElement>(null);
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data), select: (data) => mergeFileConfig(data),
@ -31,8 +39,8 @@ function Avatar() {
const { mutate: uploadAvatar, isLoading: isUploading } = useUploadAvatarMutation({ const { mutate: uploadAvatar, isLoading: isUploading } = useUploadAvatarMutation({
onSuccess: (data) => { onSuccess: (data) => {
showToast({ message: localize('com_ui_upload_success') }); showToast({ message: localize('com_ui_upload_success') });
setDialogOpen(false);
setUser((prev) => ({ ...prev, avatar: data.url } as TUser)); setUser((prev) => ({ ...prev, avatar: data.url } as TUser));
openButtonRef.current?.click();
}, },
onError: (error) => { onError: (error) => {
console.error('Error:', error); console.error('Error:', error);
@ -102,113 +110,114 @@ function Avatar() {
}, []); }, []);
return ( return (
<> <OGDialog
open={isDialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
resetImage();
setTimeout(() => {
openButtonRef.current?.focus();
}, 0);
}
}}
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>{localize('com_nav_profile_picture')}</span> <span>{localize('com_nav_profile_picture')}</span>
<button onClick={() => setDialogOpen(true)} className="btn btn-neutral relative"> <OGDialogTrigger ref={openButtonRef} className="btn btn-neutral relative">
<FileImage className="mr-2 flex w-[22px] items-center stroke-1" /> <FileImage className="mr-2 flex w-[22px] items-center stroke-1" />
<span>{localize('com_nav_change_picture')}</span> <span>{localize('com_nav_change_picture')}</span>
</button> </OGDialogTrigger>
</div> </div>
<Dialog <OGDialogContent
open={isDialogOpen} className={cn('bg-surface-tertiary text-text-primary shadow-2xl md:h-auto md:w-[450px]')}
onOpenChange={(open) => { style={{ borderRadius: '12px' }}
setDialogOpen(open);
if (!open) {
resetImage();
}
}}
> >
<DialogContent <OGDialogHeader>
className={cn('shadow-2xl dark:bg-gray-700 dark:text-white md:h-auto md:w-[450px]')} <OGDialogTitle className="text-lg font-medium leading-6 text-text-primary">
style={{ borderRadius: '12px' }} {image ? localize('com_ui_preview') : localize('com_ui_upload_image')}
> </OGDialogTitle>
<DialogHeader> </OGDialogHeader>
<DialogTitle className="text-lg font-medium leading-6 text-gray-800 dark:text-gray-200"> <div className="flex flex-col items-center justify-center">
{image ? localize('com_ui_preview') : localize('com_ui_upload_image')} {image ? (
</DialogTitle> <>
</DialogHeader> <div className="relative overflow-hidden rounded-full">
<div className="flex flex-col items-center justify-center"> <AvatarEditor
{image ? ( ref={editorRef}
<> image={image}
<div className="relative overflow-hidden rounded-full"> width={250}
<AvatarEditor height={250}
ref={editorRef} border={0}
image={image} borderRadius={125}
width={250} color={[255, 255, 255, 0.6]}
height={250} scale={scale}
border={0} rotate={rotation}
borderRadius={125}
color={[255, 255, 255, 0.6]}
scale={scale}
rotate={rotation}
/>
</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">Zoom:</span>
<Slider
value={[scale]}
min={1}
max={5}
step={0.001}
onValueChange={handleScaleChange}
className="w-2/3 max-w-xs"
/>
</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>
<button
className={cn(
'mt-4 flex items-center rounded px-4 py-2 text-white transition-colors hover:bg-green-600 hover:text-gray-200',
isUploading ? 'cursor-not-allowed bg-green-600' : 'bg-green-500',
)}
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-64 flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-700"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<FileImage className="mb-4 h-12 w-12 text-gray-400" />
<p className="mb-2 text-center text-sm text-gray-500 dark:text-gray-400">
{localize('com_ui_drag_drop')}
</p>
<button
onClick={openFileDialog}
className="rounded bg-gray-200 px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500"
>
{localize('com_ui_select_file')}
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".png, .jpg, .jpeg"
onChange={handleFileChange}
/> />
</div> </div>
)} <div className="mt-4 flex w-full flex-col items-center space-y-4">
</div> <div className="flex w-full items-center justify-center space-x-4">
</DialogContent> <span className="text-sm">Zoom:</span>
</Dialog> <Slider
</> value={[scale]}
min={1}
max={5}
step={0.001}
onValueChange={handleScaleChange}
className="w-2/3 max-w-xs"
/>
</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>
<button
className={cn(
'mt-4 flex items-center rounded px-4 py-2 text-white transition-colors hover:bg-green-600 hover:text-gray-200',
isUploading ? 'cursor-not-allowed bg-green-600' : 'bg-green-500',
)}
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-64 flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-700"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<FileImage className="mb-4 h-12 w-12 text-gray-400" />
<p className="mb-2 text-center text-sm text-gray-500 dark:text-gray-400">
{localize('com_ui_drag_drop')}
</p>
<button
onClick={openFileDialog}
className="rounded bg-gray-200 px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500"
>
{localize('com_ui_select_file')}
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".png, .jpg, .jpeg"
onChange={handleFileChange}
/>
</div>
)}
</div>
</OGDialogContent>
</OGDialog>
); );
} }

View file

@ -1,21 +1,13 @@
import { memo } from 'react'; import { memo } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import CodeArtifacts from './CodeArtifacts'; import CodeArtifacts from './CodeArtifacts';
function Beta() { function Beta() {
return ( return (
<Tabs.Content <div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
value={SettingsTabValues.BETA} <div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
role="tabpanel" <CodeArtifacts />
className="w-full md:min-h-[271px]"
>
<div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<CodeArtifacts />
</div>
</div> </div>
</Tabs.Content> </div>
); );
} }

View file

@ -1,6 +1,4 @@
import { memo } from 'react'; import { memo } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import FontSizeSelector from './FontSizeSelector'; import FontSizeSelector from './FontSizeSelector';
import SendMessageKeyEnter from './EnterToSend'; import SendMessageKeyEnter from './EnterToSend';
import ShowCodeSwitch from './ShowCodeSwitch'; import ShowCodeSwitch from './ShowCodeSwitch';
@ -12,32 +10,30 @@ import SaveDraft from './SaveDraft';
function Chat() { function Chat() {
return ( return (
<Tabs.Content value={SettingsTabValues.CHAT} role="tabpanel" className="md: w-full"> <div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50"> <div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600"> <FontSizeSelector />
<FontSizeSelector />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<ChatDirection />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<SendMessageKeyEnter />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<ShowCodeSwitch />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<SaveDraft />
</div>
<ForkSettings />
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<ModularChat />
</div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<LaTeXParsing />
</div>
</div> </div>
</Tabs.Content> <div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<ChatDirection />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<SendMessageKeyEnter />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<ShowCodeSwitch />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<SaveDraft />
</div>
<ForkSettings />
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<ModularChat />
</div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<LaTeXParsing />
</div>
</div>
); );
} }

View file

@ -14,16 +14,22 @@ const ChatDirection = () => {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span>{localize('com_nav_chat_direction')}</span> <span id="chat-direction-label">{localize('com_nav_chat_direction')}</span>
</div> </div>
<label <button
onClick={toggleChatDirection} onClick={toggleChatDirection}
data-testid="chatDirection" data-testid="chatDirection"
className="btn btn-neutral relative" className="btn btn-neutral relative ring-ring-primary"
style={{ userSelect: 'none' }} aria-labelledby="chat-direction-label chat-direction-status"
aria-pressed={direction === 'RTL'}
> >
{direction.toLowerCase()} <span aria-hidden="true">{direction.toLowerCase()}</span>
</label> <span id="chat-direction-status" className="sr-only">
{direction === 'LTR'
? localize('chat_direction_left_to_right')
: localize('chat_direction_right_to_left')}
</span>
</button>
</div> </div>
); );
}; };

View file

@ -1,6 +1,5 @@
import { memo } from 'react'; import { memo } from 'react';
import * as Tabs from '@radix-ui/react-tabs'; import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { SettingsTabValues, PermissionTypes, Permissions } from 'librechat-data-provider';
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings'; import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
import { useLocalize, useHasAccess } from '~/hooks'; import { useLocalize, useHasAccess } from '~/hooks';
import SlashCommandSwitch from './SlashCommandSwitch'; import SlashCommandSwitch from './SlashCommandSwitch';
@ -21,35 +20,29 @@ function Commands() {
}); });
return ( return (
<Tabs.Content <div className="space-y-4 p-1">
value={SettingsTabValues.COMMANDS} <div className="flex items-center gap-2">
role="tabpanel" <h3 className="text-lg font-medium text-text-primary">
className="w-full md:min-h-[271px]" {localize('com_nav_chat_commands')}
> </h3>
<div className="space-y-4"> <HoverCardSettings side="bottom" text="com_nav_chat_commands_info" />
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-text-primary">
{localize('com_nav_chat_commands')}
</h3>
<HoverCardSettings side="bottom" text="com_nav_chat_commands_info" />
</div>
<div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<AtCommandSwitch />
</div>
{hasAccessToMultiConvo === true && (
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<PlusCommandSwitch />
</div>
)}
{hasAccessToPrompts === true && (
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<SlashCommandSwitch />
</div>
)}
</div>
</div> </div>
</Tabs.Content> <div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<AtCommandSwitch />
</div>
{hasAccessToMultiConvo === true && (
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<PlusCommandSwitch />
</div>
)}
{hasAccessToPrompts === true && (
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<SlashCommandSwitch />
</div>
)}
</div>
</div>
); );
} }

View file

@ -1,7 +1,5 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { useClearConversationsMutation } from 'librechat-data-provider/react-query'; import { useClearConversationsMutation } from 'librechat-data-provider/react-query';
import { SettingsTabValues } from 'librechat-data-provider';
import { useConversation, useConversations, useOnClickOutside } from '~/hooks'; import { useConversation, useConversations, useOnClickOutside } from '~/hooks';
import { RevokeKeysButton } from './RevokeKeysButton'; import { RevokeKeysButton } from './RevokeKeysButton';
import { DeleteCacheButton } from './DeleteCacheButton'; import { DeleteCacheButton } from './DeleteCacheButton';
@ -37,35 +35,28 @@ function Data() {
}; };
return ( return (
<Tabs.Content <div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
value={SettingsTabValues.DATA} <div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
role="tabpanel" <ImportConversations />
className="w-full md:min-h-[271px]"
ref={dataTabRef}
>
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<ImportConversations />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<SharedLinks />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<RevokeKeysButton all={true} />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<DeleteCacheButton />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<ClearChatsButton
confirmClear={confirmClearConvos}
onClick={clearConvos}
showText={true}
mutation={clearConvosMutation}
/>
</div>
</div> </div>
</Tabs.Content> <div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<SharedLinks />
</div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<RevokeKeysButton all={true} />
</div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<DeleteCacheButton />
</div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<ClearChatsButton
confirmClear={confirmClearConvos}
onClick={clearConvos}
showText={true}
mutation={clearConvosMutation}
/>
</div>
</div>
); );
} }

View file

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useRef } from 'react';
import { Import } from 'lucide-react'; import { Import } from 'lucide-react';
import type { TError } from 'librechat-data-provider'; import type { TError } from 'librechat-data-provider';
import { useUploadConversationsMutation } from '~/data-provider'; import { useUploadConversationsMutation } from '~/data-provider';
@ -9,6 +9,7 @@ import { cn } from '~/utils';
function ImportConversations() { function ImportConversations() {
const localize = useLocalize(); const localize = useLocalize();
const fileInputRef = useRef<HTMLInputElement>(null);
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const [, setErrors] = useState<string[]>([]); const [, setErrors] = useState<string[]>([]);
@ -26,7 +27,7 @@ function ImportConversations() {
console.error('Error: ', error); console.error('Error: ', error);
setAllowImport(true); setAllowImport(true);
setError( setError(
(error as TError)?.response?.data?.message ?? 'An error occurred while uploading the file.', (error as TError).response?.data?.message ?? 'An error occurred while uploading the file.',
); );
if (error?.toString().includes('Unsupported import type')) { if (error?.toString().includes('Unsupported import type')) {
showToast({ showToast({
@ -44,13 +45,12 @@ function ImportConversations() {
const startUpload = async (file: File) => { const startUpload = async (file: File) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file, encodeURIComponent(file?.name || 'File')); formData.append('file', file, encodeURIComponent(file.name || 'File'));
uploadFile.mutate(formData); uploadFile.mutate(formData);
}; };
const handleFiles = async (_file: File) => { const handleFiles = async (_file: File) => {
/* Process files */
try { try {
await startUpload(_file); await startUpload(_file);
} catch (error) { } catch (error) {
@ -59,33 +59,49 @@ function ImportConversations() {
} }
}; };
const handleFileChange = (event) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files[0]; const file = event.target.files?.[0];
if (file) { if (file) {
handleFiles(file); handleFiles(file);
} }
}; };
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleImportClick();
}
};
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_ui_import_conversation_info')}</div> <div>{localize('com_ui_import_conversation_info')}</div>
<label htmlFor={'import-conversations-file'} className="btn btn-neutral relative"> <button
onClick={handleImportClick}
onKeyDown={handleKeyDown}
disabled={!allowImport}
aria-label={localize('com_ui_import_conversation')}
className="btn btn-neutral relative"
>
{allowImport ? ( {allowImport ? (
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" /> <Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
) : ( ) : (
<Spinner className="mr-1 w-4" /> <Spinner className="mr-1 w-4" />
)} )}
<span>{localize('com_ui_import_conversation')}</span> <span>{localize('com_ui_import_conversation')}</span>
<input </button>
id={'import-conversations-file'} <input
disabled={!allowImport} ref={fileInputRef}
value="" type="file"
type="file" className={cn('hidden')}
className={cn('hidden')} accept=".json"
accept=".json" onChange={handleFileChange}
onChange={handleFileChange} aria-hidden="true"
/> />
</label>
</div> </div>
); );
} }

View file

@ -25,7 +25,7 @@ export default function AutoScrollSwitch({
id="autoScroll" id="autoScroll"
checked={autoScroll} checked={autoScroll}
onCheckedChange={handleCheckedChange} onCheckedChange={handleCheckedChange}
className="ml-4 mt-2" className="ml-4 mt-2 ring-ring-primary"
data-testid="autoScroll" data-testid="autoScroll"
/> />
</div> </div>

View file

@ -1,7 +1,5 @@
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { SettingsTabValues } from 'librechat-data-provider';
import React, { useContext, useCallback, useRef } from 'react'; import React, { useContext, useCallback, useRef } from 'react';
import type { TDangerButtonProps } from '~/common'; import type { TDangerButtonProps } from '~/common';
import { ThemeContext, useLocalize } from '~/hooks'; import { ThemeContext, useLocalize } from '~/hooks';
@ -151,32 +149,25 @@ function General() {
); );
return ( return (
<Tabs.Content <div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
value={SettingsTabValues.GENERAL} <div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
role="tabpanel" <ThemeSelector theme={theme} onChange={changeTheme} />
className="w-full md:min-h-[271px]"
ref={contentRef}
>
<div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<ThemeSelector theme={theme} onChange={changeTheme} />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<LangSelector langcode={langcode} onChange={changeLang} />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<AutoScrollSwitch />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<HideSidePanelSwitch />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<ArchivedChats />
</div>
{/* <div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
</div> */}
</div> </div>
</Tabs.Content> <div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<LangSelector langcode={langcode} onChange={changeLang} />
</div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<AutoScrollSwitch />
</div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<HideSidePanelSwitch />
</div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<ArchivedChats />
</div>
{/* <div className="border-b pb-3 last-of-type:border-b-0 border-border-medium">
</div> */}
</div>
); );
} }

View file

@ -1,7 +1,6 @@
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs'; import * as Tabs from '@radix-ui/react-tabs';
import { Lightbulb, Cog } from 'lucide-react'; import { Lightbulb, Cog } from 'lucide-react';
import { SettingsTabValues } from 'librechat-data-provider';
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useGetCustomConfigSpeechQuery } from 'librechat-data-provider/react-query'; import { useGetCustomConfigSpeechQuery } from 'librechat-data-provider/react-query';
import { import {
@ -141,130 +140,123 @@ function Speech() {
useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []); useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []);
return ( return (
<Tabs.Content <Tabs.Root
value={SettingsTabValues.SPEECH} defaultValue={'simple'}
role="tabpanel" orientation="horizontal"
className="w-full md:min-h-[271px]" value={advancedMode ? 'advanced' : 'simple'}
ref={contentRef}
> >
<Tabs.Root <div className="sticky -top-1 z-50 mb-4 bg-white dark:bg-gray-700">
defaultValue={'simple'} <Tabs.List className="flex justify-center bg-white dark:bg-gray-700">
orientation="horizontal" <Tabs.Trigger
value={advancedMode ? 'advanced' : 'simple'} onClick={() => setAdvancedMode(false)}
> className={cn(
<div className="sticky -top-1 z-50 mb-4 bg-white dark:bg-gray-700"> 'group m-1 flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
<Tabs.List className="flex justify-center bg-white dark:bg-gray-700"> isSmallScreen
<Tabs.Trigger ? 'flex-row items-center justify-center text-sm text-gray-700 radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-gray-300 dark:radix-state-active:text-white'
onClick={() => setAdvancedMode(false)} : 'bg-white radix-state-active:bg-gray-100 dark:bg-gray-700',
className={cn( 'w-full',
'group m-1 flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600', )}
isSmallScreen value="simple"
? 'flex-row items-center justify-center text-sm text-gray-700 radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-gray-300 dark:radix-state-active:text-white' style={{ userSelect: 'none' }}
: 'bg-white radix-state-active:bg-gray-100 dark:bg-gray-700', >
'w-full', <Lightbulb />
)} Simple
value="simple" </Tabs.Trigger>
style={{ userSelect: 'none' }} <Tabs.Trigger
> onClick={() => setAdvancedMode(true)}
<Lightbulb /> className={cn(
Simple 'group m-1 flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
</Tabs.Trigger> isSmallScreen
<Tabs.Trigger ? 'flex-row items-center justify-center text-sm text-gray-700 radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-gray-300 dark:radix-state-active:text-white'
onClick={() => setAdvancedMode(true)} : 'bg-white radix-state-active:bg-gray-100 dark:bg-gray-700',
className={cn( 'w-full',
'group m-1 flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600', )}
isSmallScreen value="advanced"
? 'flex-row items-center justify-center text-sm text-gray-700 radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-gray-300 dark:radix-state-active:text-white' style={{ userSelect: 'none' }}
: 'bg-white radix-state-active:bg-gray-100 dark:bg-gray-700', >
'w-full', <Cog />
)} Advanced
value="advanced" </Tabs.Trigger>
style={{ userSelect: 'none' }} </Tabs.List>
> </div>
<Cog />
Advanced <Tabs.Content value={'simple'}>
</Tabs.Trigger> <div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50">
</Tabs.List> <div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<SpeechToTextSwitch />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<EngineSTTDropdown external={sttExternal} />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<LanguageSTTDropdown />
</div>
<div className="h-px bg-black/20 bg-white/20" role="none" />
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<TextToSpeechSwitch />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<EngineTTSDropdown external={ttsExternal} />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<VoiceDropdown />
</div>
</div> </div>
</Tabs.Content>
<Tabs.Content value={'simple'}> <Tabs.Content value={'advanced'}>
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50"> <div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50">
<div className="border-b last-of-type:border-b-0 dark:border-gray-700"> <div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<SpeechToTextSwitch /> <ConversationModeSwitch />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<EngineSTTDropdown external={sttExternal} />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<LanguageSTTDropdown />
</div>
<div className="h-px bg-black/20 bg-white/20" role="none" />
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<TextToSpeechSwitch />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<EngineTTSDropdown external={ttsExternal} />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<VoiceDropdown />
</div>
</div> </div>
</Tabs.Content> <div className="h-px bg-black/20 bg-white/20" role="none" />
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<Tabs.Content value={'advanced'}> <SpeechToTextSwitch />
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<ConversationModeSwitch />
</div>
<div className="h-px bg-black/20 bg-white/20" role="none" />
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<SpeechToTextSwitch />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<EngineSTTDropdown external={sttExternal} />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<LanguageSTTDropdown />
</div>
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
<AutoTranscribeAudioSwitch />
</div>
{autoTranscribeAudio && (
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
<DecibelSelector />
</div>
)}
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<AutoSendTextSelector />
</div>
<div className="h-px bg-black/20 bg-white/20" role="none" />
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<TextToSpeechSwitch />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<AutomaticPlaybackSwitch />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<EngineTTSDropdown external={ttsExternal} />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<VoiceDropdown />
</div>
{engineTTS === 'browser' && (
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
<CloudBrowserVoicesSwitch />
</div>
)}
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
<PlaybackRate />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<CacheTTSSwitch />
</div>
</div> </div>
</Tabs.Content> <div className="border-b last-of-type:border-b-0 dark:border-gray-700">
</Tabs.Root> <EngineSTTDropdown external={sttExternal} />
</Tabs.Content> </div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<LanguageSTTDropdown />
</div>
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
<AutoTranscribeAudioSwitch />
</div>
{autoTranscribeAudio && (
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
<DecibelSelector />
</div>
)}
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<AutoSendTextSelector />
</div>
<div className="h-px bg-black/20 bg-white/20" role="none" />
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<TextToSpeechSwitch />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<AutomaticPlaybackSwitch />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<EngineTTSDropdown external={ttsExternal} />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<VoiceDropdown />
</div>
{engineTTS === 'browser' && (
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
<CloudBrowserVoicesSwitch />
</div>
)}
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
<PlaybackRate />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<CacheTTSSwitch />
</div>
</div>
</Tabs.Content>
</Tabs.Root>
); );
} }

View file

@ -46,7 +46,7 @@ const Dropdown: FC<DropdownProps> = ({
<ListboxButton <ListboxButton
data-testid={testId} data-testid={testId}
className={cn( className={cn(
'focus:ring-offset-ring-offset relative inline-flex w-auto items-center justify-between rounded-md border-border-light bg-header-primary py-2 pl-3 pr-8 text-text-primary transition-all duration-100 ease-in-out hover:bg-header-hover focus:ring-ring-primary', 'btn-neutral focus:ring-offset-ring-offset relative inline-flex w-auto items-center justify-between rounded-md border-border-light bg-header-primary py-2 pl-3 pr-8 text-text-primary transition-all duration-100 ease-in-out hover:bg-header-hover focus:ring-ring-primary',
className, className,
)} )}
aria-label="Select an option" aria-label="Select an option"
@ -88,7 +88,7 @@ const Dropdown: FC<DropdownProps> = ({
<ListboxOption <ListboxOption
key={index} key={index}
value={typeof item === 'string' ? item : item.value} value={typeof item === 'string' ? item : item.value}
className="focus-visible:ring-offset ring-offset-ring-offset relative cursor-pointer select-none rounded border-border-light bg-header-primary py-2.5 pl-3 pr-3 text-sm text-text-secondary ring-ring-primary hover:bg-header-hover focus-visible:ring" className="focus-visible:ring-offset ring-offset-ring-offset relative cursor-pointer select-none rounded border-border-light bg-header-primary py-2.5 pl-3 pr-3 text-sm text-text-secondary ring-ring-primary hover:bg-header-hover focus-visible:ring data-[focus]:bg-surface-hover data-[focus]:text-text-primary"
style={{ width: '100%' }} style={{ width: '100%' }}
data-theme={typeof item === 'string' ? item : (item as Option).value} data-theme={typeof item === 'string' ? item : (item as Option).value}
> >

View file

@ -48,7 +48,7 @@ const DialogContent = React.forwardRef<
> >
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"> <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 ring-ring-primary transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>

View file

@ -10,6 +10,7 @@ const Switch = React.forwardRef<
<SwitchPrimitives.Root <SwitchPrimitives.Root
className={cn( className={cn(
'focus-visible:ring-ring focus-visible:ring-offset-background peer inline-flex h-[20px] w-[32px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-black data-[state=unchecked]:bg-gray-200 dark:data-[state=checked]:bg-green-500 dark:data-[state=unchecked]:bg-gray-500', 'focus-visible:ring-ring focus-visible:ring-offset-background peer inline-flex h-[20px] w-[32px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-black data-[state=unchecked]:bg-gray-200 dark:data-[state=checked]:bg-green-500 dark:data-[state=unchecked]:bg-gray-500',
'ring-ring-primary',
className, className,
)} )}
{...props} {...props}

View file

@ -1178,11 +1178,19 @@ button {
color: rgba(64, 65, 79, var(--tw-text-opacity)); color: rgba(64, 65, 79, var(--tw-text-opacity));
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.25rem; line-height: 1.25rem;
transition: all 0.1s ease-in-out;
} }
.btn-neutral:hover { .btn-neutral:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgba(236, 236, 241, var(--tw-bg-opacity)); background-color: rgba(236, 236, 241, var(--tw-bg-opacity));
} }
.btn-neutral:focus {
outline: none;
box-shadow: 0 0 0 2px var(--ring-primary);
}
.dark .btn-neutral { .dark .btn-neutral {
--tw-border-opacity: 1; --tw-border-opacity: 1;
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
@ -1191,10 +1199,15 @@ button {
border-color: rgba(66, 66, 66, var(--tw-border-opacity)); border-color: rgba(66, 66, 66, var(--tw-border-opacity));
color: rgba(255, 255, 240, var(--tw-text-opacity)); color: rgba(255, 255, 240, var(--tw-text-opacity));
} }
.dark .btn-neutral:hover { .dark .btn-neutral:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgba(66, 66, 66, var(--tw-bg-opacity)); background-color: rgba(66, 66, 66, var(--tw-bg-opacity));
} }
.dark .btn-neutral:focus {
box-shadow: 0 0 0 2px var(--ring-primary);
}
.btn-small { .btn-small {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
} }