-
-
+
-
-
-
+
+
+
+
+ {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 (
+ <>
+
+
+ {typeof children === "function" ? children(file) : children}
+ {!file && (
+
+ )}
+ {file && (
+
+ )}
+
+
+
+ {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;