├── .nvmrc ├── .jscsrc ├── src ├── controls │ ├── index.js │ ├── dots.js │ └── arrow.js ├── utils │ ├── nth.js │ └── areChildImagesEqual.js ├── rtl.less ├── stories │ ├── CustomArrows.js │ └── index.stories.js ├── carousel.less └── index.js ├── images └── react-img-carousal-logo.png ├── .storybook ├── config.js └── webpack.config.js ├── test ├── images │ ├── test-up-arrow.svg │ └── test-down-arrow.svg └── unit │ └── carousel.tests.js ├── .browserslistrc ├── .eslintrc ├── .babelrc ├── webpack.config.js ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── PULL_REQUEST_TEMPLATE.md ├── .npmignore ├── .circleci └── config.yml ├── .gitignore ├── LICENSE.txt ├── CHANGELOG.md ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.14.0 2 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "./node_modules/godaddy-style/dist/.jscsrc", 3 | "validateLineBreaks": false 4 | } 5 | -------------------------------------------------------------------------------- /src/controls/index.js: -------------------------------------------------------------------------------- 1 | import Dots from './dots'; 2 | import Arrow from './arrow'; 3 | 4 | export { Dots, Arrow }; 5 | -------------------------------------------------------------------------------- /images/react-img-carousal-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godaddy/react-img-carousel/HEAD/images/react-img-carousal-logo.png -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react'; 2 | 3 | configure(require.context('../src', true, /\.stories\.js$/), module); -------------------------------------------------------------------------------- /test/images/test-up-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/images/test-down-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | last 3 Chrome major versions 2 | last 2 Firefox major versions 3 | last 2 Safari major versions 4 | Firefox ESR 5 | last 2 Edge major versions 6 | last 2 Samsung major version 7 | last 2 iOS major version 8 | last 2 Android major version 9 | -------------------------------------------------------------------------------- /src/utils/nth.js: -------------------------------------------------------------------------------- 1 | export default function nth(arr, n) { 2 | if (!(arr && arr.length)) { 3 | return; 4 | } 5 | const length = arr.length; 6 | n += n < 0 ? length : 0; 7 | 8 | return (n >= 0 && n < length) ? arr[n] : void 0; 9 | } 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["godaddy-react"], 4 | "rules": { 5 | "template-curly-spacing": "off", 6 | "indent": "off", 7 | "accessor-pairs": "off", 8 | "react/jsx-pascal-case": "off", 9 | "no-nested-ternary": "off" 10 | } 11 | } -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "shippedProposals": true 7 | } 8 | ], 9 | "@babel/preset-react" 10 | ], 11 | "plugins": [ 12 | "@babel/plugin-transform-runtime", 13 | "inline-react-svg" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/areChildImagesEqual.js: -------------------------------------------------------------------------------- 1 | function areChildImagesEqual(arr1, arr2) { 2 | if (arr1.length !== arr2.length) { 3 | return false; 4 | } 5 | for (let i = 0; i < arr1.length; i++) { 6 | const src1 = arr1[i].props.src; 7 | const src2 = arr2[i].props.src; 8 | 9 | if (src1 !== src2) { 10 | return false; 11 | } 12 | } 13 | return true; 14 | } 15 | 16 | export default areChildImagesEqual; 17 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | module: { 5 | rules: [ 6 | { 7 | test: /\.svg$/, 8 | use: [ 9 | { 10 | loader: "babel-loader" 11 | }, 12 | { 13 | loader: "react-svg-loader", 14 | options: { 15 | jsx: true // true outputs JSX tags 16 | } 17 | } 18 | ] 19 | } 20 | ], 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/rtl.less: -------------------------------------------------------------------------------- 1 | [dir="rtl"] .carousel { 2 | .carousel-viewport { 3 | text-align: right; 4 | } 5 | 6 | .carousel-left-arrow { 7 | left: unset; 8 | right: 23px; 9 | } 10 | 11 | .carousel-right-arrow { 12 | right: unset; 13 | left: 23px; 14 | } 15 | 16 | .carousel-left-arrow { 17 | &.carousel-arrow-default { 18 | &:before { 19 | padding-left: 2px; 20 | padding-right: unset; 21 | } 22 | } 23 | } 24 | 25 | .carousel-right-arrow { 26 | &.carousel-arrow-default { 27 | &:before { 28 | padding-right: 2px; 29 | padding-left: unset; 30 | } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | .nyc_output/ 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 28 | node_modules 29 | 30 | npm-debug.log 31 | .idea 32 | 33 | *.swp 34 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:10 6 | 7 | working_directory: ~/repo 8 | 9 | steps: 10 | - checkout 11 | 12 | # Download and cache dependencies 13 | - restore_cache: 14 | keys: 15 | - v1-dependencies-{{ checksum "package.json" }} 16 | # fallback to using the latest cache if no exact match is found 17 | - v1-dependencies- 18 | 19 | - run: npm install 20 | 21 | - run: npm run test 22 | 23 | # Save the cache for later use 24 | - save_cache: 25 | paths: 26 | - node_modules 27 | key: v1-dependencies-{{ checksum "package.json" }} 28 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | ## Summary 7 | 8 | 11 | 12 | ## Changelog 13 | 14 | 18 | 19 | ## Test Plan 20 | 21 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | module: { 5 | rules: [ 6 | { 7 | test: /\.less$/, 8 | use: ['style-loader', 'css-loader', 'less-loader'], 9 | include: path.resolve(__dirname, '../'), 10 | }, 11 | { 12 | test: /\.svg$/, 13 | use: [ 14 | { 15 | loader: "babel-loader" 16 | }, 17 | { 18 | loader: "react-svg-loader", 19 | options: { 20 | jsx: true // true outputs JSX tags 21 | } 22 | } 23 | ] 24 | } 25 | ], 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | .nyc_output/ 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 28 | node_modules 29 | 30 | npm-debug.log 31 | .idea 32 | 33 | *.swp 34 | 35 | # Transpiled ES5 36 | lib 37 | build 38 | 39 | # OS X 40 | .DS_Store 41 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 GoDaddy Operating Company, LLC. 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 | -------------------------------------------------------------------------------- /src/controls/dots.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classnames from 'classnames'; 4 | 5 | /** 6 | * Renders a dots navigation component for the carousel, with clickable dots that transition to the corresponding slide. 7 | * 8 | * @extends React.Component 9 | */ 10 | export default class Dots extends Component { 11 | 12 | static get propTypes() { 13 | return { 14 | numSlides: PropTypes.number.isRequired, 15 | selectedIndex: PropTypes.number.isRequired, 16 | goToSlide: PropTypes.func.isRequired 17 | }; 18 | } 19 | 20 | render() { 21 | const { numSlides, selectedIndex, goToSlide } = this.props; 22 | const dots = []; 23 | 24 | for (let index = 0; index < numSlides; index++) { 25 | const buttonClass = classnames('carousel-dot', { 26 | selected: index === selectedIndex 27 | }); 28 | 29 | dots.push( 30 |
  • 31 | 32 |
  • 33 | ); 34 | } 35 | 36 | return ( 37 | 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/stories/CustomArrows.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const CustomArrows = ({ nextSlide, prevSlide, overrideArrowStyle = {}, infinite, numSlides, selectedIndex, topArrowImage, bottomArrowImage, arrowDivStyle }) => { 5 | const hasNext = (direction) => { 6 | return infinite || (['top', 'left'].includes(direction) ? selectedIndex > 0 : selectedIndex < numSlides - 1); 7 | } 8 | 9 | const hasNextBottom = hasNext('bottom'); 10 | const hasNextTop = hasNext('top'); 11 | 12 | return ( 13 |
    14 | 17 | 20 |
    21 | ); 22 | }; 23 | 24 | CustomArrows.propTypes = { 25 | prevSlide: PropTypes.func, 26 | nextSlide: PropTypes.func, 27 | visible: PropTypes.bool, 28 | overrideArrowStyle: PropTypes.object, 29 | triggerNextSlide: PropTypes.number, 30 | infinite: PropTypes.bool, 31 | numSlides: PropTypes.number, 32 | selectedIndex: PropTypes.number, 33 | topArrowImage: PropTypes.node, 34 | bottomArrowImage: PropTypes.node, 35 | arrowDivStyle: PropTypes.object 36 | }; 37 | 38 | export default CustomArrows; 39 | -------------------------------------------------------------------------------- /src/controls/arrow.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | /** 5 | * Renders an arrow component used to transition from right-to-left or left-to-right through the carousel slides. 6 | */ 7 | export default class Arrow extends Component { 8 | 9 | static get propTypes() { 10 | return { 11 | numSlides: PropTypes.number.isRequired, 12 | selectedIndex: PropTypes.number.isRequired, 13 | infinite: PropTypes.bool.isRequired, 14 | prevSlide: PropTypes.func.isRequired, 15 | nextSlide: PropTypes.func.isRequired, 16 | direction: PropTypes.oneOf(['left', 'right', 'top', 'bottom']).isRequired, 17 | arrows: PropTypes.oneOfType([ 18 | PropTypes.bool, 19 | PropTypes.shape({ 20 | left: PropTypes.node.isRequired, 21 | right: PropTypes.node.isRequired, 22 | className: PropTypes.string 23 | }) 24 | ]) 25 | }; 26 | } 27 | 28 | /** 29 | * @returns {Boolean} True if there is a next slide to transition to, else False. 30 | */ 31 | hasNext() { 32 | const { direction, infinite, numSlides, selectedIndex } = this.props; 33 | 34 | return infinite || (['top', 'left'].includes(direction) ? selectedIndex > 0 : selectedIndex < numSlides - 1); 35 | } 36 | 37 | render() { 38 | const { prevSlide, nextSlide, direction, arrows } = this.props; 39 | let arrowComponent = null; 40 | let buttonClass = 'carousel-arrow-default'; 41 | 42 | if (arrows.left) { 43 | buttonClass = arrows.className ? arrows.className : ''; 44 | arrowComponent = direction === 'left' ? arrows.left : arrows.right; 45 | } 46 | 47 | return ( 48 | 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.4.1 - Feb 2025 2 | 3 | ***(Enhancement)*** Update peer dependencies to explicitly allow Node.js 18. 4 | # 2.4.0 - Oct 2024 5 | 6 | ***(Feature)*** Add support for disabling slide transition. 7 | 8 | # 2.3.0 - March 2023 9 | 10 | ***(Feature)*** Add support for vertical carousal. 11 | 12 | # 2.2.0 - May 2022 13 | 14 | ***(Feature)*** Add support for RTL languages 15 | 16 | # 2.1.1 - Aug 2021 17 | 18 | ***(Bug Fix)*** Fixes the index passed to beforeChange callback when infinite scrolling is enabled. 19 | 20 | # 2.1.0 - May 2021 21 | 22 | ***(Feature)*** Adds support for a slideAlignment prop 23 | 24 | # 2.0.3 - May 2021 25 | 26 | ***(Bug Fix)*** Fixes an issue introduced by 2.0.1 where long text content on slides is no longer wrapping 27 | 28 | # 2.0.2 - May 2021 29 | 30 | ***(Bug Fix)*** Fixes a bug when clicking to navigate to a particular slide and crossing the end or beginning of the list 31 | 32 | # 2.0.1 - May 2021 33 | 34 | ***(Bug Fix)*** Fixes a bug in Safari 14.1 and Mobile Safari where carousel content is not visible in some scenarios 35 | 36 | # 2.0.0 - Dec 2020 37 | 38 | ***(Enhancement)*** Breaking change to optimize the bundle for modern browsers (may break legacy browser support) 39 | 40 | # 1.7.0 - Apr 2020 41 | 42 | ***(FEATURE)*** Add support for IntersectionObserver and disable autoplay functionality when the carousel is outside the viewport 43 | 44 | # 1.6.0 - Feb 2020 45 | 46 | ***(FEATURE)*** Add `onSlideTransitioned` callback and updated `arrows` prop to support custom arrows 47 | 48 | # 1.5.4 - Nov 2019 49 | 50 | ***(Enhancement)*** Replaced example application with Storybook 51 | 52 | # 1.5.3 - Nov 2019 53 | 54 | ***(Enhancement)*** Only call preventDefault in mousedown handler when the target is an img element 55 | 56 | # 1.5.2 - Sept 2019 57 | 58 | ***(Bug Fix)*** Fix issue that can cause the initial carousel positioning to be off 59 | 60 | # 1.5.1 - Sept 2019 61 | 62 | ***(Bug Fix)*** Fix issue that can cause the slideshow to never render 63 | 64 | # 1.5.0 - Sept 2019 65 | 66 | * ***(FEATURE)*** Add support for `maxRenderedSlides` prop 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-img-carousel", 3 | "version": "2.4.1", 4 | "description": "Provides an image carousel React component.", 5 | "main": "lib/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git@github.com:godaddy/react-img-carousel" 9 | }, 10 | "author": "GoDaddy Operating Company, LLC", 11 | "contributors": [ 12 | "Chris Hinrichs " 13 | ], 14 | "license": "MIT", 15 | "keywords": [ 16 | "react", 17 | "carousel" 18 | ], 19 | "bugs": { 20 | "url": "https://github.com/godaddy/react-img-carousel/issues" 21 | }, 22 | "homepage": "https://github.com/godaddy/react-img-carousel#readme", 23 | "scripts": { 24 | "clean": "rimraf ./lib", 25 | "prebuild": "npm run clean", 26 | "build": "babel src -d lib && lessc src/carousel.less lib/carousel.css && postcss --no-map --use autoprefixer -o lib/carousel.css lib/carousel.css", 27 | "lint": "eslint src/ test/", 28 | "unit": "mocha --require setup-env \"test/unit/**/*.tests.js\"", 29 | "posttest": "npm run lint", 30 | "test": "nyc --reporter=text --reporter=json-summary npm run unit", 31 | "prepublishOnly": "npm run test && npm run build", 32 | "storybook": "start-storybook" 33 | }, 34 | "peerDependencies": { 35 | "react": "15.x || 16.x || 17.x || 18.x" 36 | }, 37 | "dependencies": { 38 | "classnames": "^2.3.1", 39 | "ms": "^2.1.3", 40 | "prop-types": "^15.8.1" 41 | }, 42 | "devDependencies": { 43 | "@babel/cli": "^7.12.8", 44 | "@babel/core": "^7.12.9", 45 | "@babel/plugin-transform-runtime": "^7.12.1", 46 | "@babel/preset-env": "^7.12.7", 47 | "@babel/preset-react": "^7.12.7", 48 | "@babel/register": "^7.12.1", 49 | "@storybook/react": "^5.2.6", 50 | "autoprefixer": "^10.0.4", 51 | "babel-eslint": "^10.1.0", 52 | "babel-loader": "^8.0.0", 53 | "babel-plugin-inline-react-svg": "^2.0.2", 54 | "chai": "^4.2.0", 55 | "css-loader": "^0.28.11", 56 | "enzyme": "^3.11.0", 57 | "enzyme-adapter-react-16": "^1.15.2", 58 | "eslint": "^7.12.1", 59 | "eslint-config-godaddy-react": "^6.0.0", 60 | "eslint-plugin-json": "^2.1.2", 61 | "eslint-plugin-jsx-a11y": "^6.2.3", 62 | "eslint-plugin-mocha": "^8.0.0", 63 | "eslint-plugin-react": "^7.14.3", 64 | "jsdom": "^16.2.0", 65 | "less": "^2.7.3", 66 | "less-loader": "^4.1.0", 67 | "mocha": "^8.2.0", 68 | "nyc": "^15.0.0", 69 | "postcss": "^8.1.14", 70 | "postcss-cli": "^8.3.0", 71 | "react": "^16.9.0", 72 | "react-dom": "^16.9.0", 73 | "react-svg-loader": "^3.0.3", 74 | "rimraf": "^3.0.2", 75 | "setup-env": "^1.2.3", 76 | "sinon": "^9.2.0", 77 | "sinon-chai": "^3.5.0", 78 | "style-loader": "^0.20.3" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/carousel.less: -------------------------------------------------------------------------------- 1 | .carousel { 2 | text-align: center; 3 | position: relative; 4 | opacity: 0; 5 | margin: 0 auto; 6 | transition: opacity 0.5s; 7 | 8 | .carousel-container-inner { 9 | margin: 0 auto; 10 | position: relative; 11 | } 12 | 13 | .carousel-viewport { 14 | overflow: hidden; 15 | white-space: nowrap; 16 | text-align: left; 17 | } 18 | 19 | .carousel-arrow-default { 20 | border: 3px solid !important; 21 | border-radius: 50%; 22 | color: rgba(255, 255, 255, 0.9); 23 | height: 32px; 24 | width: 32px; 25 | font-weight: 900; 26 | background: rgba(0, 0, 0, 0.15); 27 | } 28 | 29 | .carousel-arrow { 30 | position: absolute; 31 | z-index: 1; 32 | bottom: 23px; 33 | padding: 0; 34 | cursor: pointer; 35 | border: none; 36 | 37 | &:focus { 38 | outline: none; 39 | } 40 | 41 | &:before { 42 | font-size: 19px; 43 | display: block; 44 | margin-top: -2px; 45 | } 46 | 47 | &:disabled { 48 | cursor: not-allowed; 49 | opacity: 0.5; 50 | } 51 | } 52 | 53 | .carousel-left-arrow { 54 | left: 23px; 55 | } 56 | 57 | .carousel-right-arrow { 58 | right: 23px; 59 | } 60 | 61 | .carousel-left-arrow { 62 | &.carousel-arrow-default { 63 | &:before { 64 | content: '<'; 65 | padding-right: 2px; 66 | } 67 | } 68 | } 69 | 70 | .carousel-right-arrow { 71 | &.carousel-arrow-default { 72 | &:before { 73 | content: '>'; 74 | padding-left: 2px; 75 | } 76 | } 77 | } 78 | 79 | .carousel-top-arrow { 80 | top: 75px; 81 | } 82 | 83 | .carousel-bottom-arrow { 84 | bottom: 75px; 85 | } 86 | 87 | .carousel-top-arrow { 88 | &.carousel-arrow-default { 89 | &:before { 90 | content: '\1431'; 91 | padding-bottom: 2px; 92 | } 93 | } 94 | } 95 | 96 | .carousel-bottom-arrow { 97 | &.carousel-arrow-default { 98 | &:before { 99 | content: '\142F'; 100 | padding-top: 2px; 101 | } 102 | } 103 | } 104 | 105 | .carousel-track { 106 | list-style: none; 107 | padding: 0; 108 | margin: 0; 109 | touch-action: pan-y pinch-zoom; 110 | 111 | .carousel-slide { 112 | display: inline-block; 113 | opacity: 0.7; 114 | transition: opacity 0.5s ease-in-out; 115 | 116 | & > * { 117 | display: block; 118 | white-space: normal; 119 | } 120 | &.carousel-slide-loading { 121 | background: rgba(204, 204, 204, 0.7); 122 | } 123 | &.carousel-slide-fade { 124 | position: absolute; 125 | left: 50%; 126 | transform: translateX(-50%); 127 | opacity: 0; 128 | } 129 | &.carousel-slide-selected { 130 | opacity: 1; 131 | z-index: 1; 132 | } 133 | } 134 | } 135 | 136 | &.loaded { 137 | opacity: 1; 138 | } 139 | 140 | .carousel-dots { 141 | list-style: none; 142 | padding: 0; 143 | margin: 0; 144 | position: absolute; 145 | left: 0; 146 | right: 0; 147 | bottom: -30px; 148 | text-align: center; 149 | 150 | li { 151 | display: inline-block; 152 | } 153 | 154 | button { 155 | border: 0; 156 | background: transparent; 157 | font-size: 1.1em; 158 | cursor: pointer; 159 | color: #CCC; 160 | padding-left: 6px; 161 | padding-right: 6px; 162 | 163 | &.selected { 164 | color: black; 165 | } 166 | 167 | &:focus { 168 | outline: none; 169 | } 170 | } 171 | } 172 | } 173 | 174 | @import "./rtl.less"; 175 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

    2 | React Image Carousal Logo 3 |

    4 | 5 |

    6 | This component renders a carousel with support for lazy loading, autoplay, infinite scrolling, touch events and more. 7 |

    8 | 9 | --- 10 | 11 | Usage: 12 | ---------------- 13 | 14 | Render a carousel instance passing the necessary props (Note: In order to load the required CSS file with the below syntax, 15 | you will need to use some kind of module loader/bundler like Webpack or Browserify): 16 | 17 | ```js 18 | import React from 'react'; 19 | import { render } from 'react-dom'; 20 | import Carousel from 'react-img-carousel'; 21 | 22 | require('react-img-carousel/lib/carousel.css'); 23 | 24 | render( 25 | 26 | 27 | 28 | 29 | , 30 | document.body 31 | ); 32 | 33 | ``` 34 | 35 | Running test page: 36 | ---------------- 37 | 38 | Clone the repository, run `npm i` and then run `npm run storybook`. The Storybook should open in your browser automatically. 39 | 40 | Available props: 41 | ---------------- 42 | 43 | #### initialSlide 44 | `PropTypes.number` 45 | 46 | Determines the first visible slide when the carousel loads, defaults to `0`. 47 | 48 | #### width 49 | `PropTypes.string` 50 | 51 | Determines the width of the outermost carousel div. Defaults to `100%`. 52 | 53 | #### height 54 | `PropTypes.string` 55 | 56 | Determines the height of the outermost carousel div. Defaults to `auto`. 57 | 58 | #### viewportWidth 59 | `PropTypes.string` 60 | 61 | Determines the width of the viewport which will show the images. If you don't want the previous/next images to be 62 | visible, this width should match the `slideWidth` prop or the width of the child images. Defaults to `100%`. 63 | 64 | #### viewportHeight 65 | `PropTypes.string` 66 | 67 | Determines the height of the viewport which will show the images. Defaults to `auto`. 68 | 69 | #### className 70 | `PropTypes.string` 71 | 72 | Optional class which will be added to the carousel class. 73 | 74 | #### dots 75 | `PropTypes.bool` 76 | 77 | If `false`, the dots below the carousel will not be rendered. 78 | 79 | #### arrows 80 | `PropTypes.bool` 81 | 82 | If `false`, the arrow buttons will not be rendered. 83 | 84 | #### infinite 85 | `PropTypes.bool` 86 | 87 | If `true`, clicking next/previous when at the end/beginning of the slideshow will wrap around. 88 | 89 | #### lazyLoad 90 | `PropTypes.bool` 91 | 92 | If `false`, the carousel will render all children at mount time and will not attempt to lazy load images. Note that lazy loading will only work if the slides are `img` tags or if both `slideWidth` and `slideHeight` are specified. 93 | 94 | #### imagesToPrefetch 95 | `PropTypes.number` 96 | 97 | If `lazyLoad` is set to `true`, this value will be used to determine how many images to fetch at mount time. If the slides are not simple `img` elements, this prop will have no effect. Defaults to `5`. 98 | 99 | #### maxRenderedSlides 100 | `PropTypes.number` 101 | 102 | If `lazyLoad` is set to `true`, this value will be used to determine how many slides to fully render (including the currently selected slide). For example, if the currently selected slide is slide `10`, and this prop is set to `5`, then slides `8-12` will be rendered, and all other slides will render a lightweight placeholder. Note that this prop is ignored for slides that are simply `img` tags - these carousels should use the `imagesToPrefetch` prop instead. Defaults to `5`. 103 | 104 | #### cellPadding 105 | `PropTypes.number` 106 | 107 | Number of pixels to render between slides. 108 | 109 | #### slideWidth 110 | `PropTypes.string` 111 | 112 | Used to specify a fixed width for all slides. Without specifying this, slides will simply be the width of their content. 113 | 114 | #### slideHeight 115 | `PropTypes.string` 116 | 117 | Used to specify a fixed height for all slides. Without specifying this, slides will simply be the height of their 118 | content. 119 | 120 | #### slideAlignment 121 | `PropTypes.oneOf(['left', 'center', 'right'])` 122 | 123 | Used to set the alignment of the currently selected slide in the carousel's viewport. Defaults to `center`. 124 | 125 | #### beforeChange 126 | `PropTypes.func` 127 | 128 | Optional callback which will be invoked before a slide change occurs. Should have method signature 129 | `function(newIndex, prevIndex, direction)` 130 | 131 | #### afterChange 132 | `PropTypes.func` 133 | 134 | Optional callback which will be invoked after a slide change occurs. Should have method signature 135 | `function(newIndex)` 136 | 137 | #### style 138 | ``` 139 | PropTypes.shape({ 140 | container: PropTypes.object, 141 | containerInner: PropTypes.object, 142 | viewport: PropTypes.object, 143 | track: PropTypes.object, 144 | slide: PropTypes.object, 145 | selectedSlide: PropTypes.object 146 | }) 147 | ``` 148 | 149 | If your app is using inline styles, you can use this property to specify inline styling for the individual carousel 150 | elements. The properties correspond to the DOM elements with class names `carousel`, `carousel-container-inner`, 151 | `carousel-viewport`, `carousel-track`, `carousel-slide`, and `carousel-slide-selected` respectively. If both `slide` 152 | and `selectedSlide` are specified, both will be applied with the latter overriding the former. 153 | 154 | Example: 155 | 156 | ``` 157 | 168 | ... 169 | 170 | ``` 171 | 172 | #### transition 173 | `PropTypes.oneOf(['fade', 'slide', 'none'])` 174 | 175 | The type of transition to use between slides, defaults to `slide`. 176 | 177 | #### transitionDuration 178 | `PropTypes.oneOfType([PropTypes.number, PropTypes.string])` 179 | 180 | Time for the transition effect between slides, defaults to `500`. If a number is specified, it indicates the number of 181 | milliseconds. Strings are parsed using [ms](https://www.npmjs.com/package/ms). 182 | 183 | #### easing 184 | `PropTypes.oneOf(['ease', 'linear', 'ease-in', 'ease-out', 'ease-in-out'])` 185 | 186 | The easing function to use for the transition. Defaults to `ease-in-out`. 187 | 188 | #### clickToNavigate 189 | `PropTypes.bool` 190 | 191 | Controls whether or not clicking slides other than the currently selected one should navigate to the clicked slide. 192 | Defaults to `true`. 193 | 194 | #### autoplay 195 | `PropTypes.bool` 196 | 197 | If `true`, the slideshow will automatically advance. 198 | 199 | #### autoplaySpeed 200 | `PropTypes.oneOfType([PropTypes.number, PropTypes.string])` 201 | 202 | Time to wait before advancing to the next slide when `autoplay` is `true`. Defaults to `4000`. If a number is specified, 203 | it indicates the number of milliseconds. Strings are parsed using [ms](https://www.npmjs.com/package/ms). 204 | 205 | #### draggable 206 | `PropTypes.bool` 207 | 208 | Controls whether mouse/touch swiping is enabled, defaults to `true`. 209 | 210 | #### dir 211 | `PropTypes.oneOf(['rtl', 'ltr'])` 212 | 213 | Optional, used to specify the direction of the carousel. Must pass `rtl` to support RTL languages, and a parent DOM element must have the `dir` attribute set to `rtl` as well. 214 | 215 | #### pauseOnHover 216 | `PropTypes.bool` 217 | 218 | Controls whether autoplay will pause when the user hovers the mouse cursor over the image, defaults to `true`. 219 | 220 | #### controls 221 | ``` 222 | PropTypes.arrayOf(PropTypes.shape({ 223 | component: PropTypes.func.isRequired, 224 | props: PropTypes.object, 225 | position: PropTypes.oneOf(['top', 'bottom']) 226 | })) 227 | ``` 228 | 229 | Optional array of controls to be rendered in the carousel container. Each control's component property should be a React 230 | component constructor, and will be passed callback props `nextSlide`, `prevSlide` and `goToSlide` for controlling 231 | navigation, and `numSlides`, `selectedIndex` and `infinite` for rendering the state of the carousel. 232 | 233 | #### isVertical 234 | ``` 235 | PropTypes.bool 236 | ``` 237 | 238 | Defaults to `false`. Setting `isVertical` to `true` will render vertical carousal. 239 | 240 | Tests: 241 | ---------------- 242 | 243 | ```bash 244 | npm install && npm test 245 | ``` 246 | -------------------------------------------------------------------------------- /src/stories/index.stories.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-key */ 2 | /* eslint-disable id-length */ 3 | import React, { Fragment, useState } from 'react'; 4 | import PropTypes from 'prop-types'; 5 | import Carousel from '../index'; 6 | import CustomArrows from './CustomArrows'; 7 | import DownArrow from '../../test/images/test-down-arrow.svg'; 8 | import UpArrow from '../../test/images/test-up-arrow.svg'; 9 | 10 | require('../carousel.less'); 11 | 12 | export default { 13 | component: Carousel, 14 | title: 'Carousel' 15 | }; 16 | 17 | const IMAGES = [ 18 | 'http://picsum.photos/400/300', 19 | 'http://picsum.photos/275/300', 20 | 'http://picsum.photos/400/300', 21 | 'http://picsum.photos/350/300', 22 | 'http://picsum.photos/250/300', 23 | 'http://picsum.photos/375/300', 24 | 'http://picsum.photos/425/300', 25 | 'http://picsum.photos/325/300' 26 | ]; 27 | 28 | const imgElements = IMAGES.map((image, index) => A sample); 29 | 30 | const CustomDots = ({ numSlides, selectedIndex, goToSlide, title }) => { 31 | const dots = []; 32 | 33 | for (let index = 0; index < numSlides; index++) { 34 | const buttonStyle = { 35 | border: 'none', 36 | cursor: 'pointer', 37 | background: 'transparent' 38 | }; 39 | 40 | if (index === selectedIndex) { 41 | buttonStyle.color = 'red'; 42 | } 43 | 44 | dots.push( 45 |
  • 46 | 47 |
  • 48 | ); 49 | } 50 | 51 | return ( 52 |
    53 |

    { title }

    54 | 57 |
    58 | ); 59 | }; 60 | 61 | CustomDots.propTypes = { 62 | numSlides: PropTypes.number.isRequired, 63 | selectedIndex: PropTypes.number, 64 | goToSlide: PropTypes.func.isRequired, 65 | title: PropTypes.node 66 | }; 67 | 68 | const testButtons = ['test1', 'test2', 'test3', 'test4'].map((item) => ); 69 | 70 | export const verticalInfiniteWithCellPaddingWithDotsAndDefaultArrows = () => 71 | 73 | { imgElements } 74 | ; 75 | 76 | export const verticalNonInfiniteWithCellPaddingWithDefaultArrows = () => 77 | 79 | { imgElements } 80 | ; 81 | 82 | export const verticalNonInfiniteButtonsWithCellPaddingWithCustomArrows = () => 83 | , bottomArrowImage: , arrowDivStyle: { transform: 'translate(-450px, 196px)' } } 87 | }] }> 88 | { testButtons } 89 | ; 90 | 91 | export const infiniteWithCellPadding = () => 92 | 93 | { imgElements } 94 | ; 95 | 96 | export const nonInfiniteWithCellPadding = () => 97 | 98 | { imgElements } 99 | ; 100 | 101 | export const fadeTransition = () => 102 | 109 | { imgElements } 110 | ; 111 | 112 | export const noneTransition = () => 113 | 120 | {imgElements} 121 | ; 122 | 123 | export const infiniteWithOnly2Slides = () => 124 | 125 | A sample 126 | A sample 127 | ; 128 | 129 | export const infiniteWithOnly1Slide = () => 130 | 136 | A sample 137 | ; 138 | 139 | export const autoplayWithBackgroundImages = () => 140 | 147 |
    153 |
    159 |
    165 |
    171 | ; 172 | 173 | export const backgroundImagesWithFade = () => 174 | 183 |
    189 |
    195 |
    201 |
    207 |
    213 |
    219 |
    225 | ; 226 | 227 | export const customDotsComponent = () => 228 | 238 | { imgElements } 239 | ; 240 | 241 | export const addImages = () => { 242 | const [images, setImages] = useState([IMAGES[0]]); 243 | 244 | const addImage = () => { 245 | if (images.length < IMAGES.length) { 246 | setImages(images.concat(IMAGES[images.length])); 247 | } 248 | }; 249 | 250 | return ( 251 | 252 | 261 | { 262 | images.map((image, index) => A sample) 263 | } 264 | 265 | 266 | 267 | ); 268 | }; 269 | 270 | export const leftAlignedSlides = () => 271 | 272 | { imgElements } 273 | ; 274 | 275 | export const rightAlignedSlides = () => 276 | 277 | { imgElements } 278 | ; 279 | 280 | export const rtl = () => 281 |
    282 | 283 | { imgElements } 284 | 285 |
    ; 286 | -------------------------------------------------------------------------------- /test/unit/carousel.tests.js: -------------------------------------------------------------------------------- 1 | /* eslint max-statements: 0, jsx-a11y/alt-text: 0 */ 2 | import React from 'react'; 3 | import { mount } from 'enzyme'; 4 | import chai, { expect } from 'chai'; 5 | import sinon from 'sinon'; 6 | import sinonChai from 'sinon-chai'; 7 | import Carousel from '../../src/index'; 8 | import CustomArrows from '../../src/stories/CustomArrows'; 9 | import UpArrow from '../images/test-up-arrow.svg'; 10 | import DownArrow from '../images/test-down-arrow.svg'; 11 | 12 | chai.use(sinonChai); 13 | let imagesFetched; 14 | 15 | global.Image = class MyImage { 16 | set src(val) { 17 | imagesFetched.push(val); 18 | this.onload && this.onload(); 19 | } 20 | }; 21 | 22 | describe('Carousel', () => { 23 | let tree; 24 | function renderToJsdom(component) { 25 | tree = mount(component); 26 | return tree.instance(); 27 | } 28 | 29 | beforeEach(() => { 30 | imagesFetched = []; 31 | }); 32 | 33 | afterEach(() => { 34 | tree && tree.unmount(); 35 | tree = null; 36 | }); 37 | 38 | it('should render a carousel with the specified index selected', done => { 39 | renderToJsdom( 40 | 41 |
    42 |
    43 |
    44 | 45 | ); 46 | 47 | setImmediate(() => { 48 | const dots = tree.find('.carousel-dot'); 49 | expect(dots.length).to.equal(3); 50 | expect(dots.at(2).prop('className')).to.contain('selected'); 51 | done(); 52 | }); 53 | }); 54 | 55 | it('should navigate to the next slide when the button is clicked', done => { 56 | renderToJsdom( 57 | 58 |
    59 |
    60 |
    61 | 62 | ); 63 | 64 | setImmediate(() => { 65 | let dots = tree.find('.carousel-dot'); 66 | expect(dots.length).to.equal(3); 67 | expect(dots.at(0).prop('className')).to.contain('selected'); 68 | const nextButton = tree.find('.carousel-right-arrow'); 69 | expect(nextButton.prop('className')).to.contain('carousel-arrow-default'); 70 | nextButton.simulate('click'); 71 | dots = tree.find('.carousel-dot'); 72 | expect(dots.at(0).prop('className')).to.not.contain('selected'); 73 | expect(dots.at(1).prop('className')).to.contain('selected'); 74 | done(); 75 | }); 76 | }); 77 | 78 | it('should navigate to the previous slide when the button is clicked', done => { 79 | const onSlideTransitionedStub = sinon.stub(); 80 | 81 | renderToJsdom( 82 | 87 |
    88 |
    89 |
    90 | 91 | ); 92 | 93 | setImmediate(() => { 94 | let dots = tree.find('.carousel-dot'); 95 | expect(dots.length).to.equal(3); 96 | expect(dots.at(1).prop('className')).to.contain('selected'); 97 | const prevButton = tree.find('.carousel-left-arrow'); 98 | expect(prevButton.prop('className')).to.contain('carousel-arrow-default'); 99 | prevButton.simulate('click'); 100 | dots = tree.find('.carousel-dot'); 101 | expect(dots.at(1).prop('className')).to.not.contain('selected'); 102 | expect(dots.at(0).prop('className')).to.contain('selected'); 103 | expect(onSlideTransitionedStub).to.have.been.calledWith({ 104 | autoPlay: false, 105 | index: 0, 106 | direction: 'left' 107 | }); 108 | done(); 109 | }); 110 | }); 111 | 112 | it('should wrap around from the last to first slide if infinite is true and next is clicked', done => { 113 | const onSlideTransitionedStub = sinon.stub(); 114 | const beforeChangeStub = sinon.stub(); 115 | 116 | renderToJsdom( 117 | 123 |
    124 |
    125 |
    126 | 127 | ); 128 | 129 | setImmediate(() => { 130 | let dots = tree.find('.carousel-dot'); 131 | expect(dots.length).to.equal(3); 132 | expect(dots.at(2).prop('className')).to.contain('selected'); 133 | const nextButton = tree.find('.carousel-right-arrow'); 134 | nextButton.simulate('click'); 135 | dots = tree.find('.carousel-dot'); 136 | expect(dots.at(2).prop('className')).to.not.contain('selected'); 137 | expect(dots.at(0).prop('className')).to.contain('selected'); 138 | expect(onSlideTransitionedStub).to.have.been.calledWith({ 139 | autoPlay: false, 140 | index: 0, 141 | direction: 'right' 142 | }); 143 | expect(beforeChangeStub).to.have.been.calledWith(0, 2, 'right'); 144 | done(); 145 | }); 146 | }); 147 | 148 | it('should wrap around from the first to last slide if infinite is true and prev is clicked', done => { 149 | const beforeChangeStub = sinon.stub(); 150 | 151 | renderToJsdom( 152 | 153 |
    154 |
    155 |
    156 | 157 | ); 158 | 159 | setImmediate(() => { 160 | let dots = tree.find('.carousel-dot'); 161 | expect(dots.length).to.equal(3); 162 | expect(dots.at(0).prop('className')).to.contain('selected'); 163 | const prevButton = tree.find('.carousel-left-arrow'); 164 | prevButton.simulate('click'); 165 | dots = tree.find('.carousel-dot'); 166 | expect(dots.at(0).prop('className')).to.not.contain('selected'); 167 | expect(dots.at(2).prop('className')).to.contain('selected'); 168 | expect(beforeChangeStub).to.have.been.calledWith(2, 0, 'left'); 169 | done(); 170 | }); 171 | }); 172 | 173 | it('should jump directly to a slide when the dot is clicked', done => { 174 | const onSlideTransitionedStub = sinon.stub(); 175 | 176 | renderToJsdom( 177 | 181 |
    182 |
    183 |
    184 | 185 | ); 186 | 187 | setImmediate(() => { 188 | let dots = tree.find('.carousel-dot'); 189 | expect(dots.length).to.equal(3); 190 | expect(dots.at(0).prop('className')).to.contain('selected'); 191 | dots.at(2).simulate('click'); 192 | dots = tree.find('.carousel-dot'); 193 | expect(dots.at(0).prop('className')).to.not.contain('selected'); 194 | expect(dots.at(2).prop('className')).to.contain('selected'); 195 | expect(onSlideTransitionedStub).to.have.been.calledOnce; 196 | done(); 197 | }); 198 | }); 199 | 200 | it('should jump directly to a slide when the slide is clicked', done => { 201 | const onSlideTransitionedStub = sinon.stub(); 202 | 203 | renderToJsdom( 204 | 208 |
    209 |
    210 |
    211 |
    212 |
    213 |
    214 | 215 | ); 216 | 217 | setImmediate(() => { 218 | let slides = tree.find('.carousel-slide'); 219 | const track = tree.find('.carousel-track'); 220 | expect(slides.length).to.equal(10); 221 | expect(slides.at(2).prop('className')).to.contain('carousel-slide-selected'); 222 | expect(slides.at(2).prop('data-index')).to.eql(0); 223 | slides.at(0).simulate('mousedown', { clientX: 0, clientY: 0 }); 224 | slides.at(0).simulate('click', { clientX: 0, clientY: 0 }); 225 | slides = tree.find('.carousel-slide'); 226 | expect(slides.at(6).prop('className')).to.contain('carousel-slide-selected'); 227 | expect(slides.at(6).prop('data-index')).to.eql(4); 228 | track.simulate('transitionend', { propertyName: 'transform' }); 229 | slides.at(9).simulate('mousedown', { clientX: 0, clientY: 0 }); 230 | slides.at(9).simulate('click', { clientX: 0, clientY: 0 }); 231 | slides = tree.find('.carousel-slide'); 232 | expect(slides.at(3).prop('className')).to.contain('carousel-slide-selected'); 233 | expect(slides.at(3).prop('data-index')).to.eql(1); 234 | done(); 235 | }); 236 | }); 237 | 238 | it('should not freeze when a selected dot is clicked', done => { 239 | renderToJsdom( 240 | 241 |
    242 |
    243 |
    244 | 245 | ); 246 | 247 | setImmediate(() => { 248 | let dots = tree.find('.carousel-dot'); 249 | expect(dots.length).to.equal(3); 250 | expect(dots.at(0).prop('className')).to.contain('selected'); 251 | dots.at(0).simulate('click'); 252 | 253 | dots = tree.find('.carousel-dot'); 254 | dots.at(2).simulate('click'); 255 | 256 | dots = tree.find('.carousel-dot'); 257 | expect(dots.at(0).prop('className')).to.not.contain('selected'); 258 | expect(dots.at(2).prop('className')).to.contain('selected'); 259 | done(); 260 | }); 261 | }); 262 | 263 | it('should prefetch the specified number of images', done => { 264 | renderToJsdom( 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | ); 276 | 277 | setImmediate(() => { 278 | expect(imagesFetched.length).to.equal(3); 279 | done(); 280 | }); 281 | }); 282 | 283 | it('should navigate to the next slide when a swipe event occurs', done => { 284 | const carousel = renderToJsdom( 285 | 286 |
    287 |
    288 |
    289 | 290 | ); 291 | 292 | setImmediate(() => { 293 | let dots = tree.find('.carousel-dot'); 294 | const track = tree.find('.carousel-track'); 295 | expect(dots.length).to.equal(3); 296 | expect(dots.at(0).prop('className')).to.contain('selected'); 297 | track.simulate('mouseDown', { clientX: 0 }); 298 | carousel.onMouseMove({ preventDefault: () => {}, clientX: -150 }); 299 | carousel.stopDragging(); 300 | tree.update(); 301 | setImmediate(() => { 302 | dots = tree.find('.carousel-dot'); 303 | expect(dots.at(0).prop('className')).to.not.contain('selected'); 304 | expect(dots.at(1).prop('className')).to.contain('selected'); 305 | done(); 306 | }); 307 | }); 308 | }); 309 | 310 | it('should pause when mouse is moving', done => { 311 | const carousel = renderToJsdom( 312 | 313 |
    314 |
    315 |
    316 | 317 | ); 318 | 319 | setImmediate(() => { 320 | const track = tree.find('.carousel-viewport'); 321 | const setHoverState = (bool) => { 322 | expect(bool).to.be.true; 323 | done(); 324 | }; 325 | carousel.setHoverState = setHoverState; 326 | carousel.handleMovement(track); 327 | }); 328 | }); 329 | 330 | it('should navigate to the last slide when a right swipe event occurs on the first slide', done => { 331 | const carousel = renderToJsdom( 332 | 333 |
    334 |
    335 |
    336 | 337 | ); 338 | 339 | setImmediate(() => { 340 | let dots = tree.find('.carousel-dot'); 341 | const track = tree.find('.carousel-track'); 342 | expect(dots.length).to.equal(3); 343 | expect(dots.at(0).prop('className')).to.contain('selected'); 344 | track.simulate('mouseDown', { clientX: 0 }); 345 | carousel.onMouseMove({ preventDefault: () => {}, clientX: 150 }); 346 | carousel.stopDragging(); 347 | tree.update(); 348 | setImmediate(() => { 349 | dots = tree.find('.carousel-dot'); 350 | expect(dots.at(0).prop('className')).to.not.contain('selected'); 351 | expect(dots.at(2).prop('className')).to.contain('selected'); 352 | done(); 353 | }); 354 | }); 355 | }); 356 | 357 | it('should navigate to the next slide in response to touch events', done => { 358 | const carousel = renderToJsdom( 359 | 360 |
    361 |
    362 |
    363 | 364 | ); 365 | 366 | setImmediate(() => { 367 | let dots = tree.find('.carousel-dot'); 368 | const track = tree.find('.carousel-track'); 369 | expect(dots.length).to.equal(3); 370 | expect(dots.at(0).prop('className')).to.contain('selected'); 371 | track.simulate('touchStart', { touches: [{ screenX: 0, screenY: 0 }] }); 372 | carousel.onTouchMove({ preventDefault: () => {}, touches: [{ screenX: -150, screenY: 0 }] }); 373 | carousel.stopDragging(); 374 | tree.update(); 375 | 376 | setImmediate(() => { 377 | dots = tree.find('.carousel-dot'); 378 | expect(dots.at(0).prop('className')).to.not.contain('selected'); 379 | expect(dots.at(1).prop('className')).to.contain('selected'); 380 | done(); 381 | }); 382 | }); 383 | }); 384 | 385 | it('should update the selected index if the selected slide is removed', () => { 386 | renderToJsdom( 387 | 388 |
    389 |
    390 |
    391 | 392 | ); 393 | 394 | let dots = tree.find('.carousel-dot'); 395 | expect(dots.length).to.equal(3); 396 | expect(dots.at(2).prop('className')).to.contain('selected'); 397 | 398 | tree.setProps({ 399 | children: [ 400 |
    , 401 |
    402 | ] 403 | }); 404 | dots = tree.find('.carousel-dot'); 405 | expect(dots.length).to.equal(2); 406 | expect(dots.at(1).prop('className')).to.contain('selected'); 407 | }); 408 | 409 | it('should apply passed inline styling', () => { 410 | const styles = { 411 | container: { 412 | opacity: 0.5 413 | }, 414 | containerInner: { 415 | opacity: 0.6 416 | }, 417 | viewport: { 418 | opacity: 0.7 419 | }, 420 | track: { 421 | opacity: 0.8 422 | }, 423 | slide: { 424 | opacity: 0.9 425 | }, 426 | selectedSlide: { 427 | opacity: 1 428 | } 429 | }; 430 | renderToJsdom( 431 | 432 |
    433 |
    434 |
    435 | 436 | ); 437 | 438 | const container = tree.find('.carousel'); 439 | expect(container.prop('style').opacity).to.equal(0.5); 440 | const innerContainer = tree.find('.carousel-container-inner'); 441 | expect(innerContainer.prop('style').opacity).to.equal(0.6); 442 | const viewport = tree.find('.carousel-viewport'); 443 | expect(viewport.prop('style').opacity).to.equal(0.7); 444 | const track = tree.find('.carousel-track'); 445 | expect(track.prop('style').opacity).to.equal(0.8); 446 | const slide = tree.find('.carousel-slide'); 447 | expect(slide.at(0).prop('style').opacity).to.equal(0.9); 448 | const selectedSlide = tree.find('.carousel-slide-selected'); 449 | expect(selectedSlide.prop('style').opacity).to.equal(1); 450 | }); 451 | 452 | it('should render vertical carousal with default arrows.', () => { 453 | renderToJsdom( 454 | 459 |
    460 |
    461 |
    462 | ); 463 | const topArrow = tree.find('.carousel-top-arrow'); 464 | const bottomArrow = tree.find('.carousel-bottom-arrow'); 465 | const carousalDiv = tree.find('.carousel-container-inner'); 466 | 467 | expect(carousalDiv.prop('style').display).to.eql('flex'); 468 | expect(topArrow.length).to.eql(1); 469 | expect(topArrow.html()).to.eql(''); 470 | expect(bottomArrow.length).to.eql(1); 471 | expect(bottomArrow.html()).to.eql(''); 472 | }); 473 | 474 | it('should render vertical carousal with custom arrows.', () => { 475 | renderToJsdom( 476 | , bottomArrowImage: } 485 | }] }> 486 |
    487 |
    488 |
    489 | ); 490 | 491 | const controlComponent = tree.find('.custom-arrows-div'); 492 | expect(controlComponent.childAt(0).html()).to.eql(''); 493 | expect(controlComponent.childAt(1).html()).to.eql(''); 494 | 495 | }); 496 | 497 | it('should have transitions with the given duration and easing', done => { 498 | let slidingCarousel; 499 | 500 | tree = mount( 501 |
    502 | { slidingCarousel = el; } }> 504 |
    505 |
    506 |
    507 | 508 | 510 |
    511 |
    512 |
    513 | 514 |
    515 | ); 516 | 517 | setImmediate(() => { 518 | slidingCarousel.goToSlide(1); 519 | tree.update(); 520 | const track = tree.find('.sliding-carousel .carousel-track'); 521 | expect(track.prop('style').transition).to.equal('transform 300ms ease-out'); 522 | 523 | const slide = tree.find('.fading-carousel .carousel-slide').at(0); 524 | expect(slide.prop('style').transition).to.equal('opacity 700ms linear'); 525 | done(); 526 | }); 527 | }); 528 | 529 | it('should support passing none for the transition type', done => { 530 | let noneCarousel; 531 | 532 | tree = mount( 533 | { noneCarousel = el; } }> 535 |
    536 |
    537 |
    538 | 539 | ); 540 | 541 | setImmediate(() => { 542 | noneCarousel.goToSlide(1); 543 | tree.update(); 544 | const track = tree.find('.none-carousel .carousel-track'); 545 | expect(track.prop('style').transition).to.not.exist; 546 | 547 | done(); 548 | }); 549 | }); 550 | 551 | describe('maxRenderedSlides', () => { 552 | it('should only render the specified maxRenderedSlides', () => { 553 | renderToJsdom( 554 | 561 |
    562 |
    563 |
    564 |
    565 |
    566 |
    567 |
    568 |
    569 |
    570 |
    571 | 572 | ); 573 | const loadingSlides = tree.find('.carousel-slide.carousel-slide-loading'); 574 | expect(loadingSlides.length).to.equal(7); 575 | expect(tree.find('#slide1').exists()).to.be.false; 576 | expect(tree.find('#slide2').exists()).to.be.true; 577 | expect(tree.find('#slide3').exists()).to.be.true; 578 | expect(tree.find('#slide4').exists()).to.be.true; 579 | expect(tree.find('#slide5').exists()).to.be.false; 580 | expect(tree.find('#slide6').exists()).to.be.false; 581 | expect(tree.find('#slide7').exists()).to.be.false; 582 | expect(tree.find('#slide8').exists()).to.be.false; 583 | expect(tree.find('#slide9').exists()).to.be.false; 584 | expect(tree.find('#slide10').exists()).to.be.false; 585 | }); 586 | 587 | it('should render the correct slides when infinite is true and the selected slide is near the end', () => { 588 | renderToJsdom( 589 | 596 |
    597 |
    598 |
    599 |
    600 |
    601 |
    602 |
    603 |
    604 |
    605 |
    606 | 607 | ); 608 | expect(tree.find('#slide1').exists()).to.be.true; 609 | expect(tree.find('#slide2').exists()).to.be.true; 610 | expect(tree.find('#slide3').exists()).to.be.false; 611 | expect(tree.find('#slide4').exists()).to.be.false; 612 | expect(tree.find('#slide5').exists()).to.be.false; 613 | expect(tree.find('#slide6').exists()).to.be.false; 614 | expect(tree.find('#slide7').exists()).to.be.false; 615 | expect(tree.find('#slide8').exists()).to.be.false; 616 | expect(tree.find('#slide9').exists()).to.be.false; 617 | expect(tree.find('#slide10').exists()).to.be.true; 618 | }); 619 | }); 620 | 621 | it('should render custom arrow', done => { 622 | const arrows = { 623 | className: 'test-custom-arrow', 624 | left: Left, 625 | right: Right 626 | }; 627 | 628 | renderToJsdom( 629 | 633 |
    634 |
    635 |
    636 | 637 | ); 638 | 639 | setImmediate(() => { 640 | const prevButton = tree.find('.carousel-left-arrow'); 641 | const nextButton = tree.find('.carousel-right-arrow'); 642 | expect(prevButton.prop('className')).to.contain('test-custom-arrow'); 643 | expect(nextButton.prop('className')).to.contain('test-custom-arrow'); 644 | expect(tree.find('#custom-left')).to.exist; 645 | expect(tree.find('#custom-right')).to.exist; 646 | done(); 647 | }); 648 | }); 649 | 650 | it('should render custom arrow without className', done => { 651 | const arrows = { 652 | left: Left, 653 | right: Right 654 | }; 655 | 656 | renderToJsdom( 657 | 661 |
    662 |
    663 |
    664 | 665 | ); 666 | 667 | setImmediate(() => { 668 | const prevButton = tree.find('.carousel-left-arrow'); 669 | const nextButton = tree.find('.carousel-right-arrow'); 670 | expect(prevButton.prop('className')).to.not.contain('carousel-arrow-default'); 671 | expect(nextButton.prop('className')).to.not.contain('carousel-arrow-default'); 672 | expect(tree.find('#custom-left')).to.exist; 673 | expect(tree.find('#custom-right')).to.exist; 674 | done(); 675 | }); 676 | }); 677 | 678 | it('should call onSlideTransitioned with autoPlay true', done => { 679 | const onSlideTransitionedStub = sinon.stub(); 680 | const carousel = renderToJsdom( 681 | 689 |
    690 |
    691 |
    692 | 693 | ); 694 | 695 | setTimeout(() => { 696 | const track = tree.find('.carousel-viewport'); 697 | const setHoverState = sinon.spy(); 698 | carousel.setHoverState = setHoverState; 699 | carousel.handleMovement(track); 700 | 701 | expect(setHoverState).to.have.been.calledWith(true); 702 | expect(onSlideTransitionedStub).to.have.been.calledWithMatch({ 703 | autoPlay: true, 704 | direction: 'right' 705 | }); 706 | done(); 707 | }, 20); 708 | }); 709 | }); 710 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint 2 | jsx-a11y/mouse-events-have-key-events: 0, 3 | jsx-a11y/no-noninteractive-element-interactions: 0, 4 | jsx-a11y/click-events-have-key-events: 0 */ 5 | import React, { Component, Children, cloneElement } from 'react'; 6 | import PropTypes from 'prop-types'; 7 | import ms from 'ms'; 8 | import classnames from 'classnames'; 9 | import { Dots, Arrow } from './controls'; 10 | import areChildImagesEqual from './utils/areChildImagesEqual'; 11 | import nth from './utils/nth'; 12 | 13 | const SELECTED_CLASS = 'carousel-slide-selected'; 14 | const LOADING_CLASS = 'carousel-slide-loading'; 15 | const MAX_LOAD_RETRIES = 500; 16 | 17 | /** 18 | * React component class that renders a carousel, which can contain images or other content. 19 | * 20 | * @extends React.Component 21 | */ 22 | export default class Carousel extends Component { 23 | 24 | static get propTypes() { 25 | return { 26 | initialSlide: PropTypes.number, 27 | className: PropTypes.string, 28 | transition: PropTypes.oneOf(['slide', 'fade', 'none']), 29 | dots: PropTypes.bool, 30 | arrows: PropTypes.oneOfType([ 31 | PropTypes.bool, 32 | PropTypes.shape({ 33 | left: PropTypes.node.isRequired, 34 | right: PropTypes.node.isRequired, 35 | className: PropTypes.string 36 | }) 37 | ]), 38 | infinite: PropTypes.bool, 39 | children: PropTypes.any, 40 | viewportWidth: PropTypes.string, 41 | viewportHeight: PropTypes.string, 42 | width: PropTypes.string, 43 | height: PropTypes.string, 44 | imagesToPrefetch: PropTypes.number, 45 | maxRenderedSlides: PropTypes.number, 46 | cellPadding: PropTypes.number, 47 | slideWidth: PropTypes.string, 48 | slideHeight: PropTypes.string, 49 | slideAlignment: PropTypes.oneOf(['left', 'center', 'right']), 50 | beforeChange: PropTypes.func, 51 | afterChange: PropTypes.func, 52 | transitionDuration: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 53 | autoplay: PropTypes.bool, 54 | autoplaySpeed: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 55 | lazyLoad: PropTypes.bool, 56 | controls: PropTypes.arrayOf(PropTypes.shape({ 57 | component: PropTypes.func.isRequired, 58 | props: PropTypes.object, 59 | position: PropTypes.oneOf(['top', 'bottom']) 60 | })), 61 | draggable: PropTypes.bool, 62 | pauseOnHover: PropTypes.bool, 63 | clickToNavigate: PropTypes.bool, 64 | dragThreshold: PropTypes.number, 65 | onSlideTransitioned: PropTypes.func, 66 | easing: PropTypes.oneOf([ 67 | 'ease', 68 | 'linear', 69 | 'ease-in', 70 | 'ease-out', 71 | 'ease-in-out' 72 | ]), 73 | style: PropTypes.shape({ 74 | container: PropTypes.object, 75 | containerInner: PropTypes.object, 76 | viewport: PropTypes.object, 77 | track: PropTypes.object, 78 | slide: PropTypes.object, 79 | selectedSlide: PropTypes.object 80 | }), 81 | dir: PropTypes.oneOf(['ltr', 'rtl']), 82 | isVertical: PropTypes.bool 83 | }; 84 | } 85 | 86 | static get defaultProps() { 87 | return { 88 | initialSlide: 0, 89 | dots: true, 90 | arrows: true, 91 | infinite: true, 92 | viewportWidth: '100%', 93 | width: '100%', 94 | height: 'auto', 95 | imagesToPrefetch: 5, 96 | maxRenderedSlides: 5, 97 | cellPadding: 0, 98 | slideAlignment: 'center', 99 | transitionDuration: 500, 100 | autoplay: false, 101 | autoplaySpeed: 4000, 102 | lazyLoad: true, 103 | controls: [], 104 | draggable: true, 105 | pauseOnHover: true, 106 | transition: 'slide', 107 | dragThreshold: 0.2, 108 | clickToNavigate: true, 109 | easing: 'ease-in-out', 110 | style: {}, 111 | dir: 'ltr', 112 | isVertical: false 113 | }; 114 | } 115 | 116 | constructor(props) { 117 | super(...arguments); 118 | this.state = { 119 | currentSlide: props.initialSlide, 120 | loading: props.lazyLoad, 121 | loadedImages: {}, 122 | slideDimensions: {}, 123 | dragOffset: 0, 124 | transitionDuration: 0, 125 | transitioningFrom: null 126 | }; 127 | } 128 | 129 | static getDerivedStateFromProps(props, state) { 130 | const { currentSlide } = state; 131 | const numChildren = Children.count(props.children); 132 | 133 | if (currentSlide >= numChildren) { 134 | // The currentSlide index is no longer valid, so move to the last valid index 135 | return { 136 | currentSlide: numChildren ? numChildren - 1 : 0 137 | }; 138 | } 139 | return null; 140 | } 141 | 142 | componentDidUpdate(prevProps, prevState) { 143 | const { children, autoplay, slideWidth, slideAlignment } = this.props; 144 | const { currentSlide, loadedImages, direction, loading, slideDimensions } = this.state; 145 | const oldChildren = prevProps.children; 146 | 147 | if (direction !== prevState.direction || 148 | currentSlide !== prevState.currentSlide || 149 | loadedImages !== prevState.loadedImages || 150 | slideWidth !== prevProps.slideWidth || 151 | slideDimensions.width !== prevState.slideDimensions.width || 152 | slideDimensions.height !== prevState.slideDimensions.height || 153 | slideAlignment !== prevProps.slideAlignment) { 154 | // Whenever new images are loaded, the current slide index changes, the transition direction changes, or the 155 | // slide width changes, we need to recalculate the left offset positioning of the slides. 156 | this.calcLeftOffset(); 157 | } 158 | 159 | if (!areChildImagesEqual(Children.toArray(children), Children.toArray(oldChildren))) { 160 | // If the image source or number of images changed, we need to refetch images and force an update 161 | this._animating = false; 162 | this.fetchImages(); 163 | } 164 | 165 | if (autoplay && (!loading && prevState.loading || !prevProps.autoplay)) { 166 | this.startAutoplay(); 167 | } 168 | } 169 | 170 | componentDidMount() { 171 | const { lazyLoad, autoplay } = this.props; 172 | this._isMounted = true; 173 | 174 | if (lazyLoad) { 175 | this.fetchImages(); 176 | } else { 177 | if (autoplay) { 178 | this.startAutoplay(); 179 | } 180 | this.calcLeftOffset(); 181 | } 182 | 183 | window.addEventListener('resize', this.calcLeftOffset, false); 184 | 185 | if (window.IntersectionObserver) { 186 | this._observer = new window.IntersectionObserver(entries => { 187 | if (!this.props.autoplay) { 188 | return; 189 | } 190 | 191 | if (entries && entries[0] && entries[0].isIntersecting) { 192 | this.startAutoplay(); 193 | } else { 194 | clearTimeout(this._autoplayTimer); 195 | } 196 | }); 197 | this._observer.observe(this._containerRef); 198 | } 199 | } 200 | 201 | componentWillUnmount() { 202 | // Remove all event listeners 203 | this.removeDragListeners(); 204 | window.removeEventListener('resize', this.calcLeftOffset, false); 205 | document.removeEventListener('mousemove', this.handleMovement, false); 206 | clearTimeout(this._autoplayTimer); 207 | clearTimeout(this._retryTimer); 208 | clearTimeout(this._initialLoadTimer); 209 | this._observer && this._observer.unobserve(this._containerRef); 210 | this._isMounted = false; 211 | } 212 | 213 | /** 214 | * Starts the autoplay timer if it is not already running. 215 | */ 216 | startAutoplay() { 217 | clearTimeout(this._autoplayTimer); 218 | this._autoplayTimer = setTimeout(() => { 219 | const { autoplay } = this.props; 220 | if (autoplay) { 221 | this.nextSlide(); 222 | } 223 | }, ms('' + this.props.autoplaySpeed)); 224 | } 225 | 226 | /** 227 | * Loads images surrounding the specified slide index. The number of images fetched is controlled by the 228 | * imagesToPrefetch prop. 229 | */ 230 | fetchImages() { 231 | const { children } = this.props; 232 | const { loadedImages, currentSlide } = this.state; 233 | const slides = Children.toArray(children); 234 | const imagesToPrefetch = Math.min(this.props.imagesToPrefetch, slides.length); 235 | const startIndex = currentSlide - Math.floor(imagesToPrefetch / 2); 236 | const endIndex = startIndex + imagesToPrefetch; 237 | const pendingImages = []; 238 | 239 | const currentImage = slides[currentSlide].props.src; 240 | 241 | for (let index = startIndex; index < endIndex; index++) { 242 | const slide = nth(slides, index % slides.length); 243 | const imageSrc = slide.props.src; 244 | if (imageSrc && !loadedImages[imageSrc]) { 245 | pendingImages.push(imageSrc); 246 | } 247 | } 248 | 249 | if (pendingImages.length) { 250 | pendingImages.forEach(image => { 251 | const img = new Image(); 252 | img.onload = img.onerror = () => { 253 | if (this._isMounted) { 254 | this.setState({ 255 | loadedImages: { 256 | ...this.state.loadedImages, 257 | [image]: { width: img.width || 'auto', height: img.height || 'auto' } 258 | } 259 | }, () => { 260 | if (image === currentImage) { 261 | this.handleInitialLoad(); 262 | } 263 | }); 264 | } 265 | }; 266 | img.src = image; 267 | }); 268 | } else { 269 | this.calcLeftOffset(); 270 | } 271 | } 272 | 273 | /** 274 | * Invoked when the carousel is using lazy loading and the currently selected slide's image is first rendered. This 275 | * method will clear the loading state causing the carousel to render and will calculate the dimensions of the 276 | * displayed slide to use as a loading shim if an explicit width/height were not specified. 277 | */ 278 | handleInitialLoad = () => { 279 | const { currentSlide } = this.state; 280 | const slides = this._track.childNodes; 281 | const { slideWidth, slideHeight } = this.props; 282 | if (!slideWidth || !slideHeight) { 283 | for (let i = 0; i < slides.length; i++) { 284 | const slide = slides[i]; 285 | if (parseInt(slide.getAttribute('data-index'), 10) === currentSlide) { 286 | if (!slide.offsetWidth || !slide.offsetHeight) { 287 | this._initialLoadTimer = setTimeout(this.handleInitialLoad, 10); 288 | return; 289 | } 290 | this.setState({ 291 | slideDimensions: { 292 | width: slide.offsetWidth, 293 | height: slide.offsetHeight 294 | } 295 | }); 296 | break; 297 | } 298 | } 299 | } 300 | } 301 | 302 | /** 303 | * Navigates to the specified slide index, moving in the specified direction. 304 | * 305 | * @param {Number} index - The slide index to move to. 306 | * @param {String} direction - The direction to transition, should be 'right' or 'left'. 307 | * @param {Boolean} autoSlide - The source of slide transition, should be true for autoPlay and false for user click. 308 | */ 309 | goToSlide = (index, direction, autoSlide = false) => { 310 | const { beforeChange, transitionDuration, transition, onSlideTransitioned, children } = this.props; 311 | const { currentSlide } = this.state; 312 | const lastIndex = Children.count(children) - 1; 313 | 314 | const newIndex = index < 0 ? lastIndex + index + 1 : 315 | index <= lastIndex ? index : index - lastIndex - 1; 316 | 317 | direction = direction || (index > currentSlide ? 'right' : 'left'); 318 | 319 | if (onSlideTransitioned) { 320 | onSlideTransitioned({ 321 | autoPlay: autoSlide, 322 | index: newIndex, 323 | direction 324 | }); 325 | } 326 | 327 | if (currentSlide === newIndex) { 328 | return; 329 | } 330 | 331 | if (this._animating) { 332 | return; 333 | } 334 | 335 | this._animating = true; 336 | 337 | beforeChange && beforeChange(newIndex, currentSlide, direction); 338 | this.setState({ 339 | transitionDuration 340 | }, () => { 341 | this.setState({ 342 | currentSlide: newIndex, 343 | direction, 344 | transitioningFrom: currentSlide 345 | }, () => { 346 | if (!transitionDuration || transition === 'fade' || transition === 'none') { 347 | // We don't actually animate if transitionDuration is 0, so immediately call the transition end callback 348 | this.slideTransitionEnd(); 349 | } 350 | }); 351 | }); 352 | } 353 | 354 | /** 355 | * Transitions to the next slide moving from left to right. 356 | * @param {Object} e - The event that calls nextSlide, will be undefined for autoPlay. 357 | */ 358 | nextSlide = (e) => { 359 | const { currentSlide } = this.state; 360 | this.goToSlide(currentSlide + 1, 'right', typeof e !== 'object'); 361 | } 362 | 363 | /** 364 | * Transitions to the previous slide moving from right to left. 365 | */ 366 | prevSlide = () => { 367 | const { currentSlide } = this.state; 368 | this.goToSlide(currentSlide - 1, 'left'); 369 | } 370 | 371 | /** 372 | * Invoked whenever a slide transition (CSS) completes. 373 | * 374 | * @param {Object} e Event object 375 | */ 376 | slideTransitionEnd = (e) => { 377 | const { currentSlide } = this.state; 378 | const { afterChange } = this.props; 379 | 380 | if (!e || e.propertyName === 'transform') { 381 | this._animating = false; 382 | 383 | this.setState({ 384 | direction: null, 385 | transitioningFrom: null, 386 | transitionDuration: 0 387 | }, () => { 388 | if (!this._allImagesLoaded) { 389 | this.fetchImages(); 390 | } 391 | }); 392 | 393 | if (this.props.autoplay) { 394 | this.startAutoplay(); 395 | } 396 | 397 | afterChange && afterChange(currentSlide); 398 | } 399 | } 400 | 401 | /** 402 | * @returns {Array} Controls to be rendered with the carousel. 403 | */ 404 | getControls() { 405 | const { arrows, dots, controls, isVertical } = this.props; 406 | let arr = controls.slice(0); 407 | 408 | if (dots) { 409 | arr.push({ component: Dots }); 410 | } 411 | 412 | if (arrows) { 413 | arr = arr.concat([ 414 | { ...isVertical ? { component: Arrow, props: { direction: 'top' } } : { component: Arrow, props: { direction: 'left' } } }, 415 | { ...isVertical ? { component: Arrow, props: { direction: 'bottom' } } : { component: Arrow, props: { direction: 'right' } } } 416 | ]); 417 | } 418 | 419 | return arr; 420 | } 421 | 422 | /** 423 | * Renders the carousel. 424 | * 425 | * @returns {Object} Component to be rendered. 426 | */ 427 | render() { 428 | const { className, viewportWidth, viewportHeight, width, height, dots, infinite, 429 | children, slideHeight, transition, style, draggable, easing, arrows, dir, isVertical } = this.props; 430 | const { loading, transitionDuration, dragOffset, currentSlide, leftOffset } = this.state; 431 | const numSlides = Children.count(children); 432 | const classes = classnames('carousel', className, { 433 | loaded: !loading 434 | }); 435 | const containerStyle = { ...(style.container || {}), 436 | width, 437 | height 438 | }; 439 | const innerContainerStyle = { ...(style.containerInner || {}), 440 | width, 441 | height, 442 | marginBottom: dots ? '20px' : 0, 443 | ...isVertical && { display: 'flex' } 444 | }; 445 | const viewportStyle = { ...(style.viewport || {}), 446 | width: viewportWidth, 447 | height: viewportHeight || slideHeight || 'auto' 448 | }; 449 | const isRTL = dir === 'rtl'; 450 | let trackStyle = { ...style.track }; 451 | if (transition === 'slide') { 452 | const leftPos = leftOffset + dragOffset; 453 | trackStyle = { ...trackStyle, 454 | ...isVertical && { transform: `translateY(${isRTL ? -leftPos : leftPos}px)` }, 455 | ...!isVertical && { transform: `translateX(${isRTL ? -leftPos : leftPos}px)` }, 456 | transition: transitionDuration ? `transform ${ms('' + transitionDuration)}ms ${easing}` : 'none' 457 | }; 458 | } 459 | 460 | if (!draggable) { 461 | trackStyle.touchAction = 'auto'; 462 | } 463 | 464 | const controls = this.getControls(); 465 | 466 | return ( 467 |
    { this._containerRef = c; } }> 468 |
    469 | { 470 | controls.filter(Control => { 471 | return Control.position === 'top'; 472 | }).map((Control, index) => ( 473 | 482 | )) 483 | } 484 |
    { this._viewport = v; } } style={ viewportStyle }> 485 |
      { this._track = t; } } 489 | onTransitionEnd={ this.slideTransitionEnd } 490 | onMouseDown={ this.onMouseDown } 491 | onMouseLeave={ this.onMouseLeave } 492 | onMouseOver={ this.onMouseOver } 493 | onMouseEnter={ this.onMouseEnter } 494 | onTouchStart={ this.onTouchStart } 495 | > 496 | { this.renderSlides() } 497 |
    498 |
    499 | { 500 | controls.filter(Control => { 501 | return Control.position !== 'top'; 502 | }).map((Control, index) => ( 503 | 513 | )) 514 | } 515 |
    516 |
    517 | ); 518 | } 519 | 520 | /** 521 | * Renders the slides within the carousel viewport. 522 | * 523 | * @returns {Array} Array of slide components to be rendered. 524 | */ 525 | renderSlides() { 526 | const { children, infinite, cellPadding, slideWidth, slideHeight, transition, transitionDuration, 527 | style, easing, lazyLoad, isVertical } = this.props; 528 | const { slideDimensions, currentSlide, loadedImages } = this.state; 529 | this._allImagesLoaded = true; 530 | let childrenToRender = Children.map(children, (child, index) => { 531 | const key = `slide-${index}`; 532 | const imgSrc = child.props.src; 533 | const slideClasses = classnames( 534 | 'carousel-slide', 535 | { 536 | [SELECTED_CLASS]: index === currentSlide, 537 | 'carousel-slide-fade': transition === 'fade' || transition === 'none' // Absolute positioning for fade/none transition 538 | } 539 | ); 540 | let slideStyle = { 541 | ...!isVertical && { marginLeft: `${cellPadding}px` }, 542 | ...isVertical && { marginTop: `${cellPadding}px` }, 543 | height: slideHeight, 544 | width: slideWidth 545 | }; 546 | 547 | if (transition === 'fade') { 548 | slideStyle.transition = `opacity ${ms('' + transitionDuration)}ms ${easing}`; 549 | } else if (transition === 'none') { 550 | slideStyle.transition = 'none'; 551 | } 552 | 553 | if (slideHeight) { 554 | slideStyle.overflowY = 'hidden'; 555 | slideStyle.minHeight = slideHeight; // Safari 9 bug 556 | } 557 | 558 | if (slideWidth) { 559 | slideStyle.overflowX = 'hidden'; 560 | slideStyle.minWidth = slideWidth; // Safari 9 bug 561 | } 562 | 563 | slideStyle = { ...slideStyle, ...(style.slide || {}), ...(index === currentSlide ? style.selectedSlide || {} : {}) }; 564 | 565 | const loadingSlideStyle = { ...(slideStyle || {}), 566 | marginLeft: slideStyle.marginLeft, 567 | width: slideWidth || slideDimensions.width, 568 | height: slideHeight || slideDimensions.height 569 | }; 570 | const slidesToRender = this.getIndicesToRender(); 571 | 572 | // Only render the actual slide content if lazy loading is disabled, the image is already loaded, or we 573 | // are within the configured proximity to the selected slide index. 574 | if (!lazyLoad || (imgSrc ? !!loadedImages[imgSrc] : slidesToRender.indexOf(index) > -1)) { 575 | // If the slide contains an image, set explicit width/height 576 | if (imgSrc && loadedImages[imgSrc]) { 577 | const { width, height } = loadedImages[imgSrc]; 578 | slideStyle.height = slideStyle.height || height; 579 | slideStyle.width = slideStyle.width || width; 580 | } 581 | 582 | return ( 583 |
  • 590 | { child } 591 |
  • 592 | ); 593 | } 594 | 595 | if (imgSrc) { 596 | this._allImagesLoaded = false; 597 | } 598 | 599 | return ( 600 |
  • 607 | ); 608 | }); 609 | 610 | if (infinite && transition === 'slide') { 611 | // For infinite mode, create 2 clones on each side of the track 612 | childrenToRender = this.addClones(childrenToRender); 613 | } 614 | 615 | return childrenToRender; 616 | } 617 | 618 | /** 619 | * This method returns the slides indices that should be fully rendered given the current lazyLoad and 620 | * maxRenderedSlides settings. 621 | * 622 | * @returns {Array} Array of slide indices indicating which indices should be fully rendered. 623 | */ 624 | getIndicesToRender() { 625 | const { currentSlide, transitioningFrom } = this.state; 626 | const { children, infinite, maxRenderedSlides } = this.props; 627 | const numSlides = Children.count(children); 628 | 629 | function genIndices(startIndex, endIndex) { 630 | const indices = []; 631 | for (let i = startIndex; i <= endIndex; i++) { 632 | if (infinite && i < 0) { 633 | indices.push(numSlides + i); 634 | } else if (infinite && i >= numSlides) { 635 | indices.push(i - numSlides); 636 | } else { 637 | indices.push(i); 638 | } 639 | } 640 | return indices; 641 | } 642 | 643 | // Figure out what slide indices need to be rendered 644 | const maxSlides = Math.max(1, maxRenderedSlides); 645 | const prevSlidesToRender = Math.floor((maxSlides - 1) / 2); 646 | const nextSlidesToRender = Math.floor(maxSlides / 2); 647 | let indices = genIndices(currentSlide - prevSlidesToRender, currentSlide + nextSlidesToRender); 648 | 649 | if (transitioningFrom !== null) { 650 | // Also render the slides around the previous slide during a transition 651 | indices = indices.concat(genIndices(transitioningFrom - prevSlidesToRender, transitioningFrom + nextSlidesToRender)); 652 | } 653 | 654 | return indices; 655 | } 656 | 657 | addClones(originals) { 658 | const numOriginals = originals.length; 659 | const originalsToClone = [ 660 | nth(originals, numOriginals - 2), 661 | nth(originals, numOriginals - 1), 662 | nth(originals, 0), 663 | nth(originals, Math.min(1, numOriginals - 1)) 664 | ]; 665 | const prependClones = [ 666 | cloneElement(originalsToClone[0], { 667 | 'key': 'clone-1', 668 | 'data-index': -2, 669 | 'className': originalsToClone[0].props.className.replace(SELECTED_CLASS, '') 670 | }), 671 | cloneElement(originalsToClone[1], { 672 | 'key': 'clone-0', 673 | 'data-index': -1, 674 | 'className': originalsToClone[1].props.className.replace(SELECTED_CLASS, '') 675 | }) 676 | ]; 677 | const appendClones = [ 678 | cloneElement(originalsToClone[2], { 679 | 'key': 'clone-2', 680 | 'data-index': numOriginals, 681 | 'className': originalsToClone[2].props.className.replace(SELECTED_CLASS, '') 682 | }), 683 | cloneElement(originalsToClone[3], { 684 | 'key': 'clone-3', 685 | 'data-index': numOriginals + 1, 686 | 'className': originalsToClone[3].props.className.replace(SELECTED_CLASS, '') 687 | }) 688 | ]; 689 | 690 | return prependClones.concat(originals).concat(appendClones); 691 | } 692 | 693 | /** 694 | * Updates the component state with the correct left offset position so that the slides will be positioned correctly. 695 | * 696 | * @param {Number} retryCount Used when retries are needed due to slow slide loading 697 | */ 698 | calcLeftOffset = (retryCount = 0) => { 699 | const { direction, loading } = this.state; 700 | const { isVertical } = this.props; 701 | const viewportWidth = this._viewport && (isVertical ? this._viewport.offsetHeight : this._viewport.offsetWidth); 702 | 703 | clearTimeout(this._retryTimer); 704 | 705 | if (!this._track || !viewportWidth) { 706 | this._retryTimer = setTimeout(this.calcLeftOffset, 10); 707 | return; 708 | } 709 | 710 | const { infinite, children, cellPadding, slideAlignment } = this.props; 711 | let { currentSlide } = this.state; 712 | const slides = this._track.childNodes; 713 | const numChildren = Children.count(children); 714 | 715 | if (infinite) { 716 | if (currentSlide === 0 && direction === 'right') { 717 | currentSlide = numChildren; 718 | } else if (currentSlide === numChildren - 1 && direction === 'left') { 719 | currentSlide = -1; 720 | } 721 | } 722 | 723 | let leftOffset = 0; 724 | let selectedSlide; 725 | let foundZeroWidthSlide = false; 726 | let isCurrentSlideLoading = false; 727 | let currentSlideWidth; 728 | for (let i = 0; i < slides.length; i++) { 729 | selectedSlide = slides[i]; 730 | leftOffset -= cellPadding; 731 | isCurrentSlideLoading = selectedSlide.className.indexOf(LOADING_CLASS) !== -1; 732 | currentSlideWidth = isVertical ? selectedSlide.offsetHeight : selectedSlide.offsetWidth; 733 | foundZeroWidthSlide = foundZeroWidthSlide || (!currentSlideWidth && !isCurrentSlideLoading); 734 | if (parseInt(selectedSlide.getAttribute('data-index'), 10) === currentSlide) { 735 | break; 736 | } 737 | leftOffset -= currentSlideWidth; 738 | } 739 | 740 | // Adjust the offset to get the correct alignment of current slide within the viewport 741 | if (slideAlignment === 'center') { 742 | leftOffset += (viewportWidth - currentSlideWidth) / 2; 743 | } else if (slideAlignment === 'right') { 744 | leftOffset += (viewportWidth - currentSlideWidth); 745 | } 746 | 747 | const shouldRetry = foundZeroWidthSlide && retryCount < MAX_LOAD_RETRIES; 748 | 749 | if (leftOffset !== this.state.leftOffset) { 750 | this.setState({ leftOffset }); 751 | } 752 | 753 | if (shouldRetry) { 754 | this._retryTimer = setTimeout(this.calcLeftOffset.bind(this, ++retryCount), 10); 755 | return; 756 | } 757 | 758 | if (loading) { 759 | // We have correctly positioned the slides and are done loading images, so reveal the carousel 760 | this.setState({ loading: false }); 761 | } 762 | } 763 | 764 | /** 765 | * Invoked when a slide is clicked. 766 | * 767 | * @param {Event} e DOM event object. 768 | */ 769 | handleSlideClick = (e) => { 770 | const { clickToNavigate } = this.props; 771 | const { currentSlide } = this.state; 772 | const clickedIndex = parseInt(e.currentTarget.getAttribute('data-index'), 10); 773 | 774 | // If the user clicked the current slide or it appears they are dragging, don't process the click 775 | if (!clickToNavigate || clickedIndex === currentSlide || Math.abs(this._startPos.x - e.clientX) > 0.01) { 776 | return; 777 | } 778 | 779 | this.goToSlide(clickedIndex); 780 | } 781 | 782 | /** 783 | * Invoked when mousedown occurs on a slide. 784 | * 785 | * @param {Event} e DOM event object. 786 | */ 787 | onMouseDown = (e) => { 788 | const { draggable, transition } = this.props; 789 | 790 | if (e.target.nodeName === 'IMG') { 791 | // Disable native browser select/drag for img elements 792 | e.preventDefault(); 793 | } 794 | 795 | 796 | if (draggable && transition === 'slide' && !this._animating) { 797 | if (this._autoplayTimer) { 798 | clearTimeout(this._autoplayTimer); 799 | } 800 | this._startPos = { 801 | x: e.clientX, 802 | y: e.clientY, 803 | startTime: Date.now() 804 | }; 805 | this.setState({ transitionDuration: 0 }); 806 | document.addEventListener('mousemove', this.onMouseMove, { passive: false }); 807 | document.addEventListener('mouseup', this.stopDragging, false); 808 | } 809 | } 810 | 811 | /** 812 | * Invoked when the mouse is moved over a slide while dragging. 813 | * 814 | * @param {Event} e DOM event object. 815 | */ 816 | onMouseMove = (e) => { 817 | e.preventDefault(); 818 | this.setState({ 819 | dragOffset: e.clientX - this._startPos.x 820 | }); 821 | } 822 | 823 | /** 824 | * Invoked when the mouse cursor enters over a slide. 825 | */ 826 | onMouseEnter = () => { 827 | document.addEventListener('mousemove', this.handleMovement, false); 828 | } 829 | 830 | /** 831 | * Invoked when the mouse cursor moves around a slide. 832 | */ 833 | handleMovement = () => { 834 | this.setHoverState(true); 835 | } 836 | 837 | /** 838 | * Invoked when the mouse cursor moves over a slide. 839 | */ 840 | onMouseOver = () => { 841 | this.setHoverState(true); 842 | } 843 | 844 | /** 845 | * Keeps track of the current hover state. 846 | * 847 | * @param {Boolean} hovering Current hover state. 848 | */ 849 | setHoverState(hovering) { 850 | const { pauseOnHover, autoplay } = this.props; 851 | 852 | if (pauseOnHover && autoplay) { 853 | clearTimeout(this._hoverTimer); 854 | 855 | if (hovering) { 856 | clearTimeout(this._autoplayTimer); 857 | // If the mouse doesn't move for a few seconds, we want to restart the autoplay 858 | this._hoverTimer = setTimeout(() => { 859 | this.setHoverState(false); 860 | }, 2000); 861 | } else { 862 | this.startAutoplay(); 863 | } 864 | } 865 | } 866 | 867 | /** 868 | * Invoked when the mouse cursor leaves a slide. 869 | */ 870 | onMouseLeave = () => { 871 | document.removeEventListener('mousemove', this.handleMovement, false); 872 | this.setHoverState(false); 873 | !this._animating && this._startPos && this.stopDragging(); 874 | } 875 | 876 | /** 877 | * Invoked when a touchstart event occurs on a slide. 878 | * 879 | * @param {Event} e DOM event object. 880 | */ 881 | onTouchStart = (e) => { 882 | const { draggable, transition } = this.props; 883 | 884 | if (draggable && transition === 'slide' && !this._animating) { 885 | if (this._autoplayTimer) { 886 | clearTimeout(this._autoplayTimer); 887 | } 888 | if (e.touches.length === 1) { 889 | this._startPos = { 890 | x: e.touches[0].screenX, 891 | y: e.touches[0].screenY, 892 | startTime: Date.now() 893 | }; 894 | document.addEventListener('touchmove', this.onTouchMove, { passive: false }); 895 | document.addEventListener('touchend', this.stopDragging, false); 896 | } 897 | } 898 | } 899 | 900 | /** 901 | * Invoked when a touchmove event occurs on a slide. 902 | * 903 | * @param {Event} e DOM event object. 904 | */ 905 | onTouchMove = (e) => { 906 | const { x, y } = this._prevPos || this._startPos; 907 | const { screenX, screenY } = e.touches[0]; 908 | const angle = Math.abs(Math.atan2(screenY - y, screenX - x)) * 180 / Math.PI; 909 | 910 | this._prevPos = { x: screenX, y: screenY }; 911 | 912 | if (angle < 20 || angle > 160) { 913 | e.preventDefault(); 914 | this.setState({ 915 | dragOffset: screenX - this._startPos.x 916 | }); 917 | } 918 | } 919 | 920 | /** 921 | * Removes event listeners that were added when starting a swipe operation 922 | */ 923 | removeDragListeners() { 924 | document.removeEventListener('mousemove', this.onMouseMove, { passive: false }); 925 | document.removeEventListener('mouseup', this.stopDragging, false); 926 | document.removeEventListener('touchmove', this.onTouchMove, { passive: false }); 927 | document.removeEventListener('touchend', this.stopDragging, false); 928 | } 929 | 930 | /** 931 | * Completes a dragging operation, deciding whether to transition to another slide or snap back to the current slide. 932 | */ 933 | stopDragging = () => { 934 | const { dragThreshold, transitionDuration } = this.props; 935 | const { dragOffset } = this.state; 936 | const viewportWidth = (this._viewport && this._viewport.offsetWidth) || 1; 937 | const percentDragged = Math.abs(dragOffset / viewportWidth); 938 | const swipeDuration = (Date.now() - this._startPos.startTime) || 1; 939 | const swipeSpeed = swipeDuration / (percentDragged * viewportWidth); 940 | const isQuickSwipe = percentDragged > 0.05 && swipeDuration < 250; 941 | 942 | let duration; 943 | 944 | if (isQuickSwipe || percentDragged > dragThreshold) { 945 | // Calculate the duration based on the speed of the swipe 946 | duration = Math.min(swipeSpeed * (1 - percentDragged) * viewportWidth, ms('' + transitionDuration) * (1 - percentDragged)); 947 | } else { 948 | // Just transition back to the center point 949 | duration = ms('' + transitionDuration) * percentDragged; 950 | } 951 | 952 | this.removeDragListeners(); 953 | 954 | this.setState({ 955 | transitionDuration: duration 956 | }, () => { 957 | const { children, infinite } = this.props; 958 | const { currentSlide } = this.state; 959 | const numSlides = Children.count(children); 960 | let newSlideIndex = currentSlide; 961 | let direction = ''; 962 | 963 | if (percentDragged > dragThreshold || isQuickSwipe) { 964 | if (dragOffset > 0) { 965 | newSlideIndex--; 966 | if (newSlideIndex < 0) { 967 | newSlideIndex = infinite ? numSlides - 1 : currentSlide; 968 | } 969 | } else { 970 | newSlideIndex++; 971 | if (newSlideIndex === numSlides) { 972 | newSlideIndex = infinite ? 0 : currentSlide; 973 | } 974 | } 975 | direction = dragOffset > 0 ? 'left' : 'right'; 976 | } 977 | 978 | this.setState({ 979 | dragOffset: 0, 980 | currentSlide: newSlideIndex, 981 | direction 982 | }); 983 | }); 984 | 985 | if (this.props.autoplay) { 986 | this.startAutoplay(); 987 | } 988 | } 989 | } 990 | --------------------------------------------------------------------------------