├── .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 | 
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
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 |
73 |
81 |
82 |
86 |
87 |
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 |
29 | + Add more
30 | {
35 | props.handleImage(e);
36 | if (props.onChange) {
37 | props.onChange(e);
38 | }
39 | }}
40 | style={{ display: "none" }}
41 | multiple={props.multiple ?? true}
42 | accept={props.accept ?? ""}
43 | />
44 |
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 |
120 |
126 |
133 |
138 |
139 |
140 |
147 | {file.isImage ? (
148 | file.fileSrc && (
149 |
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 |
185 |
192 |
197 |
198 |
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 |
e.stopPropagation()}
334 | >
335 | Drop files here, or click to browse files
336 | {
341 | handleImage(e);
342 | if (onChange) {
343 | onChange(e);
344 | }
345 | }}
346 | multiple={multiple ?? true}
347 | accept={accept ?? ""}
348 | style={{ display: "none" }}
349 | />
350 |
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 | });
--------------------------------------------------------------------------------