👐 style: Improve a11y/theming for Settings Dialog, Dropdown Menus; fix: SearchBar focus issues (#4091)

* fix: cursor pointer not applying correct in the root component

* fix: add cursor-not-allowed to disabled state in SendButton component

* feat: update Dropdown to ariakit and changed LLM error's style

* feat: switched to ariakit's Dropdown and style improvements

* feat: archive updates

* refactor: delete conversations in archive

* refactor: settings

* add cool settings animation

* a11y: settings update

* style: update settings

* style: settings account settings menu; a11y(AccountSettings): switched to AriaKit

* a11y: account settings update

* style: update my files dialog

* fix: tests

* chore: remove console.log()

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2024-09-22 04:45:50 +02:00 committed by GitHub
parent eba2c9a032
commit 2d62eca612
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1054 additions and 824 deletions

View file

@ -4,7 +4,7 @@ import CodeArtifacts from './CodeArtifacts';
function Beta() {
return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<CodeArtifacts />
</div>
</div>

View file

@ -11,26 +11,26 @@ import SaveDraft from './SaveDraft';
function Chat() {
return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<FontSizeSelector />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<ChatDirection />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<SendMessageKeyEnter />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<ShowCodeSwitch />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<SaveDraft />
</div>
<ForkSettings />
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<div className="border-b border-border-light 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">
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<LaTeXParsing />
</div>
</div>

View file

@ -1,6 +1,7 @@
import React from 'react';
import { useRecoilState } from 'recoil';
import { useLocalize } from '~/hooks';
import { Button } from '~/components';
import store from '~/store';
const ChatDirection = () => {
@ -16,12 +17,11 @@ const ChatDirection = () => {
<div className="flex items-center space-x-2">
<span id="chat-direction-label">{localize('com_nav_chat_direction')}</span>
</div>
<button
<Button
variant="outline"
aria-label="Toggle chat direction"
onClick={toggleChatDirection}
data-testid="chatDirection"
className="btn btn-neutral relative ring-ring-primary"
aria-labelledby="chat-direction-label chat-direction-status"
aria-pressed={direction === 'RTL'}
>
<span aria-hidden="true">{direction.toLowerCase()}</span>
<span id="chat-direction-status" className="sr-only">
@ -29,7 +29,7 @@ const ChatDirection = () => {
? localize('chat_direction_left_to_right')
: localize('chat_direction_right_to_left')}
</span>
</button>
</Button>
</div>
);
};

View file

@ -19,7 +19,7 @@ export const ForkSettings = () => {
return (
<>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_ui_fork_change_default')}</div>
@ -30,12 +30,11 @@ export const ForkSettings = () => {
onChange={setForkSetting}
options={forkOptions}
sizeClasses="w-[200px]"
anchor="bottom start"
testId="fork-setting-dropdown"
/>
</div>
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<div className="flex items-center justify-between">
<div> {localize('com_ui_fork_default')} </div>
<Switch
@ -47,7 +46,7 @@ export const ForkSettings = () => {
/>
</div>
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_ui_fork_split_target_setting')}</div>

View file

@ -28,16 +28,16 @@ function Commands() {
<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">
<div className="border-b border-border-light 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">
<div className="border-b border-border-light 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">
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<SlashCommandSwitch />
</div>
)}

View file

@ -1,36 +1,45 @@
import { useMemo, useState } from 'react';
import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Link as LinkIcon } from 'lucide-react';
import { Link as LinkIcon, TrashIcon } from 'lucide-react';
import type { SharedLinksResponse, TSharedLink } from 'librechat-data-provider';
import { useDeleteSharedLinkMutation, useSharedLinksInfiniteQuery } from '~/data-provider';
import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks';
import { Spinner, TooltipAnchor, TrashIcon } from '~/components';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { cn } from '~/utils';
import {
Button,
Label,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TooltipAnchor,
Skeleton,
Spinner,
OGDialog,
OGDialogTrigger,
} from '~/components';
function SharedLinkDeleteButton({
shareId,
setIsDeleting,
}: {
shareId: string;
setIsDeleting: (isDeleting: boolean) => void;
}) {
function ShareLinkRow({ sharedLink }: { sharedLink: TSharedLink }) {
const [isDeleting, setIsDeleting] = useState(false);
const localize = useLocalize();
const { showToast } = useToastContext();
const mutation = useDeleteSharedLinkMutation({
onError: () => {
showToast({
message: localize('com_ui_share_delete_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
setIsDeleting(false);
},
});
const handleDelete = async (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
const confirmDelete = async (shareId: TSharedLink['shareId']) => {
if (mutation.isLoading) {
return;
}
@ -38,67 +47,78 @@ function SharedLinkDeleteButton({
await mutation.mutateAsync({ shareId });
setIsDeleting(false);
};
return (
<TooltipAnchor
description={localize('com_ui_delete')}
id="delete-shared-link"
aria-label="Delete shared link"
onClick={handleDelete}
>
<TrashIcon className="size-4" />
</TooltipAnchor>
);
}
function ShareLinkRow({ sharedLink }: { sharedLink: TSharedLink }) {
const [isDeleting, setIsDeleting] = useState(false);
return (
<tr
key={sharedLink.conversationId}
className="border-b border-gray-200 text-sm font-normal dark:border-white/10"
>
<td
className={cn(
'flex items-center py-3 text-blue-800/70 dark:text-blue-500',
isDeleting && 'opacity-50',
)}
>
<Link to={`/share/${sharedLink.shareId}`} target="_blank" rel="noreferrer" className="flex">
<LinkIcon className="mr-1 h-5 w-5" />
<TableRow className={(cn(isDeleting && 'opacity-50'), 'hover:bg-transparent')}>
<TableCell>
<Link
to={`/share/${sharedLink.shareId}`}
target="_blank"
rel="noreferrer"
className="flex items-center text-blue-500 hover:underline"
>
<LinkIcon className="mr-2 h-4 w-4" />
{sharedLink.title}
</Link>
</td>
<td className="p-3">
<div className="flex justify-between">
<div className={cn('flex justify-start dark:text-gray-200', isDeleting && 'opacity-50')}>
{new Date(sharedLink.createdAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</div>
<div
className={cn(
'flex items-center justify-end gap-3 text-gray-400',
isDeleting && 'opacity-50',
)}
>
{sharedLink.conversationId && (
<div className={cn('cursor-pointer', !isDeleting && 'hover:text-gray-300')}>
<SharedLinkDeleteButton
shareId={sharedLink.shareId}
setIsDeleting={setIsDeleting}
/>
</div>
)}
</div>
</div>
</td>
</tr>
</TableCell>
<TableCell>
{new Date(sharedLink.createdAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</TableCell>
<TableCell className="text-right">
{sharedLink.conversationId && (
<OGDialog>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete')}
render={
<Button
aria-label="Delete shared link"
variant="ghost"
size="icon"
className="size-8"
>
<TrashIcon className="size-4" />
</Button>
}
></TooltipAnchor>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_conversation')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label
htmlFor="dialog-confirm-delete"
className="text-left text-sm font-medium"
>
{localize('com_ui_delete_confirm')} <strong>{sharedLink.title}</strong>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: () => confirmDelete(sharedLink.shareId),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
)}
</TableCell>
</TableRow>
);
}
export default function ShareLinkTable({ className }: { className?: string }) {
export default function ShareLinkTable({ className }) {
const localize = useLocalize();
const { isAuthenticated } = useAuthContext();
const [showLoading, setShowLoading] = useState(false);
@ -114,15 +134,28 @@ export default function ShareLinkTable({ className }: { className?: string }) {
});
const sharedLinks = useMemo(() => data?.pages.flatMap((page) => page.sharedLinks) || [], [data]);
const classProp: { className?: string } = {
className: 'p-1 hover:text-black dark:hover:text-white',
};
if (className) {
classProp.className = className;
}
const getRandomWidth = () => Math.floor(Math.random() * (400 - 170 + 1)) + 170;
const skeletons = Array.from({ length: 11 }, (_, index) => {
const randomWidth = getRandomWidth();
return (
<div key={index} className="flex h-10 w-full items-center">
<div className="flex w-[410px] items-center">
<Skeleton className="h-4" style={{ width: `${randomWidth}px` }} />
</div>
<div className="flex flex-grow justify-center">
<Skeleton className="h-4 w-28" />
</div>
<div className="mr-2 flex justify-end">
<Skeleton className="h-4 w-12" />
</div>
</div>
);
});
if (isLoading) {
return <Spinner className="m-1 mx-auto mb-4 h-4 w-4 text-black dark:text-white" />;
return <div className="text-gray-300">{skeletons}</div>;
}
if (isError) {
@ -132,35 +165,34 @@ export default function ShareLinkTable({ className }: { className?: string }) {
</div>
);
}
if (!sharedLinks || sharedLinks.length === 0) {
if (sharedLinks.length === 0) {
return <div className="text-gray-300">{localize('com_nav_shared_links_empty')}</div>;
}
return (
<div
className={cn(
'grid w-full gap-2',
'-mr-2 flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500',
'max-h-[350px]',
'-mr-2 grid max-h-[350px] w-full flex-1 flex-col gap-2 overflow-y-auto pr-2 transition-opacity duration-500',
className,
)}
ref={containerRef}
>
<table className="table-fixed text-left">
<thead className="sticky top-0 bg-white dark:bg-gray-700">
<tr className="border-b border-gray-200 text-sm font-semibold text-gray-500 dark:border-white/10 dark:text-gray-200">
<th className="p-3">{localize('com_nav_shared_links_name')}</th>
<th className="p-3">{localize('com_nav_shared_links_date_shared')}</th>
</tr>
</thead>
<tbody>
<Table>
<TableHeader>
<TableRow>
<TableHead>{localize('com_nav_shared_links_name')}</TableHead>
<TableHead>{localize('com_nav_shared_links_date_shared')}</TableHead>
<TableHead className="text-right">{localize('com_assistants_actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sharedLinks.map((sharedLink) => (
<ShareLinkRow key={sharedLink.shareId} sharedLink={sharedLink} />
))}
</tbody>
</table>
{(isFetchingNextPage || showLoading) && (
<Spinner className={cn('m-1 mx-auto mb-4 h-4 w-4 text-black dark:text-white')} />
)}
</TableBody>
</Table>
{(isFetchingNextPage || showLoading) && <Spinner className="mx-auto my-4" />}
</div>
);
}

View file

@ -1,6 +1,6 @@
import { useLocalize } from '~/hooks';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { OGDialog, OGDialogTrigger } from '~/components/ui';
import { OGDialog, OGDialogTrigger, Button } from '~/components';
import ArchivedChatsTable from './ArchivedChatsTable';
@ -12,9 +12,9 @@ export default function ArchivedChats() {
<div>{localize('com_nav_archived_chats')}</div>
<OGDialog>
<OGDialogTrigger asChild>
<button className="btn btn-neutral relative ">
<Button variant="outline" aria-label="Archived chats">
{localize('com_nav_archived_chats_manage')}
</button>
</Button>
</OGDialogTrigger>
<OGDialogTemplate
title={localize('com_nav_archived_chats')}

View file

@ -1,109 +1,252 @@
import { useMemo, useState, useCallback } from 'react';
import { MessageCircle, ArchiveRestore } from 'lucide-react';
import { useState, useCallback, useEffect } from 'react';
import { useConversationsInfiniteQuery } from '~/data-provider';
import { ConversationListResponse } from 'librechat-data-provider';
import { useAuthContext, useLocalize, useNavScrolling, useArchiveHandler } from '~/hooks';
import { DeleteButton } from '~/components/Conversations/ConvoOptions';
import { TooltipAnchor } from '~/components/ui';
import { Spinner } from '~/components/svg';
import {
Search,
ChevronRight,
ChevronLeft,
TrashIcon,
MessageCircle,
ArchiveRestore,
ChevronsRight,
ChevronsLeft,
} from 'lucide-react';
import type { TConversation } from 'librechat-data-provider';
import { useAuthContext, useLocalize, useArchiveHandler } from '~/hooks';
import { DeleteConversationDialog } from '~/components/Conversations/ConvoOptions';
import {
TooltipAnchor,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Separator,
Skeleton,
Button,
Input,
OGDialog,
OGDialogTrigger,
} from '~/components';
import { cn } from '~/utils';
export default function ArchivedChatsTable() {
const localize = useLocalize();
const { isAuthenticated } = useAuthContext();
const [showLoading, setShowLoading] = useState(false);
const [conversationId, setConversationId] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const [totalPages, setTotalPages] = useState(1);
const [isOpened, setIsOpened] = useState(false);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useConversationsInfiniteQuery(
{ pageNumber: '1', isArchived: true },
{ enabled: isAuthenticated },
const { data, isLoading, refetch } = useConversationsInfiniteQuery(
{ pageNumber: currentPage.toString(), limit: 10, isArchived: true },
{ enabled: isAuthenticated && isOpened },
);
const { containerRef, moveToTop } = useNavScrolling<ConversationListResponse>({
setShowLoading,
hasNextPage: hasNextPage,
fetchNextPage: fetchNextPage,
isFetchingNextPage: isFetchingNextPage,
useEffect(() => {
if (data) {
setTotalPages(Math.ceil(Number(data.pages)));
}
}, [data]);
const archiveHandler = useArchiveHandler(conversationId ?? '', false, () => {
refetch();
});
const conversations = useMemo(
() => data?.pages.flatMap((page) => page.conversations) || [],
[data],
);
const handleChatClick = useCallback((conversationId) => {
window.open(`/c/${conversationId}`, '_blank');
}, []);
const archiveHandler = useArchiveHandler(conversationId ?? '', false, moveToTop);
const handlePageChange = useCallback((newPage) => {
setCurrentPage(newPage);
}, []);
if (!data || conversations.length === 0) {
const handleSearch = useCallback((query) => {
setSearchQuery(query);
setCurrentPage(1);
}, []);
const getRandomWidth = () => Math.floor(Math.random() * (400 - 170 + 1)) + 170;
const skeletons = Array.from({ length: 11 }, (_, index) => {
const randomWidth = getRandomWidth();
return (
<div key={index} className="flex h-10 w-full items-center">
<div className="flex w-[410px] items-center">
<Skeleton className="h-4" style={{ width: `${randomWidth}px` }} />
</div>
<div className="flex flex-grow justify-center">
<Skeleton className="h-4 w-28" />
</div>
<div className="mr-2 flex justify-end">
<Skeleton className="h-4 w-12" />
</div>
</div>
);
});
if (isLoading) {
return <div className="text-gray-300">{skeletons}</div>;
}
if (!data || data.pages.length === 0 || data.pages[0].conversations.length === 0) {
return <div className="text-gray-300">{localize('com_nav_archived_chats_empty')}</div>;
}
const conversations = data.pages.flatMap((page) => page.conversations);
return (
<div
className={cn(
'grid w-full gap-2',
'flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500',
'max-h-[350px]',
'max-h-[629px]',
)}
ref={containerRef}
onMouseEnter={() => setIsOpened(true)}
>
<table className="table-fixed text-left">
<thead className="sticky top-0 bg-white dark:bg-gray-700">
<tr className="border-b border-gray-200 text-sm font-semibold text-gray-500 dark:border-white/10 dark:text-gray-200">
<th className="p-3">{localize('com_nav_archive_name')}</th>
<th className="p-3">{localize('com_nav_archive_created_at')}</th>
</tr>
</thead>
<tbody>
{conversations.map((conversation) => {
if (!conversation.conversationId) {
return null;
}
return (
<tr
key={conversation.conversationId}
className="border-b border-gray-200 text-sm font-normal dark:border-white/10"
>
<td className="flex items-center py-3 text-blue-800/70 dark:text-blue-500">
<MessageCircle className="mr-1 h-5 w-5" />
{conversation.title}
</td>
<td className="p-1">
<div className="flex justify-between">
<div className="flex justify-start dark:text-gray-200">
{new Date(conversation.createdAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</div>
<div className="ml-auto mr-4 flex items-center justify-end gap-1 text-gray-400">
<TooltipAnchor
description={localize('com_ui_unarchive')}
onClick={() => {
setConversationId(conversation.conversationId);
archiveHandler();
}}
className="cursor-pointer hover:text-black dark:hover:text-white"
>
<ArchiveRestore className="size-4 hover:text-gray-300" />
</TooltipAnchor>
<div className="size-5 hover:text-gray-300">
<DeleteButton
conversationId={conversation.conversationId}
retainView={moveToTop}
title={conversation.title ?? ''}
/>
<div className="flex items-center">
<Search className="size-4 text-text-secondary" />
<Input
type="text"
placeholder={localize('com_nav_search_placeholder')}
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="w-full border-none"
/>
</div>
<Separator />
{conversations.length === 0 ? (
<div className="mt-4 text-text-secondary">{localize('com_nav_no_search_results')}</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50%] p-4">{localize('com_nav_archive_name')}</TableHead>
<TableHead className="w-[35%] p-1">
{localize('com_nav_archive_created_at')}
</TableHead>
<TableHead className="w-[15%] p-1 text-right">
{localize('com_assistants_actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{conversations.map((conversation: TConversation) => (
<TableRow key={conversation.conversationId} className="hover:bg-transparent">
<TableCell className="flex items-center py-3 text-text-primary">
<button
className="flex"
aria-label="Open conversation in a new tab"
onClick={() => handleChatClick(conversation.conversationId)}
>
<MessageCircle className="mr-1 h-5 w-5" />
<u>{conversation.title}</u>
</button>
</TableCell>
<TableCell className="p-1">
<div className="flex justify-between">
<div className="flex justify-start text-text-secondary">
{new Date(conversation.createdAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</div>
</div>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{(isFetchingNextPage || showLoading) && (
<Spinner className={cn('m-1 mx-auto mb-4 h-4 w-4 text-black dark:text-white')} />
</TableCell>
<TableCell className="flex items-center justify-end gap-2 p-1">
<TooltipAnchor
description={localize('com_ui_unarchive')}
render={
<Button
aria-label="Unarchive conversation"
variant="ghost"
size="icon"
className="size-8"
onClick={() => {
setConversationId(conversation.conversationId);
archiveHandler();
}}
>
<ArchiveRestore className="size-4" />
</Button>
}
></TooltipAnchor>
<OGDialog>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete')}
render={
<Button
aria-label="Delete archived conversation"
variant="ghost"
size="icon"
className="size-8"
>
<TrashIcon className="size-4" />
</Button>
}
></TooltipAnchor>
</OGDialogTrigger>
{DeleteConversationDialog({
conversationId: conversation.conversationId ?? '',
retainView: refetch,
title: conversation.title ?? '',
})}
</OGDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-end gap-6 px-2 py-4">
<div className="text-sm font-bold text-text-primary">
Page {currentPage} of {totalPages}
</div>
<div className="flex space-x-2">
<Button
variant="outline"
size="icon"
aria-label="Go to the previous 10 pages"
onClick={() => handlePageChange(Math.max(currentPage - 10, 1))}
disabled={currentPage === 1}
>
<ChevronsLeft className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Go to the previous page"
onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Go to the next page"
onClick={() => handlePageChange(Math.min(currentPage + 1, totalPages))}
disabled={currentPage === totalPages}
>
<ChevronRight className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Go to the next 10 pages"
onClick={() => handlePageChange(Math.min(currentPage + 10, totalPages))}
disabled={currentPage === totalPages}
>
<ChevronsRight className="size-4" />
</Button>
</div>
</div>
</>
)}
</div>
);

View file

@ -24,6 +24,7 @@ export default function AutoScrollSwitch({
<Switch
id="autoScroll"
checked={autoScroll}
aria-label="Auto-Scroll to latest message on chat open"
onCheckedChange={handleCheckedChange}
className="ml-4 mt-2 ring-ring-primary"
data-testid="autoScroll"

View file

@ -34,8 +34,7 @@ export const ThemeSelector = ({
value={theme}
onChange={onChange}
options={themeOptions}
sizeClasses="w-[220px]"
anchor="bottom start"
sizeClasses="w-[180px]"
testId="theme-selector"
/>
</div>
@ -112,7 +111,6 @@ export const LangSelector = ({
value={langcode}
onChange={onChange}
sizeClasses="[--anchor-max-height:256px]"
anchor="bottom start"
options={languageOptions}
/>
</div>
@ -149,26 +147,24 @@ function General() {
return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<ThemeSelector theme={theme} onChange={changeTheme} />
</div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<div className="border-b border-border-light 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">
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<UserMsgMarkdownSwitch />
</div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<div className="border-b border-border-light 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">
<div className="border-b border-border-light 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">
<div className="border-b border-border-light 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

@ -25,6 +25,7 @@ export default function HideSidePanelSwitch({
<Switch
id="hideSidePanel"
checked={hideSidePanel}
aria-label="Hide right-most side panel"
onCheckedChange={handleCheckedChange}
className="ml-4 mt-2"
data-testid="hideSidePanel"

View file

@ -1,6 +1,6 @@
import 'test/matchMedia.mock';
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { LangSelector } from './General';
import { RecoilRoot } from 'recoil';
@ -18,14 +18,15 @@ describe('LangSelector', () => {
unobserve = jest.fn();
disconnect = jest.fn();
};
const { getByText } = render(
const { getByText, getByRole } = render(
<RecoilRoot>
<LangSelector langcode="en-US" onChange={mockOnChange} />
</RecoilRoot>,
);
expect(getByText('Language')).toBeInTheDocument();
expect(getByText('English')).toBeInTheDocument();
const dropdownButton = getByRole('combobox');
expect(dropdownButton).toHaveTextContent('English');
});
it('calls onChange when the select value changes', async () => {
@ -34,25 +35,23 @@ describe('LangSelector', () => {
unobserve = jest.fn();
disconnect = jest.fn();
};
const { getByText, getByTestId } = render(
const { getByRole, getByTestId } = render(
<RecoilRoot>
<LangSelector langcode="en-US" onChange={mockOnChange} />
</RecoilRoot>,
);
expect(getByText('English')).toBeInTheDocument();
expect(getByRole('combobox')).toHaveTextContent('English');
// Find the dropdown button by data-testid
const dropdownButton = getByTestId('dropdown-menu');
// Open the dropdown
fireEvent.click(dropdownButton);
// Find the option by text and click it
const darkOption = getByText('Italiano');
fireEvent.click(darkOption);
const italianOption = getByRole('option', { name: 'Italiano' });
fireEvent.click(italianOption);
// Ensure that the onChange is called with the expected value after a short delay
await new Promise((resolve) => setTimeout(resolve, 0));
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith('it-IT');
});
});
});

View file

@ -20,14 +20,15 @@ describe('ThemeSelector', () => {
unobserve = jest.fn();
disconnect = jest.fn();
};
const { getByText } = render(
const { getByText, getByRole } = render(
<RecoilRoot>
<ThemeSelector theme="system" onChange={mockOnChange} />
</RecoilRoot>,
);
expect(getByText('Theme')).toBeInTheDocument();
expect(getByText('System')).toBeInTheDocument();
const dropdownButton = getByRole('combobox');
expect(dropdownButton).toHaveTextContent('System');
});
it('calls onChange when the select value changes', async () => {
@ -44,17 +45,13 @@ describe('ThemeSelector', () => {
expect(getByText('Theme')).toBeInTheDocument();
// Find the dropdown button by data-testid
const dropdownButton = getByTestId('theme-selector');
// Open the dropdown
fireEvent.click(dropdownButton);
// Find the option by text and click it
const darkOption = getByText('Dark');
fireEvent.click(darkOption);
// Ensure that the onChange is called with the expected value
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith('dark');
});

View file

@ -31,7 +31,6 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
onChange={handleSelect}
options={endpointOptions}
sizeClasses="w-[180px]"
anchor="bottom start"
testId="EngineSTTDropdown"
/>
</div>

View file

@ -146,14 +146,12 @@ function Speech() {
value={advancedMode ? 'advanced' : 'simple'}
>
<div className="sticky -top-1 z-50 mb-4 bg-white dark:bg-gray-700">
<Tabs.List className="flex justify-center bg-white dark:bg-gray-700">
<Tabs.List className="flex justify-center bg-background">
<Tabs.Trigger
onClick={() => setAdvancedMode(false)}
className={cn(
'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
? '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'
: 'bg-white radix-state-active:bg-gray-100 dark:bg-gray-700',
'group m-1 flex items-center justify-center gap-2 bg-transparent px-4 py-2 text-sm text-text-secondary transition-all duration-200 ease-in-out radix-state-active:bg-secondary radix-state-active:text-foreground radix-state-active:shadow-lg',
isSmallScreen ? 'flex-row rounded-lg' : 'rounded-xl',
'w-full',
)}
value="simple"
@ -165,10 +163,8 @@ function Speech() {
<Tabs.Trigger
onClick={() => setAdvancedMode(true)}
className={cn(
'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
? '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'
: 'bg-white radix-state-active:bg-gray-100 dark:bg-gray-700',
'group m-1 flex items-center justify-center gap-2 bg-transparent px-4 py-2 text-sm text-text-secondary transition-all duration-200 ease-in-out radix-state-active:bg-secondary radix-state-active:text-foreground radix-state-active:shadow-lg',
isSmallScreen ? 'flex-row rounded-lg' : 'rounded-xl',
'w-full',
)}
value="advanced"
@ -181,79 +177,53 @@ function Speech() {
</div>
<Tabs.Content value={'simple'}>
<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">
<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 className="flex flex-col gap-3 text-sm text-text-primary">
<SpeechToTextSwitch />
<EngineSTTDropdown external={sttExternal} />
<LanguageSTTDropdown />
<div className="h-px bg-border-medium" role="none" />
<TextToSpeechSwitch />
<EngineTTSDropdown external={ttsExternal} />
<VoiceDropdown />
</div>
</Tabs.Content>
<Tabs.Content value={'advanced'}>
<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">
<div className="flex flex-col gap-3 text-sm text-text-primary">
<ConversationModeSwitch />
<div className="mt-2 h-px bg-border-medium" role="none" />
<SpeechToTextSwitch />
<EngineSTTDropdown external={sttExternal} />
<LanguageSTTDropdown />
<div className="pb-2">
<AutoTranscribeAudioSwitch />
</div>
{autoTranscribeAudio && (
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
<div className="pb-2">
<DecibelSelector />
</div>
)}
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<div className="pb-2">
<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">
<div className="h-px bg-border-medium" role="none" />
<div className="pb-3">
<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>
<AutomaticPlaybackSwitch />
<EngineTTSDropdown external={ttsExternal} />
<VoiceDropdown />
{engineTTS === 'browser' && (
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
<div className="pb-2">
<CloudBrowserVoicesSwitch />
</div>
)}
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
<div className="pb-2">
<PlaybackRate />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<CacheTTSSwitch />
</div>
<CacheTTSSwitch />
</div>
</Tabs.Content>
</Tabs.Root>