diff --git a/packages/components/file-upload/README.md b/packages/components/file-upload/README.md new file mode 100644 index 0000000000..975579fedb --- /dev/null +++ b/packages/components/file-upload/README.md @@ -0,0 +1,24 @@ +# @nextui-org/file-upload + +FileUplaod adds/removes files selected by user to upload on a server. + +Please refer to the [documentation](https://nextui.org/docs/components/file-upload) for more information. + +## Installation + +```sh +yarn add @nextui-org/file-upload +# or +npm i @nextui-org/file-upload +``` + +## Contribution + +Yes please! See the +[contributing guidelines](https://github.com/nextui-org/nextui/blob/master/CONTRIBUTING.md) +for details. + +## License + +This project is licensed under the terms of the +[MIT license](https://github.com/nextui-org/nextui/blob/master/LICENSE). diff --git a/packages/components/file-upload/__tests__/file-upload.test.tsx b/packages/components/file-upload/__tests__/file-upload.test.tsx new file mode 100644 index 0000000000..60ffe653a4 --- /dev/null +++ b/packages/components/file-upload/__tests__/file-upload.test.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import {render} from "@testing-library/react"; + +import {FileUpload} from "../src"; + +describe("FileUpload", () => { + it("should render correctly", () => { + const wrapper = render(); + + expect(() => wrapper.unmount()).not.toThrow(); + }); + + it("ref should be forwarded", () => { + const ref = React.createRef(); + + render(); + expect(ref.current).not.toBeNull(); + }); +}); diff --git a/packages/components/file-upload/package.json b/packages/components/file-upload/package.json new file mode 100644 index 0000000000..81098b49a9 --- /dev/null +++ b/packages/components/file-upload/package.json @@ -0,0 +1,56 @@ +{ + "name": "@nextui-org/file-upload", + "version": "2.0.0", + "description": "FileUplaod adds/removes files selected by user to upload on a server.", + "keywords": [ + "file-upload" + ], + "author": "Junior Garcia ", + "homepage": "https://nextui.org", + "license": "MIT", + "main": "src/index.ts", + "sideEffects": false, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nextui-org/nextui.git", + "directory": "packages/components/file-upload" + }, + "bugs": { + "url": "https://github.com/nextui-org/nextui/issues" + }, + "scripts": { + "build": "tsup src --dts", + "build:fast": "tsup src", + "dev": "pnpm build:fast --watch", + "clean": "rimraf dist .turbo", + "typecheck": "tsc --noEmit", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18", + "@nextui-org/theme": ">=2.0.0", + "@nextui-org/system": ">=2.0.0" + }, + "dependencies": { + "@nextui-org/shared-utils": "workspace:*", + "@nextui-org/react-utils": "workspace:*", + "@nextui-org/button": "workspace:*", + "@nextui-org/shared-icons": "workspace:*" + }, + "devDependencies": { + "@nextui-org/theme": "workspace:*", + "@nextui-org/system": "workspace:*", + "clean-package": "2.2.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "clean-package": "../../../clean-package.config.json" +} diff --git a/packages/components/file-upload/src/file-upload-item.tsx b/packages/components/file-upload/src/file-upload-item.tsx new file mode 100644 index 0000000000..8d733bed06 --- /dev/null +++ b/packages/components/file-upload/src/file-upload-item.tsx @@ -0,0 +1,25 @@ +import {Button} from "@nextui-org/button"; +import {HTMLNextUIProps} from "@nextui-org/system"; +import {CloseIcon} from "@nextui-org/shared-icons"; + +export interface FileUploadItemProps extends HTMLNextUIProps<"div"> { + file: File; + onFileRemove: (name: string) => void; +} + +export const FileUploadItem: React.FC = ({ + file, + onFileRemove, + ...otherProps +}) => { + return ( +
+ + {file.name} + {file.size} + {file.type} +
+ ); +}; diff --git a/packages/components/file-upload/src/file-upload.tsx b/packages/components/file-upload/src/file-upload.tsx new file mode 100644 index 0000000000..6468cd5c46 --- /dev/null +++ b/packages/components/file-upload/src/file-upload.tsx @@ -0,0 +1,213 @@ +import {forwardRef} from "@nextui-org/system"; +import {Button} from "@nextui-org/button"; +import {cloneElement, useCallback, useEffect, useMemo, useRef, useState} from "react"; + +import {UseFileUploadProps, useFileUpload} from "./use-file-upload"; +import {FileUploadItem} from "./file-upload-item"; + +export interface FileUploadProps extends UseFileUploadProps {} + +const FileUpload = forwardRef<"div", FileUploadProps>((props, ref) => { + const { + Component, + domRef, + children, + files: initialFiles, + styles, + maxItems, + maxItemsText, + maxItemsElement, + maxAllowedSize, + maxAllowedSizeText, + maxAllowedSizeElement, + totalMaxAllowedSize, + totalMaxAllowedSizeText, + totalMaxAllowedSizeElement, + browseButton, + browseButtonText, + addButton, + resetButton, + uploadButton, + fileItemElement, + onChange, + ...otherProps + } = useFileUpload({...props, ref}); + + const inputFileRef = useRef(null); + const singleInputFileRef = useRef(null); + const [files, setFiles] = useState(initialFiles ?? []); + + useEffect(() => { + initialFiles && setFiles(initialFiles); + }, [initialFiles]); + + const browseButtonElement = useMemo( + () => + browseButton ? ( + cloneElement(browseButton, { + onClick: () => { + if (props.isDisabled) return; + inputFileRef.current?.click(); + browseButton.props.onClick?.(); + }, + }) + ) : ( + + ), + [browseButton, browseButtonText, props.isDisabled], + ); + + const updateFiles = useCallback( + (files: File[]) => { + setFiles(files); + onChange?.(files); + // Setting input values to "" in order to ignore previously-selected file(s). + // This will fix some bugs when "removing" and re-adding "the exact same" file(s) (e.g. removing foo.txt and adding foo.txt again). + if (inputFileRef.current) inputFileRef.current.value = ""; + if (singleInputFileRef.current) singleInputFileRef.current.value = ""; + }, + [setFiles, onChange], + ); + + const items = useMemo( + () => + files.map((file) => { + const customFileElm = fileItemElement?.(file); + + if (!customFileElm) { + return ( + { + const newFiles = files.filter((file) => file.name !== name); + + updateFiles(newFiles); + }} + /> + ); + } + + return cloneElement(customFileElm, { + key: file.name, + }); + }), + [files], + ); + + const addButtonElement = useMemo( + () => + addButton ? ( + cloneElement(addButton, { + onClick: () => { + singleInputFileRef.current?.click(); + addButton.props.onClick?.(); + }, + }) + ) : ( + + ), + [addButton], + ); + + const resetButtonElement = useMemo( + () => + resetButton ? ( + cloneElement(resetButton, { + onClick: () => { + updateFiles([]); + resetButton.props.onClick?.(); + }, + }) + ) : ( + + ), + [resetButton, setFiles, updateFiles], + ); + + return ( + + 1} + title="file upload" + type="file" + onChange={(ev) => { + if (!ev.target.files?.length) return; + const length = ev.target.files.length > maxItems ? maxItems : ev.target.files.length; + const newFiles: File[] = []; + + for (let index = 0; index < length; index++) { + const file = ev.target.files.item(index); + + file && newFiles.push(file); + } + updateFiles(newFiles); + }} + /> + + { + const singleFile = ev.target.files?.item(0); + + if (!singleFile) return; + if (files.find((file) => file.name === singleFile.name)) return; + files.push(singleFile); + updateFiles([...files]); + }} + /> + +
+ {maxItems > 1 && + (maxItemsElement ?? ( + + {maxItemsText}: {maxItems} + + ))} + {maxAllowedSize && + (maxAllowedSizeElement ?? ( + + {maxAllowedSizeText}: {maxAllowedSize} + + ))} + {totalMaxAllowedSize && + (totalMaxAllowedSizeElement ?? ( + + {totalMaxAllowedSizeText}: {totalMaxAllowedSize} + + ))} +
+ +
+ {children} + {items} +
+
+ {maxItems > 1 && files.length !== 0 && files.length < maxItems && addButtonElement} + {files.length !== 0 && resetButtonElement} + {browseButtonElement} + {uploadButton} +
+
+ ); +}); + +FileUpload.displayName = "NextUI.FileUpload"; + +export default FileUpload; diff --git a/packages/components/file-upload/src/index.ts b/packages/components/file-upload/src/index.ts new file mode 100644 index 0000000000..196571682d --- /dev/null +++ b/packages/components/file-upload/src/index.ts @@ -0,0 +1,10 @@ +import FileUpload from "./file-upload"; + +// export types +export type {FileUploadProps} from "./file-upload"; + +// export hooks +export {useFileUpload} from "./use-file-upload"; + +// export component +export {FileUpload}; diff --git a/packages/components/file-upload/src/use-file-upload-item.ts b/packages/components/file-upload/src/use-file-upload-item.ts new file mode 100644 index 0000000000..3f5427bd68 --- /dev/null +++ b/packages/components/file-upload/src/use-file-upload-item.ts @@ -0,0 +1,39 @@ +import type {FileUploadVariantProps} from "@nextui-org/theme"; + +import {HTMLNextUIProps, mapPropsVariants} from "@nextui-org/system"; +import {fileUpload} from "@nextui-org/theme"; +import {ReactRef, useDOMRef} from "@nextui-org/react-utils"; +import {objectToDeps} from "@nextui-org/shared-utils"; +import {useMemo} from "react"; + +interface Props extends HTMLNextUIProps<"div"> { + /** + * Ref to the DOM node. + */ + ref?: ReactRef; +} + +export type UseFileUploadItemProps = Props & FileUploadVariantProps; + +export function useFileUpload(originalProps: UseFileUploadItemProps) { + const [props, variantProps] = mapPropsVariants(originalProps, fileUpload.variantKeys); + + const {ref, as, className, ...otherProps} = props; + + const Component = as || "div"; + + const domRef = useDOMRef(ref); + + const styles = useMemo( + () => + fileUpload({ + ...variantProps, + className, + }), + [objectToDeps(variantProps), className], + ); + + return {Component, styles, domRef, ...otherProps}; +} + +export type UseFileUploadReturn = ReturnType; diff --git a/packages/components/file-upload/src/use-file-upload.ts b/packages/components/file-upload/src/use-file-upload.ts new file mode 100644 index 0000000000..82a7b4dd40 --- /dev/null +++ b/packages/components/file-upload/src/use-file-upload.ts @@ -0,0 +1,133 @@ +import type {FileUploadVariantProps} from "@nextui-org/theme"; + +import {HTMLNextUIProps, mapPropsVariants} from "@nextui-org/system"; +import {fileUpload} from "@nextui-org/theme"; +import {ReactRef, useDOMRef} from "@nextui-org/react-utils"; +import {objectToDeps} from "@nextui-org/shared-utils"; +import {ReactElement, useMemo} from "react"; + +type FileSize = `${number} KB` | `${number} MB`; + +interface Props extends Omit, "onChange"> { + /** + * Ref to the DOM node. + */ + ref?: ReactRef; + /** + * A property to set initial files (which might be fetched) or to control files from outside of the component. + */ + files?: File[]; + /** + * If a different browse button is needed. + */ + browseButton?: ReactElement; + /** + * A different text for the browse button. + */ + browseButtonText?: string; + /** + * Custom Add Button. + */ + addButton?: ReactElement; + /** + * Custom Reset Button. + */ + resetButton?: ReactElement; + /** + * If an uplaod button is needed. + */ + uploadButton?: ReactElement; + /** + * Max number of items. + * @default 1 + */ + maxItems: number; + /** + * Max number of items text. + * @default "Max number of items" + */ + maxItemsText?: string; + /** + * Custom Element to show Max number of items. + */ + maxItemsElement?: ReactElement; + /** + * Max file size allowed. + */ + maxAllowedSize?: FileSize; + /** + * Max file size text. + * @default "Max File Size" + */ + maxAllowedSizeText?: string; + /** + * Custom Element to show Max file size. + */ + maxAllowedSizeElement?: ReactElement; + /** + * Total max size allowed for multiple files combined. + */ + totalMaxAllowedSize?: FileSize; + /** + * Total max file size text. + * @default "Total Max Files Size" + */ + totalMaxAllowedSizeText?: string; + /** + * Custom Element to show Total Max file size. + */ + totalMaxAllowedSizeElement?: ReactElement; + /** + * Custom Element for an Upload File Item. + */ + fileItemElement?: (file: File) => ReactElement; + /** + * Triggered when file(s) selected, added or removed. + */ + onChange?: (files: File[]) => void; +} + +export type UseFileUploadProps = Props & FileUploadVariantProps; + +export function useFileUpload(originalProps: UseFileUploadProps) { + const [props, variantProps] = mapPropsVariants(originalProps, fileUpload.variantKeys); + + const { + ref, + as, + className, + maxItems, + maxItemsText = "Max number of items", + maxAllowedSizeText = "Max File Size", + totalMaxAllowedSizeText = "Total Max Files Size", + browseButtonText = "Browse", + ...otherProps + } = props; + + const Component = as || "div"; + + const domRef = useDOMRef(ref); + + const styles = useMemo( + () => + fileUpload({ + ...variantProps, + className, + }), + [objectToDeps(variantProps), className], + ); + + return { + Component, + styles, + domRef, + maxItems, + maxItemsText, + maxAllowedSizeText, + totalMaxAllowedSizeText, + browseButtonText, + ...otherProps, + }; +} + +export type UseFileUploadReturn = ReturnType; diff --git a/packages/components/file-upload/stories/file-upload.stories.tsx b/packages/components/file-upload/stories/file-upload.stories.tsx new file mode 100644 index 0000000000..262847e74e --- /dev/null +++ b/packages/components/file-upload/stories/file-upload.stories.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import {Meta} from "@storybook/react"; +import {fileUpload} from "@nextui-org/theme"; + +import {FileUpload, FileUploadProps} from "../src"; + +export default { + title: "Components/FileUpload", + component: FileUpload, + argTypes: { + color: { + control: {type: "select"}, + options: ["default", "primary", "secondary", "success", "warning", "danger"], + }, + size: { + control: {type: "select"}, + options: ["sm", "md", "lg"], + }, + isDisabled: { + control: { + type: "boolean", + }, + }, + }, +} as Meta; + +const defaultProps = { + maxItems: 2, + maxItemsText: "Max Num of Items", + maxAllowedSize: "250 KB", + maxAllowedSizeText: "File Size for Each File", + totalMaxAllowedSize: "2 MB", + totalMaxAllowedSizeText: "Total Max", + ...fileUpload.defaultVariants, +}; + +const Template = (args: FileUploadProps) => ( + { + // console.log(files); + // }} + /> +); + +export const Default = { + render: Template, + args: { + ...defaultProps, + }, +}; diff --git a/packages/components/file-upload/tsconfig.json b/packages/components/file-upload/tsconfig.json new file mode 100644 index 0000000000..5d012f6e61 --- /dev/null +++ b/packages/components/file-upload/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "tailwind-variants": ["../../../node_modules/tailwind-variants"] + }, + }, + "include": ["src", "index.ts"] +} diff --git a/packages/components/file-upload/tsup.config.ts b/packages/components/file-upload/tsup.config.ts new file mode 100644 index 0000000000..3e2bcff6cc --- /dev/null +++ b/packages/components/file-upload/tsup.config.ts @@ -0,0 +1,8 @@ +import {defineConfig} from "tsup"; + +export default defineConfig({ + clean: true, + target: "es2019", + format: ["cjs", "esm"], + banner: {js: '"use client";'}, +}); diff --git a/packages/core/theme/src/components/file-upload.ts b/packages/core/theme/src/components/file-upload.ts new file mode 100644 index 0000000000..c3c483d351 --- /dev/null +++ b/packages/core/theme/src/components/file-upload.ts @@ -0,0 +1,71 @@ +import type {VariantProps} from "tailwind-variants"; + +import {tv} from "../utils/tv"; +import {dataFocusVisibleClasses} from "../utils"; + +/** + * Card **Tailwind Variants** component + * + * @example + * ```js + * const {base, topBar, items, buttons} = fileUpload({...}) + * + *
+ *
Top Bar
+ *
Items
+ *
Buttons
+ *
+ * ``` + */ +const fileUpload = tv({ + slots: { + base: [ + "flex", + "flex-col", + "relative", + "overflow-hidden", + "h-auto", + "outline-none", + "text-foreground", + "box-border", + "bg-content1", + ...dataFocusVisibleClasses, + ], + topBar: ["flex", "gap-3"], + items: [ + "relative", + "p-3", + "break-words", + "text-start", + "overflow-y-auto", + "subpixel-antialiased", + ], + buttons: [ + "flex", + "gap-3", + "p-3", + "w-full", + "items-center", + "overflow-hidden", + "color-inherit", + "subpixel-antialiased", + ], + }, + variants: { + isDisabled: { + true: { + base: "opacity-disabled cursor-not-allowed", + }, + }, + }, + compoundVariants: [], + defaultVariants: { + isDisabled: false, + }, +}); + +export type FileUploadVariantProps = VariantProps; +export type FileUploadSlots = keyof ReturnType; +export type FileUploadReturnType = ReturnType; + +export {fileUpload}; diff --git a/packages/core/theme/src/components/index.ts b/packages/core/theme/src/components/index.ts index 7776b24c52..2335079c54 100644 --- a/packages/core/theme/src/components/index.ts +++ b/packages/core/theme/src/components/index.ts @@ -38,3 +38,4 @@ export * from "./autocomplete"; export * from "./calendar"; export * from "./date-input"; export * from "./date-picker"; +export * from "./file-upload"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b51699d2f0..e05143e667 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1499,6 +1499,37 @@ importers: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + packages/components/file-upload: + dependencies: + '@nextui-org/button': + specifier: workspace:* + version: link:../button + '@nextui-org/react-utils': + specifier: workspace:* + version: link:../../utilities/react-utils + '@nextui-org/shared-icons': + specifier: workspace:* + version: link:../../utilities/shared-icons + '@nextui-org/shared-utils': + specifier: workspace:* + version: link:../../utilities/shared-utils + devDependencies: + '@nextui-org/system': + specifier: workspace:* + version: link:../../core/system + '@nextui-org/theme': + specifier: workspace:* + version: link:../../core/theme + clean-package: + specifier: 2.2.0 + version: 2.2.0 + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + packages/components/image: dependencies: '@nextui-org/react-utils': @@ -18101,13 +18132,15 @@ snapshots: transitivePeerDependencies: - '@parcel/core' - '@parcel/cache@2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))': + '@parcel/cache@2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9)': dependencies: '@parcel/core': 2.12.0(@swc/helpers@0.5.9) '@parcel/fs': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9) '@parcel/logger': 2.12.0 '@parcel/utils': 2.12.0 lmdb: 2.8.5 + transitivePeerDependencies: + - '@swc/helpers' '@parcel/codeframe@2.12.0': dependencies: @@ -18167,7 +18200,7 @@ snapshots: '@parcel/core@2.12.0(@swc/helpers@0.5.9)': dependencies: '@mischnic/json-sourcemap': 0.1.1 - '@parcel/cache': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9)) + '@parcel/cache': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9) '@parcel/diagnostic': 2.12.0 '@parcel/events': 2.12.0 '@parcel/fs': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9) @@ -18582,7 +18615,7 @@ snapshots: '@parcel/types@2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9)': dependencies: - '@parcel/cache': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9)) + '@parcel/cache': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9) '@parcel/diagnostic': 2.12.0 '@parcel/fs': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9) '@parcel/package-manager': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9)