Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(file-upload) #3832

Draft
wants to merge 4 commits into
base: canary
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/components/file-upload/README.md
Original file line number Diff line number Diff line change
@@ -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).
19 changes: 19 additions & 0 deletions packages/components/file-upload/__tests__/file-upload.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<FileUpload />);

expect(() => wrapper.unmount()).not.toThrow();
});

it("ref should be forwarded", () => {
const ref = React.createRef<HTMLDivElement>();

render(<FileUpload ref={ref} />);
expect(ref.current).not.toBeNull();
});
});
56 changes: 56 additions & 0 deletions packages/components/file-upload/package.json
Original file line number Diff line number Diff line change
@@ -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 <jrgarciadev@gmail.com>",
"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"
}
25 changes: 25 additions & 0 deletions packages/components/file-upload/src/file-upload-item.tsx
Original file line number Diff line number Diff line change
@@ -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<FileUploadItemProps> = ({
file,
onFileRemove,
...otherProps
}) => {
return (
<div className="flex gap-4 my-4" {...otherProps}>
<Button onClick={() => onFileRemove(file.name)}>
<CloseIcon />
</Button>
<span>{file.name}</span>
<span>{file.size}</span>
<span>{file.type}</span>
</div>
);
};
213 changes: 213 additions & 0 deletions packages/components/file-upload/src/file-upload.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null);
const singleInputFileRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<File[]>(initialFiles ?? []);

useEffect(() => {
initialFiles && setFiles(initialFiles);
}, [initialFiles]);

const browseButtonElement = useMemo(
() =>
browseButton ? (
cloneElement(browseButton, {
onClick: () => {
if (props.isDisabled) return;
inputFileRef.current?.click();
browseButton.props.onClick?.();
},
})
) : (
<Button disabled={props.isDisabled} onClick={() => inputFileRef.current?.click()}>
{browseButtonText}
</Button>
),
[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 (
<FileUploadItem
key={file.name}
file={file}
onFileRemove={(name) => {
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?.();
},
})
) : (
<Button color="secondary" onClick={() => singleInputFileRef.current?.click()}>
Add
</Button>
),
[addButton],
);

const resetButtonElement = useMemo(
() =>
resetButton ? (
cloneElement(resetButton, {
onClick: () => {
updateFiles([]);
resetButton.props.onClick?.();
},
})
) : (
<Button
color="warning"
onClick={() => {
updateFiles([]);
}}
>
Reset
</Button>
),
[resetButton, setFiles, updateFiles],
);

return (
<Component ref={domRef} className={styles.base()} {...otherProps}>
<input
ref={inputFileRef}
className="hidden"
multiple={maxItems > 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);
}}
/>

<input
ref={singleInputFileRef}
className="hidden"
title="single file upload"
type="file"
onChange={(ev) => {
const singleFile = ev.target.files?.item(0);

if (!singleFile) return;
if (files.find((file) => file.name === singleFile.name)) return;
files.push(singleFile);
updateFiles([...files]);
}}
/>

<div className={styles.topBar()}>
{maxItems > 1 &&
(maxItemsElement ?? (
<span>
{maxItemsText}: {maxItems}
</span>
))}
{maxAllowedSize &&
(maxAllowedSizeElement ?? (
<span>
{maxAllowedSizeText}: {maxAllowedSize}
</span>
))}
{totalMaxAllowedSize &&
(totalMaxAllowedSizeElement ?? (
<span>
{totalMaxAllowedSizeText}: {totalMaxAllowedSize}
</span>
))}
</div>

<div className={styles.items()}>
{children}
{items}
</div>
<div className={styles.buttons()}>
{maxItems > 1 && files.length !== 0 && files.length < maxItems && addButtonElement}
{files.length !== 0 && resetButtonElement}
{browseButtonElement}
{uploadButton}
</div>
</Component>
);
});

FileUpload.displayName = "NextUI.FileUpload";

export default FileUpload;
10 changes: 10 additions & 0 deletions packages/components/file-upload/src/index.ts
Original file line number Diff line number Diff line change
@@ -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};
39 changes: 39 additions & 0 deletions packages/components/file-upload/src/use-file-upload-item.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>;
}

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<typeof useFileUpload>;
Loading