├── .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 |
15 | {topArrowImage}
16 |
17 |
18 | {bottomArrowImage}
19 |
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 |
53 | { arrowComponent }
54 |
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 |
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) => );
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 |
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) => {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 |
126 |
127 | ;
128 |
129 | export const infiniteWithOnly1Slide = () =>
130 |
136 |
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) => )
263 | }
264 |
265 | Add Image
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 |
--------------------------------------------------------------------------------