Skip to content

Commit 86f4140

Browse files
berry-13danny-avila
authored andcommitted
🎨 feat: enhance UI & accessibility in file handling components (#5086)
* ✨ feat: Add localization for page display and enhance button styles * ✨ refactor: improve image preview component styles * ✨ refactor: enhance modal close behavior and prevent refocus on certain elements * ✨ refactor: enhance file row layout and improve image preview animation
1 parent eb2f8f9 commit 86f4140

File tree

11 files changed

+466
-279
lines changed

11 files changed

+466
-279
lines changed

‎client/src/components/Chat/Input/Files/FileRow.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,9 @@ export default function FileRow({
7373
}
7474

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

7980
return (
8081
<div style={rowStyle as React.CSSProperties}>

‎client/src/components/Chat/Input/Files/ImagePreview.tsx

Lines changed: 166 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { useState, useEffect, useCallback } from 'react';
2+
import { Maximize2 } from 'lucide-react';
13
import { FileSources } from 'librechat-data-provider';
24
import ProgressCircle from './ProgressCircle';
35
import SourceIcon from './SourceIcon';
@@ -10,67 +12,199 @@ type styleProps = {
1012
backgroundRepeat?: string;
1113
};
1214

15+
interface CloseModalEvent {
16+
stopPropagation: () => void;
17+
preventDefault: () => void;
18+
}
19+
1320
const ImagePreview = ({
1421
imageBase64,
1522
url,
1623
progress = 1,
1724
className = '',
1825
source,
26+
alt = 'Preview image',
1927
}: {
2028
imageBase64?: string;
2129
url?: string;
22-
progress?: number; // between 0 and 1
30+
progress?: number;
2331
className?: string;
2432
source?: FileSources;
33+
alt?: string;
2534
}) => {
26-
let style: styleProps = {
35+
const [isModalOpen, setIsModalOpen] = useState(false);
36+
const [isHovered, setIsHovered] = useState(false);
37+
const [previousActiveElement, setPreviousActiveElement] = useState<Element | null>(null);
38+
39+
const openModal = useCallback(() => {
40+
setPreviousActiveElement(document.activeElement);
41+
setIsModalOpen(true);
42+
}, []);
43+
44+
const closeModal = useCallback(
45+
(e: CloseModalEvent): void => {
46+
setIsModalOpen(false);
47+
e.stopPropagation();
48+
e.preventDefault();
49+
50+
if (
51+
previousActiveElement instanceof HTMLElement &&
52+
!previousActiveElement.closest('[data-skip-refocus="true"]')
53+
) {
54+
previousActiveElement.focus();
55+
}
56+
},
57+
[previousActiveElement],
58+
);
59+
60+
const handleKeyDown = useCallback(
61+
(e: KeyboardEvent) => {
62+
if (e.key === 'Escape') {
63+
closeModal(e);
64+
}
65+
},
66+
[closeModal],
67+
);
68+
69+
useEffect(() => {
70+
if (isModalOpen) {
71+
document.addEventListener('keydown', handleKeyDown);
72+
document.body.style.overflow = 'hidden';
73+
const closeButton = document.querySelector('[aria-label="Close full view"]') as HTMLElement;
74+
if (closeButton) {
75+
setTimeout(() => closeButton.focus(), 0);
76+
}
77+
}
78+
79+
return () => {
80+
document.removeEventListener('keydown', handleKeyDown);
81+
document.body.style.overflow = 'unset';
82+
};
83+
}, [isModalOpen, handleKeyDown]);
84+
85+
const baseStyle: styleProps = {
2786
backgroundSize: 'cover',
2887
backgroundPosition: 'center',
2988
backgroundRepeat: 'no-repeat',
3089
};
31-
if (imageBase64) {
32-
style = {
33-
...style,
34-
backgroundImage: `url(${imageBase64})`,
35-
};
36-
} else if (url) {
37-
style = {
38-
...style,
39-
backgroundImage: `url(${url})`,
40-
};
41-
}
4290

43-
if (!style.backgroundImage) {
91+
const imageUrl = imageBase64 ?? url ?? '';
92+
93+
const style: styleProps = imageUrl
94+
? {
95+
...baseStyle,
96+
backgroundImage: `url(${imageUrl})`,
97+
}
98+
: baseStyle;
99+
100+
if (typeof style.backgroundImage !== 'string' || style.backgroundImage.length === 0) {
44101
return null;
45102
}
46103

47-
const radius = 55; // Radius of the SVG circle
104+
const radius = 55;
48105
const circumference = 2 * Math.PI * radius;
49-
50-
// Calculate the offset based on the loading progress
51106
const offset = circumference - progress * circumference;
52107
const circleCSSProperties = {
53108
transition: 'stroke-dashoffset 0.3s linear',
54109
};
55110

56111
return (
57-
<div className={cn('h-14 w-14', className)}>
58-
<button
59-
type="button"
60-
aria-haspopup="dialog"
61-
aria-expanded="false"
62-
className="h-full w-full"
63-
style={style}
64-
/>
65-
{progress < 1 && (
66-
<ProgressCircle
67-
circumference={circumference}
68-
offset={offset}
69-
circleCSSProperties={circleCSSProperties}
112+
<>
113+
<div
114+
className={cn('relative size-14 rounded-lg', className)}
115+
onMouseEnter={() => setIsHovered(true)}
116+
onMouseLeave={() => setIsHovered(false)}
117+
>
118+
<button
119+
type="button"
120+
className="size-full overflow-hidden rounded-lg"
121+
style={style}
122+
aria-label={`View ${alt} in full size`}
123+
aria-haspopup="dialog"
124+
onClick={(e) => {
125+
e.preventDefault();
126+
e.stopPropagation();
127+
openModal();
128+
}}
70129
/>
130+
{progress < 1 ? (
131+
<ProgressCircle
132+
circumference={circumference}
133+
offset={offset}
134+
circleCSSProperties={circleCSSProperties}
135+
aria-label={`Loading progress: ${Math.round(progress * 100)}%`}
136+
/>
137+
) : (
138+
<div
139+
className={cn(
140+
'absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg transition-opacity duration-200 ease-in-out',
141+
isHovered ? 'bg-black/20 opacity-100' : 'opacity-0',
142+
)}
143+
onClick={(e) => {
144+
e.stopPropagation();
145+
openModal();
146+
}}
147+
aria-hidden="true"
148+
>
149+
<Maximize2
150+
className={cn(
151+
'size-5 transform-gpu text-white drop-shadow-lg transition-all duration-200',
152+
isHovered ? 'scale-110' : '',
153+
)}
154+
/>
155+
</div>
156+
)}
157+
<SourceIcon source={source} aria-label={source ? `Source: ${source}` : undefined} />
158+
</div>
159+
160+
{isModalOpen && (
161+
<div
162+
role="dialog"
163+
aria-modal="true"
164+
aria-label={`Full view of ${alt}`}
165+
className="fixed inset-0 z-[999] bg-black bg-opacity-80 transition-opacity duration-200 ease-in-out"
166+
onClick={closeModal}
167+
>
168+
<div className="flex h-full w-full cursor-default items-center justify-center">
169+
<button
170+
type="button"
171+
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"
172+
onClick={(e) => {
173+
e.stopPropagation();
174+
closeModal(e);
175+
}}
176+
aria-label="Close full view"
177+
>
178+
<svg
179+
className="h-6 w-6"
180+
fill="none"
181+
stroke="currentColor"
182+
viewBox="0 0 24 24"
183+
aria-hidden="true"
184+
>
185+
<path
186+
strokeLinecap="round"
187+
strokeLinejoin="round"
188+
strokeWidth={2}
189+
d="M6 18L18 6M6 6l12 12"
190+
/>
191+
</svg>
192+
</button>
193+
<div
194+
className="max-h-[90vh] max-w-[90vw] transform transition-transform duration-50 ease-in-out animate-in zoom-in-90"
195+
role="presentation"
196+
>
197+
<img
198+
src={imageUrl}
199+
alt={alt}
200+
className="max-w-screen max-h-screen object-contain"
201+
onClick={(e) => e.stopPropagation()}
202+
/>
203+
</div>
204+
</div>
205+
</div>
71206
)}
72-
<SourceIcon source={source} />
73-
</div>
207+
</>
74208
);
75209
};
76210

‎client/src/components/Chat/Input/Files/RemoveFile.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export default function RemoveFile({ onRemove }: { onRemove: () => void }) {
22
return (
33
<button
44
type="button"
5-
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"
5+
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"
66
onClick={onRemove}
77
>
88
<span>

‎client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ export default function ArchivedChatsTable() {
227227

228228
<div className="flex items-center justify-end gap-6 px-2 py-4">
229229
<div className="text-sm font-bold text-text-primary">
230-
Page {currentPage} of {totalPages}
230+
{localize('com_ui_page')} {currentPage} {localize('com_ui_of')} {totalPages}
231231
</div>
232232
<div className="flex space-x-2">
233233
{/* <Button

‎client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx

Lines changed: 50 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -50,56 +50,57 @@ const BookmarkTable = () => {
5050
const currentRows = filteredRows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
5151
return (
5252
<BookmarkContext.Provider value={{ bookmarks }}>
53-
<div className="flex items-center gap-4 py-4">
54-
<Input
55-
placeholder={localize('com_ui_bookmarks_filter')}
56-
value={searchQuery}
57-
onChange={(e) => setSearchQuery(e.target.value)}
58-
className="w-full border-border-light placeholder:text-text-secondary"
59-
/>
60-
</div>
61-
<div className="overflow-y-auto rounded-md border border-border-light">
62-
<Table className="table-fixed border-separate border-spacing-0">
63-
<TableHeader>
64-
<TableRow>
65-
<TableCell className="w-full bg-header-primary px-3 py-3.5 pl-6">
66-
<div>{localize('com_ui_bookmarks_title')}</div>
67-
</TableCell>
68-
<TableCell className="w-full bg-header-primary px-3 py-3.5 sm:pl-6">
69-
<div>{localize('com_ui_bookmarks_count')}</div>
70-
</TableCell>
71-
</TableRow>
72-
</TableHeader>
73-
<TableBody>{currentRows.map((row) => renderRow(row))}</TableBody>
74-
</Table>
75-
</div>
76-
<div className="flex items-center justify-between py-4">
77-
<div className="pl-1 text-text-secondary">
78-
{localize('com_ui_showing')} {pageIndex * pageSize + 1} -{' '}
79-
{Math.min((pageIndex + 1) * pageSize, filteredRows.length)} {localize('com_ui_of')}{' '}
80-
{filteredRows.length}
53+
<div className=" mt-2 space-y-2">
54+
<div className="flex items-center gap-4">
55+
<Input
56+
aria-label={localize('com_ui_bookmarks_filter')}
57+
placeholder={localize('com_ui_bookmarks_filter')}
58+
value={searchQuery}
59+
onChange={(e) => setSearchQuery(e.target.value)}
60+
/>
61+
</div>
62+
<div className="overflow-y-auto rounded-md border border-border-light">
63+
<Table className="table-fixed border-separate border-spacing-0">
64+
<TableHeader>
65+
<TableRow>
66+
<TableCell className="w-full bg-header-primary px-3 py-3.5 pl-6">
67+
<div>{localize('com_ui_bookmarks_title')}</div>
68+
</TableCell>
69+
<TableCell className="w-full bg-header-primary px-3 py-3.5 sm:pl-6">
70+
<div>{localize('com_ui_bookmarks_count')}</div>
71+
</TableCell>
72+
</TableRow>
73+
</TableHeader>
74+
<TableBody>{currentRows.map((row) => renderRow(row))}</TableBody>
75+
</Table>
8176
</div>
82-
<div className="flex items-center space-x-2">
83-
<Button
84-
variant="outline"
85-
size="sm"
86-
onClick={() => setPageIndex((prev) => Math.max(prev - 1, 0))}
87-
disabled={pageIndex === 0}
88-
>
89-
{localize('com_ui_prev')}
90-
</Button>
91-
<Button
92-
variant="outline"
93-
size="sm"
94-
onClick={() =>
95-
setPageIndex((prev) =>
96-
(prev + 1) * pageSize < filteredRows.length ? prev + 1 : prev,
97-
)
98-
}
99-
disabled={(pageIndex + 1) * pageSize >= filteredRows.length}
100-
>
101-
{localize('com_ui_next')}
102-
</Button>
77+
<div className="flex items-center justify-between py-4">
78+
<div className="pl-1 text-text-secondary">
79+
{localize('com_ui_page')} {pageIndex + 1} {localize('com_ui_of')}{' '}
80+
{Math.ceil(filteredRows.length / pageSize)}
81+
</div>
82+
<div className="flex items-center space-x-2">
83+
<Button
84+
variant="outline"
85+
size="sm"
86+
onClick={() => setPageIndex((prev) => Math.max(prev - 1, 0))}
87+
disabled={pageIndex === 0}
88+
>
89+
{localize('com_ui_prev')}
90+
</Button>
91+
<Button
92+
variant="outline"
93+
size="sm"
94+
onClick={() =>
95+
setPageIndex((prev) =>
96+
(prev + 1) * pageSize < filteredRows.length ? prev + 1 : prev,
97+
)
98+
}
99+
disabled={(pageIndex + 1) * pageSize >= filteredRows.length}
100+
>
101+
{localize('com_ui_next')}
102+
</Button>
103+
</div>
103104
</div>
104105
</div>
105106
</BookmarkContext.Provider>

0 commit comments

Comments
 (0)