├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .prettierrc ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __mocks__ └── fileMock.js ├── babel.config.js ├── example ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.js │ ├── App.test.js │ ├── components │ │ ├── Carousel1.js │ │ ├── Carousel2.js │ │ ├── Carousel3.js │ │ ├── Carousel4.js │ │ ├── Carousel5.js │ │ ├── Carousel6.js │ │ ├── Carousel7.js │ │ ├── Carousel8.js │ │ ├── Footer.js │ │ └── Header.js │ ├── index.css │ ├── index.js │ └── utils │ │ └── TwoWayMap.js └── yarn.lock ├── package.json ├── react-gallery-carousel-gatsby ├── .gitignore ├── README.md ├── gatsby-config.js ├── package.json └── src │ ├── images │ └── icon.png │ └── pages │ ├── 404.js │ └── index.js ├── react-gallery-carousel-nextjs ├── .gitignore ├── README.md ├── components │ ├── Carousel1.js │ ├── Carousel2.js │ ├── Carousel3.js │ ├── Carousel4.js │ └── Carousel5.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages │ ├── _app.js │ └── index.js ├── public │ ├── favicon.ico │ └── vercel.svg └── styles │ ├── Home.module.css │ └── globals.css ├── src ├── .eslintrc ├── common │ └── common.scss ├── components │ ├── Carousel │ │ ├── Carousel.js │ │ ├── Carousel.module.css │ │ ├── Carousel.test.js │ │ ├── __snapshots__ │ │ │ └── Carousel.test.js.snap │ │ ├── constants.js │ │ ├── index.js │ │ └── props.js │ ├── IconButton │ │ ├── IconButton.js │ │ ├── IconButton.module.css │ │ └── index.js │ ├── Image │ │ ├── Image.js │ │ ├── Image.module.css │ │ ├── ImageThumbnail.js │ │ ├── constants.js │ │ └── index.js │ ├── Slide │ │ ├── Slide.js │ │ ├── Slide.module.css │ │ └── index.js │ ├── Slides │ │ ├── Slides.js │ │ ├── Slides.module.css │ │ └── index.js │ ├── Thumbnail │ │ ├── Thumbnail.js │ │ ├── Thumbnail.module.scss │ │ └── index.js │ ├── Thumbnails │ │ ├── Thumbnails.js │ │ ├── Thumbnails.module.scss │ │ └── index.js │ ├── UserSlide │ │ ├── UserSlide.js │ │ ├── UserSlide.module.scss │ │ └── index.js │ └── Widgets │ │ ├── Widgets.js │ │ ├── Widgets.module.css │ │ └── index.js ├── index.js ├── index.test.js └── utils │ ├── ReversedMap.js │ ├── SlidesFactory.js │ ├── SlidesFactory.test.js │ ├── isSSR.js │ ├── useAnchor.js │ ├── useEventListener.js │ ├── useFixedPosition.js │ ├── useIntersectionObserver.js │ ├── useKeyboard.js │ ├── useKeys.js │ ├── useMediaQuery.js │ ├── useMouse.js │ ├── useMouseDrag.js │ ├── useNoDrag.js │ ├── useNoOverScroll.js │ ├── useNoSwipe.js │ ├── useSlides.js │ ├── useSwipe.js │ ├── useTimer.js │ ├── useTouch.js │ └── validators.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | node_modules/ 4 | public/ 5 | .snapshots/ 6 | *.min.js 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "standard", 5 | "standard-react", 6 | "plugin:prettier/recommended", 7 | "prettier/standard", 8 | "prettier/react" 9 | ], 10 | "env": { 11 | "node": true, 12 | "jest": true 13 | }, 14 | "parserOptions": { 15 | "ecmaVersion": 2020, 16 | "ecmaFeatures": { 17 | "legacyDecorators": true, 18 | "jsx": true 19 | } 20 | }, 21 | "settings": { 22 | "react": { 23 | "version": "17" 24 | } 25 | }, 26 | "plugins": [ 27 | "react-hooks" 28 | ], 29 | "rules": { 30 | "space-before-function-paren": 0, 31 | "react/prop-types": 0, 32 | "react/jsx-handler-names": 0, 33 | "react/jsx-fragments": 0, 34 | "react/no-unused-prop-types": 0, 35 | "import/export": 0, 36 | "react-hooks/rules-of-hooks": "error", 37 | "react-hooks/exhaustive-deps": "warn" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: yifaneye 2 | patreon: yifanai 3 | buymeacoffee: yifanai 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "semi": true, 5 | "tabWidth": 2, 6 | "bracketSpacing": true, 7 | "jsxBracketSameLine": false, 8 | "arrowParens": "always", 9 | "trailingComma": "none" 10 | } 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 14 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ```react-gallery-carousel``` 2 | 3 | [![NPM](https://img.shields.io/npm/v/react-gallery-carousel.svg)](https://www.npmjs.com/package/react-gallery-carousel) 4 | 5 | ## v0.4.0 (2023-01-28) 6 | 7 | ### Enhancements 8 | 9 | - Add support for custom elements. 10 | 11 | - Improve format in the README file. 12 | 13 | ## v0.3.0 (2022-10-15) 14 | 15 | ### Bug Fixes 16 | 17 | - Fix issue related to maximizing/minimizing not working when shouldMaximizeOnClick/shouldMinimizeOnClick is on, for Toggle Device Toolbar (simulated mobile devices). 18 | ([Issue #74](https://github.com/yifaneye/react-gallery-carousel/issues/74)) 19 | 20 | - Fix issue related to `npm install` not working for `/example`. 21 | ([Issue #72](https://github.com/yifaneye/react-gallery-carousel/issues/72)) 22 | 23 | ### Enhancements 24 | 25 | - Update package dependencies. ([Issue #71](https://github.com/yifaneye/react-gallery-carousel/issues/71)) 26 | 27 | ## v0.2.10 (2022-08-03) 28 | 29 | ### Enhancements 30 | 31 | - Add react 18 to peer dependencies. 32 | 33 | ## v0.2.9 (2022-01-30) 34 | 35 | ### Bug Fixes 36 | 37 | - Third attempt to fix swiping stuck after zoom issue. 38 | ([Issue #44](https://github.com/yifaneye/react-gallery-carousel/issues/44)) 39 | 40 | ## v0.2.8 (2021-12-23) 41 | 42 | ### Bug Fixes 43 | 44 | - Second attempt to fix swiping stuck after zoom issue. 45 | ([Issue #44](https://github.com/yifaneye/react-gallery-carousel/issues/44)) 46 | 47 | ### Enhancements 48 | 49 | - Simplify Gatsby example 50 | 51 | ## v0.2.7 (2021-12-18) 52 | 53 | ### Enhancements 54 | 55 | - Fix "invalid or unexpected token" issue in Next.js. 56 | ([Issue #51](https://github.com/yifaneye/react-gallery-carousel/issues/51)) 57 | 58 | - Remove request to "object Object" 59 | ([Issue #52](https://github.com/yifaneye/react-gallery-carousel/issues/52)) 60 | 61 | ## v0.2.6 (2021-12-06) 62 | 63 | ### Enhancements 64 | 65 | - Fix swiping stuck after zoom issue. 66 | ([Issue #44](https://github.com/yifaneye/react-gallery-carousel/issues/44)) 67 | 68 | - Remove title for icons. 69 | ([Issue #60](https://github.com/yifaneye/react-gallery-carousel/issues/60)) 70 | 71 | ## v0.2.5 (2021-10-30) 72 | 73 | ### Enhancements 74 | 75 | - Added **support for custom children for the thumbnails**. 76 | ([Issue #40](https://github.com/yifaneye/react-gallery-carousel/issues/40)) 77 | 78 | - Enhanced docs. 79 | 80 | ## v0.2.4 (2021-08-22) 81 | 82 | ### Enhancements 83 | 84 | - Enhanced docs. 85 | ([Issue #43](https://github.com/yifaneye/react-gallery-carousel/issues/43)) 86 | 87 | - Enhanced code and examples. 88 | - Set up momentum-based mouse dragging on the thumbnails. 89 | 90 | ## v0.2.3 (2021-07-12) 91 | 92 | ### Enhancements 93 | 94 | - Made the code robuster. 95 | ([Issue #31](https://github.com/yifaneye/react-gallery-carousel/issues/31)) 96 | 97 | - Enhanced docs. 98 | ([Issue #35](https://github.com/yifaneye/react-gallery-carousel/issues/35)) 99 | 100 | - Added **support for server-side rendering (SSR)**. 101 | ([Issue #36](https://github.com/yifaneye/react-gallery-carousel/issues/36)) 102 | 103 | - Enhanced examples. 104 | ([Issue #45](https://github.com/yifaneye/react-gallery-carousel/issues/45)) 105 | 106 | ## v0.2.2 (2021-05-16) 107 | 108 | ### Enhancements 109 | 110 | - Enhanced the documentation regarding user-managed slides. 111 | ([Issue #28](https://github.com/yifaneye/react-gallery-carousel/issues/28)) 112 | 113 | - Updated the [demo site](https://yifanai.com/rgc). 114 | 115 | ## v0.2.1 (2021-05-08) 116 | 117 | ### Bug Fixes 118 | 119 | - Fixed issue where caption for an image always has vertical and horizontal scroll bars even they are not required. 120 | ([Issue #26](https://github.com/yifaneye/react-gallery-carousel/issues/26)) 121 | 122 | ### Enhancements 123 | 124 | - Enhanced content on the [demo site](https://yifanai.com/rgc). 125 | 126 | - Enhanced examples and content in the [documentation](https://yifanai.com/rgc). 127 | ([Issue #28](https://github.com/yifaneye/react-gallery-carousel/issues/28)) 128 | 129 | ## v0.2.0 (2021-05-02) 130 | 131 | ### Bug Fixes 132 | 133 | - Fixed a bug where left and right keys do not work when a widget is focused by listening keydown events on the entire carousel. 134 | ([Issue #15](https://github.com/yifaneye/react-gallery-carousel/issues/15)) 135 | 136 | - Fixed a bug where swiping on a slide does not freeze autoplay countdown. 137 | ([Issue #18](https://github.com/yifaneye/react-gallery-carousel/issues/18)) 138 | 139 | ### Enhancements 140 | 141 | - Allowed the size of the thumbnails to be customized. 142 | ([Issue #11](https://github.com/yifaneye/react-gallery-carousel/issues/11)) 143 | 144 | - Added handlers and listeners to the carousel. 145 | ([Issue #12](https://github.com/yifaneye/react-gallery-carousel/issues/12)) 146 | 147 | - Enabled smooth transition on the thumbnails. 148 | ([Issue #13](https://github.com/yifaneye/react-gallery-carousel/issues/13)) 149 | 150 | - Added aria-live region for accessibility. 151 | 152 | ## v0.1.4 (2021-04-18) 153 | 154 | ### Bug Fixes 155 | 156 | - Fixed a minor bug where required props is undefined by defining default props. 157 | ([Issue #7](https://github.com/yifaneye/react-gallery-carousel/issues/7)) 158 | 159 | ### Enhancements 160 | 161 | - Enhanced content and styles on the [demo site](https://yifanai.com/rgc). 162 | 163 | ## v0.1.3 (2021-04-12) 164 | 165 | ### Bug Fixes 166 | 167 | - Fixed a bug where touch swiping did not work on the slides when the images are dynamically set in the useEffect() hook by developer users. 168 | ([Issue #5](https://github.com/yifaneye/react-gallery-carousel/issues/5)) 169 | 170 | ### Enhancements 171 | 172 | - Enhanced content, styles, responsiveness and performance (from 98% to 100%) on the [demo site](https://yifanai.com/rgc). 173 | 174 | ## v0.1.2 (2021-04-11) 175 | 176 | ### Bug Fixes 177 | 178 | - Fixed a bug where the last slide is not displayed starting from the second loop when there are exactly 2 slides. 179 | ([Issue #2](https://github.com/yifaneye/react-gallery-carousel/issues/2)) 180 | 181 | ### Enhancements 182 | 183 | - Removed the use of translate3d to make the performance consistent across browsers and platforms. 184 | - Reduced the file size of the fallback image from 7.59 kB to 3.35 kB. 185 | - Made the text displayed in the index board robuster. 186 | 187 | ## v0.1.1 (2021-04-10) 188 | 189 | ### Bug Fixes 190 | 191 | - Fixed a bug where image URLs those are not known ahead of time cannot be placed into the carousel. 192 | ([Issue #1](https://github.com/yifaneye/react-gallery-carousel/issues/1)) 193 | 194 | ### Enhancements 195 | 196 | - Enabled hitting Enter (Return) key on the focused thumbnail to display that image as the current image. 197 | - Enabled selecting text in the index board using cursor or finger. 198 | - Removed the effect of hover on thumbnails on touch devices. 199 | 200 | ## v0.1.0 (2021-03-31) 201 | 202 | ### Initial work 203 | 204 | - Complete a dependency-free React carousel component with support for lazy loading, pinch zoom, touch swiping, mouse dragging, velocity detection, maximization, thumbnails, keyboard navigation and accessibility. 205 | -------------------------------------------------------------------------------- /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 | admin@yifanai.com. 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 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes props, environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of 1 other developer, or if you 17 | do not have permission to do that, you may request the reviewer to merge it for you. 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Yifan Ai 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 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = ''; 2 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-env', '@babel/preset-react'] 3 | }; 4 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | This example was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | It is linked to the react-gallery-carousel package in the parent directory for development purposes. 4 | 5 | You can run `yarn install` and then `yarn start` to test your package. 6 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-gallery-carousel-example", 3 | "homepage": ".", 4 | "version": "0.0.0", 5 | "private": true, 6 | "scripts": { 7 | "start": "node ../node_modules/react-scripts/bin/react-scripts.js start", 8 | "build": "node ../node_modules/react-scripts/bin/react-scripts.js build", 9 | "test": "node ../node_modules/react-scripts/bin/react-scripts.js test", 10 | "eject": "node ../node_modules/react-scripts/bin/react-scripts.js eject" 11 | }, 12 | "dependencies": { 13 | "react": "link:../node_modules/react", 14 | "react-dom": "link:../node_modules/react-dom", 15 | "react-gallery-carousel": "link:..", 16 | "react-github-btn": "^1.2.0", 17 | "react-responsive-button": "^0.2.1", 18 | "react-scripts": "link:../node_modules/react-scripts" 19 | }, 20 | "devDependencies": { 21 | "@babel/plugin-syntax-object-rest-spread": "^7.8.3", 22 | "puppeteer": "^7.1.0" 23 | }, 24 | "eslintConfig": { 25 | "extends": "react-app" 26 | }, 27 | "browserslist": [ 28 | ">0.2%", 29 | "not dead", 30 | "not ie <= 11", 31 | "not op_mini all" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yifaneye/react-gallery-carousel/a3f452b143f6c1149477a36e43ee4698e67d3363/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 30 | react-gallery-carousel 31 | 32 | 33 | 34 | 35 | 38 | 39 |
40 | 41 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "react-gallery-carousel", 3 | "name": "react-gallery-carousel", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # robots.txt for https://yifaneye.github.io/react-gallery-carousel/ 2 | 3 | User-agent: * 4 | Allow: * 5 | -------------------------------------------------------------------------------- /example/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Header from './components/Header'; 3 | import Carousel1 from './components/Carousel1'; 4 | import Carousel2 from './components/Carousel2'; 5 | import Carousel3 from './components/Carousel3'; 6 | import Carousel4 from './components/Carousel4'; 7 | import Carousel5 from './components/Carousel5'; 8 | import Carousel6 from './components/Carousel6'; 9 | import Carousel7 from './components/Carousel7'; 10 | import Carousel8 from './components/Carousel8'; 11 | import Footer from './components/Footer'; 12 | 13 | const imageIDs = Array(30) // the maximum is currently 149 14 | .fill(1) 15 | .map((_, i) => i + 1); 16 | const images = imageIDs.map((imageID) => { 17 | return { 18 | src: `https://placedog.net/400/240?id=${imageID}`, 19 | srcset: `https://placedog.net/400/240?id=${imageID} 400w, https://placedog.net/700/420?id=${imageID} 700w, https://placedog.net/1000/600?id=${imageID} 1000w`, 20 | sizes: '(max-width: 1000px) 400px, (max-width: 2000px) 700px, 1000px', 21 | alt: `Dog No. ${imageID}. Dogs are domesticated mammals, not natural wild animals.`, 22 | thumbnail: `https://placedog.net/100/60?id=${imageID}` 23 | }; 24 | }); 25 | 26 | const App = () => { 27 | return ( 28 |
29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
40 | ); 41 | }; 42 | 43 | export default App; 44 | -------------------------------------------------------------------------------- /example/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import puppeteer from 'puppeteer'; 5 | const { test } = global; 6 | 7 | // unit testing 8 | test('Carousel renders without crashing', () => { 9 | // since .scrollTo() isn't implemented in jsdom 10 | Element.prototype.scrollTo = () => {}; 11 | 12 | global.IntersectionObserver = function () { 13 | return { 14 | observe: () => {}, 15 | disconnect: () => {} 16 | }; 17 | }; 18 | 19 | Object.defineProperty(window, 'matchMedia', { 20 | writable: true, 21 | value: (query) => ({ 22 | matches: false, 23 | media: query, 24 | onchange: null, 25 | addEventListener: () => {}, 26 | removeEventListener: () => {} 27 | }) 28 | }); 29 | 30 | const div = document.createElement('div'); 31 | ReactDOM.render(, div); 32 | ReactDOM.unmountComponentAtNode(div); 33 | }); 34 | 35 | // automated browser (e2e) testing 36 | test('Carousel can be controlled', async () => { 37 | const browser = await puppeteer.launch({ 38 | // headless: false, 39 | // slowMo: 100, 40 | // devtools: true, 41 | // product: 'firefox', 42 | // args: ['--window-size=998, 1178'] 43 | }); 44 | 45 | const page = await browser.newPage(); 46 | const navigationPromise = page.waitForNavigation(); 47 | await page.goto('http://localhost:3000'); 48 | await page.setViewport({ width: 998, height: 1178 }); 49 | await navigationPromise; 50 | await page.waitForTimeout(1000); 51 | 52 | // await page.waitForSelector('[aria-label="Go to Next Slide"]'); 53 | await page.click('[aria-label="Go to Next Slide"]'); 54 | 55 | // await page.waitForSelector('[aria-label="Go to Previous Slide"]'); 56 | await page.click('[aria-label="Go to Previous Slide"]'); 57 | 58 | // await page.waitForSelector('[aria-label="Maximize Slides"]'); 59 | await page.click('[aria-label="Maximize Slides"]'); 60 | 61 | // await page.waitForSelector('[aria-label="Minimize Slides"]'); 62 | await page.click('[aria-label="Minimize Slides"]'); 63 | 64 | // await page.waitForSelector('[aria-label="Go to Slide 3"]'); 65 | await page.click('[aria-label="Go to Slide 3"]'); 66 | 67 | // const index = await page.$eval( 68 | // '[aria-label="Slide 3 of 3"]', 69 | // (el) => el.textContent 70 | // ); 71 | // expect(index).toBe('3 / 3'); 72 | 73 | // await page.waitForSelector('[aria-label="Start Autoplay"]'); 74 | await page.click('[aria-label="Start Autoplay"]'); 75 | 76 | // await page.waitForSelector('[aria-label="Pause Autoplay"]'); 77 | await page.click('[aria-label="Pause Autoplay"]'); 78 | 79 | // await page.screenshot({ path: 'record/screenshot.jpg' }); 80 | // await page.pdf({ path: 'record/print.pdf', format: 'a4' }); 81 | 82 | await browser.close(); 83 | }); 84 | -------------------------------------------------------------------------------- /example/src/components/Carousel1.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TwoWayMap from '../utils/TwoWayMap'; 3 | import Carousel from 'react-gallery-carousel'; 4 | import 'react-gallery-carousel/dist/index.css'; 5 | 6 | const Carousel1 = () => { 7 | const indexToTitle = new TwoWayMap({ 8 | 0: 'Introduction', 9 | 1: 'Get%20Started', 10 | 2: 'Usage' 11 | }); 12 | 13 | return ( 14 |
15 |
16 |

17 | Example 1: Customized carousel with user-managed slides{' '} 18 | code 19 |

20 |

21 | This example has custom elements in slides (user-managed slides) using 22 | the children prop; and custom widget positions. 23 |

24 |
25 |
26 | { 40 | // const title = indexToTitle.get(curIndex); 41 | // window.history.replaceState( 42 | // undefined, 43 | // undefined, 44 | // `#${title}` 45 | // ); 46 | // document.title = `${title} | react-gallery-carousel`; 47 | // }} // this callback can be set to update the document title and URL hash on index update 48 | style={{ userSelect: 'text' }} 49 | > 50 |
51 |

Introduction

52 |

53 | react-gallery-carousel is a mobile-friendly 54 | dependency-free React carousel component with support for touch, 55 | mouse dragging, lazy loading, thumbnails, modal, keyboard 56 | navigation, RTL and pinch to zoom. 57 |

58 | Demo 59 | / 60 | 61 | GitHub 62 | 63 | / 64 | 65 | npm 66 | 67 | / 68 | 69 | Documentation 70 | 71 |
72 |
73 |

Get Started

74 | npm install react-gallery-carousel --save 75 |
76 | or 77 |
78 | yarn add react-gallery-carousel 79 |
80 |
81 |

Usage

82 |

The default carousel shown below as example 2 is created by:

83 | {''} 84 |
85 |
86 |
87 |
88 | ); 89 | }; 90 | 91 | export default Carousel1; 92 | -------------------------------------------------------------------------------- /example/src/components/Carousel2.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Carousel from 'react-gallery-carousel'; 3 | import 'react-gallery-carousel/dist/index.css'; 4 | 5 | const Carousel2 = ({ images }) => { 6 | return ( 7 |
8 |
9 |

10 | Example 2: Default carousel with images{' '} 11 | code 12 |

13 |

14 | A default carousel example has lazy loading and preloading (the 2 15 | adjacent images on either side of the current image); touch swiping 16 | and mouse dragging on the carousel; touch swiping, mouse dragging and 17 | wheel scrolling on the thumbnails; touch swipe down to exit the 18 | maximized carousel; and keyboard navigation. 19 |

20 |
21 |
22 | 23 |
24 |
25 | ); 26 | }; 27 | 28 | export default Carousel2; 29 | -------------------------------------------------------------------------------- /example/src/components/Carousel3.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Carousel from 'react-gallery-carousel'; 3 | import 'react-gallery-carousel/dist/index.css'; 4 | 5 | const Carousel3 = ({ images }) => { 6 | return ( 7 |
8 |
9 |

10 | Example 3: Default carousel with images and with right-to-left (RTL){' '} 11 | code 12 |

13 |

14 | A default carousel example has lazy loading and preloading; touch 15 | swiping and mouse dragging on the carousel; touch swiping, mouse 16 | dragging and wheel scrolling on the thumbnails; touch swipe down to 17 | exit the maximized carousel; and keyboard navigation. 18 |

19 |
20 |
21 | 22 |
23 |
24 | ); 25 | }; 26 | 27 | export default Carousel3; 28 | -------------------------------------------------------------------------------- /example/src/components/Carousel4.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import Carousel from 'react-gallery-carousel'; 3 | import 'react-gallery-carousel/dist/index.css'; 4 | 5 | const Carousel4 = ({ images }) => { 6 | const [dynamicImages, setDynamicImages] = useState([]); 7 | 8 | useEffect(() => { 9 | setDynamicImages(images); 10 | }, [setDynamicImages, images]); 11 | 12 | return ( 13 |
14 |
15 |

16 | Example 4: Customized carousel with dynamic images{' '} 17 | (available from v0.1.1){' '} 18 | code 19 |

20 |

21 | This example has images dynamically set in the{' '} 22 | useEffect() hook. This customized example additionally 23 | has click to enter and exit the maximized carousel; 24 | custom initial index; custom widget positions; custom thumbnails, 25 | custom dot buttons and captions for the maximized carousel; custom 26 | active and passive dot buttons; and custom styles for the 27 | non-maximized carousel. 28 |

29 |
30 |
31 | 51 | 🔳 52 | 53 | } 54 | passiveIcon={ 55 | 56 | 🔲 57 | 58 | } 59 | /> 60 |
61 |
62 | ); 63 | }; 64 | 65 | export default Carousel4; 66 | -------------------------------------------------------------------------------- /example/src/components/Carousel5.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import Carousel from 'react-gallery-carousel'; 3 | import 'react-gallery-carousel/dist/index.css'; 4 | import { Button } from 'react-responsive-button'; 5 | import 'react-responsive-button/dist/index.css'; 6 | 7 | const Carousel5 = ({ images }) => { 8 | const carouselRef = useRef(null); 9 | 10 | return ( 11 |
12 |
13 |

14 | Example 5: Default carousel with imperative handlers{' '} 15 | (available from v0.2.0){' '} 16 | code 17 |

18 |

19 | To customize the carousel in a declarative manner, pass the props 20 | (e.g. isAutoPlaying, isMaximized,{' '} 21 | index). To customize the carousel in an imperative 22 | manner, use the following handlers (on the ref): 23 |

24 |
25 |
26 |
27 | 33 |
34 |
35 | 41 |
42 |
43 | 49 |
50 |
51 |
52 |
53 | 60 |
61 |
62 | 69 |
70 |
71 | 78 |
79 |
80 |
81 |
82 | 89 |
90 |
91 | 98 |
99 |
100 | 107 |
108 |
109 |
110 |
111 |
112 | 113 |
114 |
115 | ); 116 | }; 117 | 118 | export default Carousel5; 119 | -------------------------------------------------------------------------------- /example/src/components/Carousel6.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Carousel from 'react-gallery-carousel'; 3 | import 'react-gallery-carousel/dist/index.css'; 4 | 5 | const Carousel6 = ({ images }) => { 6 | const thumbnails = images.map((_, index) => ( 7 | 8 | {index + 1} 9 | 10 | )); 11 | const imageElements = images.map((image, index) => ( 12 | {image.alt} 18 | )); 19 | 20 | return ( 21 |
22 |
23 |

24 | Example 6: Carousel with custom children and custom thumbnails{' '} 25 | code 26 |

27 |
28 |
29 | {imageElements} 30 |
31 |
32 | ); 33 | }; 34 | 35 | export default Carousel6; 36 | -------------------------------------------------------------------------------- /example/src/components/Carousel7.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import Carousel from 'react-gallery-carousel'; 3 | import 'react-gallery-carousel/dist/index.css'; 4 | 5 | const Carousel7 = ({ images }) => { 6 | const carouselRef = useRef(null); 7 | 8 | return ( 9 |
10 |
11 |

12 | Example 7: Default carousel with custom icons{' '} 13 | code 14 |

15 |

16 | To customize the icons, pass custom icon component to props (e.g.{' '} 17 | leftIcon, rightIcon). 18 |

19 |
20 |
21 | 36 | ↗️ 37 | 38 | } 39 | minIcon={ 40 | 48 | ↙️ 49 | 50 | } 51 | leftIcon={ 52 | 60 | ◀️ 61 | 62 | } 63 | rightIcon={ 64 | 72 | ▶️ 73 | 74 | } 75 | /> 76 |
77 |
78 | ); 79 | }; 80 | 81 | export default Carousel7; 82 | -------------------------------------------------------------------------------- /example/src/components/Carousel8.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import Carousel from 'react-gallery-carousel'; 3 | import 'react-gallery-carousel/dist/index.css'; 4 | import { Button } from 'react-responsive-button'; 5 | 6 | const Carousel8 = ({ images }) => { 7 | const carouselRef = useRef(null); 8 | 9 | return ( 10 |
11 |
12 |

13 | Example 8: Default carousel with custom elements{' '} 14 | (available from v0.4.0){' '} 15 | code 16 |

17 |

18 | To use custom elements, set the widget props (e.g.{' '} 19 | hasLeftButton, hasRightButton) to{' '} 20 | false, then pass the custom elements to the{' '} 21 | elements prop. 22 |

23 |
24 |
25 | 36 | 58 | 80 | 81 | } 82 | /> 83 |
84 |
85 | ); 86 | }; 87 | 88 | export default Carousel8; 89 | -------------------------------------------------------------------------------- /example/src/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import GitHubButton from 'react-github-btn'; 3 | 4 | const Footer = () => { 5 | return ( 6 | 42 | ); 43 | }; 44 | 45 | export default Footer; 46 | -------------------------------------------------------------------------------- /example/src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import GitHubButton from 'react-github-btn'; 3 | 4 | const Header = () => { 5 | return ( 6 |
7 |
8 |

react-gallery-carousel

9 |

10 | Mobile-friendly Carousel with batteries included (supporting touch, 11 | mouse emulation, lazy loading, thumbnails, fullscreen, RTL, keyboard 12 | navigation and customisations). 13 |

14 |
15 | 21 | Star 22 | 23 |
24 |
25 |
26 | ); 27 | }; 28 | 29 | export default Header; 30 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | padding: 0.2em 0.4em; 15 | margin: 0; 16 | font-size: 85%; 17 | background-color: rgba(27, 31, 35, 0.05); 18 | border-radius: 6px; 19 | } 20 | 21 | .text-slide { 22 | padding: 1rem 50px; 23 | } 24 | 25 | .vertical-separator { 26 | margin: 1em 0; 27 | } 28 | 29 | .carousel-page { 30 | max-width: 1200px; 31 | margin: 0 auto; 32 | overflow: hidden; 33 | } 34 | 35 | .carousel-page-header-container { 36 | width: 100%; 37 | } 38 | 39 | .carousel-page-header { 40 | display: flex; 41 | flex-direction: column; 42 | align-items: center; 43 | padding: 0 1em; 44 | } 45 | 46 | .action-container { 47 | padding: 0 1em; 48 | } 49 | 50 | .star-button-container { 51 | height: 20px; 52 | margin-top: 10px; 53 | margin-bottom: 10px; 54 | } 55 | 56 | .section { 57 | margin-bottom: 60px; 58 | } 59 | 60 | .section-header { 61 | padding: 0 1em; 62 | } 63 | 64 | .carousel-container { 65 | width: 100%; 66 | height: calc(100vw * 0.8); 67 | } 68 | 69 | @media only screen and (min-width: 500px) and (max-width: 1280px) { 70 | .carousel-container { 71 | height: calc(100vw * 0.6); 72 | max-height: 768px; 73 | } 74 | } 75 | 76 | @media only screen and (min-width: 1280px) { 77 | .carousel-container { 78 | height: 768px; 79 | } 80 | } 81 | 82 | .carousel-container.short { 83 | height: 300px; 84 | } 85 | 86 | .buttons { 87 | margin: 10px 0; 88 | display: flex; 89 | flex-wrap: wrap; 90 | } 91 | 92 | .button-container { 93 | margin-bottom: 10px; 94 | } 95 | 96 | .button-container:not(:last-child) { 97 | margin-right: 10px; 98 | } 99 | 100 | .framed-carousel { 101 | border: 5px solid #eee; 102 | overflow: hidden; 103 | border-radius: 10px; 104 | will-change: transform; 105 | mask-image: -webkit-radial-gradient(white, black); 106 | box-sizing: border-box; 107 | } 108 | 109 | .icon-text { 110 | color: white; 111 | } 112 | 113 | .image-responsive { 114 | width: 100%; 115 | height: 100%; 116 | object-fit: cover; 117 | overflow: hidden; 118 | } 119 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import App from './App'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | -------------------------------------------------------------------------------- /example/src/utils/TwoWayMap.js: -------------------------------------------------------------------------------- 1 | export default class TwoWayMap { 2 | constructor(map) { 3 | this.map = map; 4 | this.reversedMap = {}; 5 | 6 | Object.keys(map).forEach((key) => { 7 | const value = map[key]; 8 | this.reversedMap[value] = key; 9 | }); 10 | } 11 | 12 | get = (key) => { 13 | return this.map[key]; 14 | }; 15 | 16 | getReversed = (key) => { 17 | return this.reversedMap[key]; 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-gallery-carousel", 3 | "version": "0.4.0", 4 | "description": "Mobile-friendly Carousel with batteries included (supporting touch, mouse emulation, lazy loading, thumbnails, fullscreen, RTL, keyboard navigation and customisations).", 5 | "author": "yifaneye", 6 | "license": "MIT", 7 | "repository": "yifaneye/react-gallery-carousel", 8 | "main": "dist/index.js", 9 | "module": "dist/index.modern.js", 10 | "source": "src/index.js", 11 | "engines": { 12 | "node": ">=14" 13 | }, 14 | "scripts": { 15 | "build": "microbundle-crl --no-compress --format modern,cjs", 16 | "start": "microbundle-crl watch --no-compress --format modern,cjs", 17 | "prepare": "run-s build", 18 | "test": "run-s test:unit test:lint test:build", 19 | "test:build": "run-s build", 20 | "test:lint": "eslint .", 21 | "test:unit": "cross-env CI=1 react-scripts test --env=jsdom", 22 | "test:watch": "react-scripts test --env=jsdom", 23 | "predeploy": "cd example && yarn install && yarn run build", 24 | "deploy": "gh-pages -d example/build" 25 | }, 26 | "peerDependencies": { 27 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 28 | }, 29 | "devDependencies": { 30 | "babel-eslint": "^10.0.3", 31 | "cross-env": "^7.0.2", 32 | "eslint": "^6.8.0", 33 | "eslint-config-prettier": "^6.7.0", 34 | "eslint-config-standard": "^14.1.0", 35 | "eslint-config-standard-react": "^9.2.0", 36 | "eslint-plugin-import": "^2.18.2", 37 | "eslint-plugin-node": "^11.0.0", 38 | "eslint-plugin-prettier": "^3.1.1", 39 | "eslint-plugin-promise": "^4.2.1", 40 | "eslint-plugin-react": "^7.17.0", 41 | "eslint-plugin-react-hooks": "^4.2.0", 42 | "eslint-plugin-standard": "^4.0.1", 43 | "gh-pages": "^2.2.0", 44 | "microbundle-crl": "^0.13.10", 45 | "node-sass": "^5.0.0", 46 | "npm-run-all": "^4.1.5", 47 | "prettier": "^2.0.4", 48 | "prop-types": "^15.7.2", 49 | "react": "^16.13.1", 50 | "react-dom": "^16.13.1", 51 | "react-scripts": "^3.4.1", 52 | "react-test-renderer": "16.13.1" 53 | }, 54 | "files": [ 55 | "dist" 56 | ], 57 | "dependencies": {}, 58 | "jest": { 59 | "moduleNameMapper": { 60 | "\\.(jpg)$": "/__mocks__/fileMock.js", 61 | "\\.(png)$": "/__mocks__/fileMock.js", 62 | "\\.(css)$": "/__mocks__/fileMock.js" 63 | } 64 | }, 65 | "keywords": [ 66 | "carousel", 67 | "gallery", 68 | "lightbox", 69 | "slider", 70 | "slideshow", 71 | "swiper", 72 | "react", 73 | "component", 74 | "lazy load", 75 | "preload", 76 | "touch", 77 | "swipe", 78 | "pinch", 79 | "zoom", 80 | "mouse", 81 | "mouse emulation", 82 | "drag", 83 | "keyboard navigation", 84 | "accessibility", 85 | "a11y" 86 | ] 87 | } 88 | -------------------------------------------------------------------------------- /react-gallery-carousel-gatsby/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .cache/ 3 | public 4 | -------------------------------------------------------------------------------- /react-gallery-carousel-gatsby/README.md: -------------------------------------------------------------------------------- 1 | # react-gallery-carousel Gatsby example 2 | -------------------------------------------------------------------------------- /react-gallery-carousel-gatsby/gatsby-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | siteMetadata: { 3 | title: 'react-gallery-carousel Gatsby example', 4 | description: 5 | 'react-gallery-carousel is a mobile-friendly dependency-free React carousel component with support for touch, mouse dragging, lazy loading, thumbnails, modal, keyboard navigation, RTL and pinch to zoom.', 6 | url: 'https://yifanai.com/rgc', 7 | image: '/images/icon.png', 8 | twitterUsername: '@yifaneye' 9 | }, 10 | plugins: [] 11 | }; 12 | -------------------------------------------------------------------------------- /react-gallery-carousel-gatsby/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-gallery-carousel-gatsby", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "react-gallery-carousel Gatsby example", 6 | "author": "Yifan Ai", 7 | "keywords": [ 8 | "gatsby" 9 | ], 10 | "scripts": { 11 | "develop": "gatsby develop", 12 | "start": "gatsby develop", 13 | "build": "gatsby build", 14 | "serve": "gatsby serve", 15 | "clean": "gatsby clean" 16 | }, 17 | "dependencies": { 18 | "gatsby": "^3.4.1", 19 | "react": "link:../node_modules/react", 20 | "react-dom": "link:../node_modules/react-dom", 21 | "react-gallery-carousel": "link:..", 22 | "react-github-btn": "^1.2.0", 23 | "react-responsive-button": "^0.2.1", 24 | "react-scripts": "link:../node_modules/react-scripts" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /react-gallery-carousel-gatsby/src/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yifaneye/react-gallery-carousel/a3f452b143f6c1149477a36e43ee4698e67d3363/react-gallery-carousel-gatsby/src/images/icon.png -------------------------------------------------------------------------------- /react-gallery-carousel-gatsby/src/pages/404.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Link } from 'gatsby'; 3 | 4 | // styles 5 | const pageStyles = { 6 | color: '#232129', 7 | padding: '96px', 8 | fontFamily: '-apple-system, Roboto, sans-serif, serif' 9 | }; 10 | const headingStyles = { 11 | marginTop: 0, 12 | marginBottom: 64, 13 | maxWidth: 320 14 | }; 15 | 16 | const paragraphStyles = { 17 | marginBottom: 48 18 | }; 19 | const codeStyles = { 20 | color: '#8A6534', 21 | padding: 4, 22 | backgroundColor: '#FFF4DB', 23 | fontSize: '1.25rem', 24 | borderRadius: 4 25 | }; 26 | 27 | // markup 28 | const NotFoundPage = () => { 29 | return ( 30 |
31 | Not found 32 |

Page not found

33 |

34 | Sorry{' '} 35 | 36 | 😔 37 | {' '} 38 | we couldn’t find what you were looking for. 39 |
40 | {process.env.NODE_ENV === 'development' ? ( 41 | <> 42 |
43 | Try creating a page in src/pages/. 44 |
45 | 46 | ) : null} 47 |
48 | Go home. 49 |

50 |
51 | ); 52 | }; 53 | 54 | export default NotFoundPage; 55 | -------------------------------------------------------------------------------- /react-gallery-carousel-gatsby/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import App from '../../../example/src/App.js'; 2 | import '../../../example/src/index.css'; 3 | 4 | export default App; 5 | -------------------------------------------------------------------------------- /react-gallery-carousel-nextjs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /react-gallery-carousel-nextjs/README.md: -------------------------------------------------------------------------------- 1 | # react-gallery-carousel Gatsby example 2 | -------------------------------------------------------------------------------- /react-gallery-carousel-nextjs/components/Carousel1.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | // import TwoWayMap from '../utils/TwoWayMap'; 3 | import Carousel from 'react-gallery-carousel'; 4 | 5 | const Carousel1 = () => { 6 | // const indexToTitle = new TwoWayMap({ 7 | // 0: 'Introduction', 8 | // 1: 'Get%20Started', 9 | // 2: 'Usage' 10 | // }); 11 | 12 | return ( 13 |
14 |
15 |

16 | Example 1: Customized carousel with user-managed slides{' '} 17 | code 18 |

19 |

20 | This example has custom elements in slides (user-managed slides) using 21 | the children prop; and custom widget positions. 22 |

23 |
24 |
25 | { 39 | // const title = indexToTitle.get(curIndex); 40 | // window.history.replaceState( 41 | // undefined, 42 | // undefined, 43 | // `#${title}` 44 | // ); 45 | // document.title = `${title} | react-gallery-carousel`; 46 | // }} // this callback can be set to update the document title and URL hash on index update 47 | style={{ userSelect: 'text' }} 48 | > 49 |
50 |

Introduction

51 |

52 | react-gallery-carousel is a mobile-friendly 53 | dependency-free React carousel component with support for touch, 54 | mouse dragging, lazy loading, thumbnails, modal, keyboard 55 | navigation, RTL and pinch to zoom. 56 |

57 | Demo 58 | / 59 | 60 | GitHub 61 | 62 | / 63 | 64 | npm 65 | 66 | / 67 | 68 | Documentation 69 | 70 |
71 |
72 |

Get Started

73 | npm install react-gallery-carousel --save 74 |
75 | or 76 |
77 | yarn add react-gallery-carousel 78 |
79 |
80 |

Usage

81 |

The default carousel shown below as example 2 is created by:

82 | {''} 83 |
84 |
85 |
86 |
87 | ); 88 | }; 89 | 90 | export default Carousel1; 91 | -------------------------------------------------------------------------------- /react-gallery-carousel-nextjs/components/Carousel2.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Carousel from 'react-gallery-carousel'; 3 | 4 | const Carousel2 = ({ images }) => { 5 | return ( 6 |
7 |
8 |

9 | Example 2: Default carousel with images{' '} 10 | code 11 |

12 |

13 | A default carousel example has lazy loading and preloading (the 2 14 | adjacent images on either side of the current image); touch swiping 15 | and mouse dragging on the carousel; touch swiping, mouse dragging and 16 | wheel scrolling on the thumbnails; touch swipe down to exit the 17 | maximized carousel; and keyboard navigation. 18 |

19 |
20 |
21 | 22 |
23 |
24 | ); 25 | }; 26 | 27 | export default Carousel2; 28 | -------------------------------------------------------------------------------- /react-gallery-carousel-nextjs/components/Carousel3.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Carousel from 'react-gallery-carousel'; 3 | 4 | const Carousel3 = ({ images }) => { 5 | return ( 6 |
7 |
8 |

9 | Example 3: Default carousel with images and with right-to-left (RTL){' '} 10 | code 11 |

12 |

13 | A default carousel example has lazy loading and preloading; touch 14 | swiping and mouse dragging on the carousel; touch swiping, mouse 15 | dragging and wheel scrolling on the thumbnails; touch swipe down to 16 | exit the maximized carousel; and keyboard navigation. 17 |

18 |
19 |
20 | 21 |
22 |
23 | ); 24 | }; 25 | 26 | export default Carousel3; 27 | -------------------------------------------------------------------------------- /react-gallery-carousel-nextjs/components/Carousel4.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import Carousel from 'react-gallery-carousel'; 3 | 4 | const Carousel4 = ({ images }) => { 5 | const [dynamicImages, setDynamicImages] = useState([]); 6 | 7 | useEffect(() => { 8 | setDynamicImages(images); 9 | }, [setDynamicImages, images]); 10 | 11 | return ( 12 |
13 |
14 |

15 | Example 4: Customized carousel with dynamic images{' '} 16 | (available from v0.1.1){' '} 17 | code 18 |

19 |

20 | This example has images dynamically set in the{' '} 21 | useEffect() hook. This customized example additionally 22 | has click to enter and exit the maximized carousel; 23 | custom initial index; custom widget positions; custom thumbnails, 24 | custom dot buttons and captions for the maximized carousel; custom 25 | active and passive dot buttons; and custom styles for the 26 | non-maximized carousel. 27 |

28 |
29 |
30 | 50 | 🔳 51 | 52 | } 53 | passiveIcon={ 54 | 55 | 🔲 56 | 57 | } 58 | /> 59 |
60 |
61 | ); 62 | }; 63 | 64 | export default Carousel4; 65 | -------------------------------------------------------------------------------- /react-gallery-carousel-nextjs/components/Carousel5.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import Carousel from 'react-gallery-carousel'; 3 | import { Button } from 'react-responsive-button'; 4 | 5 | const Carousel5 = ({ images }) => { 6 | const carouselRef = useRef(null); 7 | 8 | return ( 9 |
10 |
11 |

12 | Example 5: Default carousel with imperative handlers{' '} 13 | (available from v0.2.0){' '} 14 | code 15 |

16 |

17 | To customize the carousel in a declarative manner, pass the props 18 | (e.g. isAutoPlaying, isMaximized,{' '} 19 | index). To customize the carousel in an imperative 20 | manner, use the following handlers (on the ref): 21 |

22 |
23 |
24 |
25 | 31 |
32 |
33 | 39 |
40 |
41 | 47 |
48 |
49 |
50 |
51 | 58 |
59 |
60 | 67 |
68 |
69 | 76 |
77 |
78 |
79 |
80 | 87 |
88 |
89 | 96 |
97 |
98 | 105 |
106 |
107 |
108 |
109 |
110 | 111 |
112 |
113 | ); 114 | }; 115 | 116 | export default Carousel5; 117 | -------------------------------------------------------------------------------- /react-gallery-carousel-nextjs/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: true 3 | }; 4 | -------------------------------------------------------------------------------- /react-gallery-carousel-nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-gallery-carousel-nextjs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "react-gallery-carousel Next.js example", 6 | "author": "Yifan Ai", 7 | "keywords": [ 8 | "nextjs" 9 | ], 10 | "scripts": { 11 | "dev": "next dev", 12 | "build": "next build", 13 | "start": "next start", 14 | "lint": "next lint" 15 | }, 16 | "dependencies": { 17 | "next": "11.0.1", 18 | "react": "link:../node_modules/react", 19 | "react-dom": "link:../node_modules/react-dom", 20 | "react-gallery-carousel": "link:..", 21 | "react-github-btn": "^1.2.0", 22 | "react-responsive-button": "^0.2.1", 23 | "react-scripts": "link:../node_modules/react-scripts" 24 | }, 25 | "devDependencies": { 26 | "eslint": "7.30.0", 27 | "eslint-config-next": "11.0.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /react-gallery-carousel-nextjs/pages/_app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '../styles/globals.css'; 3 | import Carousel1 from '../components/Carousel1'; 4 | import Carousel2 from '../components/Carousel2'; 5 | import Carousel3 from '../components/Carousel3'; 6 | import Carousel4 from '../components/Carousel4'; 7 | import Carousel5 from '../components/Carousel5'; 8 | import 'react-gallery-carousel/dist/index.css'; 9 | 10 | const imageIDs = Array(30) // the maximum is currently 149 11 | .fill(1) 12 | .map((_, i) => i + 1); 13 | const images = imageIDs.map((imageID) => { 14 | return { 15 | src: `https://placedog.net/400/240?id=${imageID}`, 16 | srcset: `https://placedog.net/400/240?id=${imageID} 400w, https://placedog.net/700/420?id=${imageID} 700w, https://placedog.net/1000/600?id=${imageID} 1000w`, 17 | sizes: '(max-width: 1000px) 400px, (max-width: 2000px) 700px, 1000px', 18 | alt: `Dog No. ${imageID}. Dogs are domesticated mammals, not natural wild animals.`, 19 | thumbnail: `https://placedog.net/100/60?id=${imageID}` 20 | }; 21 | }); 22 | 23 | function MyApp() { 24 | return ( 25 |
26 | 27 | 28 | 29 | 30 | 31 |
32 | ); 33 | } 34 | 35 | export default MyApp; 36 | -------------------------------------------------------------------------------- /react-gallery-carousel-nextjs/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MyApp from './_app'; 3 | 4 | export default function Home() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /react-gallery-carousel-nextjs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yifaneye/react-gallery-carousel/a3f452b143f6c1149477a36e43ee4698e67d3363/react-gallery-carousel-nextjs/public/favicon.ico -------------------------------------------------------------------------------- /react-gallery-carousel-nextjs/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /react-gallery-carousel-nextjs/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | padding: 0 0.5rem; 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: center; 8 | height: 100vh; 9 | } 10 | 11 | .main { 12 | padding: 5rem 0; 13 | flex: 1; 14 | display: flex; 15 | flex-direction: column; 16 | justify-content: center; 17 | align-items: center; 18 | } 19 | 20 | .footer { 21 | width: 100%; 22 | height: 100px; 23 | border-top: 1px solid #eaeaea; 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | } 28 | 29 | .footer a { 30 | display: flex; 31 | justify-content: center; 32 | align-items: center; 33 | flex-grow: 1; 34 | } 35 | 36 | .title a { 37 | color: #0070f3; 38 | text-decoration: none; 39 | } 40 | 41 | .title a:hover, 42 | .title a:focus, 43 | .title a:active { 44 | text-decoration: underline; 45 | } 46 | 47 | .title { 48 | margin: 0; 49 | line-height: 1.15; 50 | font-size: 4rem; 51 | } 52 | 53 | .title, 54 | .description { 55 | text-align: center; 56 | } 57 | 58 | .description { 59 | line-height: 1.5; 60 | font-size: 1.5rem; 61 | } 62 | 63 | .code { 64 | background: #fafafa; 65 | border-radius: 5px; 66 | padding: 0.75rem; 67 | font-size: 1.1rem; 68 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 69 | Bitstream Vera Sans Mono, Courier New, monospace; 70 | } 71 | 72 | .grid { 73 | display: flex; 74 | align-items: center; 75 | justify-content: center; 76 | flex-wrap: wrap; 77 | max-width: 800px; 78 | margin-top: 3rem; 79 | } 80 | 81 | .card { 82 | margin: 1rem; 83 | padding: 1.5rem; 84 | text-align: left; 85 | color: inherit; 86 | text-decoration: none; 87 | border: 1px solid #eaeaea; 88 | border-radius: 10px; 89 | transition: color 0.15s ease, border-color 0.15s ease; 90 | width: 45%; 91 | } 92 | 93 | .card:hover, 94 | .card:focus, 95 | .card:active { 96 | color: #0070f3; 97 | border-color: #0070f3; 98 | } 99 | 100 | .card h2 { 101 | margin: 0 0 1rem 0; 102 | font-size: 1.5rem; 103 | } 104 | 105 | .card p { 106 | margin: 0; 107 | font-size: 1.25rem; 108 | line-height: 1.5; 109 | } 110 | 111 | .logo { 112 | height: 1em; 113 | margin-left: 0.5rem; 114 | } 115 | 116 | @media (max-width: 600px) { 117 | .grid { 118 | width: 100%; 119 | flex-direction: column; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /react-gallery-carousel-nextjs/styles/globals.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | padding: 0.2em 0.4em; 15 | margin: 0; 16 | font-size: 85%; 17 | background-color: rgba(27, 31, 35, 0.05); 18 | border-radius: 6px; 19 | } 20 | 21 | .text-slide { 22 | padding: 1rem 50px; 23 | } 24 | 25 | .vertical-separator { 26 | margin: 1em 0; 27 | } 28 | 29 | .carousel-page { 30 | max-width: 1200px; 31 | margin: 0 auto; 32 | overflow: hidden; 33 | } 34 | 35 | .carousel-page-header-container { 36 | width: 100%; 37 | } 38 | 39 | .carousel-page-header { 40 | display: flex; 41 | flex-direction: column; 42 | align-items: center; 43 | padding: 0 1em; 44 | } 45 | 46 | .action-container { 47 | padding: 0 1em; 48 | } 49 | 50 | .star-button-container { 51 | height: 20px; 52 | margin-top: 10px; 53 | margin-bottom: 10px; 54 | } 55 | 56 | .section { 57 | margin-bottom: 60px; 58 | } 59 | 60 | .section-header { 61 | padding: 0 1em; 62 | } 63 | 64 | .carousel-container { 65 | width: 100%; 66 | height: calc(100vw * 0.8); 67 | } 68 | 69 | @media only screen and (min-width: 500px) and (max-width: 1280px) { 70 | .carousel-container { 71 | height: calc(100vw * 0.6); 72 | max-height: 768px; 73 | } 74 | } 75 | 76 | @media only screen and (min-width: 1280px) { 77 | .carousel-container { 78 | height: 768px; 79 | } 80 | } 81 | 82 | .carousel-container.short { 83 | height: 300px; 84 | } 85 | 86 | .buttons { 87 | margin: 10px 0; 88 | display: flex; 89 | flex-wrap: wrap; 90 | } 91 | 92 | .button-container { 93 | margin-bottom: 10px; 94 | } 95 | 96 | .button-container:not(:last-child) { 97 | margin-right: 10px; 98 | } 99 | 100 | .framed-carousel { 101 | border: 5px solid #eee; 102 | overflow: hidden; 103 | border-radius: 10px; 104 | will-change: transform; 105 | mask-image: -webkit-radial-gradient(white, black); 106 | box-sizing: border-box; 107 | } 108 | 109 | .icon-text { 110 | color: white; 111 | } 112 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/common/common.scss: -------------------------------------------------------------------------------- 1 | $scale: 10%; 2 | $margin: 3px; 3 | -------------------------------------------------------------------------------- /src/components/Carousel/Carousel.js: -------------------------------------------------------------------------------- 1 | import React, { 2 | forwardRef, 3 | Fragment, 4 | useCallback, 5 | useEffect, 6 | useImperativeHandle, 7 | useRef, 8 | useState 9 | } from 'react'; 10 | import styles from './Carousel.module.css'; 11 | import Slides from '../Slides'; 12 | import Thumbnails from '../Thumbnails'; 13 | import { 14 | LeftButton, 15 | RightButton, 16 | MediaButton, 17 | SizeButton, 18 | IndexBoard, 19 | DotButtons 20 | } from '../Widgets'; 21 | import isSSR from '../../utils/isSSR'; 22 | import useKeys from '../../utils/useKeys'; 23 | import useSwipe from '../../utils/useSwipe'; 24 | import useTimer from '../../utils/useTimer'; 25 | import useSlides from '../../utils/useSlides'; 26 | import useKeyboard from '../../utils/useKeyboard'; 27 | import useMediaQuery from '../../utils/useMediaQuery'; 28 | import useEventListener from '../../utils/useEventListener'; 29 | import useFixedPosition from '../../utils/useFixedPosition'; 30 | import { 31 | MAX_SWIPE_DOWN_DISTANCE, 32 | WIDGET_POSITIONS, 33 | WIDGET_POSITIONS_WITH_RTL 34 | } from './constants'; 35 | import ReversedMap from '../../utils/ReversedMap'; 36 | import { propTypes, defaultProps, getSettings } from './props'; 37 | 38 | const GalleryCarousel = (props, ref) => { 39 | /* initialize references */ 40 | const documentRef = useRef(isSSR ? undefined : document); 41 | const maximizedBackgroundRef = useRef(null); 42 | const carouselRef = useRef(null); 43 | const slidesContainerRef = useRef(null); 44 | const slidesRef = useRef(null); 45 | const slideMinRef = useRef(null); 46 | const slideMaxRef = useRef(null); 47 | 48 | /* process slides */ 49 | const hasImages = 'images' in props; // true even the 'images' prop is an empty Array 50 | const children = Array.isArray(props.children) 51 | ? props.children 52 | : [props.children]; 53 | const rawSlides = hasImages ? props.images : children; 54 | const [slides, slidesElements] = useSlides(rawSlides, { 55 | index: props.index, 56 | isLoop: props.isLoop 57 | }); 58 | const thumbnailElements = props.thumbnails || slidesElements; 59 | const nSlides = slides.length; 60 | const increment = props.isRTL ? -1 : +1; 61 | const slidesMin = `${nSlides * -increment}00%`; 62 | const slidesMax = `${nSlides * increment}00%`; 63 | 64 | /* handle current index change */ 65 | const [, setCurIndex] = useState(slides.curIndex); 66 | 67 | const applyCurIndexUpdate = (curIndex) => { 68 | setCurIndex(curIndex); 69 | props.onIndexChange({ 70 | curIndex: slides.curIndex, 71 | curIndexForDisplay: slides.curIndexForDisplay 72 | }); 73 | }; 74 | 75 | /* handle autoplay and reduced motion setting */ 76 | const [ 77 | isPlaying, 78 | setIsPlaying, 79 | { stopTimer, restartTimer } 80 | ] = useTimer( 81 | props.canAutoPlay && props.autoPlayInterval, 82 | props.isAutoPlaying, 83 | () => updateIndex(+1) 84 | ); 85 | 86 | const handleMediaButtonClick = () => { 87 | setIsPlaying((isPlaying) => !isPlaying); 88 | }; 89 | 90 | const isReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); 91 | 92 | useEffect(() => { 93 | if (isReducedMotion) setIsPlaying(false); 94 | }, [isReducedMotion, setIsPlaying]); 95 | 96 | /* set up smart pause */ 97 | const [wasPlaying, setWasPlaying] = useState(false); 98 | const handleVisibilityChange = useCallback(() => { 99 | if (document.visibilityState !== 'visible') { 100 | // user switches tab away from the page 101 | setWasPlaying(isPlaying); 102 | setIsPlaying(false); 103 | } else { 104 | // user switches tab back to the page 105 | setIsPlaying(wasPlaying); 106 | } 107 | }, [isPlaying, setIsPlaying, wasPlaying, setWasPlaying]); 108 | 109 | useEventListener( 110 | isSSR ? undefined : document, 111 | 'visibilitychange', 112 | handleVisibilityChange 113 | ); 114 | 115 | /* handle maximization/minimization and full screen */ 116 | const [isMaximized, setIsMaximized] = useFixedPosition( 117 | props.isMaximized, 118 | slidesContainerRef 119 | ); 120 | 121 | const handleSizeButtonClick = () => { 122 | setIsMaximized((isMaximized) => !isMaximized); 123 | }; 124 | 125 | /* handle UI updates */ 126 | const applyTransitionDuration = ( 127 | displacementX = 0, 128 | speed = props.transitionSpeed, 129 | hasToUpdate = true 130 | ) => { 131 | if (!props.hasTransition) return; 132 | if (isReducedMotion) return; 133 | 134 | // calculate transition duration 135 | const swipedDistance = Math.abs(displacementX); 136 | const transitionDistance = 137 | hasToUpdate && slidesRef.current 138 | ? Math.abs(slidesRef.current.clientWidth - swipedDistance) 139 | : swipedDistance; 140 | speed = hasToUpdate ? speed : props.swipeRollbackSpeed; 141 | let duration = transitionDistance / speed; 142 | 143 | // flatten transitionDurations 144 | if (duration > props.transitionDurationLimit / 2) 145 | duration = 146 | (Math.atan((2 * duration) / props.transitionDurationLimit) * 147 | props.transitionDurationLimit * 148 | 2) / 149 | Math.PI; 150 | 151 | // bound duration to a range 152 | if (props.transitionDurationMin) 153 | duration = Math.max(duration, props.transitionDurationMin); 154 | 155 | // transitionDurationMax has precedence over transitionDurationMin 156 | if (props.transitionDurationMax) 157 | duration = Math.min(duration, props.transitionDurationMax); 158 | 159 | // make duration greater or equal to autoPlayInterval 160 | if (isPlaying && duration > props.autoPlayInterval) 161 | duration = props.autoPlayInterval * 1; 162 | 163 | // apply transition duration for the period of duration 164 | if (slidesRef.current) 165 | slidesRef.current.style.transitionDuration = `${duration}ms`; 166 | setTimeout(() => { 167 | // revert temporary style changes made on the slides for transition and looping 168 | if (slidesRef.current) slidesRef.current.style.transitionDuration = null; 169 | }, duration); 170 | }; 171 | 172 | // handle touch swipe down specifically 173 | const applyTransitionY = (displacementX = 0, displacementY = 0) => { 174 | // do not update the maximized carousel when it is above its original position 175 | // to hint the user that swiping up will not be able to minimize the carousel 176 | const distance = displacementY > 0 ? displacementY : 0; 177 | const portion = 1 - distance / MAX_SWIPE_DOWN_DISTANCE; 178 | 179 | // move and scale the element 180 | if (carouselRef.current) { 181 | // check whether the update is necessary 182 | if ( 183 | carouselRef.current.style.transform !== 184 | `translate(${displacementX}px, ${displacementY}px) scale(${portion})` 185 | ) { 186 | carouselRef.current.style.transform = `translate(${displacementX}px, ${displacementY}px) scale(${portion})`; 187 | } 188 | } 189 | 190 | // update opacity of the background 191 | if (maximizedBackgroundRef.current) { 192 | // check whether the update is necessary 193 | if (maximizedBackgroundRef.current.style.opacity !== portion) { 194 | maximizedBackgroundRef.current.style.opacity = portion; 195 | } 196 | } 197 | }; 198 | 199 | const applyTransitionX = useCallback( 200 | (displacementX = 0) => { 201 | const targetPosition = 202 | displacementX === 0 203 | ? `${-100 * slides.curIndex * increment}%` 204 | : `calc(${-100 * slides.curIndex * increment}% + ${displacementX}px)`; 205 | // move the element 206 | if (slidesRef.current) { 207 | slidesRef.current.style.transform = `translateX(${targetPosition})`; 208 | } 209 | }, 210 | [slides.curIndex, increment] 211 | ); 212 | 213 | // change to the current slide before browser paints 214 | useEffect(() => applyTransitionX(), [applyTransitionX]); 215 | 216 | /* handle implicit current index update (e.g. +1 or -1) */ 217 | const shouldCalibrateIndex = props.isLoop && nSlides > 1; 218 | 219 | const handleSwipeMoveX = (displacementX) => { 220 | props.onSwipeMoveX(displacementX); 221 | // stop the timer for autoplay if there is a timer 222 | // should not use setIsPlaying(false) here since it will update the icon in the media button 223 | if (props.canAutoPlay) stopTimer(); 224 | 225 | const change = -displacementX * increment; 226 | 227 | // calibrate index for looping of the carousel 228 | if (shouldCalibrateIndex) { 229 | if (slides.isMinIndex() && change < 0 && slideMaxRef.current) { 230 | slideMaxRef.current.style.transform = `translateX(${slidesMin})`; 231 | } else if (slides.isMaxIndex() && change > 0 && slideMinRef.current) { 232 | slideMinRef.current.style.transform = `translateX(${slidesMax})`; 233 | } else { 234 | if (slideMinRef.current) slideMinRef.current.style.transform = null; 235 | if (slideMaxRef.current) slideMaxRef.current.style.transform = null; 236 | } 237 | } 238 | 239 | // update UI 240 | applyTransitionX(displacementX); 241 | }; 242 | 243 | const updateIndex = (change, displacementX = 0, speed) => { 244 | // restart the timer for autoplay if there is a timer and the index update is being roll-backed 245 | if (props.canAutoPlay && change === 0) restartTimer(); 246 | 247 | // calibrate index for looping of the carousel 248 | if (shouldCalibrateIndex && slideMinRef.current && slideMaxRef.current) { 249 | if (slides.isMinIndex() && change < 0) { 250 | slideMinRef.current.style.transform = `translateX(${slidesMax})`; 251 | slideMaxRef.current.style.transform = null; 252 | } else if (slides.isMaxIndex() && change > 0) { 253 | slideMinRef.current.style.transform = null; 254 | slideMaxRef.current.style.transform = `translateX(${slidesMin})`; 255 | } else if (change !== 0) { 256 | slideMinRef.current.style.transform = null; 257 | slideMaxRef.current.style.transform = null; 258 | } // if change === 0 then the adjacent slides shall be kept in place for the rollback transition 259 | } 260 | 261 | // update UI 262 | if (slides.calibrateIndex(change) && shouldCalibrateIndex) { 263 | // remove carry-over transitionDuration 264 | if (slidesRef.current) slidesRef.current.style.transitionDuration = null; 265 | applyTransitionX(displacementX); 266 | } 267 | 268 | slides.updateIndex(change); 269 | applyTransitionDuration(displacementX, speed, change !== 0); 270 | applyTransitionY(0, 0); 271 | applyTransitionX(); 272 | applyCurIndexUpdate(slides.curIndex); 273 | }; 274 | 275 | const rollBackIndexUpdate = () => updateIndex(0, 0, 0); 276 | 277 | useEventListener( 278 | isSSR ? undefined : window, 279 | 'orientationchange', 280 | rollBackIndexUpdate 281 | ); 282 | 283 | /* handle explicit current index update (e.g. go to slide number 16) */ 284 | const goToIndex = (index) => { 285 | // set both the first and the last slide back into their respective original places 286 | if (slideMinRef.current) slideMinRef.current.style.transform = null; 287 | if (slideMaxRef.current) slideMaxRef.current.style.transform = null; 288 | 289 | // update carousel 290 | slides.goToIndex(index); 291 | applyTransitionX(); 292 | applyCurIndexUpdate(slides.curIndex); 293 | }; 294 | 295 | // set up callbacks (i.e. 1 callback for each dotButton and each thumbnail) 296 | const indices = slides.allIndices; 297 | const goToIndexCallbacks = indices.map((index) => () => goToIndex(index)); 298 | const goToIndexCallbacksObject = indices.reduce( 299 | (obj, key, index) => ({ ...obj, [key]: goToIndexCallbacks[index] }), 300 | {} 301 | ); 302 | 303 | /* handle keyboard events */ 304 | useKeys(documentRef, { Escape: () => setIsMaximized(() => false) }); 305 | 306 | useKeyboard(carouselRef); 307 | 308 | const goLeft = () => updateIndex(-increment); 309 | const goRight = () => updateIndex(+increment); 310 | 311 | useKeys(carouselRef, { 312 | ArrowLeft: goLeft, 313 | ArrowRight: goRight, 314 | /* can not use useEnter hook here to mimic user click, since a click 315 | on slidesContainer should not and will not trigger anything */ 316 | Enter: (event) => { 317 | // ignore ('Enter' key) keydown events on widgets (buttons) bubbling up to here 318 | if (event.target !== event.currentTarget) return; 319 | handleSizeButtonClick(); 320 | } 321 | }); 322 | 323 | /* handle mouse and touch events */ 324 | // store isMaximized to combat stale closure 325 | const isMaximizedRef = useRef(isMaximized); 326 | isMaximizedRef.current = isMaximized; 327 | 328 | const handleSwipeMoveY = (displacementX, displacementY) => { 329 | props.onSwipeMoveY(displacementX, displacementY); 330 | if (!props.shouldMinimizeOnSwipeDown) return; 331 | if (isMaximizedRef.current) applyTransitionY(displacementX, displacementY); 332 | }; 333 | 334 | const handleSwipeEndDown = () => { 335 | props.onSwipeEndDown(); 336 | if (!props.shouldMinimizeOnSwipeDown) return; 337 | applyTransitionY(0, 0); 338 | setIsMaximized(() => false); 339 | rollBackIndexUpdate(); 340 | }; 341 | 342 | const handleTap = () => { 343 | props.onTap(); 344 | if (isMaximizedRef.current && props.shouldMinimizeOnClick) 345 | setIsMaximized(() => false); 346 | else if (!isMaximizedRef.current && props.shouldMaximizeOnClick) 347 | setIsMaximized(() => true); 348 | }; 349 | 350 | const mouseEventHandlers = useSwipe( 351 | slidesContainerRef, 352 | props.swipeThreshold, 353 | { 354 | onSwipeMoveX: handleSwipeMoveX, 355 | onSwipeMoveY: handleSwipeMoveY, 356 | onSwipeEndLeft: (displacementX, speed) => 357 | updateIndex(increment, displacementX, speed), 358 | onSwipeEndRight: (displacementX, speed) => 359 | updateIndex(-increment, displacementX, speed), 360 | onSwipeEndDisqualified: (displacementX, speed) => 361 | updateIndex(0, displacementX, speed), 362 | onSwipeEndDown: handleSwipeEndDown, 363 | onTap: handleTap 364 | } 365 | ); 366 | // touch event handlers are already added to slidesContainerRef by useSwipe hook at this point 367 | 368 | /* process class names */ 369 | const propsClassName = 'className' in props ? ' ' + props.className : ''; 370 | const galleryClassName = hasImages ? ' ' + styles.gallery : ''; 371 | const carouselClassName = styles.carousel + propsClassName + galleryClassName; 372 | const maxCarouselClassName = styles.maxCarousel + galleryClassName; 373 | 374 | /* process components for maximized carousel */ 375 | // placeholder to be placed at the carousel's non-maximized position 376 | const carouselPlaceholder = isMaximized && ( 377 |
378 | ); 379 | 380 | // background to be placed behind the maximized carousel 381 | const maxCarouselBackground = isMaximized && ( 382 |
387 | ); 388 | 389 | /* process settings */ 390 | const settings = getSettings( 391 | props, 392 | ['objectFit', 'hasCaptions', 'hasThumbnails'], 393 | isMaximized 394 | ); 395 | 396 | const widgetSettings = getSettings( 397 | props, 398 | [ 399 | 'hasLeftButton', 400 | 'hasRightButton', 401 | 'hasMediaButton', 402 | 'hasSizeButton', 403 | 'hasDotButtons', 404 | 'hasIndexBoard' 405 | ], 406 | isMaximized 407 | ); 408 | 409 | const leftButton = widgetSettings.hasLeftButton && ( 410 | 418 | ); 419 | 420 | const rightButton = widgetSettings.hasRightButton && ( 421 | 429 | ); 430 | 431 | const mediaButton = widgetSettings.hasMediaButton && props.canAutoPlay && ( 432 | 440 | ); 441 | 442 | const sizeButton = widgetSettings.hasSizeButton && ( 443 | 451 | ); 452 | 453 | const indexBoard = widgetSettings.hasIndexBoard && ( 454 | 460 | ); 461 | 462 | const dotButtons = widgetSettings.hasDotButtons && ( 463 | 472 | ); 473 | 474 | const thumbnails = settings.hasThumbnails && ( 475 | 487 | ); 488 | 489 | // organize and sort the widgets 490 | const widgetsObj = { 491 | hasLeftButton: leftButton, 492 | hasRightButton: rightButton, 493 | hasMediaButton: mediaButton, 494 | hasSizeButton: sizeButton, 495 | hasDotButtons: dotButtons, 496 | hasIndexBoard: indexBoard 497 | }; 498 | const positionsToWidgets = new ReversedMap(widgetSettings); 499 | const positions = props.isRTL ? WIDGET_POSITIONS_WITH_RTL : WIDGET_POSITIONS; 500 | const widgets = ( 501 | <> 502 | {positions.map( 503 | (position, index) => 504 | widgetsObj[positionsToWidgets.get(position)] && ( 505 | 506 | { 507 | widgetsObj[ 508 | positionsToWidgets.get(position).replace(/AtMax$/, '') 509 | ] 510 | } 511 | 512 | ) 513 | )} 514 | 515 | ); 516 | 517 | /* provide handlers for controlling the carousel in an imperative way */ 518 | useImperativeHandle(ref, () => ({ 519 | play: () => setIsPlaying(() => true), 520 | pause: () => setIsPlaying(() => false), 521 | toggleIsPlaying: () => setIsPlaying((isPlaying) => !isPlaying), 522 | maximize: () => setIsMaximized(() => true), 523 | minimize: () => setIsMaximized(() => false), 524 | toggleIsMaximized: () => setIsMaximized((isMaximized) => !isMaximized), 525 | goLeft: goLeft, 526 | goRight: goRight, 527 | goToIndex: goToIndex 528 | })); 529 | 530 | return ( 531 | <> 532 | {carouselPlaceholder} 533 | {maxCarouselBackground} 534 |
546 |
547 |
553 | 568 |
569 | {widgets} 570 | {props.elements && props.elements} 571 |
572 | {thumbnails} 573 |
574 | 575 | ); 576 | }; 577 | 578 | export const Carousel = forwardRef(GalleryCarousel); 579 | 580 | Carousel.displayName = 'Carousel'; 581 | Carousel.propTypes = propTypes; 582 | Carousel.defaultProps = defaultProps; 583 | -------------------------------------------------------------------------------- /src/components/Carousel/Carousel.module.css: -------------------------------------------------------------------------------- 1 | .carousel { 2 | position: relative; 3 | width: 100%; 4 | height: 100%; 5 | user-select: none; 6 | display: flex; 7 | background-color: #fff; 8 | flex-flow: column; 9 | align-items: stretch; 10 | box-sizing: border-box; 11 | } 12 | 13 | .maxCarousel { 14 | position: fixed; 15 | top: 0; 16 | left: 0; 17 | right: 0; 18 | bottom: 0; 19 | z-index: 2000; 20 | width: 100%; 21 | height: 100%; 22 | background-color: #fff; 23 | transform: scale(1); 24 | transform-origin: top center; 25 | display: flex; 26 | flex-flow: column; 27 | align-items: stretch; 28 | } 29 | 30 | .carouselInner { 31 | width: 100%; 32 | height: 100%; 33 | overflow: hidden; 34 | position: relative; 35 | flex: 0 1 auto; 36 | } 37 | 38 | .slidesContainer { 39 | width: 100%; 40 | height: 100%; 41 | overflow: hidden; 42 | display: flex; 43 | justify-content: flex-start; 44 | align-items: flex-start; 45 | touch-action: auto; 46 | flex: 0 1 auto; 47 | } 48 | 49 | .gallery { 50 | background-color: #000; 51 | } 52 | 53 | [data-is-not-keyboard-user='true'] *:focus { 54 | outline: none; 55 | } 56 | -------------------------------------------------------------------------------- /src/components/Carousel/Carousel.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import Carousel from '../Carousel'; 4 | import { defaultProps } from './props'; 5 | const { describe, it, expect } = global; 6 | 7 | const images = [900, 800, 700, 600, 500].map((size) => ({ 8 | src: `https://placedog.net/${size}/${size}` 9 | })); 10 | 11 | const imageIDs = Array(6) 12 | .fill(1) 13 | .map((_, i) => i + 1); 14 | const images2 = imageIDs.map((imageID) => { 15 | return { 16 | src: `https://placedog.net/8${imageID}0/6${imageID}0?id=${imageID}`, 17 | alt: `Dog No. ${imageID}. Dogs are domesticated mammals, not natural wild animals. They were originally bred from wolves. They have been bred by humans for a long time, and were the first animals ever to be domesticated.`, 18 | thumbnail: `https://placedog.net/8${imageID}/6${imageID}?id=${imageID}` 19 | }; 20 | }); 21 | 22 | // snapshot testing 23 | describe('Carousel', () => { 24 | it('is truthy', () => { 25 | expect(Carousel).toBeTruthy(); 26 | }); 27 | 28 | global.IntersectionObserver = function () { 29 | return { 30 | observe: () => {}, 31 | disconnect: () => {} 32 | }; 33 | }; 34 | 35 | Object.defineProperty(window, 'matchMedia', { 36 | writable: true, 37 | value: (query) => ({ 38 | matches: false, 39 | media: query, 40 | onchange: null, 41 | addEventListener: () => {}, 42 | removeEventListener: () => {} 43 | }) 44 | }); 45 | 46 | it('renders the same as snapshot', () => { 47 | const component = renderer.create( 48 | 49 | ); 50 | const tree = component.toJSON(); 51 | expect(tree).toMatchSnapshot(); 52 | }); 53 | 54 | it('can be maximized and renders the same as snapshot', () => { 55 | const component2 = renderer.create( 56 | 57 | ); 58 | const tree2 = component2.toJSON(); 59 | expect(tree2).toMatchSnapshot(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/components/Carousel/constants.js: -------------------------------------------------------------------------------- 1 | // Swiping down longer than MAX_SWIPE_DOWN_DISTANCE will not have more visual changes 2 | export const MAX_SWIPE_DOWN_DISTANCE = 1500; // px 3 | 4 | export const WIDGET_POSITIONS = [ 5 | 'topLeft', 6 | 'topCenter', 7 | 'topRight', 8 | 'centerLeft', 9 | 'centerCenter', 10 | 'centerRight', 11 | 'bottomLeft', 12 | 'bottomCenter', 13 | 'bottomRight', 14 | 'top', 15 | 'bottom' 16 | ]; 17 | // place large widgets those have multiple focusable elements at the end, 18 | // so that keyboard users can get to their desired elements easier and faster. 19 | 20 | export const WIDGET_POSITIONS_WITH_RTL = [ 21 | 'topRight', 22 | 'topCenter', 23 | 'topLeft', 24 | 'centerRight', 25 | 'centerCenter', 26 | 'centerLeft', 27 | 'bottomRight', 28 | 'bottomCenter', 29 | 'bottomLeft', 30 | 'top', 31 | 'bottom' 32 | ]; 33 | -------------------------------------------------------------------------------- /src/components/Carousel/index.js: -------------------------------------------------------------------------------- 1 | export { Carousel as default } from './Carousel'; 2 | -------------------------------------------------------------------------------- /src/components/Carousel/props.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { 3 | compareToProp, 4 | fallbackProps, 5 | numberBetween, 6 | positiveNumber, 7 | objectFitStyles, 8 | smallWidgetPositions, 9 | largeWidgetPositions 10 | } from '../../utils/validators'; 11 | 12 | export const propTypes = { 13 | images: PropTypes.array && fallbackProps(['children']), 14 | children: PropTypes.oneOfType([ 15 | PropTypes.arrayOf(PropTypes.node), 16 | PropTypes.node 17 | ]), 18 | thumbnails: PropTypes.arrayOf(PropTypes.node), 19 | isRTL: PropTypes.bool.isRequired, 20 | isLoop: PropTypes.bool.isRequired, 21 | isMaximized: PropTypes.bool.isRequired, 22 | index: positiveNumber(true), 23 | shouldLazyLoad: PropTypes.bool.isRequired, 24 | canAutoPlay: PropTypes.bool.isRequired, 25 | isAutoPlaying: PropTypes.bool.isRequired, 26 | autoPlayInterval: positiveNumber(false), 27 | hasTransition: PropTypes.bool.isRequired, 28 | swipeThreshold: numberBetween(0, 1), 29 | swipeRollbackSpeed: positiveNumber(true), 30 | transitionSpeed: positiveNumber(true), 31 | transitionDurationLimit: positiveNumber(true), 32 | transitionDurationMin: positiveNumber(true), 33 | transitionDurationMax: compareToProp('>=', 'transitionDurationMin'), 34 | widgetsHasShadow: PropTypes.bool.isRequired, 35 | hasLeftButton: smallWidgetPositions.isRequired, 36 | hasRightButton: smallWidgetPositions.isRequired, 37 | hasMediaButton: smallWidgetPositions.isRequired, 38 | hasSizeButton: smallWidgetPositions.isRequired, 39 | hasIndexBoard: smallWidgetPositions.isRequired, 40 | hasDotButtons: largeWidgetPositions.isRequired, 41 | hasCaptions: largeWidgetPositions.isRequired, 42 | hasThumbnails: PropTypes.bool.isRequired, 43 | hasLeftButtonAtMax: smallWidgetPositions, 44 | hasRightButtonAtMax: smallWidgetPositions, 45 | hasMediaButtonAtMax: smallWidgetPositions, 46 | hasSizeButtonAtMax: smallWidgetPositions, 47 | hasIndexBoardAtMax: smallWidgetPositions, 48 | hasDotButtonsAtMax: largeWidgetPositions, 49 | hasCaptionsAtMax: largeWidgetPositions, 50 | hasThumbnailsAtMax: PropTypes.bool, 51 | leftIcon: PropTypes.node, 52 | rightIcon: PropTypes.node, 53 | playIcon: PropTypes.node, 54 | pauseIcon: PropTypes.node, 55 | minIcon: PropTypes.node, 56 | maxIcon: PropTypes.node, 57 | activeIcon: PropTypes.node, 58 | passiveIcon: PropTypes.node, 59 | elements: PropTypes.node, 60 | shouldSwipeOnMouse: PropTypes.bool.isRequired, 61 | shouldMaximizeOnClick: PropTypes.bool.isRequired, 62 | shouldMinimizeOnClick: PropTypes.bool.isRequired, 63 | shouldMinimizeOnSwipeDown: PropTypes.bool.isRequired, 64 | onIndexChange: PropTypes.func.isRequired, 65 | className: PropTypes.string, 66 | style: PropTypes.object, 67 | objectFit: objectFitStyles.isRequired, 68 | objectFitAtMax: objectFitStyles.isRequired, 69 | thumbnailWidth: PropTypes.string, 70 | thumbnailHeight: PropTypes.string, 71 | zIndexAtMax: PropTypes.number 72 | }; 73 | 74 | export const defaultProps = { 75 | index: 0, 76 | isRTL: false, 77 | isLoop: true, 78 | isMaximized: false, 79 | shouldLazyLoad: true, 80 | canAutoPlay: true, 81 | isAutoPlaying: false, 82 | autoPlayInterval: 5000, // ms 83 | hasTransition: true, 84 | swipeThreshold: 0.1, // * 100% 85 | swipeRollbackSpeed: 0.1, // px/ms 86 | transitionSpeed: 1, // px/ms 87 | transitionDurationMin: 250, // ms 88 | transitionDurationLimit: 750, // ms 89 | widgetsHasShadow: false, 90 | hasLeftButton: 'centerLeft', 91 | hasRightButton: 'centerRight', 92 | hasMediaButton: 'topLeft', 93 | hasSizeButton: 'topRight', 94 | hasIndexBoard: 'topCenter', 95 | hasDotButtons: false, 96 | hasCaptions: false, 97 | hasThumbnails: true, 98 | shouldSwipeOnMouse: true, 99 | shouldMaximizeOnClick: false, 100 | shouldMinimizeOnClick: false, 101 | shouldMinimizeOnSwipeDown: true, 102 | onIndexChange: () => {}, 103 | onSwipeMoveX: () => {}, 104 | onSwipeMoveY: () => {}, 105 | onSwipeEndDown: () => {}, 106 | onTap: () => {}, 107 | objectFit: 'cover', 108 | objectFitAtMax: 'contain' 109 | }; 110 | 111 | // get props according to whether the carousel isMaximized 112 | export const getSettings = (props, propNames, isMaximized) => { 113 | const newProps = propNames.map((propName) => { 114 | if (!isMaximized) return props[propName]; 115 | const propNameAtMax = propName + 'AtMax'; 116 | // only take the props[propNameAtMax] if it is specified 117 | if (propNameAtMax in props) return props[propNameAtMax]; 118 | // take the props[propName] as a fallback 119 | return props[propName]; 120 | }); 121 | return propNames.reduce( 122 | (obj, key, index) => ({ ...obj, [key]: newProps[index] }), 123 | {} 124 | ); 125 | }; 126 | -------------------------------------------------------------------------------- /src/components/IconButton/IconButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './IconButton.module.css'; 3 | import PropTypes from 'prop-types'; 4 | 5 | // use inline SVGs to reduce the number of additional requests 6 | const icons = { 7 | left: ( 8 |
9 | 18 | 19 | 20 |
21 | ), 22 | right: ( 23 |
24 | 33 | 34 | 35 |
36 | ), 37 | play: ( 38 |
39 | 48 | 49 | 50 |
51 | ), 52 | pause: ( 53 |
54 | 63 | 64 | 65 |
66 | ), 67 | max: ( 68 |
69 | 78 | 79 | 80 |
81 | ), 82 | min: ( 83 |
84 | 93 | 94 | 95 |
96 | ), 97 | active: ( 98 |
99 | 108 | 109 | 110 |
111 | ), 112 | passive: ( 113 |
114 | 123 | 124 | 125 |
126 | ) 127 | }; 128 | 129 | export const IconButton = (props) => { 130 | // only take the user-supplied icon if it is not undefined; 131 | // use the default icon as a fallback 132 | const icon = props.icon !== undefined ? props.icon : icons[props.name]; 133 | return ( 134 | 145 | ); 146 | }; 147 | 148 | IconButton.propTypes = { 149 | icon: PropTypes.node, 150 | name: PropTypes.oneOf([ 151 | 'left', 152 | 'right', 153 | 'play', 154 | 'pause', 155 | 'max', 156 | 'min', 157 | 'active', 158 | 'passive' 159 | ]).isRequired, 160 | hasShadow: PropTypes.bool.isRequired, 161 | label: PropTypes.string.isRequired, 162 | onClick: PropTypes.func 163 | }; 164 | -------------------------------------------------------------------------------- /src/components/IconButton/IconButton.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | position: relative; 3 | margin: 0; 4 | border: none; 5 | padding: 0; 6 | 7 | display: inline-block; 8 | appearance: none; 9 | opacity: 0.75; 10 | background-color: transparent; 11 | background-position: center; 12 | background-repeat: no-repeat; 13 | 14 | cursor: pointer; 15 | user-select: none; 16 | touch-action: manipulation; 17 | } 18 | 19 | .buttonShadow { 20 | filter: drop-shadow(0px 0px 3px #888); 21 | } 22 | 23 | .button:hover { 24 | opacity: 1; 25 | } 26 | 27 | @media (hover: none) { 28 | .button:hover { 29 | opacity: 0.75; 30 | } 31 | } 32 | 33 | .iconWrapper { 34 | display: flex; 35 | justify-content: center; 36 | align-items: center; 37 | } 38 | 39 | .rectangle { 40 | width: 50px; 41 | height: 60px; 42 | } 43 | 44 | .square { 45 | width: 50px; 46 | height: 50px; 47 | } 48 | 49 | .circle { 50 | width: 20px; 51 | height: 20px; 52 | border-radius: 50%; 53 | } 54 | 55 | .icon { 56 | stroke: #bbb; 57 | stroke-opacity: 1; 58 | stroke-width: 1px; 59 | fill: #fff; 60 | } 61 | -------------------------------------------------------------------------------- /src/components/IconButton/index.js: -------------------------------------------------------------------------------- 1 | export { IconButton as default } from './IconButton'; 2 | -------------------------------------------------------------------------------- /src/components/Image/Image.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useRef, useState } from 'react'; 2 | import styles from './Image.module.css'; 3 | import { PLACEHOLDER_IMAGE } from './constants'; 4 | import { Caption } from '../Widgets'; 5 | import useIntersectionObserver from '../../utils/useIntersectionObserver'; 6 | import PropTypes from 'prop-types'; 7 | import { 8 | objectFitStyles, 9 | largeWidgetPositions, 10 | imageObject, 11 | elementRef 12 | } from '../../utils/validators'; 13 | 14 | const handleError = (event) => { 15 | event.target.src = PLACEHOLDER_IMAGE; 16 | }; 17 | 18 | const LazyLoadedImage = (props) => { 19 | const imageRef = useRef(null); 20 | const isInViewport = useIntersectionObserver( 21 | imageRef, 22 | props.slidesContainerRef, 23 | '0px 101% 0px 101%' 24 | // preload 2 images on either side of the slides' container (viewport) 25 | ); 26 | const [shouldShowThumbnail, setShouldShowThumbnail] = useState( 27 | props.image.thumbnail 28 | ); 29 | const [hasError, setHasError] = useState(false); 30 | 31 | const handleLoad = () => { 32 | if (isInViewport) setShouldShowThumbnail(false); 33 | // the low quality image (props.image.thumbnail) will be hidden 34 | }; 35 | 36 | const handleError = () => { 37 | setHasError(true); 38 | }; 39 | 40 | let { src, srcset, alt, thumbnail, ...otherImageProps } = props.image; 41 | 42 | src = isInViewport && !hasError ? src : PLACEHOLDER_IMAGE; 43 | srcset = isInViewport && !hasError ? srcset : null; 44 | thumbnail = isInViewport && !hasError ? thumbnail : PLACEHOLDER_IMAGE; 45 | 46 | return ( 47 | <> 48 | {alt 59 | {alt 68 | 69 | ); 70 | }; 71 | 72 | LazyLoadedImage.propTypes = { 73 | slidesContainerRef: elementRef.isRequired, 74 | image: imageObject.isRequired, 75 | style: PropTypes.object.isRequired 76 | }; 77 | 78 | export const Image = (props) => { 79 | const objectFit = props.objectFit === 'cover' ? null : props.objectFit; 80 | const style = { objectFit: objectFit }; 81 | 82 | const { src, alt, srcset, thumbnail, ...otherImageProps } = props.image; 83 | 84 | const image = props.shouldLazyLoad ? ( 85 | 90 | ) : ( 91 | {alt 101 | ); 102 | 103 | return ( 104 |
105 | {image} 106 | {props.hasCaption && props.image.alt && ( 107 | 112 | )} 113 |
114 | ); 115 | }; 116 | 117 | Image.propTypes = { 118 | objectFit: objectFitStyles.isRequired, 119 | image: imageObject.isRequired, 120 | shouldLazyLoad: PropTypes.bool.isRequired, 121 | slidesContainerRef: elementRef.isRequired, 122 | hasCaption: largeWidgetPositions.isRequired, 123 | widgetsHasShadow: PropTypes.bool.isRequired 124 | }; 125 | -------------------------------------------------------------------------------- /src/components/Image/Image.module.css: -------------------------------------------------------------------------------- 1 | .figure { 2 | position: relative; 3 | height: 100%; 4 | width: 100%; 5 | margin: 0; 6 | border: 0; 7 | border-image-width: 0; 8 | padding: 0; 9 | user-select: none; 10 | overflow: hidden; 11 | } 12 | 13 | .image { 14 | position: relative; 15 | height: 100%; 16 | width: 100%; 17 | margin: 0; 18 | border: 0; 19 | border-image-width: 0; 20 | padding: 0; 21 | object-fit: cover; 22 | user-select: none; 23 | -webkit-user-drag: none; 24 | } 25 | 26 | .thumbnail { 27 | position: absolute; 28 | height: 100%; 29 | width: 100%; 30 | top: 0; 31 | left: 0; 32 | right: 0; 33 | bottom: 0; 34 | object-fit: cover; 35 | user-select: none; 36 | -webkit-user-drag: none; 37 | } 38 | 39 | .thumbnail.hidden { 40 | display: none; 41 | } 42 | 43 | .thumbnail::before, 44 | .image::before { 45 | content: ''; 46 | display: block; 47 | position: absolute; 48 | width: 100%; 49 | min-width: 100%; 50 | height: 100%; 51 | min-height: 100%; 52 | top: 0; 53 | left: 0; 54 | right: 0; 55 | bottom: 0; 56 | background: #444; 57 | } 58 | -------------------------------------------------------------------------------- /src/components/Image/ImageThumbnail.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import styles from './Image.module.css'; 3 | import { PLACEHOLDER_IMAGE } from './constants'; 4 | import useIntersectionObserver from '../../utils/useIntersectionObserver'; 5 | import PropTypes from 'prop-types'; 6 | import { elementRef, imageObject } from '../../utils/validators'; 7 | 8 | const handleError = (event) => { 9 | // permanently replace the image with the fallback image 10 | event.target.src = PLACEHOLDER_IMAGE; 11 | }; 12 | 13 | const LazyLoadedImageThumbnail = (props) => { 14 | const imageRef = useRef(null); 15 | const isInViewport = useIntersectionObserver( 16 | imageRef, 17 | props.thumbnailsContainerRef, 18 | '0px 20% 0px 20%' 19 | // preload approximately 2 image thumbnails on either side of the thumbnails' container (viewport) 20 | // 'approximately' is due to the presence of margin between adjacent images 21 | ); 22 | 23 | // temporarily replace the image with the placeholder image 24 | const src = isInViewport ? props.src : PLACEHOLDER_IMAGE; 25 | 26 | return ( 27 | {props.alt} 35 | ); 36 | }; 37 | 38 | LazyLoadedImageThumbnail.propTypes = { 39 | thumbnailsContainerRef: elementRef.isRequired, 40 | src: PropTypes.string.isRequired, 41 | alt: PropTypes.string 42 | }; 43 | 44 | export const ImageThumbnail = (props) => { 45 | // use the original image as fallback for the thumbnail 46 | const src = props.image.thumbnail || props.image.src; 47 | const alt = props.image.alt || null; 48 | 49 | if (props.shouldLazyLoad) 50 | return ( 51 | 56 | ); 57 | 58 | return ( 59 | {alt} 67 | ); 68 | }; 69 | 70 | ImageThumbnail.propTypes = { 71 | image: imageObject.isRequired, 72 | shouldLazyLoad: PropTypes.bool.isRequired, 73 | thumbnailsContainerRef: elementRef.isRequired 74 | }; 75 | -------------------------------------------------------------------------------- /src/components/Image/constants.js: -------------------------------------------------------------------------------- 1 | export const PLACEHOLDER_IMAGE = 2 | 'data:image/jpeg;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNiAAAABgADNjd8qAAAAABJRU5ErkJggg=='; 3 | -------------------------------------------------------------------------------- /src/components/Image/index.js: -------------------------------------------------------------------------------- 1 | export { Image as default } from './Image'; 2 | export { ImageThumbnail } from './ImageThumbnail'; 3 | -------------------------------------------------------------------------------- /src/components/Slide/Slide.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './Slide.module.css'; 3 | import Image from '../Image'; 4 | import UserSlide from '../UserSlide'; 5 | import PropTypes from 'prop-types'; 6 | import { 7 | elementRef, 8 | largeWidgetPositions, 9 | objectFitStyles, 10 | slideObject 11 | } from '../../utils/validators'; 12 | 13 | export const Slide = (props) => { 14 | const slide = props.isImage ? ( 15 | 23 | ) : ( 24 | 25 | ); 26 | return ( 27 |
  • 33 | {slide} 34 |
  • 35 | ); 36 | }; 37 | 38 | Slide.propTypes = { 39 | isImage: PropTypes.bool.isRequired, 40 | slide: slideObject.isRequired, 41 | shouldLazyLoad: PropTypes.bool.isRequired, 42 | objectFit: objectFitStyles.isRequired, 43 | widgetsHasShadow: PropTypes.bool.isRequired, 44 | hasCaption: largeWidgetPositions.isRequired, 45 | slidesContainerRef: elementRef.isRequired, 46 | reference: elementRef, 47 | isCurrent: PropTypes.bool.isRequired 48 | }; 49 | -------------------------------------------------------------------------------- /src/components/Slide/Slide.module.css: -------------------------------------------------------------------------------- 1 | .slide { 2 | height: 100%; 3 | min-width: 100%; 4 | width: 100%; 5 | max-width: 100%; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/Slide/index.js: -------------------------------------------------------------------------------- 1 | export { Slide as default } from './Slide'; 2 | -------------------------------------------------------------------------------- /src/components/Slides/Slides.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './Slides.module.css'; 3 | import Slide from '../Slide'; 4 | import PropTypes from 'prop-types'; 5 | import { 6 | positiveNumber, 7 | elementRef, 8 | largeWidgetPositions, 9 | objectFitStyles 10 | } from '../../utils/validators'; 11 | 12 | export const Slides = (props) => { 13 | const slides = props.slides; 14 | 15 | return ( 16 |
      24 | {slides.map((slide, index) => { 25 | let reference = null; 26 | if (index === 0) reference = props.minRef; 27 | else if (index === props.length - 1) reference = props.maxRef; 28 | return ( 29 | 42 | ); 43 | })} 44 |
    45 | ); 46 | }; 47 | 48 | Slides.propTypes = { 49 | slides: PropTypes.array.isRequired, 50 | isRTL: PropTypes.bool.isRequired, 51 | slidesRef: elementRef.isRequired, 52 | minRef: elementRef.isRequired, 53 | length: positiveNumber(true), 54 | maxRef: elementRef.isRequired, 55 | slidesContainerRef: elementRef.isRequired, 56 | hasImages: PropTypes.bool.isRequired, 57 | shouldLazyLoad: PropTypes.bool.isRequired, 58 | objectFit: objectFitStyles.isRequired, 59 | widgetsHasShadow: PropTypes.bool.isRequired, 60 | hasCaptions: largeWidgetPositions.isRequired, 61 | curIndex: PropTypes.number 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/Slides/Slides.module.css: -------------------------------------------------------------------------------- 1 | .slides { 2 | height: 100%; 3 | width: 100%; 4 | list-style: none; 5 | margin: 0; 6 | padding: 0; 7 | display: flex; 8 | flex-direction: row; 9 | justify-content: flex-start; 10 | align-items: flex-start; 11 | transform: translateZ(0); 12 | transition-duration: 0s; 13 | transition-property: transform; 14 | transition-timing-function: linear; 15 | will-change: transform; 16 | backface-visibility: hidden; 17 | } 18 | 19 | .slides.RTL { 20 | flex-direction: row-reverse; 21 | } 22 | 23 | .hasImages { 24 | cursor: grab; 25 | } 26 | 27 | :global(.isGrabbing) > .hasImages { 28 | cursor: grabbing; 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Slides/index.js: -------------------------------------------------------------------------------- 1 | export { Slides as default } from './Slides'; 2 | -------------------------------------------------------------------------------- /src/components/Thumbnail/Thumbnail.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import styles from './Thumbnail.module.scss'; 3 | import { ImageThumbnail } from '../Image'; 4 | import { UserSlideThumbnail } from '../UserSlide'; 5 | import useNoDrag from '../../utils/useNoDrag'; 6 | import useKeys from '../../utils/useKeys'; 7 | import PropTypes from 'prop-types'; 8 | import { elementRef, slideObject } from '../../utils/validators'; 9 | 10 | export const Thumbnail = (props) => { 11 | const reference = useRef(null); 12 | 13 | const slide = props.isImage ? ( 14 | 19 | ) : ( 20 | 21 | ); 22 | 23 | const className = `${styles.thumbnail}${ 24 | props.isCurrent ? ' ' + styles.currentThumbnail : '' 25 | }`; 26 | 27 | // customize the width of the thumbnail 28 | const style = 29 | 'width' in props 30 | ? { 31 | minWidth: props.width, 32 | width: props.width, 33 | maxWidth: props.width 34 | } 35 | : {}; 36 | 37 | const ref = props.isCurrent ? props.reference : reference; 38 | 39 | useNoDrag(ref); // prevent dragging on FireFox 40 | 41 | useKeys(ref, { Enter: props.onClick }); // allow keyboard navigation 42 | 43 | return ( 44 |
  • 52 | {slide} 53 |
  • 54 | ); 55 | }; 56 | 57 | Thumbnail.propTypes = { 58 | isImage: PropTypes.bool.isRequired, 59 | thumbnailsContainerRef: elementRef.isRequired, 60 | slide: slideObject.isRequired, 61 | thumbnail: slideObject.isRequired, 62 | shouldLazyLoad: PropTypes.bool.isRequired, 63 | isCurrent: PropTypes.bool.isRequired, 64 | width: PropTypes.string, 65 | reference: elementRef.isRequired, 66 | onClick: PropTypes.func.isRequired 67 | }; 68 | -------------------------------------------------------------------------------- /src/components/Thumbnail/Thumbnail.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../common/common.scss'; 2 | 3 | $passiveOpacity: 0.5; 4 | $activeOpacity: 1; 5 | 6 | .thumbnail { 7 | display: block; 8 | height: 100%; 9 | min-width: $scale; 10 | width: $scale; 11 | max-width: $scale; 12 | overflow: hidden; 13 | margin-right: $margin; 14 | padding: 0; 15 | opacity: $passiveOpacity; 16 | list-style: none; 17 | user-select: none; 18 | -webkit-user-drag: none; 19 | } 20 | 21 | .currentThumbnail { 22 | opacity: $activeOpacity; 23 | } 24 | 25 | .thumbnail:not(.currentThumbnail):hover { 26 | opacity: $activeOpacity; 27 | } 28 | 29 | /* prevent thumbnail to stay in the hover state on touch devices, 30 | since there is no such thing as "hover" on touch devices */ 31 | @media (hover: none) { 32 | .thumbnail:not(.currentThumbnail):hover { 33 | opacity: $passiveOpacity; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/Thumbnail/index.js: -------------------------------------------------------------------------------- 1 | export { Thumbnail as default } from './Thumbnail'; 2 | -------------------------------------------------------------------------------- /src/components/Thumbnails/Thumbnails.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import styles from './Thumbnails.module.scss'; 3 | import Thumbnail from '../Thumbnail'; 4 | import useAnchor from '../../utils/useAnchor'; 5 | import useNoOverScroll from '../../utils/useNoOverScroll'; 6 | import useMouseDrag from '../../utils/useMouseDrag'; 7 | import PropTypes from 'prop-types'; 8 | 9 | export const Thumbnails = (props) => { 10 | const callbacks = props.callbacks; 11 | const thumbnailsContainerRef = useRef(null); 12 | const thumbnailRef = useRef(null); 13 | 14 | useAnchor(thumbnailRef, props.isMaximized); 15 | 16 | // customize the height of the thumbnails wrapper which wraps the thumbnails 17 | const style = 'height' in props ? { flexBasis: props.height } : {}; 18 | 19 | const wheelEventHandler = useNoOverScroll(thumbnailsContainerRef); 20 | const mouseEventHandlers = useMouseDrag(thumbnailsContainerRef); 21 | 22 | return ( 23 |
    30 |
      33 | {Object.keys(callbacks).map((key, index) => { 34 | return ( 35 | 47 | ); 48 | })} 49 |
    50 |
    51 | ); 52 | }; 53 | 54 | Thumbnails.propTypes = { 55 | callbacks: PropTypes.objectOf(PropTypes.func.isRequired).isRequired, 56 | isMaximized: PropTypes.bool.isRequired, 57 | width: PropTypes.string, 58 | height: PropTypes.string, 59 | isRTL: PropTypes.bool.isRequired, 60 | slides: PropTypes.array.isRequired, 61 | thumbnails: PropTypes.array.isRequired, 62 | hasImages: PropTypes.bool.isRequired, 63 | shouldLazyLoad: PropTypes.bool.isRequired, 64 | curIndex: PropTypes.number.isRequired 65 | }; 66 | 67 | Thumbnails.defaultProps = { 68 | curIndex: 0 69 | }; 70 | -------------------------------------------------------------------------------- /src/components/Thumbnails/Thumbnails.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../common/common.scss'; 2 | 3 | .thumbnailsWrapper { 4 | flex: 0 0 $scale; 5 | margin: $margin 0 0; 6 | padding: 0; 7 | overflow-y: hidden; 8 | overflow-x: scroll; 9 | cursor: grab; 10 | scrollbar-width: none; 11 | 12 | /* Prevent the default behaviour of "scroll chaining" where parent element 13 | gets scrolled when the child element is over scrolled, 14 | in order to prevent going to the previous or the next page. */ 15 | overflow-scrolling: auto; 16 | -webkit-overflow-scrolling: touch; 17 | -ms-scroll-chaining: none; 18 | overscroll-behavior-x: contain; 19 | -ms-overflow-style: none; 20 | } 21 | 22 | :global(.isGrabbing).thumbnailsWrapper { 23 | cursor: grabbing; 24 | } 25 | 26 | .thumbnailsWrapper::-webkit-scrollbar { 27 | display: none; 28 | } 29 | 30 | .thumbnails { 31 | width: 100%; 32 | height: 100%; 33 | margin: 0; 34 | padding: 0; 35 | list-style: none; 36 | display: flex; 37 | flex-direction: row; 38 | justify-content: flex-start; 39 | align-items: flex-start; 40 | } 41 | 42 | .thumbnails.RTL { 43 | flex-direction: row-reverse; 44 | /*thumbnails would be frozen (can not be scrolled) with justify-content: flex-start;*/ 45 | justify-content: flex-end; 46 | } 47 | -------------------------------------------------------------------------------- /src/components/Thumbnails/index.js: -------------------------------------------------------------------------------- 1 | export { Thumbnails as default } from './Thumbnails'; 2 | -------------------------------------------------------------------------------- /src/components/UserSlide/UserSlide.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './UserSlide.module.scss'; 3 | import PropTypes from 'prop-types'; 4 | 5 | export const UserSlide = (props) => { 6 | return
    {props.slide}
    ; 7 | }; 8 | 9 | UserSlide.propTypes = { 10 | slide: PropTypes.node.isRequired 11 | }; 12 | 13 | export const UserSlideThumbnail = (props) => { 14 | return ( 15 |
    16 | {props.slide} 17 |
    18 | ); 19 | }; 20 | 21 | UserSlideThumbnail.propTypes = { 22 | slide: PropTypes.node.isRequired 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/UserSlide/UserSlide.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../common/common.scss'; 2 | 3 | .userSlide { 4 | height: 100%; 5 | width: 100%; 6 | display: flex; 7 | flex-direction: row; 8 | justify-content: center; 9 | align-items: flex-start; 10 | overflow-wrap: break-word; 11 | word-break: normal; 12 | white-space: normal; 13 | user-select: auto; 14 | } 15 | 16 | .thumbnail { 17 | user-select: none; 18 | -ms-zoom: $scale; 19 | zoom: $scale; 20 | font-size: $scale; 21 | text-size-adjust: none; 22 | pointer-events: none; 23 | } 24 | 25 | .thumbnail * { 26 | text-size-adjust: none; 27 | } 28 | 29 | .thumbnail > * { 30 | pointer-events: none; 31 | } 32 | 33 | /*Only for Chrome, Opera and Edge*/ 34 | @media screen and (-webkit-min-device-pixel-ratio: 0) and (min-resolution: 0.001dpcm) { 35 | .thumbnail { 36 | font-size: 100%; 37 | } 38 | } 39 | 40 | /*Only for FireFox*/ 41 | @-moz-document url-prefix() { 42 | .thumbnail { 43 | font-size: $scale; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/UserSlide/index.js: -------------------------------------------------------------------------------- 1 | export { UserSlide as default, UserSlideThumbnail } from './UserSlide'; 2 | -------------------------------------------------------------------------------- /src/components/Widgets/Widgets.js: -------------------------------------------------------------------------------- 1 | import React, { memo, useRef } from 'react'; 2 | import styles from './Widgets.module.css'; 3 | import IconButton from '../IconButton'; 4 | import useNoSwipe from '../../utils/useNoSwipe'; 5 | import PropTypes from 'prop-types'; 6 | import { 7 | smallWidgetPositions, 8 | largeWidgetPositions 9 | } from '../../utils/validators'; 10 | 11 | const arrowButtonPropTypes = { 12 | position: smallWidgetPositions.isRequired, 13 | isDisabled: PropTypes.bool.isRequired, 14 | icon: PropTypes.node, 15 | isRTL: PropTypes.bool.isRequired, 16 | hasShadow: PropTypes.bool.isRequired, 17 | onClick: PropTypes.func.isRequired 18 | }; 19 | 20 | export const LeftButton = (props) => { 21 | return ( 22 |
    30 | 37 |
    38 | ); 39 | }; 40 | 41 | LeftButton.propTypes = arrowButtonPropTypes; 42 | 43 | export const RightButton = (props) => { 44 | return ( 45 |
    53 | 60 |
    61 | ); 62 | }; 63 | 64 | RightButton.propTypes = arrowButtonPropTypes; 65 | 66 | export const MediaButton = (props) => { 67 | return ( 68 |
    69 | 76 |
    77 | ); 78 | }; 79 | 80 | MediaButton.propTypes = { 81 | position: smallWidgetPositions.isRequired, 82 | isPlaying: PropTypes.bool.isRequired, 83 | pauseIcon: PropTypes.node, 84 | playIcon: PropTypes.node, 85 | hasShadow: PropTypes.bool.isRequired, 86 | onClick: PropTypes.func.isRequired 87 | }; 88 | 89 | export const SizeButton = (props) => { 90 | return ( 91 |
    92 | 99 |
    100 | ); 101 | }; 102 | 103 | SizeButton.propTypes = { 104 | position: smallWidgetPositions.isRequired, 105 | isMaximized: PropTypes.bool.isRequired, 106 | minIcon: PropTypes.node, 107 | maxIcon: PropTypes.node, 108 | hasShadow: PropTypes.bool.isRequired, 109 | onClick: PropTypes.func.isRequired 110 | }; 111 | 112 | export const IndexBoard = (props) => { 113 | const ref = useRef(null); 114 | 115 | return ( 116 |
    128 | 129 | {/* make curIndex and totalIndices fallback to 0 */} 130 | {props.curIndex || 0} / {props.totalIndices || 0} 131 | 132 |
    133 | ); 134 | }; 135 | 136 | IndexBoard.propTypes = { 137 | position: smallWidgetPositions.isRequired, 138 | hasShadow: PropTypes.bool.isRequired, 139 | curIndex: PropTypes.number.isRequired, 140 | totalIndices: PropTypes.number.isRequired 141 | }; 142 | 143 | IndexBoard.defaultProps = { 144 | curIndex: 0, 145 | totalIndices: 0 146 | }; 147 | 148 | export const DotButtons = (props) => { 149 | const callbacks = props.callbacks; 150 | 151 | return ( 152 |
    153 |
    158 | {Object.keys(callbacks).map((key, index) => ( 159 | 175 | ))} 176 |
    177 |
    178 | ); 179 | }; 180 | 181 | DotButtons.propTypes = { 182 | callbacks: PropTypes.objectOf(PropTypes.func).isRequired, 183 | position: largeWidgetPositions.isRequired, 184 | isRTL: PropTypes.bool.isRequired, 185 | curIndex: PropTypes.number.isRequired, 186 | activeIcon: PropTypes.node, 187 | passiveIcon: PropTypes.node, 188 | hasShadow: PropTypes.bool.isRequired 189 | }; 190 | 191 | DotButtons.defaultProps = { 192 | curIndex: 0 193 | }; 194 | 195 | // memo is useful here 196 | export const Caption = memo((props) => { 197 | const captionRef = useRef(null); 198 | 199 | // allow the user to select text using cursor or finger 200 | useNoSwipe(captionRef); 201 | 202 | return ( 203 |
    216 | {props.text} 217 |
    218 | ); 219 | }); 220 | 221 | Caption.type.displayName = 'Caption'; 222 | 223 | Caption.propTypes = { 224 | position: largeWidgetPositions.isRequired, 225 | hasShadow: PropTypes.bool.isRequired, 226 | text: PropTypes.string 227 | }; 228 | -------------------------------------------------------------------------------- /src/components/Widgets/Widgets.module.css: -------------------------------------------------------------------------------- 1 | .widgetWrapper { 2 | position: absolute; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | } 7 | 8 | .disabled { 9 | opacity: 0.25; 10 | pointer-events: none; 11 | cursor: not-allowed; 12 | } 13 | 14 | .textWrapper { 15 | height: 50px; 16 | padding: 0 10px 0; 17 | display: flex; 18 | flex-direction: row; 19 | justify-content: center; 20 | align-items: center; 21 | user-select: text; 22 | cursor: text; 23 | } 24 | 25 | .captionWrapper { 26 | width: 100%; 27 | max-width: 100%; 28 | max-height: 100%; 29 | overflow: auto; 30 | display: flex; 31 | flex-direction: row; 32 | justify-content: center; 33 | align-items: flex-start; 34 | user-select: text; 35 | cursor: text; 36 | } 37 | 38 | .text { 39 | background: #fff; 40 | font-weight: bold; 41 | padding: 5px; 42 | opacity: 0.75; 43 | height: min-content; 44 | } 45 | 46 | .shadow { 47 | filter: drop-shadow(0px 0px 5px #888); 48 | } 49 | 50 | .buttonsWrapper { 51 | height: 100%; 52 | overflow-x: hidden; 53 | display: flex; 54 | flex-direction: row; 55 | justify-content: center; 56 | align-items: center; 57 | flex-wrap: wrap; 58 | } 59 | 60 | .buttonsWrapper.RTL { 61 | flex-direction: row-reverse; 62 | } 63 | 64 | /*center positions*/ 65 | 66 | .centerLeft { 67 | top: 50%; 68 | left: 0; 69 | transform: translateY(-50%); 70 | } 71 | 72 | .centerCenter { 73 | top: 50%; 74 | left: 50%; 75 | transform: translate(-50%, -50%); 76 | } 77 | 78 | .centerRight { 79 | top: 50%; 80 | right: 0; 81 | transform: translateY(-50%); 82 | } 83 | 84 | /*top positions*/ 85 | 86 | .topLeft { 87 | top: 0; 88 | left: 0; 89 | } 90 | 91 | .topCenter { 92 | top: 0; 93 | left: 0; 94 | right: 0; 95 | margin: 0 auto; 96 | width: fit-content; 97 | } 98 | 99 | .top { 100 | top: 0; 101 | left: 0; 102 | right: 0; 103 | margin: 0 auto; 104 | width: fit-content; 105 | } 106 | 107 | .topRight { 108 | top: 0; 109 | right: 0; 110 | } 111 | 112 | /*bottom positions*/ 113 | 114 | .bottomLeft { 115 | bottom: 0; 116 | left: 0; 117 | } 118 | 119 | .bottomCenter { 120 | bottom: 0; 121 | left: 0; 122 | right: 0; 123 | margin: 0 auto; 124 | width: fit-content; 125 | } 126 | 127 | .bottom { 128 | bottom: 0; 129 | left: 0; 130 | right: 0; 131 | margin: 0 auto; 132 | width: fit-content; 133 | } 134 | 135 | .bottomRight { 136 | bottom: 0; 137 | right: 0; 138 | } 139 | -------------------------------------------------------------------------------- /src/components/Widgets/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | LeftButton, 3 | RightButton, 4 | MediaButton, 5 | SizeButton, 6 | DotButtons, 7 | IndexBoard, 8 | Caption 9 | } from './Widgets'; 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Carousel from './components/Carousel'; 2 | 3 | export default Carousel; 4 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import Carousel from '.'; 2 | const { describe, it, expect } = global; 3 | 4 | // unit testing 5 | describe('Carousel', () => { 6 | it('is truthy', () => { 7 | expect(Carousel).toBeTruthy(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/utils/ReversedMap.js: -------------------------------------------------------------------------------- 1 | export default class ReversedMap { 2 | constructor(map, isMaximized) { 3 | this.reversedMap = {}; 4 | 5 | Object.keys(map).forEach((key) => { 6 | if (isMaximized && key.endsWith('AtMax')) return; 7 | const value = map[key]; 8 | this.reversedMap[value] = key; 9 | }); 10 | } 11 | 12 | get = (key) => { 13 | return this.reversedMap[key]; 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/SlidesFactory.js: -------------------------------------------------------------------------------- 1 | // keep SlidesFactory for future use (for other features) 2 | export default class SlidesFactory { 3 | CreateSlides(slides, options) { 4 | // if (options.isRTL) return new SlidesWithRTL(slides, options); 5 | return new Slides(slides, options); 6 | } 7 | } 8 | 9 | class Slides { 10 | constructor(items, { index, isLoop = false }) { 11 | this._isLoop = isLoop; 12 | 13 | // generate _slides 14 | this._slides = items; 15 | if (!this._slides || !this._slides.length) return; 16 | this._length = this._slides.length; 17 | 18 | // calculate indices 19 | this._minIndex = 0; 20 | this._maxIndex = this._length - 1; 21 | this._headIndex = this._minIndex; 22 | this._curIndex = this._convertCurIndexForDisplayToCurIndex(index); 23 | } 24 | 25 | _isIndexInRange(index) { 26 | return this._minIndex <= index && index <= this._maxIndex; 27 | } 28 | 29 | _convertCurIndexForDisplayToCurIndex(index) { 30 | if (!index) return this._headIndex; 31 | if (!this._isIndexInRange(index)) return this._headIndex; 32 | return index; 33 | } 34 | 35 | get slides() { 36 | return this._slides; 37 | } 38 | 39 | get length() { 40 | return this._length; 41 | } 42 | 43 | get curIndex() { 44 | return this._curIndex; 45 | } 46 | 47 | get curIndexForDisplay() { 48 | return this._curIndex + 1; 49 | } 50 | 51 | isMinIndex() { 52 | return this._curIndex === this._minIndex; 53 | } 54 | 55 | isMaxIndex() { 56 | return this._curIndex === this._maxIndex; 57 | } 58 | 59 | static _range(min, max) { 60 | const length = max - min + 1; 61 | return Array(length) 62 | .fill(min) 63 | .map((x, index) => x + index); 64 | } 65 | 66 | get allIndices() { 67 | if (!this._length) return []; 68 | return this.constructor._range(this._minIndex, this._maxIndex); 69 | } 70 | 71 | calibrateIndex(change) { 72 | if (!this._length) return false; 73 | if (!this._isLoop) return false; 74 | if (this._curIndex === this._minIndex && change < 0) { 75 | this._curIndex = this._maxIndex + 1; 76 | return true; 77 | } else if (this._curIndex === this._maxIndex && change > 0) { 78 | this._curIndex = this._minIndex - 1; 79 | return true; 80 | } 81 | return false; 82 | } 83 | 84 | canUpdateIndex(change) { 85 | if (!this._length) return false; 86 | if (change === 0) return false; 87 | if (this._isLoop) return true; 88 | return this._isIndexInRange(this._curIndex + change); 89 | } 90 | 91 | updateIndex(change) { 92 | if (!this._length) return false; 93 | if (!this.canUpdateIndex(change)) return false; 94 | this._curIndex = Math.abs( 95 | (this._length + this._curIndex + change) % this._length 96 | ); 97 | return true; 98 | } 99 | 100 | _canGoToIndex(index) { 101 | if (!this._length) return false; 102 | return this._isIndexInRange(index); 103 | } 104 | 105 | goToIndex(index) { 106 | if (!this._length) return false; 107 | if (!this._canGoToIndex(index)) return false; 108 | this._curIndex = index; 109 | return true; 110 | } 111 | } 112 | 113 | /* 114 | // deprecated code used for RTL support, in favour of CSS flex-direction: row-reverse, 115 | // since merely reverse the order of slides in RTL carousel can not reverse 116 | // the order of "tabbing" (keyboard navigation) of dot buttons and thumbnails. 117 | 118 | class SlidesWithRTL extends Slides { 119 | constructor(items, options) { 120 | super(items, options); 121 | 122 | // generate _slides 123 | this._slides = [...items].reverse(); 124 | 125 | // calculate indices 126 | this._headIndex = this._maxIndex; 127 | this._curIndex = this._convertCurIndexForDisplayToCurIndex(options.index); 128 | } 129 | 130 | _convertCurIndexForDisplayToCurIndex(index) { 131 | if (!index) return this._headIndex; 132 | index -= 1; 133 | if (!this._isIndexInRange(index)) return this._headIndex; 134 | return this._headIndex - index; 135 | } 136 | 137 | get curIndexForDisplay() { 138 | return this._headIndex - this._curIndex + 1; 139 | } 140 | } 141 | */ 142 | -------------------------------------------------------------------------------- /src/utils/SlidesFactory.test.js: -------------------------------------------------------------------------------- 1 | import SlidesFactory from './SlidesFactory'; 2 | const { describe, it, expect } = global; 3 | 4 | const slidesFactory = new SlidesFactory(); 5 | 6 | // Tests for base cases 7 | const items = [1, 2, 3, 4, 5, 6]; 8 | 9 | describe('6 items without RTL', () => { 10 | const slides = slidesFactory.CreateSlides(items, {}); 11 | const expectedCurIndex = 0; 12 | it('has slides in the correct order', () => { 13 | expect(slides.slides).toStrictEqual([1, 2, 3, 4, 5, 6]); 14 | }); 15 | it('has the correct current index', () => { 16 | expect(slides.curIndex).toBe(expectedCurIndex); 17 | }); 18 | it('cannot move left', () => { 19 | expect(slides.calibrateIndex(-1)).toBe(false); 20 | expect(slides.updateIndex(-1)).toBe(false); 21 | expect(slides.curIndex).toBe(expectedCurIndex); 22 | }); 23 | it('can move right', () => { 24 | expect(slides.calibrateIndex(+1)).toBe(false); 25 | expect(slides.updateIndex(+1)).toBe(true); 26 | expect(slides.curIndex).toBe(expectedCurIndex + 1); 27 | }); 28 | }); 29 | 30 | describe('6 items with RTL', () => { 31 | const slides = slidesFactory.CreateSlides(items, { isRTL: true }); 32 | const expectedCurIndex = 0; 33 | it('has slides in the correct order', () => { 34 | expect(slides.slides).toStrictEqual([1, 2, 3, 4, 5, 6]); 35 | }); 36 | it('has the correct current index', () => { 37 | expect(slides.curIndex).toBe(expectedCurIndex); 38 | }); 39 | it('cannot move right', () => { 40 | expect(slides.calibrateIndex(-1)).toBe(false); 41 | expect(slides.updateIndex(-1)).toBe(false); 42 | expect(slides.curIndex).toBe(expectedCurIndex); 43 | }); 44 | it('can move left', () => { 45 | expect(slides.calibrateIndex(+1)).toBe(false); 46 | expect(slides.updateIndex(+1)).toBe(true); 47 | expect(slides.curIndex).toBe(expectedCurIndex + 1); 48 | }); 49 | }); 50 | 51 | // Test for edge case 52 | describe('0 item without RTL', () => { 53 | const slides = slidesFactory.CreateSlides([], {}); 54 | const expectedCurIndex = undefined; 55 | it('does not have a slide', () => { 56 | expect(slides.slides).toStrictEqual([]); 57 | }); 58 | it('does not have current index', () => { 59 | expect(slides.curIndex).toBe(expectedCurIndex); 60 | }); 61 | it('cannot move left', () => { 62 | expect(slides.calibrateIndex(-1)).toBe(false); 63 | expect(slides.updateIndex(-1)).toBe(false); 64 | expect(slides.curIndex).toBe(expectedCurIndex); 65 | }); 66 | it('cannot move right', () => { 67 | expect(slides.calibrateIndex(+1)).toBe(false); 68 | expect(slides.updateIndex(+1)).toBe(false); 69 | expect(slides.curIndex).toBe(expectedCurIndex); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/utils/isSSR.js: -------------------------------------------------------------------------------- 1 | // determine if the code is executed on a server (as oppose to a browser) 2 | const isSSR = !( 3 | typeof window !== 'undefined' && window.document?.createElement 4 | ); 5 | 6 | export default isSSR; 7 | -------------------------------------------------------------------------------- /src/utils/useAnchor.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | import useEventListener from './useEventListener'; 3 | import useMediaQuery from './useMediaQuery'; 4 | import isSSR from './isSSR'; 5 | 6 | const useAnchor = (elementRef, isMaximized) => { 7 | const element = elementRef && elementRef.current; 8 | const container = element && element.parentNode.parentNode; 9 | 10 | // the word 'was' is used to denote the condition before React has updated the DOM, 11 | // that is in contrast with the condition after React has updated the DOM in useEffect(). 12 | const wasInitialRender = !(element && container); 13 | const wasLeftMost = element && element.offsetLeft <= 0; 14 | const wasRightMost = 15 | element && 16 | container && 17 | element.offsetLeft + element.clientWidth >= container.clientWidth; 18 | 19 | // get reduced motion setting for determining the need to smooth scrolling for later 20 | const isReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); 21 | 22 | // center the element in the container without smoothness 23 | const centerElement = useCallback(() => { 24 | const element = elementRef.current; 25 | if (!element) return; 26 | const container = element.parentNode.parentNode; 27 | 28 | // cannot use element.scrollIntoView(element, { behavior: 'smooth', block: 'nearest', inline: 'center' }); 29 | // because it will also cause unwanted vertical movement when the element is not vertically in the viewport 30 | // (e.g. the element is somewhere down the page) 31 | container.scrollTo({ 32 | top: 0, 33 | left: 34 | element.offsetLeft - container.clientWidth / 2 + element.clientWidth / 2 35 | }); 36 | }, [elementRef]); 37 | 38 | // center the element in the container with smoothness under certain conditions 39 | const centerElementSmoothly = useCallback(() => { 40 | const element = elementRef.current; 41 | if (!element) return; 42 | const container = element.parentNode.parentNode; 43 | 44 | const isLeftMost = element && element.offsetLeft === 0; 45 | const isRightMost = 46 | element && 47 | container && 48 | element.offsetLeft + element.clientWidth >= container.clientWidth; 49 | 50 | // cannot use CSS scroll-behavior: smooth; due to the necessity of dynamically determining the need to smooth scrolling; 51 | // the need to smooth scrolling is determined dynamically because: 52 | // 1. smooth scrolling for the initial render with the current index not on the left-most thumbnail will traverse intermediate thumbnails, thus it will download unnecessary thumbnails; 53 | // 2. smooth scrolling for moving between the left-most thumbnail and the right-most thumbnail will traverse intermediate slides (essentially all the thumbnails), thus it will download unnecessary thumbnails; 54 | // 3. smooth scrolling should not be applied for users with reduce motion setting turned on 55 | const options = 56 | wasInitialRender || 57 | (wasLeftMost && isRightMost) || 58 | (wasRightMost && isLeftMost) || 59 | isReducedMotion 60 | ? {} 61 | : { behavior: 'smooth' }; 62 | 63 | container.scrollTo({ 64 | top: 0, 65 | left: 66 | element.offsetLeft - 67 | container.clientWidth / 2 + 68 | element.clientWidth / 2, 69 | ...options 70 | }); 71 | // smooth scrolling on Element.scrollTo(), currently does not work on Safari and IE, unlike Window.scrollTo(); 72 | // in the future, a polyfill is therefore needed here for smooth scrolling to work across browsers 73 | }, [ 74 | elementRef, 75 | wasInitialRender, 76 | wasLeftMost, 77 | wasRightMost, 78 | isReducedMotion 79 | ]); 80 | 81 | // center the current element without smoothness on init, on maximize and on minimize 82 | useEffect(() => centerElement(), [centerElement, isMaximized]); 83 | 84 | // center the current element with smoothness on index update 85 | useEffect(() => centerElementSmoothly()); 86 | 87 | // center the current element on click 88 | useEventListener(elementRef.current, 'click', centerElement); 89 | 90 | // center the current element on resize (including orientationchange) 91 | useEventListener(isSSR ? undefined : window, 'resize', centerElement); 92 | }; 93 | 94 | export default useAnchor; 95 | -------------------------------------------------------------------------------- /src/utils/useEventListener.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | const useEventListener = (element, event, callback) => { 4 | const callbackRef = useRef(null); 5 | callbackRef.current = callback; 6 | 7 | useEffect(() => { 8 | const callback = callbackRef.current; 9 | if (element) element.addEventListener(event, callback); 10 | return () => { 11 | if (element) element.removeEventListener(event, callback); 12 | }; 13 | }, [element, event, callback]); 14 | }; 15 | 16 | export default useEventListener; 17 | -------------------------------------------------------------------------------- /src/utils/useFixedPosition.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | const useFixedPosition = (initialState, elementToFocusRef) => { 4 | const [isFixed, setIsFixed] = useState(initialState); 5 | 6 | useEffect(() => { 7 | const scrollX = window.scrollX; 8 | const scrollY = window.scrollY; 9 | 10 | const bodyElement = document.querySelector('body'); 11 | const overflowValue = bodyElement.style.overflow; 12 | 13 | if (isFixed) { 14 | bodyElement.style.overflow = 'hidden'; 15 | if (elementToFocusRef.current) elementToFocusRef.current.focus(); 16 | } 17 | 18 | return () => { 19 | if (isFixed) { 20 | window.scrollTo(scrollX, scrollY); 21 | bodyElement.style.overflow = overflowValue; 22 | } 23 | }; 24 | }, [isFixed, elementToFocusRef]); 25 | 26 | return [isFixed, setIsFixed]; 27 | }; 28 | 29 | export default useFixedPosition; 30 | -------------------------------------------------------------------------------- /src/utils/useIntersectionObserver.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import isSSR from './isSSR'; 3 | 4 | const useIntersectionObserver = ( 5 | elementRef, 6 | rootRef, 7 | rootMargin = '0px 0px 0px 0px' 8 | ) => { 9 | const [isIntersecting, setIsIntersecting] = useState(false); 10 | 11 | useEffect(() => { 12 | // fallback for browsers those do not support IntersectionObserver (i.e. IE) 13 | if (!('IntersectionObserver' in window)) { 14 | return () => {}; 15 | } 16 | 17 | const root = rootRef?.current ? rootRef.current : null; 18 | 19 | // eslint-disable-next-line no-undef 20 | const observer = new IntersectionObserver( 21 | ([entry], observer) => { 22 | if (!isIntersecting && entry.isIntersecting) { 23 | setIsIntersecting(true); 24 | observer.disconnect(); 25 | } 26 | }, 27 | // increase the size of the viewport using rootMargin to preload images 28 | { root: root, rootMargin: rootMargin, threshold: 0 } 29 | ); 30 | if (elementRef.current) observer.observe(elementRef.current); 31 | 32 | return () => { 33 | if (observer) observer.disconnect(); 34 | }; 35 | }, [rootRef, rootMargin, elementRef, isIntersecting]); 36 | 37 | // fallback for SSR 38 | if (isSSR) { 39 | return false; 40 | } 41 | 42 | // fallback for browsers those do not support IntersectionObserver (i.e. IE) 43 | if (!('IntersectionObserver' in window)) { 44 | return true; 45 | } 46 | 47 | return isIntersecting; 48 | }; 49 | 50 | export default useIntersectionObserver; 51 | -------------------------------------------------------------------------------- /src/utils/useKeyboard.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | // import styles from '../components/Carousel/Carousel.module.css'; 3 | 4 | const useKeyboard = (elementRef) => { 5 | useEffect(() => { 6 | const element = elementRef.current; 7 | 8 | const handleMouseDown = () => { 9 | // no need to check elementRef.current here, 10 | // because event listener is added on element 11 | if (!element) return; 12 | element.setAttribute('data-is-not-keyboard-user', 'true'); 13 | // cannot use classList due to classList will get changed 14 | // element.classList.add(styles.isNotKeyboardUser); 15 | }; 16 | 17 | const handleKeyDown = (event) => { 18 | if (!element) return; 19 | if (event.key !== 'Tab') return; 20 | element.setAttribute('data-is-not-keyboard-user', 'false'); 21 | }; 22 | 23 | if (element) { 24 | element.addEventListener('mousedown', handleMouseDown); 25 | element.addEventListener('keydown', handleKeyDown); 26 | } 27 | 28 | return () => { 29 | if (element) { 30 | element.removeEventListener('mousedown', handleMouseDown); 31 | element.removeEventListener('keydown', handleKeyDown); 32 | } 33 | }; 34 | }, [elementRef]); 35 | }; 36 | 37 | export default useKeyboard; 38 | -------------------------------------------------------------------------------- /src/utils/useKeys.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | const useKeys = (elementRef, callbacks) => { 4 | const callbackRef = useRef(null); 5 | callbackRef.current = callbacks; 6 | 7 | useEffect(() => { 8 | const handleKeyDown = (event) => { 9 | callbackRef.current[event.key] && callbackRef.current[event.key](event); 10 | }; 11 | 12 | const element = elementRef.current; 13 | if (element) element.addEventListener('keydown', handleKeyDown); 14 | 15 | return () => { 16 | if (element) element.removeEventListener('keydown', handleKeyDown); 17 | }; 18 | }, [elementRef, callbackRef]); 19 | }; 20 | 21 | export default useKeys; 22 | -------------------------------------------------------------------------------- /src/utils/useMediaQuery.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import isSSR from './isSSR'; 3 | 4 | const useMediaQuery = (query) => { 5 | const mediaQueryList = !isSSR && window.matchMedia(query); 6 | const [matches, setMatches] = useState(mediaQueryList.matches); 7 | 8 | useEffect(() => { 9 | const callback = () => setMatches(mediaQueryList.matches); 10 | mediaQueryList.addEventListener 11 | ? mediaQueryList.addEventListener('change', callback) 12 | : window.addEventListener('resize', callback); 13 | 14 | return () => 15 | mediaQueryList.removeEventListener 16 | ? mediaQueryList.removeEventListener('change', callback) 17 | : window.removeEventListener('resize', callback); 18 | }, [mediaQueryList]); 19 | 20 | return matches; 21 | }; 22 | 23 | export default useMediaQuery; 24 | -------------------------------------------------------------------------------- /src/utils/useMouse.js: -------------------------------------------------------------------------------- 1 | const useMouse = (elementRef, { onMouseMove, onMouseUp, onTap }) => { 2 | let isMouseDown = false; 3 | let isMouseMoved = false; 4 | let mouseDownX = 0; 5 | let previousX = 0; 6 | let previousTime = Date.now(); 7 | let instantaneousVelocity = 0; 8 | 9 | const handleMouseDown = (event) => { 10 | if (elementRef.current) elementRef.current.classList.add('isGrabbing'); 11 | if (event.buttons > 0) isMouseDown = true; 12 | mouseDownX = event.clientX; 13 | previousX = event.clientX; 14 | previousTime = Date.now(); 15 | }; 16 | 17 | const handleMouseMove = (event) => { 18 | if (isMouseDown && event.buttons > 0) { 19 | onMouseMove(event.clientX - mouseDownX, 0, event.clientX - previousX); 20 | isMouseMoved = true; 21 | instantaneousVelocity = 22 | (event.clientX - previousX) / (Date.now() - previousTime); 23 | previousX = event.clientX; 24 | previousTime = Date.now(); 25 | } 26 | }; 27 | 28 | const handleMouseUp = (event) => { 29 | if (elementRef.current) elementRef.current.classList.remove('isGrabbing'); 30 | if (isMouseDown) { 31 | if (isMouseMoved) { 32 | // can not calculate velocity here since event.clientX === previousX; 33 | // ignore vertical displacement by not passing it as argument 34 | onMouseUp(event.clientX - mouseDownX, 0, instantaneousVelocity, event); 35 | } else onTap(); 36 | } 37 | isMouseDown = false; // reset isMouseDown for next series of mouse events 38 | isMouseMoved = false; // reset isMouseMoved for next series of mouse events 39 | }; 40 | 41 | return { 42 | onMouseDown: (event) => handleMouseDown(event), 43 | onMouseMove: (event) => handleMouseMove(event), 44 | onMouseUpCapture: (event) => handleMouseUp(event), 45 | onMouseLeave: (event) => handleMouseUp(event) 46 | }; 47 | }; 48 | 49 | export default useMouse; 50 | -------------------------------------------------------------------------------- /src/utils/useMouseDrag.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import useMouse from './useMouse'; 3 | 4 | const useMouseDrag = (elementRef) => { 5 | const handleMouseMove = (displacementX, displacementY, deltaX) => { 6 | const element = elementRef.current; 7 | if (!element) return; 8 | 9 | // convert drag to scroll 10 | // displacementX is the cumulative change, whereas deltaX is the individual change 11 | element.scrollTo({ 12 | top: 0, 13 | left: element.scrollLeft - deltaX 14 | }); 15 | }; 16 | 17 | const handleMouseUp = (displacementX, displacementY, velocity, event) => { 18 | // allow a mouseup event after a tiny mouse movement (<= 2px) to propagate 19 | if (Math.abs(displacementX) <= 2) return; 20 | 21 | // prevent any other mouseup events to propagate 22 | event.stopPropagation(); 23 | 24 | /* set up momentum-based dragging by giving inertia to the element which allows it 25 | to continue travelling in its direction and gradually slow down by friction, 26 | like the mouse drag version of "-webkit-overflow-scrolling: touch;" */ 27 | const speed = Math.abs(velocity); 28 | // neglect dragging with small instantaneous velocity 29 | if (speed < 0.5) return false; 30 | 31 | const element = elementRef.current; 32 | const childElement = element && element.childNodes[0]; 33 | const isLeftMost = element && element.scrollLeft === 0; 34 | const isRightMost = 35 | element && 36 | childElement && 37 | element.scrollLeft + element.clientWidth >= childElement.scrollWidth; 38 | // neglect dragging at the two ends 39 | if (isLeftMost || isRightMost) return false; 40 | 41 | const CONSTANT = 0.025; 42 | const totalTime = speed / CONSTANT; 43 | let initialTime; 44 | let previousElapsedTime; 45 | 46 | function step(currentTime) { 47 | // assign value to initialTime for the initial step (run of this function) 48 | if (initialTime === undefined) initialTime = currentTime; 49 | 50 | // calculate the change in displacement since the last time 51 | const elapsedTime = currentTime - initialTime; 52 | const intervalDiff = elapsedTime - previousElapsedTime; 53 | const intervalSum = elapsedTime + previousElapsedTime; 54 | const distance = 55 | speed * intervalDiff - (CONSTANT * intervalDiff * intervalSum) / 2; 56 | const displacement = velocity < 0 ? -distance : distance; 57 | 58 | // update the UI which shifts the element 59 | if (!isNaN(displacement)) { 60 | element.scrollTo({ 61 | top: 0, 62 | left: element.scrollLeft - displacement 63 | }); 64 | } 65 | 66 | // check if the elapsedTime is within the totalTime 67 | if (elapsedTime < totalTime) { 68 | // record the elapsedTime 69 | previousElapsedTime = elapsedTime; 70 | window.requestAnimationFrame(step); 71 | } 72 | } 73 | 74 | window.requestAnimationFrame(step); 75 | 76 | return false; 77 | }; 78 | 79 | useEffect(() => { 80 | // disable selection on the element to ensure the value of 81 | // CSS 'cursor' property is not the default 'text' on select on Safari 82 | if (elementRef.current) elementRef.current.onselectstart = () => false; 83 | }, [elementRef]); 84 | 85 | return useMouse(elementRef, { 86 | onMouseMove: handleMouseMove, 87 | onMouseUp: handleMouseUp, 88 | onTap: () => {} 89 | }); 90 | }; 91 | 92 | export default useMouseDrag; 93 | -------------------------------------------------------------------------------- /src/utils/useNoDrag.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | const useNoDrag = (elementRef) => { 4 | useEffect(() => { 5 | const element = elementRef.current; 6 | const handleDrag = (e) => e.preventDefault(); 7 | 8 | if (element) element.addEventListener('dragstart', handleDrag); 9 | 10 | return () => { 11 | if (element) element.removeEventListener('dragstart', handleDrag); 12 | }; 13 | }, [elementRef]); 14 | }; 15 | 16 | export default useNoDrag; 17 | -------------------------------------------------------------------------------- /src/utils/useNoOverScroll.js: -------------------------------------------------------------------------------- 1 | const useNoOverScroll = (elementRef) => { 2 | // Prevent the default behaviour of "scroll chaining" where parent element 3 | // gets scrolled when the child element is over scrolled, 4 | // in order to prevent going to the previous or the next page. 5 | // This code is for Safari only, while other browsers are taken care by CSS 6 | return (event) => { 7 | if (Math.abs(event.deltaX) < Math.abs(event.deltaY)) return; 8 | const { scrollLeft, scrollWidth, offsetWidth } = elementRef.current; 9 | if ( 10 | (scrollLeft + event.deltaX < 0 || 11 | scrollLeft + event.deltaX > scrollWidth - offsetWidth) && 12 | event.cancelable 13 | ) 14 | event.preventDefault(); 15 | }; 16 | }; 17 | 18 | export default useNoOverScroll; 19 | -------------------------------------------------------------------------------- /src/utils/useNoSwipe.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | const useNoSwipe = (elementRef) => { 4 | useEffect(() => { 5 | const el = elementRef.current; 6 | const handleSwipe = (e) => { 7 | e.stopPropagation(); 8 | }; 9 | 10 | if (el) { 11 | el.addEventListener('mousedown', handleSwipe); 12 | el.addEventListener('touchstart', handleSwipe, { passive: true }); 13 | } 14 | 15 | return () => { 16 | if (!el) { 17 | el.removeEventListener('mousedown', handleSwipe); 18 | el.removeEventListener('touchstart', handleSwipe, { passive: true }); 19 | } 20 | }; 21 | }, [elementRef]); 22 | }; 23 | 24 | export default useNoSwipe; 25 | -------------------------------------------------------------------------------- /src/utils/useSlides.js: -------------------------------------------------------------------------------- 1 | import SlidesFactory from './SlidesFactory'; 2 | import { useMemo } from 'react'; 3 | 4 | const useSlides = (items, { index, isLoop }) => { 5 | return useMemo(() => { 6 | const slidesFactory = new SlidesFactory(); 7 | const slides = slidesFactory.CreateSlides(items, { 8 | index: index, 9 | isLoop: isLoop 10 | }); 11 | const slidesElements = slides.slides; 12 | return [slides, slidesElements]; 13 | }, [items, index, isLoop]); 14 | }; 15 | 16 | export default useSlides; 17 | -------------------------------------------------------------------------------- /src/utils/useSwipe.js: -------------------------------------------------------------------------------- 1 | import useTouch from './useTouch'; 2 | import useMouse from './useMouse'; 3 | import useNoDrag from './useNoDrag'; 4 | 5 | const useSwipe = ( 6 | elementRef, 7 | swipePercentageMin, 8 | { 9 | onSwipeMoveX, 10 | onSwipeMoveY, 11 | onSwipeEndRight, 12 | onSwipeEndLeft, 13 | onSwipeEndDisqualified, 14 | onSwipeEndDown, 15 | onTap 16 | } 17 | ) => { 18 | let isInitialSwipeVertical; 19 | 20 | const handleSwipeEnd = (displacementX, displacementY = 0, velocity = 0) => { 21 | const { clientWidth: width, clientHeight: height } = elementRef.current; 22 | const distanceXMin = width * swipePercentageMin; 23 | const distanceYMin = height * swipePercentageMin; 24 | const speed = Math.abs(velocity); 25 | if ( 26 | !isInitialSwipeVertical && 27 | // displacementX <= -Math.abs(displacementY) && 28 | displacementX <= -distanceXMin 29 | ) 30 | onSwipeEndLeft(displacementX, speed); 31 | else if ( 32 | !isInitialSwipeVertical && 33 | // displacementX >= Math.abs(displacementY) && 34 | displacementX >= distanceXMin 35 | ) 36 | onSwipeEndRight(displacementX, speed); 37 | else if ( 38 | isInitialSwipeVertical && 39 | // Math.abs(displacementX) < displacementY && 40 | displacementY >= distanceYMin 41 | ) 42 | onSwipeEndDown(); 43 | else onSwipeEndDisqualified(displacementX, speed); 44 | isInitialSwipeVertical = undefined; 45 | }; 46 | 47 | const handleSwipeMove = (displacementX, displacementY = 0) => { 48 | if (isInitialSwipeVertical === false) onSwipeMoveX(displacementX); 49 | else if (isInitialSwipeVertical) onSwipeMoveY(displacementX, displacementY); 50 | else { 51 | // when isInitialVerticalSwipe is undefined 52 | isInitialSwipeVertical = 53 | displacementY !== 0 && 54 | Math.abs(displacementX) < Math.abs(displacementY); 55 | handleSwipeMove(displacementX, displacementY); 56 | } 57 | }; 58 | 59 | // have to use event listeners (active event listeners) to deal with undesired tiny vertical movements 60 | useTouch(elementRef, { 61 | onTouchMove: handleSwipeMove, 62 | onTouchEnd: handleSwipeEnd, 63 | onTap: onTap 64 | }); 65 | 66 | const mouseEventHandlers = useMouse(elementRef, { 67 | onMouseMove: handleSwipeMove, 68 | onMouseUp: handleSwipeEnd, 69 | onTap: onTap 70 | }); 71 | 72 | useNoDrag(elementRef); // prevent dragging on FireFox 73 | 74 | return mouseEventHandlers; 75 | }; 76 | 77 | export default useSwipe; 78 | -------------------------------------------------------------------------------- /src/utils/useTimer.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | const getTimer = (interval, callback) => { 4 | const _interval = interval; 5 | const _callback = callback; 6 | let _timer; 7 | 8 | function start() { 9 | _timer = setInterval(_callback, _interval); 10 | } 11 | 12 | function stop() { 13 | _timer && clearInterval(_timer); 14 | } 15 | 16 | function restart() { 17 | stop(); 18 | start(); 19 | } 20 | 21 | return { start, stop, restart }; 22 | }; 23 | 24 | const useTimer = (interval, isStarted, callback) => { 25 | const timer = interval ? getTimer(interval, callback) : null; 26 | const [isRunning, setIsRunning] = useState(!!timer && isStarted); 27 | 28 | const start = () => !!timer && timer.start(); 29 | const stop = () => !!timer && timer.stop(); 30 | const restart = () => !!timer && timer.restart(); 31 | 32 | useEffect(() => { 33 | if (isRunning) start(); 34 | 35 | return () => { 36 | stop(); 37 | }; 38 | }); 39 | 40 | return [isRunning, setIsRunning, { stopTimer: stop, restartTimer: restart }]; 41 | }; 42 | 43 | export default useTimer; 44 | -------------------------------------------------------------------------------- /src/utils/useTouch.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | const getTouchDistinguisher = () => { 4 | const pinchTouchIdentifiers = new Set(); // record all pinch touch identifiers 5 | 6 | function _recordPinchTouchIdentifiers(event) { 7 | for (const touch of event.touches) { 8 | pinchTouchIdentifiers.add(touch.identifier); 9 | } 10 | } 11 | 12 | function _isPinch(event) { 13 | return event.touches !== undefined && event.touches.length > 1; 14 | } 15 | 16 | function _wasPinch(event) { 17 | // check for the one finger that was part of a multi-finger pinch zoom 18 | return ( 19 | event.changedTouches && 20 | pinchTouchIdentifiers.has(event.changedTouches[0].identifier) 21 | ); 22 | } 23 | 24 | function isPinch(event) { 25 | if (_isPinch(event)) { 26 | _recordPinchTouchIdentifiers(event); 27 | return true; 28 | } 29 | return _wasPinch(event); 30 | } 31 | 32 | return { isPinch }; 33 | }; 34 | 35 | const useTouch = (elementRef, { onTouchMove, onTouchEnd, onTap }) => { 36 | const touchDistinguisher = getTouchDistinguisher(); 37 | let touchStartX = 0; 38 | let touchStartY = 0; 39 | let isTouchStarted = false; 40 | let isTouchMoved = false; 41 | let previousX = 0; 42 | let previousTime = Date.now(); 43 | let instantaneousVelocity = 0; 44 | 45 | const handleVerticalMovement = (event, displacementX, displacementY) => { 46 | if (Math.abs(displacementX) > Math.abs(displacementY) && event.cancelable) 47 | event.preventDefault(); 48 | }; 49 | 50 | const shouldOmitEvent = (event, displacementX = 0) => { 51 | if ( 52 | // touchDistinguisher only works for iOS from my investigation 53 | navigator.platform.match(/iPhone|iPad|iPod|MacIntel/) && 54 | touchDistinguisher.isPinch(event) 55 | ) 56 | return true; 57 | 58 | // window.visualViewport is not yet supported on IE 59 | if (!('visualViewport' in window)) return false; 60 | 61 | const { scale, offsetLeft, width } = window.visualViewport; 62 | if (scale <= 1.1) return false; 63 | // pan right at or beyond the left edge 64 | if (offsetLeft <= 0 && displacementX > 0) return false; 65 | // pan left at or beyond the right edge 66 | // noinspection RedundantIfStatementJS 67 | if (offsetLeft + width >= width * scale && displacementX < 0) return false; 68 | return true; 69 | }; 70 | 71 | const handleTouchStart = (event) => { 72 | event.stopPropagation(); 73 | isTouchStarted = true; 74 | touchStartX = event.touches[0].clientX; 75 | touchStartY = event.touches[0].clientY; 76 | previousX = touchStartX; 77 | previousTime = Date.now(); 78 | }; 79 | 80 | const handleTouchMove = (event) => { 81 | event.stopPropagation(); 82 | if (!isTouchStarted) return; 83 | const displacementX = event.changedTouches[0].clientX - touchStartX; 84 | if (shouldOmitEvent(event, displacementX)) return; 85 | const displacementY = event.changedTouches[0].clientY - touchStartY; 86 | handleVerticalMovement(event, displacementX, displacementY); 87 | onTouchMove(displacementX, displacementY); 88 | isTouchMoved = true; 89 | instantaneousVelocity = 90 | (event.changedTouches[0].clientX - previousX) / 91 | (Date.now() - previousTime); 92 | previousX = event.changedTouches[0].clientX; 93 | previousTime = Date.now(); 94 | }; 95 | 96 | const handleTouchEnd = (event) => { 97 | // prevent the event from being recognized additionally as a mouse event on simulated mobile devices (e.g. Toggle Device Toolbar on Chrome). 98 | event.preventDefault(); 99 | event.stopPropagation(); 100 | if (!isTouchStarted) return; 101 | const displacementX = event.changedTouches[0].clientX - touchStartX; 102 | if (shouldOmitEvent(event, displacementX)) { 103 | onTouchEnd(0, 0, 0); 104 | return; 105 | } 106 | const displacementY = event.changedTouches[0].clientY - touchStartY; 107 | handleVerticalMovement(event, displacementX, displacementY); 108 | if (isTouchMoved) 109 | // can not calculate velocity here since event.clientX === previousX; 110 | onTouchEnd(displacementX, displacementY, instantaneousVelocity); 111 | else onTap(); 112 | isTouchStarted = false; // reset isTouchStarted for next series of touch events 113 | isTouchMoved = false; // reset isTouchMoved for next series of touch events 114 | }; 115 | 116 | // to fix the dependency, I needed to wrapping pretty much everything in this 117 | // file in useCallback or useMemo, so I disable exhaustive-deps check for now 118 | // eslint-disable-next-line react-hooks/exhaustive-deps 119 | const events = [ 120 | { event: 'touchstart', callback: handleTouchStart }, 121 | { event: 'touchmove', callback: handleTouchMove }, 122 | { event: 'touchend', callback: handleTouchEnd }, 123 | { event: 'touchcancel', callback: handleTouchEnd } 124 | ]; 125 | 126 | useEffect(() => { 127 | const el = elementRef.current; 128 | if (el) 129 | // use active event listeners to have event.cancelable === true, for later use to in calling event.preventDefault() 130 | events.forEach(({ event, callback }) => 131 | el.addEventListener(event, callback, { passive: false }) 132 | ); 133 | 134 | return () => { 135 | if (el) 136 | events.forEach(({ event, callback }) => 137 | el.removeEventListener(event, callback) 138 | ); 139 | }; 140 | }, [elementRef, events]); 141 | }; 142 | 143 | export default useTouch; 144 | -------------------------------------------------------------------------------- /src/utils/validators.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | export const positiveNumber = (allow0 = false, optional = true) => { 4 | return (props, propName, componentName) => { 5 | const prop = props[propName]; 6 | if (optional && prop === undefined) return; 7 | if (typeof prop !== 'number' || prop < 0 || (!allow0 && prop === 0)) 8 | return new Error( 9 | `Invalid prop \`${propName}\` of type \`${typeof prop}\` supplied to \`${componentName}\`, expected \`number\` ${ 10 | allow0 ? '>=' : '>' 11 | } 0.` 12 | ); 13 | }; 14 | }; 15 | 16 | export const numberBetween = ( 17 | min, 18 | max, 19 | { includeMin = false, includeMax = false, optional = true } = {} 20 | ) => { 21 | return (props, propName, componentName) => { 22 | const prop = props[propName]; 23 | if (optional && prop === undefined) return; 24 | if ( 25 | typeof prop !== 'number' || 26 | !(min <= prop <= max) || 27 | (!includeMin && min === prop) || 28 | (!includeMax && max === prop) 29 | ) 30 | return new Error( 31 | `Invalid prop \`${propName}\` of type \`${typeof prop}\` supplied to \`${componentName}\`, expected ${min} ${ 32 | includeMin ? '<=' : '<' 33 | } \`number\` ${includeMax ? '<=' : '<'} ${max}.` 34 | ); 35 | }; 36 | }; 37 | 38 | const comparator = { 39 | '>=': (a, b) => a >= b 40 | }; 41 | 42 | export const compareToProp = (operator, otherPropName, optional = true) => { 43 | return (props, propName, componentName) => { 44 | const prop = props[propName]; 45 | const otherProp = props[otherPropName]; 46 | if (optional && prop === undefined) return; 47 | if ( 48 | typeof prop !== 'number' || 49 | typeof otherProp !== 'number' || 50 | !comparator[operator](prop, otherProp) 51 | ) 52 | return new Error( 53 | `Invalid prop \`${propName}\` of type \`${typeof prop}\` supplied to \`${componentName}\`, expected ${propName} ${operator} ${otherPropName}.` 54 | ); 55 | }; 56 | }; 57 | 58 | export const fallbackProps = (fallbackProps) => { 59 | return (props, propName, componentName) => { 60 | const prop = props[propName]; 61 | if (prop !== undefined) return; 62 | for (const fallbackProp of fallbackProps) { 63 | if (props[fallbackProp] !== undefined) return; 64 | } 65 | return new Error( 66 | `The props \`${fallbackProps}\` and \`${propName} are marked as at least one required in \`${componentName}, but their values are all \`undefined\`.` 67 | ); 68 | }; 69 | }; 70 | 71 | export const elementRef = PropTypes.shape({ current: PropTypes.object }); 72 | 73 | export const objectFitStyles = PropTypes.oneOf([ 74 | 'contain', 75 | 'cover', 76 | 'fill', 77 | 'none', 78 | 'scale-down' 79 | ]); 80 | 81 | export const smallWidgetPositions = PropTypes.oneOf([ 82 | false, 83 | 'topLeft', 84 | 'topCenter', 85 | 'topRight', 86 | 'centerLeft', 87 | 'centerCenter', 88 | 'centerRight', 89 | 'bottomLeft', 90 | 'bottomCenter', 91 | 'bottomRight' 92 | ]); 93 | 94 | export const largeWidgetPositions = PropTypes.oneOf([false, 'top', 'bottom']); 95 | 96 | export const slideObject = PropTypes.oneOfType([ 97 | PropTypes.object.isRequired, 98 | PropTypes.element.isRequired 99 | ]); 100 | 101 | export const imageObject = PropTypes.shape({ 102 | src: PropTypes.string.isRequired, 103 | srcset: PropTypes.string, 104 | alt: PropTypes.string, 105 | thumbnail: PropTypes.string 106 | }); 107 | --------------------------------------------------------------------------------