File Upload

A datetime picker built on top of shadcn-ui and no additional library needed.

Thanks to Yerfa

Img Preview

Chat

Installation

npm install chrono-node react-day-picker@^8.9.1
1
'use client';
2
3
import { cn } from '@/lib/utils';
4
import {
5
Dispatch,
6
SetStateAction,
7
createContext,
8
forwardRef,
9
useCallback,
10
useContext,
11
useEffect,
12
useRef,
13
useState,
14
} from 'react';
15
import {
16
useDropzone,
17
DropzoneState,
18
FileRejection,
19
DropzoneOptions,
20
} from 'react-dropzone';
21
import { toast } from 'sonner';
22
import { Trash2 as RemoveIcon } from 'lucide-react';
23
24
type DirectionOptions = 'rtl' | 'ltr' | undefined;
25
26
type FileUploaderContextType = {
27
dropzoneState: DropzoneState;
28
isLOF: boolean;
29
isFileTooBig: boolean;
30
removeFileFromSet: (index: number) => void;
31
activeIndex: number;
32
setActiveIndex: Dispatch<SetStateAction<number>>;
33
orientation: 'horizontal' | 'vertical';
34
direction: DirectionOptions;
35
};
36
37
const FileUploaderContext = createContext<FileUploaderContextType | null>(null);
38
39
export const useFileUpload = () => {
40
const context = useContext(FileUploaderContext);
41
if (!context) {
42
throw new Error('useFileUpload must be used within a FileUploaderProvider');
43
}
44
return context;
45
};
46
47
type FileUploaderProps = {
48
value: File[] | null;
49
reSelect?: boolean;
50
onValueChange: (value: File[] | null) => void;
51
dropzoneOptions: DropzoneOptions;
52
orientation?: 'horizontal' | 'vertical';
53
};
54
55
/**
56
* File upload Docs: {@link: https://localhost:3000/docs/file-upload}
57
*/
58
59
export const FileUploader = forwardRef<
60
HTMLDivElement,
61
FileUploaderProps & React.HTMLAttributes<HTMLDivElement>
62
>(
63
(
64
{
65
className,
66
dropzoneOptions,
67
value,
68
onValueChange,
69
reSelect,
70
orientation = 'vertical',
71
children,
72
dir,
73
...props
74
},
75
ref
76
) => {
77
const [isFileTooBig, setIsFileTooBig] = useState(false);
78
const [isLOF, setIsLOF] = useState(false);
79
const [activeIndex, setActiveIndex] = useState(-1);
80
const {
81
accept = {
82
'image/*': ['.jpg', '.jpeg', '.png', '.gif'],
83
'video/*': ['.mp4', '.MOV', '.AVI'],
84
},
85
maxFiles = 1,
86
maxSize = 4 * 1024 * 1024,
87
multiple = true,
88
} = dropzoneOptions;
89
90
const reSelectAll = maxFiles === 1 ? true : reSelect;
91
const direction: DirectionOptions = dir === 'rtl' ? 'rtl' : 'ltr';
92
93
const removeFileFromSet = useCallback(
94
(i: number) => {
95
if (!value) return;
96
const newFiles = value.filter((_, index) => index !== i);
97
onValueChange(newFiles);
98
},
99
[value, onValueChange]
100
);
101
102
const handleKeyDown = useCallback(
103
(e: React.KeyboardEvent<HTMLDivElement>) => {
104
e.preventDefault();
105
e.stopPropagation();
106
107
if (!value) return;
108
109
const moveNext = () => {
110
const nextIndex = activeIndex + 1;
111
setActiveIndex(nextIndex > value.length - 1 ? 0 : nextIndex);
112
};
113
114
const movePrev = () => {
115
const nextIndex = activeIndex - 1;
116
setActiveIndex(nextIndex < 0 ? value.length - 1 : nextIndex);
117
};
118
119
const prevKey =
120
orientation === 'horizontal'
121
? direction === 'ltr'
122
? 'ArrowLeft'
123
: 'ArrowRight'
124
: 'ArrowUp';
125
126
const nextKey =
127
orientation === 'horizontal'
128
? direction === 'ltr'
129
? 'ArrowRight'
130
: 'ArrowLeft'
131
: 'ArrowDown';
132
133
if (e.key === nextKey) {
134
moveNext();
135
} else if (e.key === prevKey) {
136
movePrev();
137
} else if (e.key === 'Enter' || e.key === 'Space') {
138
if (activeIndex === -1) {
139
dropzoneState.inputRef.current?.click();
140
}
141
} else if (e.key === 'Delete' || e.key === 'Backspace') {
142
if (activeIndex !== -1) {
143
removeFileFromSet(activeIndex);
144
if (value.length - 1 === 0) {
145
setActiveIndex(-1);
146
return;
147
}
148
movePrev();
149
}
150
} else if (e.key === 'Escape') {
151
setActiveIndex(-1);
152
}
153
},
154
// eslint-disable-next-line react-hooks/exhaustive-deps
155
[value, activeIndex, removeFileFromSet]
156
);
157
158
const onDrop = useCallback(
159
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
160
const files = acceptedFiles;
161
162
if (!files) {
163
toast.error('file error , probably too big');
164
return;
165
}
166
167
const newValues: File[] = value ? [...value] : [];
168
169
if (reSelectAll) {
170
newValues.splice(0, newValues.length);
171
}
172
173
files.forEach((file) => {
174
if (newValues.length < maxFiles) {
175
newValues.push(file);
176
}
177
});
178
179
onValueChange(newValues);
180
181
if (rejectedFiles.length > 0) {
182
for (let i = 0; i < rejectedFiles.length; i++) {
183
if (rejectedFiles[i].errors[0]?.code === 'file-too-large') {
184
toast.error(
185
`File is too large. Max size is ${maxSize / 1024 / 1024}MB`
186
);
187
break;
188
}
189
if (rejectedFiles[i].errors[0]?.message) {
190
toast.error(rejectedFiles[i].errors[0].message);
191
break;
192
}
193
}
194
}
195
},
196
// eslint-disable-next-line react-hooks/exhaustive-deps
197
[reSelectAll, value]
198
);
199
200
useEffect(() => {
201
if (!value) return;
202
if (value.length === maxFiles) {
203
setIsLOF(true);
204
return;
205
}
206
setIsLOF(false);
207
}, [value, maxFiles]);
208
209
const opts = dropzoneOptions
210
? dropzoneOptions
211
: { accept, maxFiles, maxSize, multiple };
212
213
const dropzoneState = useDropzone({
214
...opts,
215
onDrop,
216
onDropRejected: () => setIsFileTooBig(true),
217
onDropAccepted: () => setIsFileTooBig(false),
218
});
219
220
return (
221
<FileUploaderContext.Provider
222
value={{
223
dropzoneState,
224
isLOF,
225
isFileTooBig,
226
removeFileFromSet,
227
activeIndex,
228
setActiveIndex,
229
orientation,
230
direction,
231
}}
232
>
233
<div
234
ref={ref}
235
tabIndex={0}
236
onKeyDownCapture={handleKeyDown}
237
className={cn(
238
'grid w-full focus:outline-none overflow-hidden ',
239
className,
240
{
241
'gap-2': value && value.length > 0,
242
}
243
)}
244
dir={dir}
245
{...props}
246
>
247
{children}
248
</div>
249
</FileUploaderContext.Provider>
250
);
251
}
252
);
253
254
FileUploader.displayName = 'FileUploader';
255
256
export const FileUploaderContent = forwardRef<
257
HTMLDivElement,
258
React.HTMLAttributes<HTMLDivElement>
259
>(({ children, className, ...props }, ref) => {
260
const { orientation } = useFileUpload();
261
const containerRef = useRef<HTMLDivElement>(null);
262
263
return (
264
<div
265
className={cn('w-full px-1')}
266
ref={containerRef}
267
aria-description='content file holder'
268
>
269
<div
270
{...props}
271
ref={ref}
272
className={cn(
273
' rounded-xl gap-1',
274
orientation === 'horizontal' ? 'grid grid-cols-2' : 'flex flex-col',
275
className
276
)}
277
>
278
{children}
279
</div>
280
</div>
281
);
282
});
283
284
FileUploaderContent.displayName = 'FileUploaderContent';
285
286
export const FileUploaderItem = forwardRef<
287
HTMLDivElement,
288
{ index: number } & React.HTMLAttributes<HTMLDivElement>
289
>(({ className, index, children, ...props }, ref) => {
290
const { removeFileFromSet, activeIndex, direction } = useFileUpload();
291
const isSelected = index === activeIndex;
292
return (
293
<div
294
ref={ref}
295
className={cn(
296
'h-7 p-1 border rounded-md justify-between overflow-hidden w-full cursor-pointer relative hover:bg-primary-foreground',
297
className,
298
isSelected ? 'bg-muted' : ''
299
)}
300
{...props}
301
>
302
<div className='font-medium leading-none tracking-tight flex items-center gap-1.5 h-full w-full'>
303
{children}
304
</div>
305
<button
306
type='button'
307
className={cn(
308
'absolute bg-primary rounded text-background p-1',
309
direction === 'rtl' ? 'top-1 left-1' : 'top-[0.145em] right-1'
310
)}
311
onClick={() => removeFileFromSet(index)}
312
>
313
<span className='sr-only'>remove item {index}</span>
314
<RemoveIcon className='w-3 h-3 hover:stroke-destructive duration-200 ease-in-out' />
315
</button>
316
</div>
317
);
318
});
319
320
FileUploaderItem.displayName = 'FileUploaderItem';
321
322
interface FileInputProps extends React.HTMLAttributes<HTMLDivElement> {
323
parentclass?: string;
324
dropmsg?: string;
325
}
326
export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
327
({ className, parentclass, dropmsg, children, ...props }, ref) => {
328
const { dropzoneState, isFileTooBig, isLOF } = useFileUpload();
329
const rootProps = isLOF ? {} : dropzoneState.getRootProps();
330
331
return (
332
<div
333
ref={ref}
334
{...props}
335
className={cn(
336
'relative w-full',
337
parentclass,
338
isLOF ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
339
)}
340
>
341
<div
342
className={cn(
343
'w-full rounded-lg transition-colors duration-300 ease-in-out',
344
dropzoneState.isDragAccept && 'border-green-500 bg-green-50',
345
dropzoneState.isDragReject && 'border-red-500 bg-red-50',
346
isFileTooBig && 'border-red-500 bg-red-200',
347
!dropzoneState.isDragActive &&
348
'border-gray-300 hover:border-gray-400',
349
className
350
)}
351
{...rootProps}
352
>
353
{children}
354
{dropzoneState.isDragActive && (
355
<div className='absolute inset-0 flex items-center justify-center bg-primary-foreground/60 backdrop-blur-sm rounded-lg'>
356
<p className='text-primary font-medium'>{dropmsg}</p>
357
</div>
358
)}
359
</div>
360
<input
361
ref={dropzoneState.inputRef}
362
disabled={isLOF}
363
{...dropzoneState.getInputProps()}
364
className={cn(isLOF && 'cursor-not-allowed')}
365
/>
366
</div>
367
);
368
}
369
);

FileUploader Props

PropTypeDefaultDescription
valueFile[] | nullnullThe array of uploaded files.
reSelectbooleanundefinedIf true, allows reselecting files when maxFiles is 1.
onValueChange(value: File[] | null) => void-Callback triggered when the file selection changes.
dropzoneOptionsDropzoneOptions{}Configuration options for the dropzone, such as maxFiles, maxSize, and accept file types.
orientation'horizontal' | 'vertical''vertical'Sets the layout direction of the file uploader.
childrenReact.ReactNodeundefinedComponents or elements to be rendered within the file uploader.
classNamestringundefinedAdditional CSS classes for custom styling.
dir'rtl' | 'ltr' | undefinedundefinedSets text directionality, affecting navigation and layout in horizontal mode.
refReact.Ref<HTMLDivElement>undefinedReference to the uploader’s root div element.

FileUploaderContext

The FileUploaderContext is a context containing the following properties:

PropertyTypeDescription
dropzoneStateDropzoneStateThe state object provided by react-dropzone, used for handling file drag-and-drop behavior.
isLOFbooleanIndicates if the maximum number of files has been reached.
isFileTooBigbooleanIndicates if the rejected file exceeds the maximum allowed size.
removeFileFromSet(index: number) => voidFunction to remove a file at a specific index from the current selection.
activeIndexnumberThe index of the currently selected file in the list, used for navigation.
setActiveIndexDispatch<SetStateAction<number>>Function to set the active index.
orientation'horizontal' | 'vertical'Defines whether files are displayed horizontally or vertically.
direction'rtl' | 'ltr' | undefinedSpecifies the text direction; affects keyboard navigation in horizontal mode.