├── .editorconfig ├── .eslintrc.cjs ├── .github └── workflows │ └── publish.yml ├── .gitignore ├── .storybook ├── codelyTheme.ts ├── main.ts ├── manager.ts └── preview.ts ├── .stylelintrc.json ├── LICENSE ├── Makefile ├── README.md ├── cypress.config.ts ├── index.html ├── jest.config.cjs ├── package-lock.json ├── package.json ├── public ├── brand.svg └── img │ ├── georgi-kalaydzhiev-neF7gKk9708-unsplash.jpg │ ├── josh-hild-zWCKJjPCl0s-unsplash.jpg │ ├── lena-polishko-EM_thLGU0yw-unsplash.jpg │ ├── lena-polishko-EYrLkNNOyhM-unsplash.jpg │ ├── luka-verc-2iBUuz4z3B8-unsplash.jpg │ ├── nika-tchokhonelidze-Ojsyw6_5NDw-unsplash.jpg │ └── yuliya-matuzava-28WVjNXuFV8-unsplash.jpg ├── src ├── ArrowLeft.tsx ├── ArrowRight.tsx ├── Carousel.scss ├── Carousel.stories.tsx ├── Carousel.tsx ├── assets │ └── docs.css ├── core │ ├── isCompletelyVisible.ts │ └── scroll.ts ├── index.ts └── vite-env.d.ts ├── tests ├── Carousel │ ├── ButtonProps.cy.tsx │ ├── Pagination.cy.tsx │ └── Rendering.cy.tsx ├── core │ └── isCompletelyVisible.cy.tsx ├── tests-config │ └── cypress │ │ └── support │ │ ├── commands.ts │ │ ├── component-index.html │ │ └── component.ts ├── tests-helpers │ ├── CarouselMother.tsx │ ├── CarouselPageObject.ts │ ├── SlideMother.tsx │ ├── scroll.ts │ └── visibility.ts └── tsconfig.json ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | max_line_length = 100 7 | indent_style = tab 8 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["react-app", "eslint-config-codely/typescript", "plugin:storybook/recommended"], 3 | settings: { 4 | "import/resolver": { 5 | node: { 6 | extensions: [".js", ".jsx", ".ts", ".tsx"], 7 | }, 8 | }, 9 | }, 10 | overrides: [ 11 | { 12 | files: ["*.ts", "*.tsx"], 13 | parserOptions: { 14 | project: ["./tsconfig.json"], 15 | tsconfigRootDir: __dirname, 16 | }, 17 | }, 18 | { 19 | files: [ 20 | "tests/**/*.cy.tsx", 21 | "tests/tests-config/cypress/**/*.ts", 22 | "tests/tests-helpers/**/*.ts", 23 | "tests/tests-helpers/**/*.tsx", 24 | ], 25 | parserOptions: { 26 | project: ["./tests/tsconfig.json"], 27 | tsconfigRootDir: __dirname, 28 | }, 29 | rules: { 30 | "@typescript-eslint/no-unused-expressions": "off", 31 | }, 32 | }, 33 | ], 34 | }; 35 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | name: 🧪 Lint and test 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: 👍 Checkout 12 | uses: actions/checkout@v3 13 | 14 | - name: ❇️ Setup node.js 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 16 18 | cache: 'npm' 19 | 20 | - name: 📥 Install Dependencies 21 | run: npm install 22 | 23 | - name: 💅 Lint 24 | run: npm run lint 25 | 26 | - name: 🧑‍🔬 Component Tests 27 | run: npm run cy:run 28 | 29 | publish: 30 | name: 🚀 Publish 31 | runs-on: ubuntu-latest 32 | needs: test 33 | if: github.ref == 'refs/heads/main' 34 | 35 | steps: 36 | - name: 👍 Checkout 37 | uses: actions/checkout@v3 38 | 39 | - name: ❇️ Setup node.js 40 | uses: actions/setup-node@v3 41 | with: 42 | node-version: 16 43 | cache: 'npm' 44 | 45 | - name: 📥 Install Dependencies 46 | run: npm install 47 | 48 | - name: 🛠️ Build 49 | run: npm run build 50 | 51 | - name: 🚀 Publish to npm 52 | uses: JS-DevTools/npm-publish@v1 53 | with: 54 | token: ${{ secrets.NPM_TOKEN }} 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # Optional npm cache directory 5 | .npm 6 | 7 | # dotenv environment variables file 8 | .env 9 | .env.test 10 | 11 | # Build output 12 | dist 13 | -------------------------------------------------------------------------------- /.storybook/codelyTheme.ts: -------------------------------------------------------------------------------- 1 | import { create } from "@storybook/theming"; 2 | 3 | export default create({ 4 | base: "light", 5 | brandTitle: "Codely's React Carousel", 6 | brandUrl: "https://www.codely.com", 7 | brandImage: "/brand.svg", 8 | colorPrimary: "#3cff64", 9 | colorSecondary: "#7026f4", 10 | 11 | textColor: "#1a2233", 12 | textInverseColor: "rgba(255,255,255,0.9)", 13 | textMutedColor: "#787676", 14 | 15 | barTextColor: "#787676", 16 | barSelectedColor: "#1a2233", 17 | }); 18 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/react-vite"; 2 | const config: StorybookConfig = { 3 | stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], 4 | addons: [ 5 | "@storybook/addon-links", 6 | "@storybook/addon-essentials", 7 | "@storybook/addon-interactions", 8 | ], 9 | framework: { 10 | name: "@storybook/react-vite", 11 | options: {}, 12 | }, 13 | docs: { 14 | autodocs: "tag", 15 | }, 16 | staticDirs: ['../public'], 17 | }; 18 | export default config; 19 | -------------------------------------------------------------------------------- /.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/manager-api'; 2 | import codelyTheme from './codelyTheme'; 3 | 4 | addons.setConfig({ 5 | theme: codelyTheme, 6 | }); 7 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from "@storybook/react"; 2 | 3 | const preview: Preview = { 4 | parameters: { 5 | actions: { argTypesRegex: "^on[A-Z].*" }, 6 | controls: { 7 | matchers: { 8 | color: /(background|color)$/i, 9 | date: /Date$/, 10 | }, 11 | }, 12 | }, 13 | }; 14 | 15 | export default preview; 16 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": ["**/*.scss"], 5 | "customSyntax": "postcss-scss" 6 | } 7 | ], 8 | "extends": ["stylelint-config-standard-scss", "stylelint-config-rational-order"], 9 | "rules": { 10 | "scss/dollar-variable-pattern": null, 11 | "scss/dollar-variable-empty-line-before": null, 12 | "selector-id-pattern": null, 13 | "selector-class-pattern": null 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Codely Enseña y Entretiene SL 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. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: deps compile 3 | 4 | .PHONY: deps 5 | deps: 6 | npm install 7 | 8 | .PHONY: compile 9 | compile: 10 | npm run build 11 | 12 | .PHONY: test 13 | test: 14 | npm run cy:run 15 | 16 | .PHONY: lint 17 | lint: 18 | npm run lint 19 | 20 | .PHONY: lint-fix 21 | lint-fix: 22 | npm run lint:fix 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Codely logo 4 | 5 |

6 | 7 |

8 | 🎠 Codely Carousel 9 |

10 | 11 |

12 | Build status 13 | NPM version 14 | Codely Open Source 15 | CodelyTV Courses 16 |

17 | 18 |

19 | A React Carousel supporting different slides sizes, responsive, custom styling, accesible by default, SSR compatible, and tested. 20 |
21 |
22 | Stars are welcome 😊 23 |

24 | 25 | ## ✨ Features 26 | 27 | - Automagically responsive: 28 | - Any size: no need to set a specific size via props 29 | - Multiple items: no need to set the number of items per "page" 30 | - Supports images, videos, everything: each direct child is a slide 31 | - Scroll based: works on mobile or trackpad 32 | - Control buttons 33 | - Custom styling 34 | - Accessible by default 35 | - Show next/previous items partially 36 | - Works with server-side rendering 37 | 38 | ## 👀 Demos 39 | 40 | View the full [Storybook documentation](https://react-carousel.codely.com/). 41 | 42 | - [Standard Carousel](https://react-carousel.codely.com/?path=/story/carousel--default) 43 | - [Variable Slides](https://react-carousel.codely.com/?path=/story/carousel--variable-slides) 44 | - [With Gap](https://react-carousel.codely.com/?path=/story/carousel--with-gap) 45 | - [With Custom Button Content](https://react-carousel.codely.com/?path=/story/carousel--with-custom-button-content) 46 | - [With Custom Aria Labels](https://react-carousel.codely.com/?path=/story/carousel--with-custom-aria-labels) 47 | - [Button Styling](https://react-carousel.codely.com/?path=/story/carousel--button-styling) 48 | 49 | ## ⚙️ How to use 50 | 51 | 1. Install the dependency 52 | ```sh 53 | npm install @codelytv/react-carousel 54 | ``` 55 | or 56 | ```sh 57 | yarn add @codelytv/react-carousel 58 | ``` 59 | 2. Import and use: 60 | ```javascript 61 | import { Carousel } from "@codelytv/react-carousel" 62 | ``` 63 | ```jsx 64 | 65 |
A simple slide
66 |
a slide can contain anything
67 |
68 |

It can be any tag

69 |

and contain any number of items

70 |
71 |
72 | ``` 73 | The carousel automatically detects the size of each slide and when navigating via buttons, it will scroll smoothly until the first not visible slide is in view. 74 | 75 | ### 🎛️ Props 76 | 77 | | Name | Value | Default | Description | 78 | | ------------------- | ------------------- | --------------------------- | --------------------------- | 79 | | `prevButtonContent` | `React.ReactNode` | [``](https://github.com/CodelyTV/react-carousel/tree/main/src/components/ArrowLeft.tsx) | The HTML content of the previous navigation button | 80 | | `nextButtonContent` | `React.ReactNode` | [``](https://github.com/CodelyTV/react-carousel/tree/main/src/components/ArrowRight.tsx) | The HTML content of the next navigation button | 81 | | `prevAriaLabel` | `string` | "Previous" | Defines the previous navigation button `aria-label` attribute. Useful when the button content is an element without accessible text. | 82 | | `nextAriaLabel` | `string` | "Next" | Defines the previous navigation button `aria-label` attribute. Useful when the button content is an element without accessible text. | 83 | 84 | ### 🎨 Styling 85 | There are some CSS Variables that will help you style the carousel: 86 | 87 | | Name | Default | Description | 88 | | ------------------------- | ------------------- | --------------------------------------------- | 89 | | `--slider-gap` | `0` | Sets the gap between slides | 90 | | `--slider-nav-margin-top` | `0.5rem` | Sets the top margin of the navigation buttons | 91 | | `--slider-button-width` | `2.5rem` | Sets the navigation buttons width | 92 | | `--slider-button-height` | `2.5rem` | Sets the navigation buttons height | 93 | | `--slider-button-padding` | `0.2rem` | Sets the padding of the navigation buttons | 94 | 95 | If this is not enough, you can always style via CSS classes. They all have low specificity so they are easy to overwrite, but be careful, changing this elements could cause the carousel to break. Try to limit the changes to colors, background, etc. to prevent unexpected results. 96 | 97 | | Class | Description | 98 | | ------------------- | -------------------------------------- | 99 | | `.carousel` | The main carousel wrapper | 100 | | `.carousel__slider` | The carousel scroller | 101 | | `.carousel__slide` | The wrapper for each slide | 102 | | `.carousel__nav` | The wrapper for the navigation buttons | 103 | | `.carousel__button` | The navigation buttons | 104 | 105 | 106 | ## 🤝 Contributing 107 | 108 | ### 📚 Run 109 | 110 | - `npm run build`: Compiles the Carousel package 111 | - `npm run storybook`: Opens Storybook documentation with all of the Carousel demos 112 | 113 | ### ✅ Testing 114 | 115 | `npm run test`: Run unit tests with Jest and React Testing Library 116 | 117 | ### 🔦 Linting 118 | 119 | - `npm run lint`: Run linter 120 | - `npm run lint:fix`: Fix lint issues 121 | 122 | ## 👌 Codely Code Quality Standards 123 | 124 | Publishing this package we are committing ourselves to the following code quality standards: 125 | 126 | - 🤝 Respect **Semantic Versioning**: No breaking changes in patch or minor versions 127 | - 🤏 No surprises in transitive dependencies: Use the **bare minimum dependencies** needed to meet the purpose 128 | - 🎯 **One specific purpose** to meet without having to carry a bunch of unnecessary other utilities 129 | - ✅ **Tests** as documentation and usage examples 130 | - 📖 **Well documented ReadMe** showing how to install and use 131 | - ⚖️ **License favoring Open Source** and collaboration 132 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | video: false, 5 | component: { 6 | specPattern: "tests/**/*.cy.{js,jsx,ts,tsx}", 7 | supportFile: "tests/tests-config/cypress/support/component.ts", 8 | indexHtmlFile: "tests/tests-config/cypress/support/component-index.html", 9 | devServer: { 10 | framework: "react", 11 | bundler: "vite", 12 | }, 13 | viewportWidth: 1000, 14 | viewportHeight: 700, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <⚡⚛️> Vite React Best Practices Template (by Codely) 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "jsdom", 3 | setupFilesAfterEnv: ["/tests/tests-config/jest/setupTests.ts"], 4 | testMatch: ["/tests/**/*.(spec).(ts|tsx)"], 5 | transform: { 6 | "^.+\\.(js|jsx|ts|tsx)$": [ 7 | "@swc/jest", 8 | { 9 | sourceMaps: true, 10 | jsc: { 11 | parser: { 12 | syntax: "typescript", 13 | tsx: true, 14 | }, 15 | transform: { 16 | react: { 17 | runtime: "automatic", 18 | }, 19 | }, 20 | }, 21 | }, 22 | ], 23 | }, 24 | moduleNameMapper: { 25 | "\\.(css|less|scss|sass)$": "identity-obj-proxy", 26 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": 27 | "jest-transform-stub", 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codelytv/react-carousel", 3 | "author": "codelytv", 4 | "license": "MIT", 5 | "version": "1.0.3", 6 | "type": "module", 7 | "files": [ 8 | "dist" 9 | ], 10 | "main": "./dist/react-carousel.umd.cjs", 11 | "module": "./dist/react-carousel.js", 12 | "types": "./dist/index.d.ts", 13 | "peerDependencies": { 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0" 16 | }, 17 | "scripts": { 18 | "build": "vite build", 19 | "lint": "eslint --ignore-path .gitignore . && stylelint **/*.scss", 20 | "lint:fix": "eslint --fix --ignore-path .gitignore . && stylelint --fix **/*.scss", 21 | "docs": "storybook dev -p 6006", 22 | "build:docs": "storybook build", 23 | "cy:open": "cypress open", 24 | "cy:run": "cypress run --component" 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | }, 38 | "devDependencies": { 39 | "@storybook/addon-essentials": "^7.0.17", 40 | "@storybook/addon-interactions": "^7.0.17", 41 | "@storybook/addon-links": "^7.0.17", 42 | "@storybook/blocks": "^7.0.17", 43 | "@storybook/react": "^7.0.17", 44 | "@storybook/react-vite": "^7.0.17", 45 | "@storybook/testing-library": "^0.1.0", 46 | "@swc/core": "^1.3.42", 47 | "@testing-library/cypress": "^9.0.0", 48 | "@types/node": "^16.18.21", 49 | "@types/react": "^18.0.30", 50 | "@types/react-dom": "^18.0.11", 51 | "@vitejs/plugin-react": "^3.1.0", 52 | "cypress": "^12.9.0", 53 | "eslint": "^8.36.0", 54 | "eslint-config-codely": "^2.1.3", 55 | "eslint-config-react-app": "^7.0.1", 56 | "eslint-plugin-storybook": "^0.6.12", 57 | "identity-obj-proxy": "^3.0.0", 58 | "prop-types": "^15.8.1", 59 | "react": "^18.2.0", 60 | "react-dom": "^18.2.0", 61 | "sass": "^1.60.0", 62 | "storybook": "^7.0.17", 63 | "stylelint": "^14.16.1", 64 | "stylelint-config-rational-order": "^0.0.4", 65 | "stylelint-config-standard-scss": "^3.0.0", 66 | "stylelint-order": "^5.0.0", 67 | "typescript": "^4.9.5", 68 | "vite": "^4.3.8", 69 | "vite-plugin-css-injected-by-js": "^3.1.1", 70 | "vite-plugin-dts": "^2.3.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /public/brand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /public/img/georgi-kalaydzhiev-neF7gKk9708-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/react-carousel/3c659bca2732f38fe45d11eaa789ed3b9617c40d/public/img/georgi-kalaydzhiev-neF7gKk9708-unsplash.jpg -------------------------------------------------------------------------------- /public/img/josh-hild-zWCKJjPCl0s-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/react-carousel/3c659bca2732f38fe45d11eaa789ed3b9617c40d/public/img/josh-hild-zWCKJjPCl0s-unsplash.jpg -------------------------------------------------------------------------------- /public/img/lena-polishko-EM_thLGU0yw-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/react-carousel/3c659bca2732f38fe45d11eaa789ed3b9617c40d/public/img/lena-polishko-EM_thLGU0yw-unsplash.jpg -------------------------------------------------------------------------------- /public/img/lena-polishko-EYrLkNNOyhM-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/react-carousel/3c659bca2732f38fe45d11eaa789ed3b9617c40d/public/img/lena-polishko-EYrLkNNOyhM-unsplash.jpg -------------------------------------------------------------------------------- /public/img/luka-verc-2iBUuz4z3B8-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/react-carousel/3c659bca2732f38fe45d11eaa789ed3b9617c40d/public/img/luka-verc-2iBUuz4z3B8-unsplash.jpg -------------------------------------------------------------------------------- /public/img/nika-tchokhonelidze-Ojsyw6_5NDw-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/react-carousel/3c659bca2732f38fe45d11eaa789ed3b9617c40d/public/img/nika-tchokhonelidze-Ojsyw6_5NDw-unsplash.jpg -------------------------------------------------------------------------------- /public/img/yuliya-matuzava-28WVjNXuFV8-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/react-carousel/3c659bca2732f38fe45d11eaa789ed3b9617c40d/public/img/yuliya-matuzava-28WVjNXuFV8-unsplash.jpg -------------------------------------------------------------------------------- /src/ArrowLeft.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function ArrowLeft() { 4 | return ( 5 | 6 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/ArrowRight.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function ArrowRight() { 4 | return ( 5 | 6 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/Carousel.scss: -------------------------------------------------------------------------------- 1 | .carousel { 2 | width: 100%; 3 | overflow: hidden; 4 | 5 | &__slider { 6 | position: relative; 7 | display: flex; 8 | gap: var(--slider-gap, 0); 9 | overflow-x: scroll; 10 | overflow-y: hidden; 11 | scrollbar-width: none; 12 | scroll-behavior: smooth; 13 | 14 | &::-webkit-scrollbar { 15 | display: none; 16 | } 17 | } 18 | 19 | &__nav { 20 | margin-top: var(--slider-nav-margin-top, 0.5rem); 21 | text-align: left; 22 | 23 | @media (hover: none) { 24 | display: none; 25 | } 26 | } 27 | 28 | &__button { 29 | display: inline-flex; 30 | justify-content: center; 31 | align-items: center; 32 | width: var(--slider-button-width, 2.5rem); 33 | height: var(--slider-button-height, 2.5rem); 34 | padding: var(--slider-button-padding, 0.2rem); 35 | color: inherit; 36 | background: transparent; 37 | border: none; 38 | transition: all 0.2s; 39 | 40 | &:hover { 41 | opacity: 0.7; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Carousel.stories.tsx: -------------------------------------------------------------------------------- 1 | import "./assets/docs.css"; 2 | 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | import { Carousel } from "./Carousel"; 6 | 7 | const meta = { 8 | title: "Carousel", 9 | component: Carousel, 10 | tags: ["autodocs"], 11 | } satisfies Meta; 12 | 13 | export default meta; 14 | type Story = StoryObj; 15 | 16 | export const Default: Story = { 17 | parameters: { 18 | docs: { 19 | description: { 20 | story: 21 | "The carousel will automatically fit the width of its container, it's responsive by default and paginates according to the slides size, so there is no need to specify a number of slides per page or different breakpoints to make the carousel responsive. You can also scroll horizontally on mobile or trackpad (or pressing `shift` while scrolling with a mouse). You only need to import the `Carousel` component and each child will be considered a slide. Click the 'show code' below for a usage example.", 22 | }, 23 | }, 24 | }, 25 | args: { 26 | children: [ 27 |
28 | a black building with a grass roof and a white steeple 32 |
33 | Photo by{" "} 34 | 35 | Georgi Kalaydzhiev 36 | 37 |
38 |
, 39 |
40 | a path in the middle of a forest surrounded by tall trees 44 |
45 | Photo by{" "} 46 | 47 | Josh Hild 48 | 49 |
50 |
, 51 |
52 | a pineapple, an egg and an orange on a table 56 |
57 | Photo by{" "} 58 | 59 | Lena Polishko 60 | 61 |
62 |
, 63 |
64 | a bike is parked in front of a building 68 |
69 | Photo by{" "} 70 | 71 | Lena Polishko 72 | 73 |
74 |
, 75 |
76 | a person standing in front of a huge storm 80 |
81 | Photo by{" "} 82 | 83 | Luka Verč 84 | 85 |
86 |
, 87 |
88 | a man and a woman walking down a street 92 |
93 | Photo by{" "} 94 | 95 | nika tchokhonelidze 96 | 97 |
98 |
, 99 |
100 | a towel, sunglasses, and flowers on the beach 104 |
105 | Photo by{" "} 106 | 107 | Yuliya Matuzava 108 | 109 |
110 |
, 111 | ], 112 | }, 113 | render: (args) => , 114 | }; 115 | 116 | export const VariableSlides: Story = { 117 | parameters: { 118 | docs: { 119 | description: { 120 | story: 121 | "The carousel supports any kind of content, as well as variable width and height for the slides.", 122 | }, 123 | }, 124 | }, 125 | args: { 126 | children: [ 127 |
A simple slide
, 128 |
129 | a slide can contain anything 130 |
, 131 |
134 |

It can be any tag

135 |

and contain any number of items

136 |
, 137 |
138 | 145 |
, 146 | ], 147 | }, 148 | }; 149 | 150 | export const WithGap: Story = { 151 | parameters: { 152 | docs: { 153 | description: { 154 | story: 155 | "To add gap between slides, you must specify a `--slider-gap` custom property via CSS. You can specify it in a wrapping div (see 'show code' below for an example), or in any other parent element in your CSS. [Read more about CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties).", 156 | }, 157 | }, 158 | }, 159 | args: Default.args, 160 | render: (args) => ( 161 |
162 | 163 |
164 | ), 165 | }; 166 | 167 | export const WithCustomButtonContent: Story = { 168 | parameters: { 169 | docs: { 170 | description: { 171 | story: 172 | "By default the buttons display an arrow SVG. With the `nextButtonContent` and `prevButtonContent` props you can pass any string, SVG or HTML to be placed inside the previous and next button. Keep in mind that this will be inside the `button` tag, so you should not pass another button. Passing `` would be invalid.", 173 | }, 174 | }, 175 | }, 176 | args: { 177 | prevButtonContent: 👈, 178 | nextButtonContent: 👉, 179 | ...Default.args, 180 | }, 181 | }; 182 | 183 | export const WithCustomAriaLabels: Story = { 184 | parameters: { 185 | docs: { 186 | description: { 187 | story: 188 | "The buttons by default have a 'Previous' and 'Next' aria-label for accessibility. With the `nextAriaLabel` and `prevAriaLabel` props you can pass a custom one if for example your website is in another language. You can also pass an empty string if you already passed a buttonContent with accessible text.", 189 | }, 190 | }, 191 | }, 192 | args: { 193 | prevAriaLabel: "Anterior", 194 | nextAriaLabel: "Siguiente", 195 | ...Default.args, 196 | }, 197 | }; 198 | 199 | export const ButtonStyling: Story = { 200 | parameters: { 201 | docs: { 202 | description: { 203 | story: 204 | "If passing a custom content is not enough, you can adjust the button style with CSS custom properties. You can adjust `--slider-button-width`, `--slider-button-height` and `--slider-button-padding`, as well as the buttons top margin with `--slider-nav-margin-top`. You can specify this variables in a wrapping div (see 'show code' below for an example), or in any other parent element in your CSS. If the variables are not enough, you can also style the carousel's classes. [Check the Readme for the full documentation](https://github.com/CodelyTV/react-carousel#readme).", 205 | }, 206 | }, 207 | }, 208 | args: Default.args, 209 | render: (args) => ( 210 |
220 | 221 |
222 | ), 223 | }; 224 | -------------------------------------------------------------------------------- /src/Carousel.tsx: -------------------------------------------------------------------------------- 1 | import "./Carousel.scss"; 2 | 3 | import { useRef } from "react"; 4 | 5 | import { ArrowLeft } from "./ArrowLeft"; 6 | import { ArrowRight } from "./ArrowRight"; 7 | import { scrollSliderNext, scrollSliderPrevious } from "./core/scroll"; 8 | 9 | export interface CarouselProps { 10 | children: JSX.Element[]; 11 | prevAriaLabel?: string; 12 | nextAriaLabel?: string; 13 | prevButtonContent?: React.ReactNode; 14 | nextButtonContent?: React.ReactNode; 15 | } 16 | 17 | export function Carousel({ 18 | children, 19 | prevAriaLabel = "Previous", 20 | nextAriaLabel = "Next", 21 | prevButtonContent = , 22 | nextButtonContent = , 23 | }: CarouselProps) { 24 | const slider = useRef(null); 25 | 26 | function getSliderOrThrow() { 27 | if (!slider.current) { 28 | throw new Error("Slider not found"); 29 | } 30 | 31 | return slider.current; 32 | } 33 | 34 | function scrollNext() { 35 | const slider = getSliderOrThrow(); 36 | 37 | scrollSliderNext(slider); 38 | } 39 | 40 | function scrollPrevious() { 41 | const slider = getSliderOrThrow(); 42 | 43 | scrollSliderPrevious(slider); 44 | } 45 | 46 | return ( 47 |
48 |
49 | {children.map((child, index) => ( 50 |
51 | {child} 52 |
53 | ))} 54 |
55 |
56 | 59 | 62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/assets/docs.css: -------------------------------------------------------------------------------- 1 | .defaultSlide { 2 | width: 400px; 3 | margin: 0; 4 | color: #999; 5 | font-family: sans-serif; 6 | } 7 | 8 | .defaultSlide a { 9 | color: #333; 10 | text-decoration: none; 11 | } 12 | 13 | .defaultSlide a:hover { 14 | text-decoration: underline; 15 | } 16 | -------------------------------------------------------------------------------- /src/core/isCompletelyVisible.ts: -------------------------------------------------------------------------------- 1 | export function isCompletelyVisible(element: HTMLElement): boolean { 2 | if (!element.parentElement) { 3 | throw new Error("not a carousel slide"); 4 | } 5 | 6 | const elementBoundingRect = element.getBoundingClientRect(); 7 | const sliderBoundingRect = element.parentElement.getBoundingClientRect(); 8 | const sliderWidth = sliderBoundingRect.width; 9 | 10 | const positionLeft = elementBoundingRect.left - sliderBoundingRect.left; 11 | const positionRight = positionLeft + elementBoundingRect.width; 12 | 13 | return positionLeft >= 0 && positionRight <= sliderWidth; 14 | } 15 | -------------------------------------------------------------------------------- /src/core/scroll.ts: -------------------------------------------------------------------------------- 1 | import { isCompletelyVisible } from "./isCompletelyVisible"; 2 | 3 | function scrollSliderTo(slider: HTMLElement, horizontalPosition: number): void { 4 | const verticalPosition = 0; 5 | 6 | slider.scrollTo(horizontalPosition, verticalPosition); 7 | } 8 | 9 | export function scrollSliderNext(slider: HTMLElement): void { 10 | const slides = slider.querySelectorAll(`.carousel__slide`); 11 | 12 | let firstNotVisibleSlideAfterVisibleSlide, firstVisibleSlide; 13 | 14 | for (const slide of Array.from(slides)) { 15 | if (!firstVisibleSlide && isCompletelyVisible(slide)) { 16 | firstVisibleSlide = slide; 17 | } 18 | if (firstVisibleSlide && !isCompletelyVisible(slide)) { 19 | firstNotVisibleSlideAfterVisibleSlide = slide; 20 | break; 21 | } 22 | } 23 | 24 | const initialScrollPosition = 0; 25 | const position = firstNotVisibleSlideAfterVisibleSlide?.offsetLeft ?? initialScrollPosition; 26 | 27 | scrollSliderTo(slider, position); 28 | } 29 | 30 | export function scrollSliderPrevious(slider: HTMLElement): void { 31 | if (slider.scrollLeft === 0) { 32 | scrollSliderTo(slider, slider.scrollWidth); 33 | 34 | return; 35 | } 36 | 37 | const slides = slider.querySelectorAll(`.carousel__slide`); 38 | 39 | let firstVisibleSlideIndex = null; 40 | 41 | for (const [index, slide] of Array.from(slides).entries()) { 42 | if (isCompletelyVisible(slide)) { 43 | firstVisibleSlideIndex = index; 44 | break; 45 | } 46 | } 47 | 48 | if (firstVisibleSlideIndex === null) { 49 | return; 50 | } 51 | 52 | if (!slider.parentElement) { 53 | throw new Error("Could not find carousel div"); 54 | } 55 | 56 | const carouselWidth = slider.parentElement.clientWidth; 57 | let accumulatedWidth = 0; 58 | let slideToScrollTo = slides[firstVisibleSlideIndex]; 59 | 60 | for (let i = firstVisibleSlideIndex; i >= 0; i--) { 61 | accumulatedWidth += slides[i].clientWidth; 62 | slideToScrollTo = slides[i]; 63 | 64 | if (accumulatedWidth > carouselWidth) { 65 | break; 66 | } 67 | } 68 | 69 | const position = slideToScrollTo.offsetLeft; 70 | scrollSliderTo(slider, position); 71 | } 72 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Carousel } from "./Carousel"; 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tests/Carousel/ButtonProps.cy.tsx: -------------------------------------------------------------------------------- 1 | import { CarouselMother } from "../tests-helpers/CarouselMother"; 2 | 3 | describe("Carousel button props", () => { 4 | it("sets next and previous button aria labels correctly", () => { 5 | const carousel = CarouselMother.random({ 6 | props: { 7 | prevAriaLabel: "Anterior", 8 | nextAriaLabel: "Siguiente", 9 | }, 10 | }); 11 | cy.mount(carousel); 12 | 13 | cy.findByLabelText("Anterior").should("exist"); 14 | cy.findByLabelText("Siguiente").should("exist"); 15 | }); 16 | 17 | it("sets next and previous button contents passed by props correctly", () => { 18 | const carousel = CarouselMother.random({ 19 | props: { 20 | prevButtonContent: "👈", 21 | nextButtonContent: "👉", 22 | }, 23 | }); 24 | cy.mount(carousel); 25 | 26 | cy.findByText("👈").should("exist"); 27 | cy.findByText("👉").should("exist"); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/Carousel/Pagination.cy.tsx: -------------------------------------------------------------------------------- 1 | import { CarouselMother } from "../tests-helpers/CarouselMother"; 2 | import { CarouselPageObject } from "../tests-helpers/CarouselPageObject"; 3 | 4 | describe("Carousel pagination", () => { 5 | it("next button should scroll until the first not visible slide is visible", () => { 6 | const slideWidth = 350; 7 | const visibleSlides = 2; 8 | const carouselWithThirdSlidePartiallyVisible = CarouselMother.random({ 9 | carouselWidth: slideWidth * visibleSlides + slideWidth / 2, 10 | minSlideWidth: slideWidth, 11 | maxSlideWidth: slideWidth, 12 | slidesCount: visibleSlides + 1, 13 | }); 14 | const partiallyVisibleSlide = 3; 15 | cy.mount(carouselWithThirdSlidePartiallyVisible); 16 | 17 | const carousel = new CarouselPageObject(); 18 | 19 | carousel.verifySlideIsNotCompletelyVisible(partiallyVisibleSlide); 20 | 21 | carousel.clickNext(); 22 | 23 | carousel.verifySlideIsCompletelyVisible(partiallyVisibleSlide); 24 | }); 25 | 26 | it("next button should scroll until the first not visible slide after a visible slide becomes visible", () => { 27 | const slideWidth = 300; 28 | const randomCarousel = CarouselMother.random({ 29 | carouselWidth: slideWidth * 3, 30 | minSlideWidth: slideWidth, 31 | maxSlideWidth: slideWidth, 32 | }); 33 | const firstSlideNotVisibleIndex = 5; 34 | cy.mount(randomCarousel); 35 | 36 | const carousel = new CarouselPageObject(); 37 | 38 | carousel.scrollPast(slideWidth); 39 | 40 | carousel.clickNext(); 41 | 42 | carousel.verifySlideIsCompletelyVisible(firstSlideNotVisibleIndex); 43 | }); 44 | 45 | it("next button should scroll back to initial position if there are no not visible slides after first visible slide", () => { 46 | const slideWidth = 300; 47 | const randomCarousel = CarouselMother.random({ 48 | carouselWidth: slideWidth * 3, 49 | minSlideWidth: slideWidth, 50 | maxSlideWidth: slideWidth, 51 | slidesCount: 5, 52 | }); 53 | cy.mount(randomCarousel); 54 | 55 | const carousel = new CarouselPageObject(); 56 | 57 | carousel.scrollPast(slideWidth * 3); 58 | 59 | carousel.clickNext(); 60 | 61 | carousel.verifyFirstSlideIsCompletelyVisible(); 62 | }); 63 | 64 | it("previous button should scroll until the first not visible slide is visible", () => { 65 | const slideWidth = 400; 66 | const randomCarousel = CarouselMother.random({ 67 | carouselWidth: slideWidth * 2, 68 | minSlideWidth: slideWidth, 69 | maxSlideWidth: slideWidth, 70 | }); 71 | 72 | cy.mount(randomCarousel); 73 | 74 | const carousel = new CarouselPageObject(); 75 | 76 | carousel.scrollPast(slideWidth * 2).verifyFirstSlideIsNotCompletelyVisible(); 77 | 78 | carousel.clickPrevious(); 79 | 80 | carousel.verifyFirstSlideIsCompletelyVisible(); 81 | }); 82 | 83 | it("previous button should scroll to the end of the carousel when there are no slides before the first visible slide", () => { 84 | const randomCarousel = CarouselMother.random(); 85 | cy.mount(randomCarousel); 86 | 87 | const carousel = new CarouselPageObject(); 88 | 89 | carousel.clickPrevious(); 90 | 91 | carousel.verifyLastSlideIsCompletelyVisible(); 92 | }); 93 | 94 | it("next button should scroll correctly with random slide widths", () => { 95 | const randomCarousel = CarouselMother.random(); 96 | cy.mount(randomCarousel); 97 | 98 | const carousel = new CarouselPageObject(); 99 | 100 | carousel.disableScrollTransition(); 101 | 102 | const maxAttempts = 20; 103 | 104 | for (let i = 0; i < maxAttempts; i++) { 105 | carousel.clickNextIfLastSlideIsNotVisible(); 106 | } 107 | 108 | carousel.verifyLastSlideIsCompletelyVisible(); 109 | }); 110 | 111 | it("previous button should scroll correctly with random slide widths", () => { 112 | const randomCarousel = CarouselMother.random(); 113 | cy.mount(randomCarousel); 114 | 115 | const carousel = new CarouselPageObject(); 116 | 117 | carousel.disableScrollTransition().scrollUntilTheEnd(); 118 | 119 | const maxAttempts = 20; 120 | 121 | for (let i = 0; i < maxAttempts; i++) { 122 | carousel.clickPreviousIfFirstSlideIsNotVisible(); 123 | } 124 | 125 | carousel.verifyFirstSlideIsCompletelyVisible(); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /tests/Carousel/Rendering.cy.tsx: -------------------------------------------------------------------------------- 1 | import { Carousel } from "../../src/Carousel"; 2 | import { CarouselMother } from "../tests-helpers/CarouselMother"; 3 | import { CarouselPageObject } from "../tests-helpers/CarouselPageObject"; 4 | 5 | describe("", () => { 6 | it("renders children as slides", () => { 7 | cy.mount( 8 | 9 |
A simple slide
10 |
11 | a slide can contain anything 12 |
13 |
16 |

It can be any tag

17 |

and contain any number of items

18 |
19 |
20 | 27 |
28 |
29 | ); 30 | 31 | cy.findByText(/A simple slide/i).should("exist"); 32 | cy.findByAltText(/a slide can contain anything/i).should("exist"); 33 | cy.findByRole("article").should("exist"); 34 | cy.findByTitle("YouTube video player").should("exist"); 35 | }); 36 | 37 | it("scrolls the slides correctly", () => { 38 | const minSlideWidth = 300; 39 | const slidesCount = 4; 40 | const carouselWithLastSlideNotVisible = CarouselMother.random({ 41 | carouselWidth: minSlideWidth * (slidesCount - 1), 42 | minSlideWidth, 43 | slidesCount, 44 | }); 45 | 46 | cy.mount(carouselWithLastSlideNotVisible); 47 | 48 | const carousel = new CarouselPageObject(); 49 | 50 | carousel.getLastSlide().should("not.be.visible"); 51 | 52 | carousel.getLastSlide().scrollIntoView(); 53 | 54 | carousel.getLastSlide().should("be.visible"); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/core/isCompletelyVisible.cy.tsx: -------------------------------------------------------------------------------- 1 | import { Carousel } from "../../src/Carousel"; 2 | import { isCompletelyVisible } from "../../src/core/isCompletelyVisible"; 3 | 4 | describe("isCompletelyVisible", () => { 5 | it("returns true if a slide is completely visible", () => { 6 | const carouselWithFirstSlideCompletelyVisible = ( 7 |
8 | 9 |
slide 1
10 |
slide 2
11 |
slide 3
12 |
13 |
14 | ); 15 | 16 | cy.mount(carouselWithFirstSlideCompletelyVisible); 17 | 18 | const completelyVisibleSlide = ".carousel__slide:first-child"; 19 | 20 | cy.get(completelyVisibleSlide).should(($el) => { 21 | const htmlElement = $el.get(0); 22 | 23 | expect(isCompletelyVisible(htmlElement)).to.be.true; 24 | }); 25 | }); 26 | 27 | it("returns false if a slide is completely not visible", () => { 28 | const carouselWithLastSlideCompletelyNotVisible = ( 29 |
30 | 31 |
slide 1
32 |
slide 2
33 |
slide 3
34 |
slide 4
35 |
36 |
37 | ); 38 | 39 | cy.mount(carouselWithLastSlideCompletelyNotVisible); 40 | 41 | const completelyNotVisibleSlide = ".carousel__slide:last-child"; 42 | 43 | cy.get(completelyNotVisibleSlide).should(($el) => { 44 | const htmlElement = $el.get(0); 45 | 46 | expect(isCompletelyVisible(htmlElement)).to.be.false; 47 | }); 48 | }); 49 | 50 | it("returns false if a slide is partially visible", () => { 51 | const carouselWithLastSlidePartiallyVisible = ( 52 |
53 | 54 |
slide 1
55 |
slide 2
56 |
slide 3
57 |
slide 4
58 |
59 |
60 | ); 61 | 62 | cy.mount(carouselWithLastSlidePartiallyVisible); 63 | 64 | const partiallyVisibleSlide = ".carousel__slide:last-child"; 65 | 66 | cy.get(partiallyVisibleSlide).should(($el) => { 67 | const htmlElement = $el.get(0); 68 | 69 | expect(isCompletelyVisible(htmlElement)).to.be.false; 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /tests/tests-config/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import "@testing-library/cypress/add-commands"; 4 | -------------------------------------------------------------------------------- /tests/tests-config/cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /tests/tests-config/cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | import "./commands"; 3 | 4 | import { mount } from "cypress/react18"; 5 | 6 | declare global { 7 | namespace Cypress { 8 | interface Chainable { 9 | mount: typeof mount; 10 | } 11 | } 12 | } 13 | 14 | Cypress.Commands.add("mount", mount); 15 | -------------------------------------------------------------------------------- /tests/tests-helpers/CarouselMother.tsx: -------------------------------------------------------------------------------- 1 | import { Carousel, CarouselProps } from "../../src/Carousel"; 2 | import { SlideMother } from "./SlideMother"; 3 | 4 | type CarouselPropsWithoutChildren = Omit; 5 | 6 | export const CarouselMother = { 7 | random({ 8 | carouselWidth = 900, 9 | slidesCount = 5, 10 | minSlideWidth = 300, 11 | maxSlideWidth = 500, 12 | props, 13 | }: { 14 | carouselWidth?: number; 15 | slidesCount?: number; 16 | minSlideWidth?: number; 17 | maxSlideWidth?: number; 18 | props?: CarouselPropsWithoutChildren; 19 | } = {}) { 20 | const slides = SlideMother.list(slidesCount, minSlideWidth, maxSlideWidth); 21 | 22 | return ( 23 |
24 | {slides} 25 |
26 | ); 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /tests/tests-helpers/CarouselPageObject.ts: -------------------------------------------------------------------------------- 1 | import { isCompletelyVisible } from "./visibility"; 2 | 3 | export class CarouselPageObject { 4 | nextButton: Cypress.Chainable>; 5 | previousButton: Cypress.Chainable>; 6 | 7 | constructor() { 8 | this.nextButton = cy.findByLabelText(/Next/i); 9 | this.previousButton = cy.findByLabelText(/Previous/i); 10 | } 11 | 12 | getSlide(index: number): Cypress.Chainable> { 13 | return cy.get(`.carousel__slide:nth-child(${index})`); 14 | } 15 | 16 | getFirstSlide(): Cypress.Chainable> { 17 | return cy.get(".carousel__slide:first-child"); 18 | } 19 | 20 | getLastSlide(): Cypress.Chainable> { 21 | return cy.get(".carousel__slide:last-child"); 22 | } 23 | 24 | clickNext(): CarouselPageObject { 25 | this.nextButton.click(); 26 | 27 | return this; 28 | } 29 | 30 | clickPrevious(): CarouselPageObject { 31 | this.previousButton.click(); 32 | 33 | return this; 34 | } 35 | 36 | clickNextIfLastSlideIsNotVisible(): CarouselPageObject { 37 | this.getLastSlide().then(($el: JQuery) => { 38 | const htmlElement = $el[0]; 39 | 40 | if (!isCompletelyVisible(htmlElement)) { 41 | this.clickNext(); 42 | } 43 | }); 44 | 45 | return this; 46 | } 47 | 48 | clickPreviousIfFirstSlideIsNotVisible(): CarouselPageObject { 49 | this.getFirstSlide().then(($el: JQuery) => { 50 | const htmlElement = $el[0]; 51 | 52 | if (!isCompletelyVisible(htmlElement)) { 53 | this.clickPrevious(); 54 | } 55 | }); 56 | 57 | return this; 58 | } 59 | 60 | verifyFirstSlideIsCompletelyVisible(): CarouselPageObject { 61 | this.verifyIsCompletelyVisible(this.getFirstSlide()); 62 | 63 | return this; 64 | } 65 | 66 | verifyLastSlideIsCompletelyVisible(): CarouselPageObject { 67 | this.verifyIsCompletelyVisible(this.getLastSlide()); 68 | 69 | return this; 70 | } 71 | 72 | verifyFirstSlideIsNotCompletelyVisible(): CarouselPageObject { 73 | this.verifyIsNotCompletelyVisible(this.getFirstSlide()); 74 | 75 | return this; 76 | } 77 | 78 | verifyLastSlideIsNotCompletelyVisible(): CarouselPageObject { 79 | this.verifyIsNotCompletelyVisible(this.getLastSlide()); 80 | 81 | return this; 82 | } 83 | 84 | verifySlideIsCompletelyVisible(index: number): CarouselPageObject { 85 | this.verifyIsCompletelyVisible(this.getSlide(index)); 86 | 87 | return this; 88 | } 89 | 90 | verifySlideIsNotCompletelyVisible(index: number): CarouselPageObject { 91 | this.verifyIsNotCompletelyVisible(this.getSlide(index)); 92 | 93 | return this; 94 | } 95 | 96 | scrollPast(position: number): CarouselPageObject { 97 | cy.document().then((document) => { 98 | const slider = document.querySelector(".carousel__slider") as HTMLElement; 99 | slider.style.setProperty("scroll-behavior", "auto"); 100 | slider.scrollLeft = position; 101 | slider.style.setProperty("scroll-behavior", "smooth"); 102 | }); 103 | 104 | return this; 105 | } 106 | 107 | scrollUntilTheEnd(): CarouselPageObject { 108 | cy.document().then((document) => { 109 | const slider = document.querySelector(".carousel__slider") as HTMLElement; 110 | slider.scrollLeft = slider.scrollWidth; 111 | }); 112 | 113 | return this; 114 | } 115 | 116 | disableScrollTransition(): CarouselPageObject { 117 | cy.document().then((document) => { 118 | const slider = document.querySelector(".carousel__slider") as HTMLElement; 119 | slider.style.setProperty("scroll-behavior", "auto"); 120 | }); 121 | 122 | return this; 123 | } 124 | 125 | enableScrollTransition(): CarouselPageObject { 126 | cy.document().then((document) => { 127 | const slider = document.querySelector(".carousel__slider") as HTMLElement; 128 | slider.style.setProperty("scroll-behavior", "smooth"); 129 | }); 130 | 131 | return this; 132 | } 133 | 134 | private verifyIsCompletelyVisible($el: Cypress.Chainable>): void { 135 | $el.should(($element: JQuery) => { 136 | const htmlElement = $element.get(0); 137 | expect(isCompletelyVisible(htmlElement)).to.be.true; 138 | }); 139 | } 140 | 141 | private verifyIsNotCompletelyVisible($el: Cypress.Chainable>): void { 142 | $el.should(($element: JQuery) => { 143 | const htmlElement = $element.get(0); 144 | expect(isCompletelyVisible(htmlElement)).to.be.false; 145 | }); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /tests/tests-helpers/SlideMother.tsx: -------------------------------------------------------------------------------- 1 | export const SlideMother = { 2 | random(minWidth = 300, maxWidth = 500) { 3 | const width = Math.floor(Math.random() * (maxWidth - minWidth)) + minWidth; 4 | 5 | return
slide
; 6 | }, 7 | list(count = 5, minWidth = 300, maxWidth = 500) { 8 | return Array.from({ length: count }, () => this.random(minWidth, maxWidth)); 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /tests/tests-helpers/scroll.ts: -------------------------------------------------------------------------------- 1 | export function scrollPast(position: number): Cypress.Chainable { 2 | return cy.document().then((document) => { 3 | const slider = document.querySelector(".carousel__slider") as HTMLElement; 4 | slider.style.setProperty("scroll-behavior", "auto"); 5 | slider.scrollLeft = position; 6 | slider.style.setProperty("scroll-behavior", "smooth"); 7 | }); 8 | } 9 | 10 | export function scrollUntilTheEnd(): Cypress.Chainable { 11 | return cy.document().then((document) => { 12 | const slider = document.querySelector(".carousel__slider") as HTMLElement; 13 | slider.scrollLeft = slider.scrollWidth; 14 | }); 15 | } 16 | 17 | export function disableScrollTransition(): Cypress.Chainable { 18 | return cy.document().then((document) => { 19 | const slider = document.querySelector(".carousel__slider") as HTMLElement; 20 | slider.style.setProperty("scroll-behavior", "auto"); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /tests/tests-helpers/visibility.ts: -------------------------------------------------------------------------------- 1 | export function isCompletelyVisible(element: HTMLElement): boolean { 2 | if (!element.parentElement) { 3 | throw new Error("not a carousel slide"); 4 | } 5 | 6 | const elementBoundingRect = element.getBoundingClientRect(); 7 | const sliderBoundingRect = element.parentElement.getBoundingClientRect(); 8 | const sliderWidth = sliderBoundingRect.width; 9 | 10 | const positionLeft = elementBoundingRect.left - sliderBoundingRect.left; 11 | const positionRight = positionLeft + elementBoundingRect.width; 12 | 13 | return positionLeft >= 0 && positionRight <= sliderWidth; 14 | } 15 | 16 | export const beNotVisible = ($el: JQuery): void => { 17 | const htmlElement = $el.get(0); 18 | expect(isCompletelyVisible(htmlElement)).to.be.false; 19 | }; 20 | 21 | export const beVisible = ($el: JQuery): void => { 22 | const htmlElement = $el.get(0); 23 | expect(isCompletelyVisible(htmlElement)).to.be.true; 24 | }; 25 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "types": ["cypress", "@testing-library/cypress"], 6 | "isolatedModules": false 7 | }, 8 | "include": ["../node_modules/cypress", "./tests-config/cypress/**/*.ts", "./**/*.cy.tsx", "./tests-helpers/**/*.ts", "./tests-helpers/**/*.tsx"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "downlevelIteration": true, 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "jsx": "react-jsx" 23 | }, 24 | "include": [ 25 | "src", 26 | "tests/**/*.ts", 27 | "cypress.config.ts", 28 | "vite.config.ts" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { resolve } from "path"; 3 | import { defineConfig } from "vite"; 4 | import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; 5 | import dts from "vite-plugin-dts"; 6 | 7 | export default defineConfig({ 8 | publicDir: false, 9 | plugins: [react(), dts(), cssInjectedByJsPlugin()], 10 | build: { 11 | lib: { 12 | entry: resolve(__dirname, "src/index.ts"), 13 | name: "Carousel", 14 | fileName: "react-carousel", 15 | }, 16 | rollupOptions: { 17 | external: ["react"], 18 | output: { 19 | globals: { 20 | react: "React", 21 | }, 22 | }, 23 | }, 24 | }, 25 | }); 26 | --------------------------------------------------------------------------------