diff --git a/.changeset/gorgeous-cars-jump.md b/.changeset/gorgeous-cars-jump.md deleted file mode 100644 index a845151c..00000000 --- a/.changeset/gorgeous-cars-jump.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/twelve-rabbits-march.md b/.changeset/twelve-rabbits-march.md new file mode 100644 index 00000000..8157190a --- /dev/null +++ b/.changeset/twelve-rabbits-march.md @@ -0,0 +1,5 @@ +--- +"@heathmont/moon-core-tw": patch +--- + +feat: Add File Input component [MDS-1365] diff --git a/docs/app/client/input/examples/TextInputTypes.tsx b/docs/app/client/input/examples/TextInputTypes.tsx index c8df592f..7cb4985c 100644 --- a/docs/app/client/input/examples/TextInputTypes.tsx +++ b/docs/app/client/input/examples/TextInputTypes.tsx @@ -1,52 +1,79 @@ "use client"; -import { Input, Label } from "@heathmont/moon-core-tw"; +import { FileInput, Input, Label, Hint } from "@heathmont/moon-core-tw"; +import { useState } from "react"; -const TextInputTypes = () => ( - <> -
-
- - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - +const TextInputTypes = () => { + const [file, setFile] = useState(); + const fileHandler = (file: File | undefined) => { + setFile(file); + }; + + const removeFileHandler = () => { + setFile(undefined); + }; + + return ( + <> +
+
+ + +
+ +
+ + +
+
+ + +
-
-
-
- - +
+
+ + +
+
+ + +
+
+ + +
-
- - +
+
+ + +
+
+ + +
+
+ + +
-
- - +
+
+ + + {file && File uploaded: {file.name}} +
-
- -); + + ); +}; export default TextInputTypes; diff --git a/docs/app/client/input/props.json b/docs/app/client/input/props.json index 3197e453..28433e6b 100644 --- a/docs/app/client/input/props.json +++ b/docs/app/client/input/props.json @@ -60,5 +60,46 @@ "description": "CSS classes for customization" } ] + }, + { + "name": "FileInput", + "props": [ + { + "name": "accept", + "type": ["string"], + "description": "Accepted formats. It follows same accept attribute. See Developer Mozilla docs input file #accept attribute for more information. e.g. image/*,video/*,.pdf", + "defaultState": false + }, + { + "name": "maxFileSize", + "type": ["number"], + "description": "Max file size in Bytes", + "defaultState": "100 MB" + }, + { + "name": "onFileUpload", + "type": ["(file: File) => void"], + "description": "On File upload callback. It expects only one argument of type File.", + "defaultState": false + }, + { + "name": "onFileRemove", + "type": ["() => void"], + "description": "On File remove callback", + "defaultState": false + }, + { + "name": "initFile", + "type": ["File"], + "description": "Initial file to display on the input.", + "defaultState": false + }, + { + "name": "errorMessages", + "type": ["Errors"], + "description": "Error messages to be displayed. 'maxFileSize' and 'type' only supported currently.", + "defaultState": false + } + ] } ] diff --git a/docs/app/client/insetInput/examples/TextInputTypes.tsx b/docs/app/client/insetInput/examples/TextInputTypes.tsx index fe97046e..0ff05cf6 100644 --- a/docs/app/client/insetInput/examples/TextInputTypes.tsx +++ b/docs/app/client/insetInput/examples/TextInputTypes.tsx @@ -1,43 +1,68 @@ "use client"; -import { InsetInput } from "@heathmont/moon-core-tw"; +import { InsetFileInput, InsetInput, Hint } from "@heathmont/moon-core-tw"; +import { useState } from "react"; -const TextInputTypes = () => ( - <> -
- - Number - - - Date - - - Time - -
-
- - Datetime local - - - Email - - - Password - -
-
- - Search - - - Tel - - - Url - -
- -); +const TextInputTypes = () => { + const [file, setFile] = useState(); + const fileHandler = (file: File | undefined) => { + setFile(file); + }; + + const removeFileHandler = () => { + setFile(undefined); + }; + + return ( + <> +
+ + Number + + + Date + + + Time + +
+
+ + Datetime local + + + Email + + + Password + +
+
+ + Search + + + Tel + + + Url + +
+
+
+ + {file && File uploaded: {file.name}} +
+
+ + ); +}; export default TextInputTypes; diff --git a/docs/app/client/insetInput/props.json b/docs/app/client/insetInput/props.json index ee4ea714..068de4cf 100644 --- a/docs/app/client/insetInput/props.json +++ b/docs/app/client/insetInput/props.json @@ -36,5 +36,46 @@ "description": "CSS classes for customization" } ] + }, + { + "name": "InsetFileInput", + "props": [ + { + "name": "accept", + "type": ["string"], + "description": "Accepted formats. It follows same accept attribute. See Developer Mozilla docs input file #accept attribute for more information. e.g. image/*,video/*,.pdf", + "defaultState": false + }, + { + "name": "maxFileSize", + "type": ["number"], + "description": "Max file size in Bytes", + "defaultState": "100 MB" + }, + { + "name": "onFileUpload", + "type": ["(file: File) => void"], + "description": "On File upload callback. It expects only one argument of type File.", + "defaultState": false + }, + { + "name": "onFileRemove", + "type": ["() => void"], + "description": "On File remove callback", + "defaultState": false + }, + { + "name": "initFile", + "type": ["File"], + "description": "Initial file to display on the input.", + "defaultState": false + }, + { + "name": "errorMessages", + "type": ["Errors"], + "description": "Error messages to be displayed. 'maxFileSize' and 'type' only supported currently.", + "defaultState": false + } + ] } ] diff --git a/packages/core/src/fileInput/FileInput.tsx b/packages/core/src/fileInput/FileInput.tsx new file mode 100644 index 00000000..f307a2b6 --- /dev/null +++ b/packages/core/src/fileInput/FileInput.tsx @@ -0,0 +1,52 @@ +import React, { forwardRef, memo } from "react"; +import Input from "../input/Input"; +import mergeClassnames from "../mergeClassnames/mergeClassnames"; +import FileInputProps from "./types/FileInputProps"; +import FileInputBase from "../private/components/fileInputBase/FileInputBase"; +import FileInputRef from "../private/components/fileInputBase/types/FileInputRef"; + +const FileInput = memo( + forwardRef((props, ref) => { + const { + id, + onFileUpload, + onFileRemove, + initFile, + placeholder, + className, + accept, + maxFileSize, + errorMessages, + ...rest + } = props; + + return ( + + {(file: File | undefined) => ( + + )} + + ); + }), +); + +export default FileInput; diff --git a/packages/core/src/fileInput/types/FileInputProps.ts b/packages/core/src/fileInput/types/FileInputProps.ts new file mode 100644 index 00000000..7258fe3e --- /dev/null +++ b/packages/core/src/fileInput/types/FileInputProps.ts @@ -0,0 +1,7 @@ +import InputProps from "../../input/private/types/InputProps"; +import FileInputBaseProps from "../../private/components/fileInputBase/types/FileInputBaseProps"; + +type FileInputProps = Omit & + Omit; + +export default FileInputProps; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e57044b7..6298fb50 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -86,3 +86,7 @@ export { default as Textarea } from "./textarea/Textarea"; export * from "./textarea/Textarea"; export { default as Tooltip } from "./tooltip/Tooltip"; export * from "./tooltip/Tooltip"; +export { default as FileInput } from "./fileInput/FileInput"; +export * from "./fileInput/FileInput"; +export { default as InsetFileInput } from "./insetFileInput/InsetFileInput"; +export * from "./insetFileInput/InsetFileInput"; diff --git a/packages/core/src/input/private/types/InputProps.ts b/packages/core/src/input/private/types/InputProps.ts index fbc17cf9..f0f74c40 100644 --- a/packages/core/src/input/private/types/InputProps.ts +++ b/packages/core/src/input/private/types/InputProps.ts @@ -1,7 +1,7 @@ interface InputProps extends Omit, "size"> { className?: string; - type?: React.HTMLInputTypeAttribute; + type?: Exclude; size?: "sm" | "md" | "lg"; error?: boolean; isRtl?: boolean; diff --git a/packages/core/src/insetFileInput/InsetFileInput.tsx b/packages/core/src/insetFileInput/InsetFileInput.tsx new file mode 100644 index 00000000..fff2fb73 --- /dev/null +++ b/packages/core/src/insetFileInput/InsetFileInput.tsx @@ -0,0 +1,56 @@ +import React, { forwardRef, memo } from "react"; +import InsetInput from "../insetInput/InsetInput"; +import InsetFileInputProps from "./types/InsetFileInputProps"; +import FileInputRef from "../private/components/fileInputBase/types/FileInputRef"; +import FileInputBase from "../private/components/fileInputBase/FileInputBase"; + +const InsetFileInput = memo( + forwardRef((props, ref) => { + const { + id, + accept, + maxFileSize, + initFile, + onFileUpload, + onFileRemove, + errorMessages, + label = "Choose a file", + placeholder = "No file chosen", + ...rest + } = props; + + const fileUploadHandler = (file: File | undefined) => { + onFileUpload?.(file); + }; + + const fileRemoveHandler = () => { + onFileRemove?.(); + }; + + return ( + + {(file: File | undefined) => ( + + {label} + + )} + + ); + }), +); + +export default InsetFileInput; diff --git a/packages/core/src/insetFileInput/types/InsetFileInputProps.ts b/packages/core/src/insetFileInput/types/InsetFileInputProps.ts new file mode 100644 index 00000000..a7b18e27 --- /dev/null +++ b/packages/core/src/insetFileInput/types/InsetFileInputProps.ts @@ -0,0 +1,10 @@ +import FileInputBaseProps from "../../private/components/fileInputBase/types/FileInputBaseProps"; +import InsetInputProps from "../../insetInput/private/types/InsetInputProps"; +import React from "react"; + +type InsetFileInputProps = Omit & + Omit & { + label?: React.ReactNode; + }; + +export default InsetFileInputProps; diff --git a/packages/core/src/insetInput/private/types/InsetInputProps.ts b/packages/core/src/insetInput/private/types/InsetInputProps.ts index a3c92081..5eef9076 100644 --- a/packages/core/src/insetInput/private/types/InsetInputProps.ts +++ b/packages/core/src/insetInput/private/types/InsetInputProps.ts @@ -1,6 +1,7 @@ type InsetInputProps = React.InputHTMLAttributes & { className?: string; error?: boolean; + type?: Exclude; isRtl?: boolean; // not in use isLabel?: boolean; // not in use }; diff --git a/packages/core/src/private/components/fileInputBase/FileInputBase.tsx b/packages/core/src/private/components/fileInputBase/FileInputBase.tsx new file mode 100644 index 00000000..7443468e --- /dev/null +++ b/packages/core/src/private/components/fileInputBase/FileInputBase.tsx @@ -0,0 +1,66 @@ +import React, { forwardRef, memo } from "react"; +import GenericUpload from "../../icons/GenericUpload"; +import GenericCloseSmall from "../../icons/ControlsCloseSmall"; +import mergeClassnames from "../../../mergeClassnames/mergeClassnames"; + +import useFileInput from "./hooks/useFileInput"; +import FileInputRef from "./types/FileInputRef"; +import FileInputBaseProps from "./types/FileInputBaseProps"; + +const FileInputBase = forwardRef( + (props, ref) => { + const { id, accept = "*/*", children } = props; + + const { + file, + hasErrors, + handleKeyDown, + handleFileRemove, + handleFileUpload, + inputFileRef, + errors, + } = useFileInput(props, ref); + + return ( + <> +
+
+
    + {Object.entries(errors).map(([key, value]) => ( +
  • {value}
  • + ))} +
+ + ); + }, +); + +export default FileInputBase; diff --git a/packages/core/src/private/components/fileInputBase/hooks/useFileInput.ts b/packages/core/src/private/components/fileInputBase/hooks/useFileInput.ts new file mode 100644 index 00000000..8e41ad2d --- /dev/null +++ b/packages/core/src/private/components/fileInputBase/hooks/useFileInput.ts @@ -0,0 +1,149 @@ +import React, { + ForwardedRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import createAcceptRegex from "../utils/createAcceptRegex"; +import Errors from "../types/Errors"; +import FileInputBaseProps from "../types/FileInputBaseProps"; +import FileInputRef from "../types/FileInputRef"; + +const DEFAULT_MAX_FILE_SIZE = 100 * 1014 * 1024; + +const useFileInput = ( + props: FileInputBaseProps, + ref: ForwardedRef, +) => { + const { + onFileUpload, + onFileRemove, + initFile, + accept = "*/*", + maxFileSize = DEFAULT_MAX_FILE_SIZE, + errorMessages = { + maxFileSize: "File is too large", + type: "Invalid file type", + }, + } = props; + + const [file, setFile] = useState(); + const [errors, setErrors] = React.useState({}); + const inputFileRef = useRef(null); + const acceptRegexp = useMemo( + () => (accept !== "*/*" ? createAcceptRegex(accept) : /^.*\/.*$/), + [accept], + ); + const hasErrors = Object.keys(errors).length > 0; + const fileName = file?.name || ""; + + useImperativeHandle( + ref, + () => ({ + click: () => { + inputFileRef?.current?.click(); + }, + focus: () => { + inputFileRef?.current?.focus(); + }, + }), + [], + ); + + useEffect(() => { + handleFileUpdate(initFile); + }, [initFile]); + + const clearFile = () => { + setFile(undefined); + }; + + const clearErrors = () => { + setErrors({}); + }; + + const handleFileUpload: React.ChangeEventHandler = ( + event, + ) => { + clearErrors(); + const file = event?.target?.files?.[0]; + + if (!file) { + return; + } + + handleFileUpdate(file) && onFileUpload?.(file); + }; + + const handleFileUpdate = (file: File | undefined) => { + const hasErrors = handleFileErrors(file); + + if (hasErrors) { + return false; + } + + setFile(file); + return true; + }; + + const handleFileRemove = () => { + clearFile(); + clearErrors(); + onFileRemove?.(); + }; + + const handleFileErrors = (file: File | undefined) => { + const fileErrors = errorHandling(file); + if (Object.keys(fileErrors).length) { + setErrors(fileErrors); + return true; + } + return false; + }; + + const errorHandling = (file: File | undefined) => { + const fileErrors: Errors = {}; + if (!file) { + return {}; + } + + if (maxFileSize && file.size > maxFileSize) { + fileErrors.maxFileSize = errorMessages.maxFileSize; + } + + if (accept === "*/*") { + return fileErrors; + } + + const isValidExtension = acceptRegexp.test(file.name); + const isValidMimeType = acceptRegexp.test(file.type); + + if (!isValidMimeType && !isValidExtension) { + fileErrors.type = errorMessages.type; + } + + return fileErrors; + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " " || event.key === "Space") { + event.preventDefault(); + inputFileRef?.current?.click(); + } + }; + + return { + handleFileUpload, + handleFileRemove, + handleKeyDown, + file, + errors, + inputFileRef, + hasErrors, + fileName, + }; +}; + +export default useFileInput; diff --git a/packages/core/src/private/components/fileInputBase/types/Errors.ts b/packages/core/src/private/components/fileInputBase/types/Errors.ts new file mode 100644 index 00000000..c96dd2f6 --- /dev/null +++ b/packages/core/src/private/components/fileInputBase/types/Errors.ts @@ -0,0 +1,6 @@ +type Errors = { + maxFileSize?: string; + type?: string; +}; + +export default Errors; diff --git a/packages/core/src/private/components/fileInputBase/types/FileInputBaseProps.ts b/packages/core/src/private/components/fileInputBase/types/FileInputBaseProps.ts new file mode 100644 index 00000000..e6b1c022 --- /dev/null +++ b/packages/core/src/private/components/fileInputBase/types/FileInputBaseProps.ts @@ -0,0 +1,14 @@ +import Errors from "./Errors"; + +type FileInputBaseProps = { + children?: React.ReactNode | ((file?: File) => React.ReactElement); + accept?: string; + maxFileSize?: number; + onFileUpload?: (file?: File) => void; + onFileRemove?: () => void; + initFile?: File; + errorMessages?: Errors; + id?: string; +}; + +export default FileInputBaseProps; diff --git a/packages/core/src/private/components/fileInputBase/types/FileInputRef.ts b/packages/core/src/private/components/fileInputBase/types/FileInputRef.ts new file mode 100644 index 00000000..64cc912f --- /dev/null +++ b/packages/core/src/private/components/fileInputBase/types/FileInputRef.ts @@ -0,0 +1,6 @@ +type FileInputRef = { + click: () => void; + focus: () => void; +}; + +export default FileInputRef; diff --git a/packages/core/src/private/components/fileInputBase/utils/createAcceptRegex.ts b/packages/core/src/private/components/fileInputBase/utils/createAcceptRegex.ts new file mode 100644 index 00000000..7aaf5777 --- /dev/null +++ b/packages/core/src/private/components/fileInputBase/utils/createAcceptRegex.ts @@ -0,0 +1,17 @@ +const createAcceptRegex = (accept: string) => { + const types = accept.split(",").map((type) => type.trim()); + + const regexParts = types.map((type: string) => { + if (type.includes("/*")) { + return `^${type.replace("/*", "/.+")}$`; + } else if (type.startsWith(".")) { + return `\\${type}$`; + } else { + return `^${type}$`; + } + }); + + return new RegExp(regexParts.join("|")); +}; + +export default createAcceptRegex; diff --git a/packages/core/src/private/icons/GenericUpload.tsx b/packages/core/src/private/icons/GenericUpload.tsx new file mode 100644 index 00000000..38fc1f5c --- /dev/null +++ b/packages/core/src/private/icons/GenericUpload.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +const GenericUpload = (props: React.SVGProps) => ( + + + +); + +export default GenericUpload;