File Upload
A datetime picker built on top of shadcn-ui and no additional library needed.
A datetime picker built on top of shadcn-ui and no additional library needed.
Thanks to Yerfa
Click to upload or drag and drop
SVG, PNG, JPG or GIF
Click to upload or drag and drop
SVG, PNG, JPG or GIF
npm install chrono-node react-day-picker@^8.9.1
1'use client';23import { cn } from '@/lib/utils';4import {5Dispatch,6SetStateAction,7createContext,8forwardRef,9useCallback,10useContext,11useEffect,12useRef,13useState,14} from 'react';15import {16useDropzone,17DropzoneState,18FileRejection,19DropzoneOptions,20} from 'react-dropzone';21import { toast } from 'sonner';22import { Trash2 as RemoveIcon } from 'lucide-react';2324type DirectionOptions = 'rtl' | 'ltr' | undefined;2526type FileUploaderContextType = {27dropzoneState: DropzoneState;28isLOF: boolean;29isFileTooBig: boolean;30removeFileFromSet: (index: number) => void;31activeIndex: number;32setActiveIndex: Dispatch<SetStateAction<number>>;33orientation: 'horizontal' | 'vertical';34direction: DirectionOptions;35};3637const FileUploaderContext = createContext<FileUploaderContextType | null>(null);3839export const useFileUpload = () => {40const context = useContext(FileUploaderContext);41if (!context) {42throw new Error('useFileUpload must be used within a FileUploaderProvider');43}44return context;45};4647type FileUploaderProps = {48value: File[] | null;49reSelect?: boolean;50onValueChange: (value: File[] | null) => void;51dropzoneOptions: DropzoneOptions;52orientation?: 'horizontal' | 'vertical';53};5455/**56* File upload Docs: {@link: https://localhost:3000/docs/file-upload}57*/5859export const FileUploader = forwardRef<60HTMLDivElement,61FileUploaderProps & React.HTMLAttributes<HTMLDivElement>62>(63(64{65className,66dropzoneOptions,67value,68onValueChange,69reSelect,70orientation = 'vertical',71children,72dir,73...props74},75ref76) => {77const [isFileTooBig, setIsFileTooBig] = useState(false);78const [isLOF, setIsLOF] = useState(false);79const [activeIndex, setActiveIndex] = useState(-1);80const {81accept = {82'image/*': ['.jpg', '.jpeg', '.png', '.gif'],83'video/*': ['.mp4', '.MOV', '.AVI'],84},85maxFiles = 1,86maxSize = 4 * 1024 * 1024,87multiple = true,88} = dropzoneOptions;8990const reSelectAll = maxFiles === 1 ? true : reSelect;91const direction: DirectionOptions = dir === 'rtl' ? 'rtl' : 'ltr';9293const removeFileFromSet = useCallback(94(i: number) => {95if (!value) return;96const newFiles = value.filter((_, index) => index !== i);97onValueChange(newFiles);98},99[value, onValueChange]100);101102const handleKeyDown = useCallback(103(e: React.KeyboardEvent<HTMLDivElement>) => {104e.preventDefault();105e.stopPropagation();106107if (!value) return;108109const moveNext = () => {110const nextIndex = activeIndex + 1;111setActiveIndex(nextIndex > value.length - 1 ? 0 : nextIndex);112};113114const movePrev = () => {115const nextIndex = activeIndex - 1;116setActiveIndex(nextIndex < 0 ? value.length - 1 : nextIndex);117};118119const prevKey =120orientation === 'horizontal'121? direction === 'ltr'122? 'ArrowLeft'123: 'ArrowRight'124: 'ArrowUp';125126const nextKey =127orientation === 'horizontal'128? direction === 'ltr'129? 'ArrowRight'130: 'ArrowLeft'131: 'ArrowDown';132133if (e.key === nextKey) {134moveNext();135} else if (e.key === prevKey) {136movePrev();137} else if (e.key === 'Enter' || e.key === 'Space') {138if (activeIndex === -1) {139dropzoneState.inputRef.current?.click();140}141} else if (e.key === 'Delete' || e.key === 'Backspace') {142if (activeIndex !== -1) {143removeFileFromSet(activeIndex);144if (value.length - 1 === 0) {145setActiveIndex(-1);146return;147}148movePrev();149}150} else if (e.key === 'Escape') {151setActiveIndex(-1);152}153},154// eslint-disable-next-line react-hooks/exhaustive-deps155[value, activeIndex, removeFileFromSet]156);157158const onDrop = useCallback(159(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {160const files = acceptedFiles;161162if (!files) {163toast.error('file error , probably too big');164return;165}166167const newValues: File[] = value ? [...value] : [];168169if (reSelectAll) {170newValues.splice(0, newValues.length);171}172173files.forEach((file) => {174if (newValues.length < maxFiles) {175newValues.push(file);176}177});178179onValueChange(newValues);180181if (rejectedFiles.length > 0) {182for (let i = 0; i < rejectedFiles.length; i++) {183if (rejectedFiles[i].errors[0]?.code === 'file-too-large') {184toast.error(185`File is too large. Max size is ${maxSize / 1024 / 1024}MB`186);187break;188}189if (rejectedFiles[i].errors[0]?.message) {190toast.error(rejectedFiles[i].errors[0].message);191break;192}193}194}195},196// eslint-disable-next-line react-hooks/exhaustive-deps197[reSelectAll, value]198);199200useEffect(() => {201if (!value) return;202if (value.length === maxFiles) {203setIsLOF(true);204return;205}206setIsLOF(false);207}, [value, maxFiles]);208209const opts = dropzoneOptions210? dropzoneOptions211: { accept, maxFiles, maxSize, multiple };212213const dropzoneState = useDropzone({214...opts,215onDrop,216onDropRejected: () => setIsFileTooBig(true),217onDropAccepted: () => setIsFileTooBig(false),218});219220return (221<FileUploaderContext.Provider222value={{223dropzoneState,224isLOF,225isFileTooBig,226removeFileFromSet,227activeIndex,228setActiveIndex,229orientation,230direction,231}}232>233<div234ref={ref}235tabIndex={0}236onKeyDownCapture={handleKeyDown}237className={cn(238'grid w-full focus:outline-none overflow-hidden ',239className,240{241'gap-2': value && value.length > 0,242}243)}244dir={dir}245{...props}246>247{children}248</div>249</FileUploaderContext.Provider>250);251}252);253254FileUploader.displayName = 'FileUploader';255256export const FileUploaderContent = forwardRef<257HTMLDivElement,258React.HTMLAttributes<HTMLDivElement>259>(({ children, className, ...props }, ref) => {260const { orientation } = useFileUpload();261const containerRef = useRef<HTMLDivElement>(null);262263return (264<div265className={cn('w-full px-1')}266ref={containerRef}267aria-description='content file holder'268>269<div270{...props}271ref={ref}272className={cn(273' rounded-xl gap-1',274orientation === 'horizontal' ? 'grid grid-cols-2' : 'flex flex-col',275className276)}277>278{children}279</div>280</div>281);282});283284FileUploaderContent.displayName = 'FileUploaderContent';285286export const FileUploaderItem = forwardRef<287HTMLDivElement,288{ index: number } & React.HTMLAttributes<HTMLDivElement>289>(({ className, index, children, ...props }, ref) => {290const { removeFileFromSet, activeIndex, direction } = useFileUpload();291const isSelected = index === activeIndex;292return (293<div294ref={ref}295className={cn(296'h-7 p-1 border rounded-md justify-between overflow-hidden w-full cursor-pointer relative hover:bg-primary-foreground',297className,298isSelected ? '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<button306type='button'307className={cn(308'absolute bg-primary rounded text-background p-1',309direction === 'rtl' ? 'top-1 left-1' : 'top-[0.145em] right-1'310)}311onClick={() => 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});319320FileUploaderItem.displayName = 'FileUploaderItem';321322interface FileInputProps extends React.HTMLAttributes<HTMLDivElement> {323parentclass?: string;324dropmsg?: string;325}326export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(327({ className, parentclass, dropmsg, children, ...props }, ref) => {328const { dropzoneState, isFileTooBig, isLOF } = useFileUpload();329const rootProps = isLOF ? {} : dropzoneState.getRootProps();330331return (332<div333ref={ref}334{...props}335className={cn(336'relative w-full',337parentclass,338isLOF ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'339)}340>341<div342className={cn(343'w-full rounded-lg transition-colors duration-300 ease-in-out',344dropzoneState.isDragAccept && 'border-green-500 bg-green-50',345dropzoneState.isDragReject && 'border-red-500 bg-red-50',346isFileTooBig && 'border-red-500 bg-red-200',347!dropzoneState.isDragActive &&348'border-gray-300 hover:border-gray-400',349className350)}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<input361ref={dropzoneState.inputRef}362disabled={isLOF}363{...dropzoneState.getInputProps()}364className={cn(isLOF && 'cursor-not-allowed')}365/>366</div>367);368}369);
Prop | Type | Default | Description |
---|---|---|---|
value | File[] | null | null | The array of uploaded files. |
reSelect | boolean | undefined | If true , allows reselecting files when maxFiles is 1. |
onValueChange | (value: File[] | null) => void | - | Callback triggered when the file selection changes. |
dropzoneOptions | DropzoneOptions | {} | 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. |
children | React.ReactNode | undefined | Components or elements to be rendered within the file uploader. |
className | string | undefined | Additional CSS classes for custom styling. |
dir | 'rtl' | 'ltr' | undefined | undefined | Sets text directionality, affecting navigation and layout in horizontal mode. |
ref | React.Ref<HTMLDivElement> | undefined | Reference to the uploader’s root div element. |
The FileUploaderContext
is a context containing the following properties:
Property | Type | Description |
---|---|---|
dropzoneState | DropzoneState | The state object provided by react-dropzone , used for handling file drag-and-drop behavior. |
isLOF | boolean | Indicates if the maximum number of files has been reached. |
isFileTooBig | boolean | Indicates if the rejected file exceeds the maximum allowed size. |
removeFileFromSet | (index: number) => void | Function to remove a file at a specific index from the current selection. |
activeIndex | number | The index of the currently selected file in the list, used for navigation. |
setActiveIndex | Dispatch<SetStateAction<number>> | Function to set the active index. |
orientation | 'horizontal' | 'vertical' | Defines whether files are displayed horizontally or vertically. |
direction | 'rtl' | 'ltr' | undefined | Specifies the text direction; affects keyboard navigation in horizontal mode. |