├── .eslintrc.cjs ├── .github ├── ISSUE_TEMPLATE │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── codecov.yaml │ └── netlify-deploy.yaml ├── .gitignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── react-files-preview.png ├── src ├── App.tsx ├── components │ ├── FileFooter.tsx │ ├── FilePreview.tsx │ ├── FilePreviewStyle.tsx │ ├── Header.tsx │ ├── ImageSlider.tsx │ ├── Main.tsx │ ├── ReactFilesPreview.tsx │ ├── SlideCount.tsx │ ├── index.ts │ ├── interface.ts │ ├── style.css │ └── types.ts ├── context │ └── FileContext.tsx ├── index.tsx ├── main.tsx ├── tests │ ├── FileFooter.test.tsx │ ├── FilePreview.test.tsx │ ├── ImageSlider.test.tsx │ ├── Main.test.tsx │ ├── SlideCount.test.tsx │ └── setup.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vitest.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2020: true, 5 | }, 6 | extends: [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:react-hooks/recommended", 10 | ], 11 | parser: "@typescript-eslint/parser", 12 | parserOptions: { 13 | ecmaVersion: "latest", 14 | sourceType: "module", 15 | }, 16 | plugins: ["react-refresh"], 17 | rules: { 18 | "react-refresh/only-export-components": "warn", 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## What type of PR is this? (check all applicable) 2 | 3 | - [ ] Refactor 4 | - [ ] Feature 5 | - [ ] Bug Fix 6 | - [ ] Optimization 7 | - [ ] Documentation Update 8 | 9 | ## Description 10 | 11 | ## Related Tickets & Documents 12 | 13 | 21 | 22 | - Related Issue # 23 | - Closes # 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yaml: -------------------------------------------------------------------------------- 1 | name: Codecov 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | codecov: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 16 20 | 21 | - name: Install dependencies 22 | run: npm install 23 | 24 | - name: Run tests 25 | run: npm run coverage 26 | 27 | - name: Upload coverage report to Codecov 28 | uses: codecov/codecov-action@v3 29 | with: 30 | token: ${{ secrets.CODECOV_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/netlify-deploy.yaml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: Publish on Netlify 3 | jobs: 4 | publish: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - uses: actions/checkout@master 9 | 10 | - name: Publish 11 | uses: netlify/actions/cli@master 12 | with: 13 | args: deploy --dir=site --functions=functions 14 | env: 15 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 16 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | coverage 27 | storybook-static 28 | analyse.html -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": true, 5 | "singleQuote": false, 6 | "endOfLine": "auto" 7 | } 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | Want to contribute to this project? There are few things you may need to know. 4 | 5 | Here is the [open source contribution guide](https://github.com/firstcontributions/first-contributions/blob/main/README.md). 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 musama 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-files-preview 2 | 3 | ![react-files-preview](https://github.com/musama619/react-files-preview/blob/main/react-files-preview.png?raw=true) 4 | 5 |

6 | 7 | NPM Version 8 | 9 | 10 | NPM Downloads 11 | 12 | 13 | License 14 | 15 | 16 | CodeQL 17 | 18 |

19 | 20 | A versatile React component to display and manage file previews, supporting various customization options. 21 | 22 | ## 📋 Table of Contents 23 | 24 | - [Features](#-features) 25 | - [Installation](#-installation) 26 | - [Basic Usage](#-basic-usage) 27 | - [Live Demo](#-live-demo) 28 | - [Configuration Options](#️-configuration-options) 29 | - [Contributing](#-contributing) 30 | - [License](#-license) 31 | 32 | ## ✨ Features 33 | 34 | - 🖼️ **Visual File Representation:** Displays previews for various image file types. 35 | - ✏️ **Integrated Image Editing:** Allows users to edit images using the features of `react-photo-editor` (brightness, contrast, rotate, flip, draw, etc.). 36 | - 📤 **Drag and Drop Support:** Allows users to easily add files by dragging and dropping. 37 | - 🖱️ **Click to Browse:** Enables file selection through a standard file input dialog. 38 | - 🗑️ **Remove Files:** Option to display a remove icon for individual files. 39 | - ⬇️ **Download Files:** Functionality to enable downloading of displayed files. 40 | - 🔢 **Slider Count:** Shows the current slide number and total count for image sliders. 41 | - 📏 **File Size Display:** Option to show the size of each file. 42 | - ⚙️ **Customizable Styling:** Offers props for adjusting width, height, and rounded corners using Tailwind CSS classes. 43 | - 🚫 **Disable Input:** Option to disable file selection. 44 | - 📄 **Accept Specific Types:** Control which file types are accepted. 45 | - 🔢 **Maximum File Limits:** Set constraints on the number and size of files. 46 | - 🔄 **Controlled Component:** Accepts an array of `files` as a prop for controlled behavior. 47 | - 👂 **Event Callbacks:** Provides callbacks for `onChange`, `onRemove`, `onError`, `onClick`, and `onDrop`. 48 | 49 | ## 📦 Installation 50 | 51 | ```bash 52 | # Using npm 53 | npm install react-files-preview 54 | 55 | # Using yarn 56 | yarn add react-files-preview 57 | 58 | ``` 59 | 60 | ## 🚀 Basic Usage 61 | 62 | ```jsx 63 | import React, { useState } from "react"; 64 | import { ReactFilesPreview } from "./components/ReactFilesPreview"; 65 | 66 | function App() { 67 | const [files, setFiles] = useState([]); 68 | 69 | const handleFileChange = (event: React.ChangeEvent) => { 70 | const newFiles = Array.from(event.target.files || []); 71 | setFiles((prevFiles) => [...prevFiles, ...newFiles]); 72 | console.log("Selected files:", newFiles); 73 | console.log("All files:", [...files, ...newFiles]); 74 | }; 75 | 76 | const handleFileRemove = (removedFile: File) => { 77 | setFiles((prevFiles) => prevFiles.filter((file) => file !== removedFile)); 78 | console.log("Removed file:", removedFile); 79 | }; 80 | 81 | return ( 82 |
83 | 90 |
91 | ); 92 | } 93 | 94 | export default App; 95 | ``` 96 | 97 | ## 📱 Live Demo 98 | 99 | See it in action on [Stackblitz](https://stackblitz.com/edit/vitejs-vite-xjck27?file=src%2FApp.tsx) 100 | 101 | ## Props 102 | 103 | | Name | Type | Default | Description | 104 | | --------------------- | ------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | 105 | | **`files`** | File[] | [] | Pass array of file objects for default files | 106 | | **`url`** | string | null | Set image by passing image URL | 107 | | **`downloadFile`** | boolean | true | Enables file download | 108 | | **`removeFile`** | boolean | true | Show file remove icon on file hover | 109 | | **`showFileSize`** | boolean | true | Show file size under files | 110 | | **`showSliderCount`** | boolean | true | Show slides count under file slider | 111 | | **`disabled`** | boolean | false | If true, prevents user to add files by disabling the component | 112 | | **`multiple`** | boolean | true | Accepts one or more files | 113 | | **`accept`** | string | | Comma-separated lists of file types. See [MIME Types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types) | 114 | | **`maxFileSize`** | number | | Maximum allowed file size in bytes _e.g. 1024 x 1024 x 5 == 5MB_ | 115 | | **`maxFiles`** | number | | Maximum files allowed to be added | 116 | | **`width`** | string | rfp-basis-11/12 | Tailwind CSS **flex-basis** class https://tailwindcss.com/docs/flex-basis | 117 | | **`height`** | string | | Tailwind CSS **height** class https://tailwindcss.com/docs/height | 118 | | **`fileWidth`** | string | rfp-w-44 | Tailwind CSS **width** class https://tailwindcss.com/docs/width | 119 | | **`fileHeight`** | string | rfp-h-32 | Tailwind CSS **height** class https://tailwindcss.com/docs/height | 120 | | **`getFile`** | func | | Returns all current files | 121 | | **`onChange`** | func | | Returns selected file(s) | 122 | | **`onRemove`** | func | | Returns the removed file | 123 | | **`onError`** | func | | Returns error message as string | 124 | | **`onClick`** | func | | Returns file on click | 125 | 126 | ## 🤝 Contributing 127 | 128 | Contributions to `react-files-preview` are welcome! If you have any issues, feature requests, or improvements, please open an issue or submit a pull request on the [GitHub repository](https://github.com/musama619/react-files-preview). 129 | 130 | ### How to Contribute 131 | 132 | 1. Fork the repository 133 | 2. Create your feature branch: `git checkout -b feature/amazing-feature` 134 | 3. Commit your changes: `git commit -m 'Add some amazing feature'` 135 | 4. Push to the branch: `git push origin feature/amazing-feature` 136 | 5. Open a pull request 137 | 138 | ### Reporting Issues 139 | 140 | When reporting issues, please provide: 141 | 142 | - A clear description of the problem 143 | - Steps to reproduce 144 | - Expected vs actual behavior 145 | - Screenshots if applicable 146 | - Environment details (browser, OS, etc.) 147 | 148 | ## 📄 License 149 | 150 | This project is licensed under the MIT License - see the [LICENSE](https://github.com/musama619/react-files-preview/blob/main/LICENSE) file for details. 151 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you have encountered a potential security vulnerability in this project, 6 | please report by creating issue or pull request. We will work with you to 7 | verify the vulnerability and patch it. 8 | 9 | When reporting issues, please provide the following information: 10 | 11 | - Component(s) affected 12 | - A description indicating how to reproduce the issue 13 | - A summary of the security vulnerability and impact 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React File Preview 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-files-preview", 3 | "description": "A React file preview component with built-in support for image editing, image carousel/slider, and download functionality, making it easy to display and manage files in your application", 4 | "private": false, 5 | "version": "3.0.0", 6 | "author": "Usama Chouhan (musama619)", 7 | "license": "MIT", 8 | "type": "module", 9 | "homepage": "https://github.com/musama619/react-files-preview", 10 | "bugs": { 11 | "url": "https://github.com/musama619/react-files-preview/issues" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/musama619/react-files-preview.git" 16 | }, 17 | "module": "./dist/react-files-preview.es.js", 18 | "types": "./dist/index.d.ts", 19 | "exports": { 20 | ".": { 21 | "import": "./dist/react-files-preview.es.js", 22 | "types": "./dist/index.d.ts" 23 | } 24 | }, 25 | "keywords": [ 26 | "react", 27 | "file preview", 28 | "react file previewer", 29 | "react images", 30 | "react image viewer", 31 | "image viewer", 32 | "react image editor", 33 | "image editor", 34 | "react image carousel", 35 | "image carousel", 36 | "carousel", 37 | "react image slider", 38 | "image slider", 39 | "file viewer", 40 | "react-file-view", 41 | "react file viewer", 42 | "react file preview", 43 | "react files" 44 | ], 45 | "files": [ 46 | "dist", 47 | "README.md", 48 | "LICENSE" 49 | ], 50 | "publishConfig": { 51 | "access": "public", 52 | "registry": "https://registry.npmjs.org/" 53 | }, 54 | "scripts": { 55 | "dev": "vite", 56 | "build": "tsc && vite build", 57 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 58 | "preview": "vite preview", 59 | "test": "vitest", 60 | "coverage": "vitest run --coverage", 61 | "prepare": "npm run build" 62 | }, 63 | "peerDependencies": { 64 | "react": ">=16.2.0", 65 | "react-dom": ">=16.2.0" 66 | }, 67 | "devDependencies": { 68 | "@testing-library/jest-dom": "^5.16.5", 69 | "@testing-library/react": "^14.0.0", 70 | "@types/react": "^18.0.37", 71 | "@types/react-dom": "^18.0.11", 72 | "@typescript-eslint/eslint-plugin": "^6.5.0", 73 | "@vitejs/plugin-react": "^4.3.4", 74 | "@vitest/coverage-v8": "^3.1.1", 75 | "autoprefixer": "^10.4.14", 76 | "eslint": "^8.38.0", 77 | "eslint-plugin-react": "^7.32.2", 78 | "eslint-plugin-react-hooks": "^4.6.0", 79 | "eslint-plugin-react-refresh": "^0.4.3", 80 | "jsdom": "^24.1.0", 81 | "path": "^0.12.7", 82 | "postcss": "^8.4.24", 83 | "prettier": "^3.3.2", 84 | "prop-types": "^15.8.1", 85 | "react": "^18.2.0", 86 | "react-dom": "^18.2.0", 87 | "tailwindcss": "^3.4.4", 88 | "vite": "^6.2.5", 89 | "vite-plugin-dts": "^4.5.3", 90 | "vite-plugin-lib-inject-css": "^2.2.1", 91 | "vite-plugin-svgr": "^4.3.0", 92 | "vitest": "^3.1.1" 93 | }, 94 | "dependencies": { 95 | "react-photo-editor": "^3.0.0" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /react-files-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musama619/react-files-preview/9d54219b13c705a0bdb0bfa5d0da7b912619f93c/react-files-preview.png -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { ReactFilesPreview } from "./components/ReactFilesPreview"; 2 | 3 | function App() { 4 | return ( 5 |
6 | console.log("onChange", f.target.files)} 9 | onDrop={(f) => console.log("onDrop", f.dataTransfer.files)} 10 | getFiles={(f) => console.log("getFiles", f)} 11 | // disabled={true} 12 | /> 13 |
14 | ); 15 | } 16 | 17 | export default App; 18 | -------------------------------------------------------------------------------- /src/components/FileFooter.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | import { FileFooterProps } from "./types"; 3 | import { FileContext } from "../context/FileContext"; 4 | const FileFooter: React.FC = ({ file, fileSrc, index, isImage }) => { 5 | const [fileSize, setFileSize] = useState(null); 6 | 7 | useEffect(() => { 8 | if (file.size < 1000000) { 9 | setFileSize(Math.floor(file.size / 1000) + " KB"); 10 | } else { 11 | setFileSize(Math.floor(file.size / 1000000) + " MB"); 12 | } 13 | }, [file]); 14 | 15 | const nameArray = file.name.split("."); 16 | let fileName = nameArray[0]; 17 | const extension = nameArray.pop(); 18 | if (fileName.length > 20) { 19 | fileName = 20 | fileName.substring(0, 5) + ".." + fileName.substring(fileName.length - 3, fileName.length); 21 | } 22 | const result = fileName + "." + extension; 23 | const componentState = useContext(FileContext).state.componentState; 24 | const { dispatch } = useContext(FileContext); 25 | 26 | const setIsEditing = () => { 27 | dispatch({ 28 | type: "SET_IMAGE_EDITOR_DATA", 29 | payload: { 30 | isEditing: true, 31 | file: file, 32 | index: index, 33 | }, 34 | }); 35 | }; 36 | return ( 37 |
38 |
39 | {result} 40 |
41 | {componentState.showFileSize && ( 42 | 46 | {fileSize} 47 | 48 | )} 49 | {componentState.downloadFile && fileSrc && ( 50 | 57 | 63 | 64 | 65 | 66 | 67 | )} 68 | {componentState.allowEditing && !componentState.disabled && isImage && ( 69 | 88 | )} 89 |
90 | ); 91 | }; 92 | 93 | export default FileFooter; 94 | -------------------------------------------------------------------------------- /src/components/FilePreview.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | import FileFooter from "./FileFooter"; 3 | import { FilePreviewProps } from "./types"; 4 | import { filePreviewStyle } from "./FilePreviewStyle"; 5 | import { FileContext } from "../context/FileContext"; 6 | 7 | const imageFileTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/tiff"]; 8 | 9 | const FilePreview: React.FC = ({ file, index }) => { 10 | const [fileSrc, setFileSrc] = useState(null); 11 | useEffect(() => { 12 | if (file) { 13 | const fileUrl = URL.createObjectURL(file); 14 | setFileSrc(fileUrl); 15 | return () => URL.revokeObjectURL(fileUrl); 16 | } 17 | }, [file]); 18 | 19 | const previewStyle = filePreviewStyle.filter((item) => item.type == file.type); 20 | const componentState = useContext(FileContext).state.componentState; 21 | 22 | const { dispatch } = useContext(FileContext); 23 | 24 | const setZoom = () => { 25 | dispatch({ 26 | type: "STORE_FILE_STATE", 27 | payload: { 28 | zoom: true, 29 | fileSrc: URL.createObjectURL(file), 30 | index: index, 31 | isImage: imageFileTypes.includes(file.type), 32 | fileName: file.name, 33 | type: file.type, 34 | size: file.size, 35 | }, 36 | }); 37 | }; 38 | 39 | return ( 40 | <> 41 |
setZoom()} 44 | className={`${ 45 | componentState.rounded && "rfp-rounded-lg" 46 | } rfp-border-solid hover:rfp-shadow-lg dark:rfp-bg-zinc-900 rfp-shadow-md hover:rfp-cursor-pointer`} 47 | > 48 | {imageFileTypes.includes(file.type) ? ( 49 | fileSrc && ( 50 | 55 | ) 56 | ) : ( 57 |
61 | 0 ? previewStyle[0].color : "rfp-bg-slate-400"} 63 | rfp-rounded rfp-flex rfp-w-16 rfp-justify-center rfp-h-20 rfp-items-center`} 64 | > 65 | {previewStyle.length > 0 ? ( 66 | previewStyle[0].icon 67 | ) : ( 68 | 74 | 75 | 76 | 77 | )} 78 | 79 |
80 | )} 81 |
82 |
83 | {fileSrc && ( 84 | 90 | )} 91 |
92 | 93 | ); 94 | }; 95 | 96 | export default FilePreview; 97 | -------------------------------------------------------------------------------- /src/components/FilePreviewStyle.tsx: -------------------------------------------------------------------------------- 1 | import { FileIcon } from "./types"; 2 | 3 | export const filePreviewStyle: FileIcon[] = [ 4 | { 5 | type: "application/pdf", 6 | icon: ( 7 | 13 | 14 | 18 | 19 | ), 20 | color: "rfp-bg-red-500", 21 | }, 22 | { 23 | type: "text/csv", 24 | icon: ( 25 | 31 | 35 | 36 | ), 37 | color: "rfp-bg-emerald-600", 38 | }, 39 | { 40 | type: "text/plain", 41 | icon: ( 42 | 49 | 50 | 51 | 52 | ), 53 | color: "rfp-bg-slate-500", 54 | }, 55 | { 56 | type: "application/msword", 57 | icon: ( 58 | 64 | 68 | 69 | ), 70 | color: "rfp-bg-sky-600", 71 | }, 72 | { 73 | type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 74 | icon: ( 75 | 81 | 85 | 86 | ), 87 | color: "bg-sky-600", 88 | }, 89 | { 90 | type: "application/vnd.ms-excel", 91 | icon: ( 92 | 98 | 102 | 103 | ), 104 | color: "rfp-bg-emerald-600", 105 | }, 106 | { 107 | type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 108 | icon: ( 109 | 115 | 119 | 120 | ), 121 | color: "rfp-bg-emerald-600", 122 | }, 123 | ]; 124 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEventHandler } from "react"; 2 | 3 | const Header = (props: { 4 | id: string; 5 | fileData: File[]; 6 | disabled: boolean | undefined; 7 | onChange?: ChangeEventHandler; 8 | handleImage: ChangeEventHandler; 9 | multiple: boolean | undefined; 10 | accept: string | undefined; 11 | }) => { 12 | return ( 13 | <> 14 |
15 |
16 |
17 | 18 | {`Files: ${props.fileData.length}`} 19 | 20 |
21 | 45 |
46 |
47 | 48 | ); 49 | }; 50 | 51 | export default Header; 52 | -------------------------------------------------------------------------------- /src/components/ImageSlider.tsx: -------------------------------------------------------------------------------- 1 | import SlideCount from "./SlideCount"; 2 | import { filePreviewStyle } from "./FilePreviewStyle"; 3 | import { FileContext } from "../context/FileContext"; 4 | import { useContext } from "react"; 5 | 6 | const ImageSlider = () => { 7 | const file = useContext(FileContext).state.fileState; 8 | const componentState = useContext(FileContext).state.componentState; 9 | const previewStyle = filePreviewStyle.filter((item) => item.type === file.type); 10 | const { dispatch } = useContext(FileContext); 11 | const hideZoom = () => { 12 | if (file.fileSrc) { 13 | URL.revokeObjectURL(file.fileSrc); 14 | } 15 | dispatch({ 16 | type: "STORE_FILE_STATE", 17 | payload: { 18 | zoom: false, 19 | fileSrc: null, 20 | index: 0, 21 | isImage: false, 22 | fileName: null, 23 | type: null, 24 | size: 0, 25 | }, 26 | }); 27 | }; 28 | 29 | const nextFile = () => { 30 | dispatch({ type: "GET_NEXT_FILE" }); 31 | }; 32 | 33 | const prevFile = () => { 34 | dispatch({ type: "GET_PREV_FILE" }); 35 | }; 36 | 37 | const toggleFullScreen = () => { 38 | const element = document.documentElement; 39 | if (!document.fullscreenElement) { 40 | if (element.requestFullscreen) { 41 | element.requestFullscreen(); 42 | } 43 | } else { 44 | if (document.exitFullscreen) { 45 | document.exitFullscreen(); 46 | } 47 | } 48 | }; 49 | 50 | if (file.zoom) { 51 | return ( 52 |
53 |
57 |
61 | 62 | {file.fileName} 63 | 64 | 78 | {file.fileSrc && componentState.downloadFile && ( 79 | 86 | 100 | Download 101 | 102 | )} 103 | 119 |
120 | 140 |
147 | {file.isImage ? ( 148 | file.fileSrc && ( 149 | Zoomed Image 154 | ) 155 | ) : ( 156 | 157 | 0 ? previewStyle[0].color : "rfp-bg-slate-400" 160 | } rfp-rounded rfp-flex rfp-justify-center rfp-w-48 rfp-h-48 rfp-items-center`} 161 | > 162 | {previewStyle.length > 0 ? ( 163 | previewStyle[0].icon 164 | ) : ( 165 | 171 | 172 | 173 | 174 | )} 175 | 176 | 177 | )} 178 |
179 | 199 | {componentState.showSliderCount ? : <>} 200 |
201 |
202 | ); 203 | } 204 | return null; 205 | }; 206 | 207 | export default ImageSlider; 208 | -------------------------------------------------------------------------------- /src/components/Main.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense, useContext, useEffect } from "react"; 2 | import FilePreview from "./FilePreview"; 3 | import ImageSlider from "./ImageSlider"; 4 | import Header from "./Header"; 5 | import { Props } from "./interface"; 6 | import { FileContext } from "../context/FileContext"; 7 | 8 | const ReactPhotoEditor = React.lazy(async () => { 9 | const { ReactPhotoEditor } = await import("react-photo-editor"); 10 | return { default: ReactPhotoEditor }; 11 | }); 12 | 13 | export const Main: React.FC = ({ 14 | id, 15 | files, 16 | url, 17 | downloadFile, 18 | removeFile, 19 | showFileSize, 20 | showSliderCount, 21 | allowEditing, 22 | multiple, 23 | accept, 24 | maxFileSize, 25 | maxFiles, 26 | width, 27 | rounded, 28 | height, 29 | fileHeight, 30 | fileWidth, 31 | disabled, 32 | onChange, 33 | onRemove, 34 | onError, 35 | getFiles, 36 | onClick, 37 | onDrop, 38 | }) => { 39 | const fileData = useContext(FileContext).state.fileData; 40 | const fileState = useContext(FileContext).state.fileState; 41 | const componentState = useContext(FileContext).state.componentState; 42 | const imageEditorState = useContext(FileContext).state.imageEditorState; 43 | const inputId = id ?? `fileInput-${Date.now()}`; 44 | 45 | const { dispatch } = useContext(FileContext); 46 | 47 | const checkErrors = (files: File[]) => { 48 | let hasError = false; 49 | if (maxFiles && (fileData.length + files.length > maxFiles || files.length > maxFiles)) { 50 | hasError = true; 51 | if (onError) { 52 | onError(new Error(`Max ${maxFiles} files are allowed to be selected`)); 53 | } 54 | throw new Error(`Max ${maxFiles} files are allowed to be selected`); 55 | } 56 | 57 | if (maxFileSize) { 58 | files.forEach((file: File) => { 59 | if (file.size > maxFileSize) { 60 | hasError = true; 61 | if (onError) { 62 | onError(new Error(`File size limit exceeded: ${file.name}`)); 63 | } 64 | throw new Error(`File size limit exceeded: ${file.name}`); 65 | } 66 | }); 67 | } 68 | 69 | return hasError; 70 | }; 71 | 72 | useEffect(() => { 73 | async function fetchData() { 74 | try { 75 | if (url) { 76 | const imageFileTypes = [ 77 | { type: "image/jpeg", ext: ".jpg" }, 78 | { type: "image/jpg", ext: ".jpg" }, 79 | { type: "image/png", ext: ".png" }, 80 | { type: "image/gif", ext: "gif" }, 81 | { type: "image/tiff", ext: ".tiff" }, 82 | ]; 83 | 84 | const response = await fetch(url); 85 | const blob = await response.blob(); 86 | 87 | let fileExt = null; 88 | const filteredName = imageFileTypes.filter((fileType) => fileType.type === blob.type); 89 | if (filteredName.length > 0) { 90 | fileExt = filteredName[0].ext; 91 | } 92 | 93 | const file = new File([blob], "file" + (fileExt ?? ".img"), { 94 | type: blob.type, 95 | }); 96 | 97 | dispatch({ type: "STORE_FILE_DATA", payload: { files: [file] } }); 98 | } 99 | } catch (err) { 100 | if (err instanceof Error) { 101 | if (onError) { 102 | onError(err); 103 | } 104 | throw err; 105 | } 106 | } 107 | } 108 | fetchData(); 109 | }, []); 110 | 111 | const filterDuplicateFiles = (newFiles: File[], existingFiles: File[]) => { 112 | const existingMap = new Map(); 113 | existingFiles.forEach((file) => { 114 | existingMap.set(`${file.name}_${file.size}`, true); 115 | }); 116 | 117 | return newFiles.filter((file) => !existingMap.has(`${file.name}_${file.size}`)); 118 | }; 119 | 120 | useEffect(() => { 121 | if (files && files.length > 0) { 122 | const uniqueFiles = filterDuplicateFiles(files, fileData); 123 | 124 | if (uniqueFiles && !checkErrors(files)) { 125 | dispatch({ type: "STORE_FILE_DATA", payload: { files: files } }); 126 | } 127 | } 128 | }, [files]); 129 | 130 | useEffect(() => { 131 | dispatch({ 132 | type: "SET_COMPONENT_STATE", 133 | payload: { 134 | downloadFile: downloadFile != undefined ? downloadFile : true, 135 | removeFile: removeFile != undefined ? removeFile : true, 136 | showFileSize: showFileSize != undefined ? showFileSize : true, 137 | showSliderCount: showSliderCount != undefined ? showSliderCount : true, 138 | rounded: rounded != undefined ? rounded : true, 139 | fileHeight: fileHeight ?? "rfp-h-32", 140 | fileWidth: fileWidth ?? "rfp-w-44", 141 | disabled: disabled ?? false, 142 | allowEditing: allowEditing ?? false, 143 | }, 144 | }); 145 | }, [ 146 | downloadFile, 147 | removeFile, 148 | showFileSize, 149 | showSliderCount, 150 | fileHeight, 151 | fileWidth, 152 | rounded, 153 | disabled, 154 | allowEditing, 155 | ]); 156 | 157 | const handleImage = (e: React.ChangeEvent): void => { 158 | const files = Array.from(e.target.files || []); 159 | const newFiles = filterDuplicateFiles(files, fileData); 160 | 161 | if (newFiles.length && !checkErrors(newFiles)) { 162 | dispatch({ type: "APPEND_FILE_DATA", payload: { files: newFiles } }); 163 | } 164 | }; 165 | 166 | const remove = (file: File) => { 167 | dispatch({ type: "REMOVE_FILE_DATA", payload: file }); 168 | if (onRemove) { 169 | onRemove(file); 170 | } 171 | }; 172 | 173 | const handleClick = (file: File) => { 174 | if (onClick) { 175 | onClick(file); 176 | } 177 | }; 178 | 179 | useEffect(() => { 180 | if (getFiles) { 181 | getFiles(fileData); 182 | } 183 | }, [fileData]); 184 | 185 | const handleDragOver = (event: React.DragEvent) => { 186 | event.preventDefault(); 187 | event.dataTransfer.dropEffect = "copy"; 188 | }; 189 | 190 | const handleDragLeave = (event: React.DragEvent) => { 191 | event.preventDefault(); 192 | }; 193 | 194 | const handleDrop = (event: React.DragEvent) => { 195 | event.preventDefault(); 196 | const files = Array.from(event.dataTransfer.files); 197 | const newFiles = filterDuplicateFiles(files, fileData); 198 | 199 | if (newFiles.length && !checkErrors(newFiles)) { 200 | dispatch({ type: "APPEND_FILE_DATA", payload: { files: newFiles } }); 201 | onDrop && onDrop(event); 202 | } 203 | }; 204 | 205 | const saveEditedImage = (image: File) => { 206 | if (image && imageEditorState.index != null) { 207 | const fileList = Array.from(fileData); 208 | fileList[imageEditorState.index] = image; 209 | dispatch({ 210 | type: "STORE_FILE_DATA", 211 | payload: { 212 | files: fileList, 213 | }, 214 | }); 215 | } 216 | }; 217 | 218 | const closeImageEditor = () => { 219 | dispatch({ 220 | type: "SET_IMAGE_EDITOR_DATA", 221 | payload: { 222 | isEditing: false, 223 | file: null, 224 | index: null, 225 | }, 226 | }); 227 | }; 228 | 229 | if (fileState.zoom) { 230 | return ( 231 |
232 | 233 |
234 | ); 235 | } 236 | 237 | if (imageEditorState.isEditing && imageEditorState.file && !disabled) { 238 | return ( 239 | }> 240 | 247 | 248 | ); 249 | } 250 | 251 | return ( 252 |
259 |
260 |
261 | {fileData.length > 0 && ( 262 |
271 | )} 272 | 273 |
{ 283 | if (!disabled && fileData.length === 0) { 284 | e.stopPropagation(); 285 | document.getElementById(inputId)?.click(); 286 | } 287 | }} 288 | > 289 | {fileData.length > 0 ? ( 290 | fileData.map((file, idx) => { 291 | return ( 292 |
{ 296 | e.stopPropagation(); 297 | handleClick(file); 298 | }} 299 | > 300 |
301 | {componentState.removeFile ? ( 302 | { 306 | e.stopPropagation(); 307 | remove(file); 308 | }} 309 | className="rfp-absolute -rfp-top-2 rfp-right-0 rfp-z-10 dark:rfp-text-white rfp-text-black dark:hover:rfp-text-slate-200 rfp-opacity-0 group-hover:rfp-opacity-100 rfp-transition-opacity rfp-cursor-pointer rfp-h-5 rfp-w-5" 310 | fill="currentColor" 311 | viewBox="0 0 16 16" 312 | > 313 | 314 | 315 | ) : ( 316 | <> 317 | )} 318 |
319 |
320 | 321 |
322 |
323 | ); 324 | }) 325 | ) : ( 326 | 351 | )} 352 |
353 |
354 |
355 |
356 | ); 357 | }; 358 | -------------------------------------------------------------------------------- /src/components/ReactFilesPreview.tsx: -------------------------------------------------------------------------------- 1 | import { Props } from "./interface"; 2 | import { Main } from "./Main"; 3 | import "./style.css"; 4 | import { FileProvider } from "../context/FileContext"; 5 | 6 | export const ReactFilesPreview: React.FC = (props) => { 7 | return ( 8 | 9 |
10 | 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/SlideCount.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { FileContext } from "../context/FileContext"; 3 | const SlideCount = () => { 4 | const fileData = useContext(FileContext).state.fileData; 5 | const fileState = useContext(FileContext).state.fileState; 6 | 7 | return ( 8 |
12 | {fileState?.index !== null && ( 13 |
{`${fileState.index + 1} of ${ 14 | fileData?.length 15 | }`}
16 | )} 17 |
18 | ); 19 | }; 20 | 21 | export default SlideCount; 22 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { ReactFilesPreview } from "./ReactFilesPreview"; 2 | -------------------------------------------------------------------------------- /src/components/interface.ts: -------------------------------------------------------------------------------- 1 | import { ChangeEventHandler } from "react"; 2 | 3 | /** 4 | * Props for the ReactFilePreview component. 5 | */ 6 | export interface Props { 7 | /** 8 | * The id attribute for the file input element. 9 | * If not provided, a unique id will be generated using Date.now(). 10 | */ 11 | id?: string; 12 | 13 | /** 14 | * An array of files to be displayed and previewed. 15 | */ 16 | files?: File[] | []; 17 | 18 | /** 19 | * The URL of the file to be displayed. 20 | */ 21 | url?: string | null; 22 | 23 | /** 24 | * Enable downloading of the displayed file. 25 | */ 26 | downloadFile?: boolean; 27 | 28 | /** 29 | * Enable removing of the displayed file. 30 | */ 31 | removeFile?: boolean; 32 | 33 | /** 34 | * Show the file size of the displayed file. 35 | */ 36 | showFileSize?: boolean; 37 | 38 | /** 39 | * Show the count of files under image slider. 40 | */ 41 | showSliderCount?: boolean; 42 | 43 | /** 44 | * Enable image editing. 45 | */ 46 | allowEditing?: boolean; 47 | 48 | /** 49 | * Allow selection of multiple files. 50 | */ 51 | multiple?: boolean; 52 | 53 | /** 54 | * Accepted file types based on MIME types or file extensions. 55 | */ 56 | accept?: string; 57 | 58 | /** 59 | * The maximum allowed file size in bytes. 60 | */ 61 | maxFileSize?: number; 62 | 63 | /** 64 | * The maximum number of files that can be selected. 65 | */ 66 | maxFiles?: number; 67 | 68 | /** 69 | * The width of the file previewer component. 70 | */ 71 | width?: string; 72 | 73 | /** 74 | * The height of the file previewer component. 75 | */ 76 | height?: string; 77 | 78 | /** 79 | * Apply rounded corners to the file previewer component. 80 | */ 81 | rounded?: boolean; 82 | 83 | /** 84 | * The height of each individual file thumbnail. 85 | */ 86 | fileHeight?: string; 87 | 88 | /** 89 | * The width of each individual file thumbnail. 90 | */ 91 | fileWidth?: string; 92 | 93 | /** 94 | * Disable interactions with the file previewer. 95 | */ 96 | disabled?: boolean; 97 | 98 | /** 99 | * Function to get all displayed files. 100 | * @param files - An array of all displayed File objects. 101 | */ 102 | getFiles?: (files: File[]) => void; 103 | 104 | /** 105 | * Callback function invoked when the value of the file input changes. 106 | */ 107 | onChange?: ChangeEventHandler; 108 | 109 | /** 110 | * Callback function invoked when a file is dropped. 111 | */ 112 | onDrop?: (event: React.DragEvent) => void; 113 | 114 | /** 115 | * Callback function invoked when a file is clicked. 116 | * @param file - The clicked file. 117 | */ 118 | onClick?: (file: File) => void; 119 | 120 | /** 121 | * Callback function invoked when a file is removed. 122 | * @param removedFile - The file that was removed. 123 | */ 124 | onRemove?: (removedFile: File) => void; 125 | 126 | /** 127 | * Callback function invoked when an error occurs. 128 | * @param error - The error object. 129 | */ 130 | onError?: (error: Error) => void; 131 | } 132 | -------------------------------------------------------------------------------- /src/components/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/components/types.ts: -------------------------------------------------------------------------------- 1 | export type FilePreviewProps = { 2 | file: File; 3 | index: number; 4 | }; 5 | 6 | export type FileIcon = { 7 | type: string; 8 | icon: JSX.Element; 9 | color: string; 10 | }; 11 | export type FileFooterProps = { 12 | file: File; 13 | fileSrc: string | null; 14 | index: number; 15 | isImage: boolean; 16 | }; 17 | -------------------------------------------------------------------------------- /src/context/FileContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useReducer } from "react"; 2 | 3 | interface FileState { 4 | zoom: boolean; 5 | fileSrc: string | null; 6 | index: number; 7 | isImage: boolean; 8 | fileName: string | null; 9 | type: string | null; 10 | size: number; 11 | } 12 | 13 | interface ComponentState { 14 | showFileSize: boolean; 15 | showSliderCount: boolean; 16 | downloadFile: boolean; 17 | removeFile: boolean; 18 | rounded: boolean; 19 | fileHeight: string; 20 | fileWidth: string; 21 | disabled: boolean; 22 | allowEditing: boolean; 23 | } 24 | 25 | interface ImageEditorState { 26 | isEditing: boolean; 27 | index: number | null; 28 | file: File | null; 29 | } 30 | 31 | export interface InitialState { 32 | fileData: File[]; 33 | fileState: FileState; 34 | componentState: ComponentState; 35 | imageEditorState: ImageEditorState; 36 | } 37 | 38 | export type FileAction = 39 | | { type: "STORE_FILE_DATA"; payload: { files: File[] } } 40 | | { type: "SET_COMPONENT_STATE"; payload: ComponentState } 41 | | { type: "APPEND_FILE_DATA"; payload: { files: File[] } } 42 | | { type: "STORE_FILE_STATE"; payload: FileState } 43 | | { type: "SET_IMAGE_EDITOR_DATA"; payload: ImageEditorState } 44 | | { type: "REMOVE_FILE_DATA"; payload: File } 45 | | { type: "GET_NEXT_FILE" } 46 | | { type: "GET_PREV_FILE" }; 47 | 48 | const imageFileTypes: string[] = [ 49 | "image/jpeg", 50 | "image/jpg", 51 | "image/png", 52 | "image/gif", 53 | "image/tiff", 54 | ]; 55 | export const fileReducer = (state: InitialState, action: FileAction) => { 56 | const lastIndex = state.fileData.length - 1; 57 | 58 | const updateFileState = (index: number) => { 59 | const file = state.fileData[index]; 60 | return { 61 | zoom: true, 62 | fileSrc: URL.createObjectURL(file), 63 | index, 64 | isImage: imageFileTypes.includes(file.type), 65 | fileName: file.name, 66 | type: file.type, 67 | size: file.size, 68 | }; 69 | }; 70 | 71 | const revokeObjectURL = (fileState: FileState) => { 72 | if (fileState && fileState.fileSrc) { 73 | if (fileState.fileSrc.startsWith("blob:")) { 74 | URL.revokeObjectURL(fileState.fileSrc); 75 | } 76 | } 77 | }; 78 | 79 | switch (action.type) { 80 | case "STORE_FILE_DATA": 81 | return { ...state, fileData: action.payload.files }; 82 | case "SET_COMPONENT_STATE": 83 | return { ...state, componentState: action.payload }; 84 | case "APPEND_FILE_DATA": 85 | return { ...state, fileData: [...state.fileData, ...action.payload.files] }; 86 | case "STORE_FILE_STATE": 87 | return { ...state, fileState: action.payload }; 88 | case "SET_IMAGE_EDITOR_DATA": 89 | return { ...state, imageEditorState: action.payload }; 90 | case "REMOVE_FILE_DATA": 91 | return { 92 | ...state, 93 | fileData: state.fileData.filter((i: File) => i.name !== action.payload.name), 94 | }; 95 | case "GET_NEXT_FILE": { 96 | const nextIndex = state.fileState.index + 1; 97 | const newIndex = nextIndex > lastIndex ? 0 : nextIndex; 98 | revokeObjectURL(state.fileState); 99 | return { 100 | ...state, 101 | fileState: updateFileState(newIndex), 102 | }; 103 | } 104 | case "GET_PREV_FILE": { 105 | const prevIndex = state.fileState.index - 1; 106 | const newIdx = prevIndex < 0 ? lastIndex : prevIndex; 107 | revokeObjectURL(state.fileState); 108 | return { 109 | ...state, 110 | fileState: updateFileState(newIdx), 111 | }; 112 | } 113 | default: 114 | return state; 115 | } 116 | }; 117 | 118 | export interface FileContext { 119 | state: InitialState; 120 | dispatch: (action: FileAction) => void; 121 | } 122 | 123 | export const FileContext = createContext({ 124 | state: { 125 | fileData: [], 126 | fileState: { 127 | zoom: false, 128 | fileSrc: null, 129 | index: 0, 130 | isImage: false, 131 | fileName: null, 132 | type: null, 133 | size: 0, 134 | }, 135 | componentState: { 136 | showFileSize: true, 137 | showSliderCount: true, 138 | downloadFile: true, 139 | removeFile: true, 140 | rounded: true, 141 | fileHeight: "rfp-h-32", 142 | fileWidth: "rfp-w-44", 143 | disabled: false, 144 | allowEditing: false, 145 | }, 146 | imageEditorState: { 147 | isEditing: false, 148 | index: null, 149 | file: null, 150 | }, 151 | }, 152 | dispatch: () => {}, 153 | }); 154 | 155 | export const FileProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 156 | const [state, dispatch] = useReducer(fileReducer, { 157 | fileData: [], 158 | fileState: { 159 | zoom: false, 160 | fileSrc: null, 161 | index: 0, 162 | isImage: false, 163 | fileName: null, 164 | type: null, 165 | size: 0, 166 | }, 167 | componentState: { 168 | showFileSize: true, 169 | showSliderCount: true, 170 | downloadFile: true, 171 | removeFile: true, 172 | rounded: true, 173 | fileHeight: "rfp-h-32", 174 | fileWidth: "rfp-w-44", 175 | disabled: false, 176 | allowEditing: false, 177 | }, 178 | imageEditorState: { 179 | isEditing: false, 180 | index: null, 181 | file: null, 182 | }, 183 | }); 184 | 185 | return {children}; 186 | }; 187 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export { ReactFilesPreview } from "./components/ReactFilesPreview"; -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client"; 2 | import App from "./App.js"; 3 | 4 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 5 | 6 | ); 7 | -------------------------------------------------------------------------------- /src/tests/FileFooter.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import FileFooter from "../components/FileFooter"; 3 | import { vi } from "vitest"; 4 | import { FileContext } from "../context/FileContext"; 5 | 6 | describe("FileFooter component", () => { 7 | 8 | const mockComponentState = { 9 | showFileSize: true, 10 | showSliderCount: true, 11 | downloadFile: true, 12 | removeFile: true, 13 | rounded: true, 14 | fileHeight: "rfp-h-32", 15 | fileWidth: "rfp-w-44", 16 | disabled: false, 17 | allowEditing: false 18 | }; 19 | 20 | const mockFileState = { 21 | zoom: true, 22 | fileSrc: "image.jpg", 23 | index: 0, 24 | isImage: true, 25 | fileName: "image.jpg", 26 | type: "image/jpeg", 27 | size: 1000, 28 | }; 29 | const mockImageEditorState = { 30 | isEditing: false, 31 | file: null, 32 | index: null 33 | }; 34 | const mockDispatch = vi.fn(); 35 | 36 | const mockFileContext = { 37 | state: { 38 | fileData: [], 39 | fileState: mockFileState, 40 | componentState: mockComponentState, 41 | imageEditorState: mockImageEditorState 42 | }, 43 | dispatch: mockDispatch, 44 | }; 45 | 46 | 47 | const mockFile = new File(["test content"], "test.txt", { type: "text/plain" }); 48 | Object.defineProperty(mockFile, "size", { value: 5000000 }); 49 | 50 | const mockFileSrc = "https://example.com/test.txt"; 51 | 52 | it("renders file name correctly", () => { 53 | 54 | render( 55 | 56 | 57 | 58 | ); 59 | const fileNameElement = screen.getByText("test.txt"); 60 | expect(fileNameElement).toBeInTheDocument(); 61 | }); 62 | 63 | it("displays file size in KB if less than 1 MB", () => { 64 | 65 | const mockFile = new File(["test content"], "test.txt", { type: "text/plain" }); 66 | Object.defineProperty(mockFile, "size", { value: 500000 }); 67 | 68 | render( 69 | 70 | 71 | 72 | ); 73 | const fileSizeElement = screen.getByTestId("file-size"); 74 | expect(fileSizeElement).toHaveTextContent("500 KB"); 75 | }); 76 | 77 | it("displays file size in MB if 1 MB or greater", () => { 78 | 79 | const mockFile = new File(["test content"], "test.txt", { type: "text/plain" }); 80 | Object.defineProperty(mockFile, "size", { value: 15000000 }); 81 | 82 | render( 83 | 84 | 85 | 86 | ); 87 | const fileSizeElement = screen.getByTestId("file-size"); 88 | expect(fileSizeElement).toHaveTextContent("15 MB"); 89 | }); 90 | 91 | it("truncates long file names", () => { 92 | 93 | const nameArray = "test123456789101112134516.txt".split("."); 94 | let fileName = nameArray[0]; 95 | const extension = nameArray.pop(); 96 | if (fileName.length > 20) { 97 | fileName = 98 | fileName.substring(0, 5) + ".." + fileName.substring(fileName.length - 3, fileName.length); 99 | } 100 | const result = fileName + "." + extension; 101 | 102 | 103 | const mockFile = new File(["test content"], "test123456789101112134516.txt", { type: "text/plain" }); 104 | Object.defineProperty(mockFile, "size", { value: 5000000 }); 105 | 106 | render( 107 | 108 | 109 | 110 | ); 111 | const fileNameElement = screen.getByText(result); 112 | expect(fileNameElement).toBeInTheDocument(); 113 | }); 114 | 115 | it("displays download link when downloadFile is true", () => { 116 | 117 | const mockIsDownloadTrue = { 118 | state: { 119 | fileData: [], 120 | fileState: mockFileState, 121 | componentState: { ...mockComponentState, downloadFile: true }, 122 | imageEditorState: mockImageEditorState 123 | }, 124 | dispatch: mockDispatch, 125 | }; 126 | 127 | render( 128 | 129 | 130 | 131 | ); 132 | const downloadLinkElement = screen.getByRole("link"); 133 | expect(downloadLinkElement).toBeInTheDocument(); 134 | expect(downloadLinkElement).toHaveAttribute("target", "_blank"); 135 | expect(downloadLinkElement).toHaveAttribute("rel", "noreferrer"); 136 | }); 137 | 138 | it("does not display download link when downloadFile is false", () => { 139 | 140 | const mockIsDownloadFalse = { 141 | state: { 142 | fileData: [], 143 | fileState: mockFileState, 144 | componentState: { ...mockComponentState, downloadFile: false }, 145 | imageEditorState: mockImageEditorState 146 | }, 147 | dispatch: mockDispatch, 148 | }; 149 | render( 150 | 151 | 152 | 153 | ); 154 | const downloadLinkElement = screen.queryByText("Download"); 155 | expect(downloadLinkElement).not.toBeInTheDocument(); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /src/tests/FilePreview.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen, waitFor } from "@testing-library/react"; 2 | import { FileContext } from "../context/FileContext"; 3 | import FilePreview from "../components/FilePreview"; 4 | import { vi, it, describe } from "vitest"; 5 | 6 | describe("FilePreview component", () => { 7 | const mockFileState = { 8 | zoom: false, 9 | fileSrc: "image.jpg", 10 | index: 0, 11 | isImage: true, 12 | fileName: "image.jpg", 13 | type: "image/jpeg", 14 | size: 1000, 15 | }; 16 | 17 | const mockComponentState = { 18 | showFileSize: true, 19 | showSliderCount: true, 20 | downloadFile: true, 21 | removeFile: true, 22 | rounded: true, 23 | fileHeight: "h-32", 24 | fileWidth: "w-44", 25 | disabled: false, 26 | allowEditing: false 27 | }; 28 | const mockImageEditorState = { 29 | isEditing: false, 30 | file: null, 31 | index: null 32 | }; 33 | const mockDispatch = vi.fn(); 34 | 35 | const mockFileContext = { 36 | state: { 37 | fileData: [], 38 | fileState: mockFileState, 39 | componentState: mockComponentState, 40 | imageEditorState: mockImageEditorState 41 | }, 42 | dispatch: mockDispatch, 43 | }; 44 | it("displays file icon preview when file type is not an image", () => { 45 | const mockFile = new File(["file content"], "document.pdf", { type: "application/pdf" }); 46 | const mockFileContext = { 47 | state: { 48 | fileData: [], 49 | fileState: { ...mockFileState, isImage: false }, 50 | componentState: mockComponentState, 51 | imageEditorState: mockImageEditorState 52 | }, 53 | dispatch: mockDispatch, 54 | }; 55 | render( 56 | 57 | 58 | 59 | ); 60 | 61 | const fileIconPreviewElement = screen.getByTestId("file-icon-preview"); 62 | expect(fileIconPreviewElement).toBeInTheDocument(); 63 | }); 64 | 65 | it("displays default file icon when file type is not recognized", () => { 66 | const mockFile = new File(["file content"], "file.unknown", { type: "unknown/type" }); 67 | // }; 68 | render( 69 | 70 | 71 | 72 | ); 73 | 74 | const defaultIconElement = screen.getByTestId("default-icon"); 75 | expect(defaultIconElement).toBeInTheDocument(); 76 | }); 77 | 78 | it("displays FileFooter component when fileSrc is available", async () => { 79 | const mockFile = new File(["image content"], "image.jpg", { type: "image/jpeg" }); 80 | 81 | render( 82 | 83 | 84 | 85 | ); 86 | 87 | const fileFooterElement = screen.queryByTestId("file-footer"); 88 | waitFor(() => expect(fileFooterElement).toBeInTheDocument()); 89 | }); 90 | 91 | it("does not display FileFooter component when fileSrc is not available", async () => { 92 | const mockFile = new File(["image conteeent"], "image.ef.jewpg", { type: "imagrege/uy/jpeg" }); 93 | 94 | render( 95 | 96 | 97 | 98 | ); 99 | 100 | const fileFooterElement = screen.queryByTestId("file-footer"); 101 | expect(fileFooterElement).not.toBeInTheDocument(); 102 | }); 103 | 104 | it("call setZoom function onClick", async () => { 105 | const mockDispatch = vi.fn(); 106 | 107 | const mockFileContext = { 108 | state: { 109 | fileData: [], 110 | fileState: { ...mockFileState, isImage: false }, 111 | componentState: mockComponentState, 112 | imageEditorState: mockImageEditorState 113 | }, 114 | dispatch: mockDispatch, 115 | }; 116 | 117 | const mockFile = new File(["image conteeent"], "image.jpg", { type: "image/jpeg" }); 118 | 119 | render( 120 | 121 | 122 | 123 | ); 124 | 125 | fireEvent.click(screen.getByTestId("file-preview")); 126 | expect(mockDispatch).toHaveBeenCalledWith({ 127 | type: "STORE_FILE_STATE", 128 | payload: { 129 | zoom: true, 130 | fileSrc: undefined, 131 | index: 0, 132 | isImage: true, 133 | fileName: "image.jpg", 134 | type: "image/jpeg", 135 | size: 15, 136 | }, 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /src/tests/ImageSlider.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent, screen } from "@testing-library/react"; 2 | import { FileContext } from "../context/FileContext"; 3 | import ImageSlider from "../components/ImageSlider"; 4 | import { vi, expect, describe, it } from "vitest"; 5 | 6 | describe("ImageSlider", () => { 7 | const mockFile = { 8 | zoom: true, 9 | fileSrc: "image.jpg", 10 | index: 0, 11 | isImage: true, 12 | fileName: "image.jpg", 13 | type: "image/jpeg", 14 | size: 1000, 15 | }; 16 | 17 | const mockComponentState = { 18 | showFileSize: true, 19 | showSliderCount: true, 20 | downloadFile: true, 21 | removeFile: true, 22 | rounded: true, 23 | fileHeight: "h-32", 24 | fileWidth: "w-44", 25 | disabled: false, 26 | allowEditing: false 27 | }; 28 | const mockImageEditorState = { 29 | isEditing: false, 30 | file: null, 31 | index: null 32 | }; 33 | const mockDispatch = vi.fn(); 34 | 35 | const mockFileContext = { 36 | state: { 37 | fileData: [], 38 | fileState: mockFile, 39 | componentState: mockComponentState, 40 | imageEditorState: mockImageEditorState 41 | }, 42 | dispatch: mockDispatch, 43 | }; 44 | 45 | it("renders ImageSlider when zoom is true", () => { 46 | render( 47 | 48 | 49 | 50 | ); 51 | 52 | const imageSlider = screen.getByTestId("image-slider"); 53 | expect(imageSlider).toBeInTheDocument(); 54 | }); 55 | 56 | it("does not render ImageSlider when zoom is false", () => { 57 | const mockFileContextNoZoom = { 58 | state: { 59 | fileData: [], 60 | fileState: { ...mockFile, zoom: false }, 61 | componentState: mockComponentState, 62 | imageEditorState: mockImageEditorState 63 | }, 64 | dispatch: mockDispatch, 65 | }; 66 | 67 | const { queryByTestId } = render( 68 | 69 | 70 | 71 | ); 72 | 73 | const imageSlider = queryByTestId("image-slider"); 74 | expect(imageSlider).toBeNull(); 75 | }); 76 | 77 | it("calls dispatch with the correct action type when hideZoom is called", () => { 78 | const { getByText } = render( 79 | 80 | 81 | 82 | ); 83 | 84 | const closeButton = getByText("Close"); 85 | fireEvent.click(closeButton); 86 | 87 | expect(mockDispatch).toHaveBeenCalledWith({ 88 | type: "STORE_FILE_STATE", 89 | payload: { 90 | zoom: false, 91 | fileSrc: null, 92 | index: 0, 93 | isImage: false, 94 | fileName: null, 95 | type: null, 96 | size: 0, 97 | }, 98 | }); 99 | }); 100 | 101 | it("calls dispatch with the correct action type when nextFile is called", () => { 102 | const { getByTestId } = render( 103 | 104 | 105 | 106 | ); 107 | 108 | const nextButton = getByTestId("next-file"); 109 | fireEvent.click(nextButton); 110 | 111 | expect(mockDispatch).toHaveBeenCalledWith({ type: "GET_NEXT_FILE" }); 112 | }); 113 | 114 | it("calls dispatch with the correct action type when prevFile is called", () => { 115 | const { getByTestId } = render( 116 | 117 | 118 | 119 | ); 120 | 121 | const prevButton = getByTestId("prev-file"); 122 | fireEvent.click(prevButton); 123 | 124 | expect(mockDispatch).toHaveBeenCalledWith({ type: "GET_PREV_FILE" }); 125 | }); 126 | 127 | it("should render default preview icon", async () => { 128 | const mockFileContextNoZoom = { 129 | state: { 130 | fileData: [], 131 | fileState: { 132 | ...mockFile, 133 | fileSrc: 134 | "https://images.pexels.com/photos/13658554/pexels-photo-13658554.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1", 135 | isImage: false, 136 | }, 137 | componentState: { ...mockComponentState, zoom: false }, 138 | imageEditorState: mockImageEditorState 139 | }, 140 | dispatch: mockDispatch, 141 | }; 142 | 143 | render( 144 | 145 | 146 | 147 | ); 148 | 149 | const previewIcon = screen.getByTestId("default-icon"); 150 | expect(previewIcon).toBeInTheDocument(); 151 | }); 152 | 153 | it('toggleFullScreen when clicked', () => { 154 | render( 155 | 156 | 157 | 158 | ); 159 | 160 | const fullscreenButton = screen.getByRole('button', { name: 'toggle-fullscreen' }); 161 | fireEvent.click(fullscreenButton); 162 | 163 | expect(document.fullscreenElement).not.toBeNull(); 164 | 165 | fireEvent.click(fullscreenButton); 166 | expect(document.fullscreenElement).toBeUndefined(); 167 | }); 168 | 169 | }); 170 | -------------------------------------------------------------------------------- /src/tests/Main.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from "@testing-library/react"; 2 | import { FileContext } from "../context/FileContext"; 3 | import { Main } from "../components/Main"; 4 | import { describe, it, vi } from "vitest"; 5 | 6 | describe("Main Component", () => { 7 | const mockFiles = [ 8 | new File(["file1"], "file1.txt", { type: "text/plain" }), 9 | new File(["file2"], "file2.txt", { type: "text/plain" }), 10 | ]; 11 | 12 | const mockFileState = { 13 | zoom: false, 14 | fileSrc: "image.jpg", 15 | index: 0, 16 | isImage: true, 17 | fileName: "image.jpg", 18 | type: "image/jpeg", 19 | size: 1000, 20 | }; 21 | 22 | const mockComponentState = { 23 | showFileSize: true, 24 | showSliderCount: true, 25 | downloadFile: true, 26 | removeFile: true, 27 | rounded: true, 28 | fileHeight: "h-32", 29 | fileWidth: "w-44", 30 | disabled: false, 31 | allowEditing: false, 32 | }; 33 | const mockImageEditorState = { 34 | isEditing: false, 35 | file: null, 36 | index: null, 37 | }; 38 | 39 | const mockDispatch = vi.fn(); 40 | 41 | const mockFileContext = { 42 | state: { 43 | fileData: [], 44 | fileState: mockFileState, 45 | componentState: mockComponentState, 46 | imageEditorState: mockImageEditorState, 47 | }, 48 | dispatch: mockDispatch, 49 | }; 50 | const mockProps = { 51 | files: mockFiles, 52 | url: null, 53 | downloadFile: true, 54 | removeFile: true, 55 | showFileSize: true, 56 | showSliderCount: true, 57 | multiple: true, 58 | disabled: true, 59 | }; 60 | 61 | it("renders file previews", () => { 62 | const mockFileContext = { 63 | state: { 64 | fileData: [new File(["test content"], "test.txt", { type: "text/plain" })], 65 | fileState: mockFileState, 66 | componentState: { ...mockComponentState, removeFile: true }, 67 | imageEditorState: mockImageEditorState, 68 | }, 69 | dispatch: mockDispatch, 70 | }; 71 | render( 72 | 73 |
74 | , 75 | ); 76 | 77 | const filePreviews = screen.queryByTestId("file-preview"); 78 | expect(filePreviews).toBeInTheDocument(); 79 | }); 80 | 81 | it("adds more files on input change", async () => { 82 | const mockFileContext = { 83 | state: { 84 | fileData: [new File(["test content"], "test.txt", { type: "text/plain" })], 85 | fileState: mockFileState, 86 | componentState: { ...mockComponentState, removeFile: true }, 87 | imageEditorState: mockImageEditorState, 88 | }, 89 | dispatch: mockDispatch, 90 | }; 91 | render( 92 | 93 |
94 | , 95 | ); 96 | 97 | const addMoreLabel = screen.getByText(/Add more/i); 98 | expect(addMoreLabel).toBeInTheDocument(); 99 | }); 100 | 101 | it("removes a file", async () => { 102 | const mockFileContext = { 103 | state: { 104 | fileData: [new File(["test content"], "test.txt", { type: "text/plain" })], 105 | fileState: mockFileState, 106 | componentState: { ...mockComponentState, removeFile: true }, 107 | imageEditorState: mockImageEditorState, 108 | }, 109 | dispatch: mockDispatch, 110 | }; 111 | render( 112 | 113 |
122 | , 123 | ); 124 | 125 | const removeFileButton = screen.queryByTestId("remove-file-button"); 126 | expect(removeFileButton).toBeInTheDocument(); 127 | }); 128 | 129 | it("renders browse files input when fileData is empty", () => { 130 | render( 131 | 132 |
141 | , 142 | ); 143 | 144 | expect(screen.queryByText(/Drop files here, or click to browse files/i)).toBeInTheDocument(); 145 | }); 146 | 147 | it("shoudl not render remove button if removeFile is false", async () => { 148 | const mockFileContext = { 149 | state: { 150 | fileData: mockFiles, 151 | fileState: mockFileState, 152 | componentState: { ...mockComponentState, removeFile: false }, 153 | imageEditorState: mockImageEditorState, 154 | }, 155 | dispatch: mockDispatch, 156 | }; 157 | render( 158 | 159 |
160 | , 161 | ); 162 | 163 | expect(screen.queryByTestId("remove-file-button")).toBeNull(); 164 | }); 165 | 166 | it("handleImage functions should be called", async () => { 167 | const mockDispatch = vi.fn(); 168 | 169 | const mockFileContext = { 170 | state: { 171 | fileData: [], 172 | fileState: mockFileState, 173 | componentState: { ...mockComponentState, removeFile: false, zoom: false }, 174 | imageEditorState: mockImageEditorState, 175 | }, 176 | dispatch: mockDispatch, 177 | }; 178 | 179 | render( 180 | 181 |
192 | , 193 | ); 194 | 195 | fireEvent.change(screen.getByLabelText(/Drop files here, or click to browse files/i), { 196 | target: { files: [new File([], "filename")] }, 197 | }); 198 | expect(mockDispatch).toHaveBeenCalledWith({ 199 | type: "APPEND_FILE_DATA", 200 | payload: { files: [new File([], "filename")] }, 201 | }); 202 | }); 203 | it("onChange should be called if passed as props", async () => { 204 | const onChange = vi.fn(); 205 | 206 | const mockFileContext = { 207 | state: { 208 | fileData: [], 209 | fileState: mockFileState, 210 | componentState: { ...mockComponentState, removeFile: false, zoom: false }, 211 | imageEditorState: mockImageEditorState, 212 | }, 213 | dispatch: mockDispatch, 214 | }; 215 | 216 | render( 217 | 218 |
230 | , 231 | ); 232 | 233 | fireEvent.change(screen.getByLabelText(/Drop files here, or click to browse files/i), { 234 | target: { files: [new File([], "filename")] }, 235 | }); 236 | expect(onChange).toBeCalled(); 237 | }); 238 | 239 | it("onChange should be called if passed as props and when fileData exists", async () => { 240 | const onChange = vi.fn(); 241 | 242 | const mockFileContext = { 243 | state: { 244 | fileData: [new File(["test content"], "test.txt", { type: "text/plain" })], 245 | fileState: mockFileState, 246 | componentState: { ...mockComponentState, removeFile: true }, 247 | imageEditorState: mockImageEditorState, 248 | }, 249 | dispatch: mockDispatch, 250 | }; 251 | render( 252 | 253 |
264 | , 265 | ); 266 | 267 | fireEvent.change(screen.getByLabelText(/Add more/i), { 268 | target: { files: [new File([], "filename")] }, 269 | }); 270 | expect(onChange).toBeCalled(); 271 | }); 272 | 273 | it("If fileState is zoom, ImageSlider component should render", async () => { 274 | const mockFileContext = { 275 | state: { 276 | fileData: [new File(["test content"], "test.txt", { type: "text/plain" })], 277 | fileState: { ...mockFileState, zoom: true, fileName: "test.txt" }, 278 | componentState: { ...mockComponentState, removeFile: true }, 279 | imageEditorState: mockImageEditorState, 280 | }, 281 | dispatch: vi.fn(), 282 | }; 283 | render( 284 | 285 |
296 | , 297 | ); 298 | 299 | expect(screen.getByText(/test.txt/i)).toBeInTheDocument(); 300 | }); 301 | 302 | it("renders browse files input when fileData is empty", () => { 303 | const getFiles = vi.fn(); 304 | render( 305 | 306 |
316 | , 317 | ); 318 | 319 | expect(getFiles).toBeCalled(); 320 | }); 321 | 322 | it("handleDragOver should set dropEffect to 'copy'", async () => { 323 | const mockFileContext = { 324 | state: { 325 | fileData: [], 326 | fileState: { ...mockFileState, zoom: false, fileName: "test.txt" }, 327 | componentState: { ...mockComponentState, removeFile: true }, 328 | imageEditorState: mockImageEditorState, 329 | }, 330 | dispatch: vi.fn(), 331 | }; 332 | render( 333 | 334 |
343 | , 344 | ); 345 | const divElement = screen.getByTestId("dropzone"); 346 | const dragEventMock = { 347 | preventDefault: vi.fn(), 348 | dataTransfer: { 349 | dropEffect: "", 350 | files: [ 351 | new File(["test file content"], "test-file.txt", { 352 | type: "text/plain", 353 | }), 354 | ], 355 | }, 356 | }; 357 | 358 | fireEvent.dragOver(divElement, dragEventMock); 359 | expect(dragEventMock.dataTransfer.dropEffect).toBe("copy"); 360 | }); 361 | 362 | it("handleDragLeave should set dropEffect to ''", async () => { 363 | const mockFileContext = { 364 | state: { 365 | fileData: [], 366 | fileState: { ...mockFileState, zoom: false, fileName: "test.txt" }, 367 | componentState: { ...mockComponentState, removeFile: true }, 368 | imageEditorState: mockImageEditorState, 369 | }, 370 | dispatch: vi.fn(), 371 | }; 372 | render( 373 | 374 |
383 | , 384 | ); 385 | const divElement = screen.getByTestId("dropzone"); 386 | const dragEventMock = { 387 | preventDefault: vi.fn(), 388 | dataTransfer: { 389 | dropEffect: "", 390 | files: [ 391 | new File(["test file content"], "test-file.txt", { 392 | type: "text/plain", 393 | }), 394 | ], 395 | }, 396 | }; 397 | 398 | fireEvent.dragLeave(divElement, dragEventMock); 399 | expect(dragEventMock.dataTransfer.dropEffect).toBe(""); 400 | }); 401 | 402 | it("throw error and call onError when maxfiles limit exceeds", () => { 403 | const files = [ 404 | new File(["file1"], "file1.txt"), 405 | new File(["file2"], "file2.txt"), 406 | new File(["file3"], "file3.txt"), 407 | ]; 408 | const mockOnError = vi.fn(); 409 | 410 | const mockFileContext = { 411 | state: { 412 | fileData: files, 413 | fileState: { ...mockFileState, zoom: false, fileName: "test.txt" }, 414 | componentState: { ...mockComponentState, removeFile: true }, 415 | imageEditorState: mockImageEditorState, 416 | }, 417 | dispatch: vi.fn(), 418 | }; 419 | const renderMainComponent = () => { 420 | return render( 421 | 422 |
423 | , 424 | ); 425 | }; 426 | 427 | expect(() => { 428 | renderMainComponent(); 429 | }).toThrow("Max 2 files are allowed to be selected"); 430 | expect(mockOnError).toHaveBeenCalledWith(new Error("Max 2 files are allowed to be selected")); 431 | }); 432 | 433 | it("throw error and call onError when a maxfileSize exceeds", () => { 434 | const file1 = new File(["file1"], "file1.txt"); 435 | Object.defineProperty(file1, "size", { value: 100 }); 436 | 437 | const file2 = new File(["file2"], "file2.txt"); 438 | Object.defineProperty(file2, "size", { value: 5000000 }); 439 | const mockOnError = vi.fn(); 440 | 441 | const mockFileContext = { 442 | state: { 443 | fileData: [file1, file2], 444 | fileState: { ...mockFileState, zoom: false, fileName: "test.txt" }, 445 | componentState: { ...mockComponentState, removeFile: true }, 446 | imageEditorState: mockImageEditorState, 447 | }, 448 | dispatch: vi.fn(), 449 | }; 450 | const renderMainComponent = () => { 451 | return render( 452 | 453 |
454 | , 455 | ); 456 | }; 457 | 458 | expect(() => { 459 | renderMainComponent(); 460 | }).toThrow("File size limit exceeded: file2.txt"); 461 | expect(mockOnError).toHaveBeenCalledWith(new Error("File size limit exceeded: file2.txt")); 462 | }); 463 | 464 | it("should have disabled cursor class if disabled is true", () => { 465 | const { container } = render( 466 | 467 |
477 | , 478 | ); 479 | expect(container.getElementsByClassName("rfp-cursor-not-allowed").length).toBe(3); 480 | }); 481 | 482 | it("should have disabled cursor class if files not empty and disabled is true", async () => { 483 | const mockFileContext = { 484 | state: { 485 | fileData: [new File(["test content"], "test.txt", { type: "text/plain" })], 486 | fileState: mockFileState, 487 | componentState: { ...mockComponentState, removeFile: true, disabled: true }, 488 | imageEditorState: mockImageEditorState, 489 | }, 490 | dispatch: mockDispatch, 491 | }; 492 | const { container } = render( 493 | 494 |
495 | , 496 | ); 497 | 498 | expect(container.getElementsByClassName("rfp-cursor-not-allowed").length).toBe(2); 499 | }); 500 | it("should class onClick function", async () => { 501 | const onClick = vi.fn(); 502 | const mockFileContext = { 503 | state: { 504 | fileData: [new File(["test content"], "test.txt", { type: "text/plain" })], 505 | fileState: mockFileState, 506 | componentState: { ...mockComponentState, removeFile: true, disabled: true }, 507 | imageEditorState: mockImageEditorState, 508 | }, 509 | dispatch: mockDispatch, 510 | }; 511 | const { container } = render( 512 | 513 |
514 | , 515 | ); 516 | 517 | fireEvent.click(container.getElementsByClassName("rfp-relative")[0]); 518 | 519 | expect(onClick).toHaveBeenCalledTimes(1); 520 | }); 521 | it("should class onRemove function if click on remove button", async () => { 522 | const remove = vi.fn(); 523 | const mockFileContext = { 524 | state: { 525 | fileData: [new File(["test content"], "test.txt", { type: "text/plain" })], 526 | fileState: mockFileState, 527 | componentState: { ...mockComponentState, removeFile: true, disabled: true }, 528 | imageEditorState: mockImageEditorState, 529 | }, 530 | dispatch: mockDispatch, 531 | }; 532 | render( 533 | 534 |
535 | , 536 | ); 537 | 538 | fireEvent.click(screen.getByTestId("remove-file-button")); 539 | 540 | expect(remove).toHaveBeenCalledTimes(1); 541 | }); 542 | }); 543 | -------------------------------------------------------------------------------- /src/tests/SlideCount.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | import { FileContext } from "../context/FileContext"; 4 | import SlideCount from "../components/SlideCount"; 5 | import { vi } from "vitest"; 6 | 7 | describe("SlideCount component", () => { 8 | 9 | const mockComponentState = { 10 | showFileSize: true, 11 | showSliderCount: true, 12 | downloadFile: true, 13 | removeFile: true, 14 | rounded: true, 15 | fileHeight: "h-32", 16 | fileWidth: "w-44", 17 | disabled: false, 18 | allowEditing: false 19 | }; 20 | 21 | const mockFileState = { 22 | zoom: true, 23 | fileSrc: "image.jpg", 24 | index: 0, 25 | isImage: true, 26 | fileName: "image.jpg", 27 | type: "image/jpeg", 28 | size: 1000, 29 | }; 30 | const mockImageEditorState = { 31 | isEditing: false, 32 | file: null, 33 | index: null 34 | }; 35 | 36 | const mockDispatch = vi.fn(); 37 | 38 | it("displays the current slide count", () => { 39 | 40 | const mockFileContext = { 41 | state: { 42 | fileData: [ 43 | new File(["test content"], "test1.txt"), 44 | new File(["test content"], "test2.txt"), 45 | new File(["test content"], "test3.txt"), 46 | ], 47 | fileState: mockFileState, 48 | componentState: mockComponentState, 49 | imageEditorState: mockImageEditorState 50 | }, 51 | dispatch: mockDispatch, 52 | }; 53 | 54 | render( 55 | 58 | 59 | 60 | ); 61 | 62 | const slideCountElement = screen.getByText("1 of 3"); 63 | expect(slideCountElement).toBeInTheDocument(); 64 | }); 65 | 66 | it("does not display slide count if fileState index is null", () => { 67 | const mockFileContext = { 68 | state: { 69 | fileData: [ 70 | new File(["test content"], "test1.txt"), 71 | new File(["test content"], "test2.txt"), 72 | new File(["test content"], "test3.txt"), 73 | ], 74 | fileState: { ...mockFileState, index: 1 }, 75 | componentState: mockComponentState, 76 | imageEditorState: mockImageEditorState 77 | }, 78 | dispatch: mockDispatch, 79 | }; 80 | 81 | render( 82 | 85 | 86 | 87 | ); 88 | 89 | const slideCountElement = screen.queryByText("3 of 3"); 90 | expect(slideCountElement).not.toBeInTheDocument(); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /src/tests/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | import matchers from '@testing-library/jest-dom/matchers'; 3 | import { cleanup } from '@testing-library/react'; 4 | import { afterEach, vi } from 'vitest'; 5 | 6 | expect.extend(matchers); 7 | afterEach(() => { 8 | cleanup(); 9 | vi.restoreAllMocks(); 10 | }); 11 | 12 | global.fetch = vi.fn(); 13 | global.URL.createObjectURL = vi.fn(); 14 | global.URL.revokeObjectURL = vi.fn(); -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | prefix: "rfp-", 9 | }; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "declaration": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | }, 20 | "include": ["src"], 21 | "references": [{ "path": "./tsconfig.node.json" }] 22 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import path from "path"; 4 | import dts from "vite-plugin-dts"; 5 | import { libInjectCss } from "vite-plugin-lib-inject-css"; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | react(), 11 | dts({ 12 | insertTypesEntry: true, 13 | }), 14 | libInjectCss(), 15 | ], 16 | build: { 17 | lib: { 18 | entry: path.resolve(__dirname, "src/index.tsx"), 19 | name: "ReactFilesPreview", 20 | fileName: (format) => `react-files-preview.${format}.js`, 21 | formats: ["es"], 22 | }, 23 | rollupOptions: { 24 | external: ["react", "react-dom", "react/jsx-runtime"], 25 | output: { 26 | globals: { 27 | react: "React", 28 | }, 29 | }, 30 | }, 31 | minify: true, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: "jsdom", 6 | globals: true, 7 | setupFiles: "./src/tests/setup.ts", 8 | }, 9 | }); --------------------------------------------------------------------------------