diff --git a/src/components/dropzone/dropzone.stories.tsx b/src/components/dropzone/dropzone.stories.tsx new file mode 100644 index 00000000..e9127d1d --- /dev/null +++ b/src/components/dropzone/dropzone.stories.tsx @@ -0,0 +1,57 @@ +// Dropzone.stories.js + +import Dropzone from './dropzone'; // Adjust the import path as needed +import type { Meta, StoryFn } from '@storybook/react'; + +const meta: Meta = { + title: 'Molecules/Dropzone', + component: Dropzone, + parameters: { + layout: 'centered', + }, + tags: [ 'autodocs' ], + argTypes: { + size: { + control: { type: 'select' }, + }, + }, +}; + +export default meta; + +const Template: StoryFn = ( args ) => ; + +export const Basic = Template.bind( {} ); +Basic.args = { + size: 'md', + label: 'Drag and drop or browse files', + helpText: 'Click to upload your files', + inlineIcon: false, + disabled: false, + error: false, + errorText: 'Upload failed, please try again.', +}; + +export const LargeSize = Template.bind( {} ); +LargeSize.args = { + ...Basic.args, + size: 'lg', +}; + +export const DisabledDropzone = Template.bind( {} ); +DisabledDropzone.args = { + ...Basic.args, + disabled: true, +}; + +export const InlineIcon = Template.bind( {} ); +InlineIcon.args = { + ...Basic.args, + inlineIcon: true, +}; + +export const CustomHelpText = Template.bind( {} ); +CustomHelpText.args = { + ...Basic.args, + helpText: 'Ensure your files do not exceed 10MB', +}; diff --git a/src/components/dropzone/dropzone.tsx b/src/components/dropzone/dropzone.tsx new file mode 100644 index 00000000..8fc286f4 --- /dev/null +++ b/src/components/dropzone/dropzone.tsx @@ -0,0 +1,284 @@ +import { useState, createContext, useContext, useRef } from 'react'; +import { CloudUpload, File, ImageOff, Trash } from 'lucide-react'; +import { cn, formatFileSize } from '@/utilities/functions'; +import Loader from '../loader'; +import { nanoid } from 'nanoid'; + +export interface DropzoneProps { + /** Callback function when a file is uploaded */ + onFileUpload?: ( file: File ) => void; + /** Determines if the icon should be inline */ + inlineIcon?: boolean; + /** Label for the dropzone */ + label?: string; + /** Help text for the dropzone */ + helpText?: string; + /** Size variant of the dropzone */ + size?: 'sm' | 'md' | 'lg'; + /** Indicates if the component is disabled */ + disabled?: boolean; + /** Indicates if the component is in error state */ + error?: boolean; + /** Error text to display */ + errorText?: string; +} + +// Context interface for file data sharing +export interface FileUploadContextType { + file: File | null; + removeFile: () => void; + isLoading: boolean; + error: boolean; + errorText?: string; +} + +// Create a context to share file data between Dropzone and FilePreview +const FileUploadContext = createContext( null ); + +const useFileUploadContext = () => useContext( FileUploadContext ); + +// FilePreview +export const FilePreview = () => { + const { file, removeFile, isLoading, error, errorText } = + useFileUploadContext()!; + + if ( ! file ) { + return null; + } + + return ( +
+
+ { isLoading && } + { ! isLoading && + ( file.type.startsWith( 'image/' ) ? ( +
+ { error ? ( + + ) : ( + Preview + ) } +
+ ) : ( + + + + ) ) } + +
+ + { isLoading ? 'Loading...' : file.name } + + { ! isLoading && ( + + { error ? errorText : formatFileSize( file.size ) } + + ) } +
+
+ { isLoading ? ( + + ) : ( + + ) } +
+ ); +}; + +// Dropzone Component with embedded FilePreview subcomponent +export const Dropzone = ( { + onFileUpload, + inlineIcon = false, + label = 'Drag and drop or browse files', + helpText = 'Help Text', + size = 'sm', + disabled = false, + error = false, + errorText = 'Upload failed, please try again.', +}: DropzoneProps ) => { + const [ isLoading, setIsLoading ] = useState( false ); + const [ file, setFile ] = useState( null ); + const [ isDragging, setIsDragging ] = useState( false ); + + const handleDrop = ( e: React.DragEvent ) => { + if ( disabled ) { + return; + } + setIsLoading( true ); + e.preventDefault(); + e.stopPropagation(); + setIsDragging( false ); + const droppedFile = e.dataTransfer.files[ 0 ]; + if ( droppedFile ) { + setFile( droppedFile ); + if ( onFileUpload ) { + onFileUpload( droppedFile ); + } + } + setIsLoading( false ); + }; + + const handleDragOver = ( e: React.DragEvent ) => { + if ( disabled ) { + return; + } + e.preventDefault(); + setIsDragging( true ); + }; + + const handleDragLeave = () => { + if ( ! disabled ) { + setIsDragging( false ); + } + }; + + const handleFileChange = ( e: React.ChangeEvent ) => { + if ( disabled ) { + return; + } + setIsLoading( true ); + const files = e.target.files; + if ( ! files ) { + return; + } + const selectedFile = files[ 0 ]; + if ( selectedFile ) { + setFile( selectedFile ); + if ( onFileUpload ) { + onFileUpload( selectedFile ); + } + } + setIsLoading( false ); + }; + + const removeFile = () => { + setFile( null ); + }; + const sizeClasses = { + sm: { + label: 'text-sm', + helpText: 'text-xs', + icon: 'size-5', + padding: inlineIcon ? 'p-3' : 'p-5', + gap: 'gap-2.5', + }, + md: { + label: 'text-sm', + helpText: 'text-xs', + icon: 'size-5', + padding: inlineIcon ? 'p-4' : 'p-6', + gap: 'gap-3', + }, + lg: { + label: 'text-base', + helpText: 'text-sm', + icon: 'size-6', + padding: inlineIcon ? 'p-4' : 'p-6', + gap: 'gap-3', + }, + }; + const uploadInputID = useRef( `fui-file-upload-${ nanoid() }` ); + return ( + +
+ + + +
+
+ ); +}; +Dropzone.displayName = 'Dropzone'; + +export default Dropzone; diff --git a/src/components/dropzone/index.ts b/src/components/dropzone/index.ts new file mode 100644 index 00000000..de19c1a4 --- /dev/null +++ b/src/components/dropzone/index.ts @@ -0,0 +1 @@ +export { default } from './dropzone'; diff --git a/src/components/index.ts b/src/components/index.ts index 743e3bc7..990b5edd 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -35,3 +35,4 @@ export { default as BarChart } from './bar-chart'; export { default as LineChart } from './line-chart'; export { default as PieChart } from './pie-chart'; export { default as AreaChart } from './area-chart'; +export { default as Dropzone } from './dropzone'; diff --git a/src/components/loader/loader.tsx b/src/components/loader/loader.tsx index d7b7387e..9f555b5c 100644 --- a/src/components/loader/loader.tsx +++ b/src/components/loader/loader.tsx @@ -23,7 +23,7 @@ export const Loader = ( { className = '', }: LoaderProps ) => { const variantClassNames = { - primary: 'text-brand-primary-600 bg-background-primary', + primary: 'text-brand-primary-600', secondary: 'text-background-primary', }?.[ variant ]; diff --git a/src/components/search/search.tsx b/src/components/search/search.tsx index 3034e838..906a733f 100644 --- a/src/components/search/search.tsx +++ b/src/components/search/search.tsx @@ -238,7 +238,7 @@ export const SearchBoxInput = forwardRef( searchTerm, setSearchTerm, } = useSearchContext(); - const bagdeSize = size === 'lg' ? 'sm' : 'xs'; + const badgeSize = size === 'lg' ? 'sm' : 'xs'; const handleChange = ( event: React.ChangeEvent ) => { const newValue = event.target.value; setSearchTerm!( newValue ); @@ -303,7 +303,7 @@ export const SearchBoxInput = forwardRef( /> diff --git a/src/utilities/functions.js b/src/utilities/functions.js index f231ba80..9aaf2f52 100644 --- a/src/utilities/functions.js +++ b/src/utilities/functions.js @@ -72,3 +72,17 @@ export const getOperatingSystem = () => { } return operatingSystem; }; + +// Utility function to convert bytes to a more readable format +export const formatFileSize = ( size ) => { + if ( size < 1024 ) { + return `${ size } bytes`; + } + if ( size < 1024 * 1024 ) { + return `${ ( size / 1024 ).toFixed( 2 ) } KB`; + } + if ( size < 1024 * 1024 * 1024 ) { + return `${ ( size / ( 1024 * 1024 ) ).toFixed( 2 ) } MB`; + } + return `${ ( size / ( 1024 * 1024 * 1024 ) ).toFixed( 2 ) } GB`; // Format size to GB +};