Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

๐ŸŽจ feat: enhance UI & accessibility in file handling components #5086

Merged
merged 4 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions client/src/components/Chat/Input/Files/FileRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ export default function FileRow({
}

const renderFiles = () => {
// Inline style for RTL
const rowStyle = isRTL ? { display: 'flex', flexDirection: 'row-reverse' } : {};
const rowStyle = isRTL
? { display: 'flex', flexDirection: 'row-reverse', gap: '4px' }
: { display: 'flex', gap: '4px' };

return (
<div style={rowStyle as React.CSSProperties}>
Expand Down
198 changes: 166 additions & 32 deletions client/src/components/Chat/Input/Files/ImagePreview.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { Maximize2 } from 'lucide-react';
import { FileSources } from 'librechat-data-provider';
import ProgressCircle from './ProgressCircle';
import SourceIcon from './SourceIcon';
Expand All @@ -10,67 +12,199 @@ type styleProps = {
backgroundRepeat?: string;
};

interface CloseModalEvent {
stopPropagation: () => void;
preventDefault: () => void;
}

const ImagePreview = ({
imageBase64,
url,
progress = 1,
className = '',
source,
alt = 'Preview image',
}: {
imageBase64?: string;
url?: string;
progress?: number; // between 0 and 1
progress?: number;
className?: string;
source?: FileSources;
alt?: string;
}) => {
let style: styleProps = {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [previousActiveElement, setPreviousActiveElement] = useState<Element | null>(null);

const openModal = useCallback(() => {
setPreviousActiveElement(document.activeElement);
setIsModalOpen(true);
}, []);

const closeModal = useCallback(
(e: CloseModalEvent): void => {
setIsModalOpen(false);
e.stopPropagation();
e.preventDefault();

if (
previousActiveElement instanceof HTMLElement &&
!previousActiveElement.closest('[data-skip-refocus="true"]')
) {
previousActiveElement.focus();
}
},
[previousActiveElement],
);

const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeModal(e);
}
},
[closeModal],
);

useEffect(() => {
if (isModalOpen) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
const closeButton = document.querySelector('[aria-label="Close full view"]') as HTMLElement;
if (closeButton) {
setTimeout(() => closeButton.focus(), 0);
}
}

return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'unset';
};
}, [isModalOpen, handleKeyDown]);

const baseStyle: styleProps = {
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
};
if (imageBase64) {
style = {
...style,
backgroundImage: `url(${imageBase64})`,
};
} else if (url) {
style = {
...style,
backgroundImage: `url(${url})`,
};
}

if (!style.backgroundImage) {
const imageUrl = imageBase64 ?? url ?? '';

const style: styleProps = imageUrl
? {
...baseStyle,
backgroundImage: `url(${imageUrl})`,
}
: baseStyle;

if (typeof style.backgroundImage !== 'string' || style.backgroundImage.length === 0) {
return null;
}

const radius = 55; // Radius of the SVG circle
const radius = 55;
const circumference = 2 * Math.PI * radius;

// Calculate the offset based on the loading progress
const offset = circumference - progress * circumference;
const circleCSSProperties = {
transition: 'stroke-dashoffset 0.3s linear',
};

return (
<div className={cn('h-14 w-14', className)}>
<button
type="button"
aria-haspopup="dialog"
aria-expanded="false"
className="h-full w-full"
style={style}
/>
{progress < 1 && (
<ProgressCircle
circumference={circumference}
offset={offset}
circleCSSProperties={circleCSSProperties}
<>
<div
className={cn('relative size-14 rounded-lg', className)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<button
type="button"
className="size-full overflow-hidden rounded-lg"
style={style}
aria-label={`View ${alt} in full size`}
aria-haspopup="dialog"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openModal();
}}
/>
{progress < 1 ? (
<ProgressCircle
circumference={circumference}
offset={offset}
circleCSSProperties={circleCSSProperties}
aria-label={`Loading progress: ${Math.round(progress * 100)}%`}
/>
) : (
<div
className={cn(
'absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg transition-opacity duration-200 ease-in-out',
isHovered ? 'bg-black/20 opacity-100' : 'opacity-0',
)}
onClick={(e) => {
e.stopPropagation();
openModal();
}}
aria-hidden="true"
>
<Maximize2
className={cn(
'size-5 transform-gpu text-white drop-shadow-lg transition-all duration-200',
isHovered ? 'scale-110' : '',
)}
/>
</div>
)}
<SourceIcon source={source} aria-label={source ? `Source: ${source}` : undefined} />
</div>

{isModalOpen && (
<div
role="dialog"
aria-modal="true"
aria-label={`Full view of ${alt}`}
className="fixed inset-0 z-[999] bg-black bg-opacity-80 transition-opacity duration-200 ease-in-out"
onClick={closeModal}
>
<div className="flex h-full w-full cursor-default items-center justify-center">
<button
type="button"
className="absolute right-4 top-4 z-[1000] rounded-full p-2 text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
onClick={(e) => {
e.stopPropagation();
closeModal(e);
}}
aria-label="Close full view"
>
<svg
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<div
className="max-h-[90vh] max-w-[90vw] transform transition-transform duration-50 ease-in-out animate-in zoom-in-90"
role="presentation"
>
<img
src={imageUrl}
alt={alt}
className="max-w-screen max-h-screen object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
</div>
)}
<SourceIcon source={source} />
</div>
</>
);
};

Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Chat/Input/Files/RemoveFile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export default function RemoveFile({ onRemove }: { onRemove: () => void }) {
return (
<button
type="button"
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full border border-gray-500 bg-gray-500 p-0.5 text-white transition-colors hover:bg-gray-700 hover:opacity-100 group-hover:opacity-100 md:opacity-0"
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full bg-surface-secondary p-0.5 transition-colors duration-200 hover:bg-surface-primary z-50"
onClick={onRemove}
>
<span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export default function ArchivedChatsTable() {

<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}
{localize('com_ui_page')} {currentPage} {localize('com_ui_of')} {totalPages}
</div>
<div className="flex space-x-2">
{/* <Button
Expand Down
99 changes: 50 additions & 49 deletions client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,56 +50,57 @@ const BookmarkTable = () => {
const currentRows = filteredRows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
return (
<BookmarkContext.Provider value={{ bookmarks }}>
<div className="flex items-center gap-4 py-4">
<Input
placeholder={localize('com_ui_bookmarks_filter')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full border-border-light placeholder:text-text-secondary"
/>
</div>
<div className="overflow-y-auto rounded-md border border-border-light">
<Table className="table-fixed border-separate border-spacing-0">
<TableHeader>
<TableRow>
<TableCell className="w-full bg-header-primary px-3 py-3.5 pl-6">
<div>{localize('com_ui_bookmarks_title')}</div>
</TableCell>
<TableCell className="w-full bg-header-primary px-3 py-3.5 sm:pl-6">
<div>{localize('com_ui_bookmarks_count')}</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>{currentRows.map((row) => renderRow(row))}</TableBody>
</Table>
</div>
<div className="flex items-center justify-between py-4">
<div className="pl-1 text-text-secondary">
{localize('com_ui_showing')} {pageIndex * pageSize + 1} -{' '}
{Math.min((pageIndex + 1) * pageSize, filteredRows.length)} {localize('com_ui_of')}{' '}
{filteredRows.length}
<div className=" mt-2 space-y-2">
<div className="flex items-center gap-4">
<Input
aria-label={localize('com_ui_bookmarks_filter')}
placeholder={localize('com_ui_bookmarks_filter')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="overflow-y-auto rounded-md border border-border-light">
<Table className="table-fixed border-separate border-spacing-0">
<TableHeader>
<TableRow>
<TableCell className="w-full bg-header-primary px-3 py-3.5 pl-6">
<div>{localize('com_ui_bookmarks_title')}</div>
</TableCell>
<TableCell className="w-full bg-header-primary px-3 py-3.5 sm:pl-6">
<div>{localize('com_ui_bookmarks_count')}</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>{currentRows.map((row) => renderRow(row))}</TableBody>
</Table>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex((prev) => Math.max(prev - 1, 0))}
disabled={pageIndex === 0}
>
{localize('com_ui_prev')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setPageIndex((prev) =>
(prev + 1) * pageSize < filteredRows.length ? prev + 1 : prev,
)
}
disabled={(pageIndex + 1) * pageSize >= filteredRows.length}
>
{localize('com_ui_next')}
</Button>
<div className="flex items-center justify-between py-4">
<div className="pl-1 text-text-secondary">
{localize('com_ui_page')} {pageIndex + 1} {localize('com_ui_of')}{' '}
{Math.ceil(filteredRows.length / pageSize)}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex((prev) => Math.max(prev - 1, 0))}
disabled={pageIndex === 0}
>
{localize('com_ui_prev')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setPageIndex((prev) =>
(prev + 1) * pageSize < filteredRows.length ? prev + 1 : prev,
)
}
disabled={(pageIndex + 1) * pageSize >= filteredRows.length}
>
{localize('com_ui_next')}
</Button>
</div>
</div>
</div>
</BookmarkContext.Provider>
Expand Down
Loading
Loading