├── .editorconfig
├── .eslintrc
├── .github
├── FUNDING.yml
└── workflows
│ ├── ci.yml
│ ├── publish-preview.yml
│ └── ssr.yml
├── .gitignore
├── .husky
├── post-merge
└── pre-commit
├── .npmrc
├── .nvmrc
├── .prettierignore
├── .prettierrc.js
├── .vscode
├── extensions.json
└── settings.json
├── LICENSE
├── README.md
├── docs
├── CONTRIBUTING.md
├── example
│ ├── .eslintrc
│ ├── .gitignore
│ ├── README.md
│ ├── index.html
│ ├── package.json
│ ├── public
│ │ └── vite.svg
│ ├── src
│ │ ├── App.tsx
│ │ ├── index.css
│ │ ├── main.tsx
│ │ └── vite-env.d.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── ssr-tests
│ ├── README.md
│ ├── package.json
│ └── ssr.test.mjs
└── storybook
│ ├── .eslintrc
│ ├── .storybook
│ ├── hack-coverage.js
│ ├── main.ts
│ ├── manager-head.html
│ ├── manager.ts
│ ├── preview-head.html
│ ├── preview.ts
│ └── theme.ts
│ ├── content
│ ├── 00-introduction.stories.mdx
│ ├── 01-api.stories.mdx
│ ├── 01-images.stories.mdx
│ ├── 02-bounds-padding.stories.mdx
│ ├── 03-browsing-context.stories.mdx
│ ├── 03-change-position-on-hover.stories.mdx
│ ├── 03-clip.stories.mdx
│ ├── 04-disabled.stories.mdx
│ ├── 05-handles.stories.mdx
│ ├── 06-keyboard-increment.stories.mdx
│ ├── 07-only-handle-draggable.stories.mdx
│ ├── 08-transition.stories.mdx
│ ├── 09-use-react-compare-slider-ref.stories.mdx
│ ├── 99-real-world-examples.stories.mdx
│ └── stories
│ │ ├── 00-demos
│ │ ├── 00-index.stories.tsx
│ │ └── 02-edge-cases.stories.tsx
│ │ ├── 01-recipes
│ │ ├── 00-index.stories.tsx
│ │ └── 01-google-maps.stories.tsx
│ │ ├── 02-handles
│ │ ├── 00-react-compare-slider-handle.stories.tsx
│ │ └── 01-custom.stories.tsx
│ │ ├── 99-tests
│ │ ├── clip.test.stories.tsx
│ │ ├── default.test.stories.tsx
│ │ ├── disabled.test.stories.tsx
│ │ ├── keyboard-interactions.test.stories.tsx
│ │ ├── pointer-interactions.stories.tsx
│ │ ├── position.test.stories.tsx
│ │ ├── react-compare-slider-handle.test.stories.tsx
│ │ ├── react-compare-slider-image.test.stories.tsx
│ │ ├── right-to-left.test.stories.tsx
│ │ ├── scroll.test.stories.tsx
│ │ ├── test-utils.test.tsx
│ │ ├── transition.test.stories.tsx
│ │ ├── use-react-compare-slider-ref.test.stories.tsx
│ │ └── zero-bounds.test.stories.tsx
│ │ └── config.ts
│ ├── package.json
│ └── tsconfig.json
├── lib
├── .release-it.json
├── package.json
├── src
│ ├── Container.tsx
│ ├── ReactCompareSlider.tsx
│ ├── ReactCompareSliderHandle.tsx
│ ├── ReactCompareSliderImage.tsx
│ ├── index.ts
│ ├── types.ts
│ ├── useReactCompareSliderRef.ts
│ └── utils.ts
├── tsconfig.json
└── tsup.config.ts
├── package.json
├── pnpm-lock.yaml
└── pnpm-workspace.yaml
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig helps developers define and maintain consistent
2 | # coding styles between different editors and IDEs
3 | # editorconfig.org
4 | root = true
5 |
6 | [*]
7 | charset = utf-8
8 | end_of_line = lf
9 | indent_size = 2
10 | insert_final_newline = true
11 | max_line_length = 90
12 | trim_trailing_whitespace = true
13 |
14 | [*.{md,mdx}]
15 | trim_trailing_whitespace = false
16 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parser": "@typescript-eslint/parser",
4 | "parserOptions": {
5 | "sourceType": "module",
6 | "ecmaFeatures": {
7 | "jsx": true
8 | }
9 | },
10 | "plugins": ["@typescript-eslint", "react"],
11 | "extends": [
12 | "eslint:recommended",
13 | "plugin:react/recommended",
14 | "plugin:react-hooks/recommended",
15 | "plugin:@typescript-eslint/eslint-recommended",
16 | "plugin:@typescript-eslint/recommended"
17 | ],
18 | "env": {
19 | "browser": true,
20 | "es6": true
21 | },
22 | "settings": {
23 | "react": { "version": "detect" }
24 | },
25 | "rules": {
26 | "linebreak-style": ["error", "unix"],
27 | "lines-around-comment": 0,
28 | "no-constant-binary-expression": "error",
29 | "no-confusing-arrow": 0,
30 | "no-console": "error",
31 | "no-unused-vars": 0,
32 | "require-await": 0,
33 | "react/jsx-indent": 0,
34 | "react/jsx-indent-props": 0,
35 | "react/prop-types": 0,
36 | "@typescript-eslint/consistent-type-imports": "warn",
37 | "@typescript-eslint/explicit-function-return-type": "warn",
38 | "@typescript-eslint/no-non-null-assertion": "warn",
39 | "@typescript-eslint/no-shadow": "warn",
40 | "@typescript-eslint/no-unused-vars": [
41 | "error",
42 | {
43 | "args": "all",
44 | "argsIgnorePattern": "^__",
45 | "varsIgnorePattern": "^__"
46 | }
47 | ]
48 | },
49 | "ignorePatterns": [
50 | "*.log",
51 | "*.md",
52 | "*.mdx",
53 | "node_modules/",
54 | "dist/",
55 | "coverage/",
56 | ".nyc_output/",
57 | ".pnpm-store/",
58 | "pnpm-lock.yaml",
59 | "storybook-static/",
60 | ".next/",
61 | "docs/storybook/src/"
62 | ]
63 | }
64 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [nerdyman]
2 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | types:
8 | - opened
9 | - synchronize
10 | workflow_dispatch:
11 |
12 | jobs:
13 | build:
14 | name: Build, lint and test
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - name: 🛒 Checkout repo
19 | uses: actions/checkout@v3
20 |
21 | - uses: pnpm/action-setup@v2
22 |
23 | - name: ⚒️ Use Node.js
24 | uses: actions/setup-node@v3
25 | with:
26 | node-version-file: '.nvmrc'
27 | cache: 'pnpm'
28 |
29 | - name: 📦 Install Dependencies
30 | run: npm run bootstrap
31 |
32 | - name: 🚦 Lint
33 | run: pnpm run lint
34 |
35 | - name: 🔨 Build
36 | run: pnpm run --filter react-compare-slider build
37 |
38 | - name: 🧑🏫 Build Example Project
39 | run: pnpm run --filter @this/example build
40 |
41 | - name: 🕵️ Check Package Configuration
42 | run: pnpm run --filter react-compare-slider check:package
43 |
44 | - name: 🧪 Test
45 | run: |
46 | pnpm dlx playwright@1.41.1 install --with-deps chromium
47 | pnpm run test:ci
48 |
49 | - name: Upload Code Climate Test Coverage
50 | uses: paambaati/codeclimate-action@v5.0.0
51 | env:
52 | CC_TEST_REPORTER_ID: '${{ secrets.CC_TEST_REPORTER_ID }}'
53 | with:
54 | debug: true
55 | coverageLocations: |
56 | ${{github.workspace}}/coverage/ssr-tests/*.info:lcov
57 | ${{github.workspace}}/coverage/storybook/*.info:lcov
58 |
59 | - uses: preactjs/compressed-size-action@v2
60 | with:
61 | repo-token: '${{ secrets.GITHUB_TOKEN }}'
62 | install-script: 'pnpm install --frozen-lockfile --filter react-compare-slider'
63 | build-script: 'build'
64 | cwd: lib
65 |
--------------------------------------------------------------------------------
/.github/workflows/publish-preview.yml:
--------------------------------------------------------------------------------
1 | name: Publish Preview Release
2 | on:
3 | pull_request:
4 | types: [synchronize]
5 | jobs:
6 | approved:
7 | if: contains(join(github.event.pull_request.labels.*.name, ','), 'publish-preview')
8 | runs-on: ubuntu-latest
9 |
10 | steps:
11 | - name: 🛒 Checkout repo
12 | uses: actions/checkout@v3
13 |
14 | - uses: pnpm/action-setup@v2
15 |
16 | - name: ⚒️ Use Node.js
17 | uses: actions/setup-node@v3
18 | with:
19 | node-version-file: '.nvmrc'
20 | cache: 'pnpm'
21 |
22 | - name: 📦 Install Dependencies
23 | run: |
24 | corepack enable
25 | pnpm install --frozen-lockfile --filter react-compare-slider
26 |
27 | - name: 🔨 Build
28 | run: pnpm run --filter react-compare-slider build
29 |
30 | - name: 🚀 Publish Preview Release
31 | run: pnpx pkg-pr-new publish './lib' --template='./docs/example'
32 |
--------------------------------------------------------------------------------
/.github/workflows/ssr.yml:
--------------------------------------------------------------------------------
1 | name: SSR
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | types:
8 | - opened
9 | - synchronize
10 | workflow_dispatch:
11 |
12 | jobs:
13 | build:
14 | name: Run Legacy Node.js Tests
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - name: 🛒 Checkout repo
19 | uses: actions/checkout@v3
20 |
21 | - uses: pnpm/action-setup@v2
22 |
23 | - name: ⚒️ Use Node.js
24 | uses: actions/setup-node@v3
25 | with:
26 | node-version: 18
27 | cache: 'pnpm'
28 |
29 | - name: 📦 Install Dependencies
30 | run: |
31 | corepack enable
32 | pnpm install --frozen-lockfile --filter . --filter @this/ssr-tests --filter react-compare-slider
33 |
34 | - name: 🔨 Build
35 | run: pnpm run --filter react-compare-slider build
36 |
37 | - name: 🧪 Test
38 | run: pnpm --filter @this/ssr-tests run test-legacy
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
2 | .DS_Store
3 | node_modules
4 | .cache
5 | *.tgz
6 | dist
7 | coverage
8 | .env
9 | .pnpm-store/
10 | storybook-static
11 | docs/storybook/src
12 | lib/README.md
13 | lib/LICENSE
14 |
--------------------------------------------------------------------------------
/.husky/post-merge:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | pnpm i --frozen-lockfile
5 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | pnpm lint-staged
5 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | auto-install-peers=true
2 | public-hoist-pattern[]=@types*
3 | public-hoist-pattern[]=@storybook*
4 | public-hoist-pattern[]=@testing-library*
5 | public-hoist-pattern[]=jest*
6 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | lts/iron
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | *.md
2 | *.mdx
3 | *.log
4 | node_modules/
5 | dist/
6 | coverage/
7 | .nyc_output/
8 | .pnpm-store/
9 | pnpm-lock.yaml
10 | storybook-static/
11 | .next/
12 | docs/storybook/src
13 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | /** @type {import('@ianvs/prettier-plugin-sort-imports').PrettierConfig} */
2 | module.exports = {
3 | plugins: [require.resolve('@ianvs/prettier-plugin-sort-imports')],
4 | printWidth: 100,
5 | semi: true,
6 | singleQuote: true,
7 | trailingComma: 'all',
8 | endOfLine: 'lf',
9 | useTabs: false,
10 |
11 | importOrder: ['^~/(.*)$', '^[./]'],
12 | importOrderBuiltinModulesToTop: true,
13 | importOrderCombineTypeAndValueImports: false,
14 | importOrderSeparation: true,
15 | importOrderSortSpecifiers: true,
16 | };
17 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint",
4 | "EditorConfig.EditorConfig",
5 | "esbenp.prettier-vscode"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.codeActionsOnSave": {
3 | "source.fixAll.eslint": "explicit"
4 | },
5 | "editor.rulers": [100],
6 | "editor.formatOnSave": true,
7 | "search.exclude": {
8 | "**/node_modules": true,
9 | "**/bower_components": true,
10 | "**/*.code-search": true,
11 | ".env": false
12 | },
13 |
14 | "jest.autoRun": "off",
15 | "jest.showCoverageOnLoad": false,
16 |
17 | "[javascript]": {
18 | "editor.defaultFormatter": "esbenp.prettier-vscode"
19 | },
20 | "[javascriptreact]": {
21 | "editor.defaultFormatter": "esbenp.prettier-vscode"
22 | },
23 | "[jsonc]": {
24 | "editor.defaultFormatter": "esbenp.prettier-vscode"
25 | },
26 | "[typescript]": {
27 | "editor.defaultFormatter": "esbenp.prettier-vscode"
28 | },
29 | "[typescriptreact]": {
30 | "editor.defaultFormatter": "esbenp.prettier-vscode"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2019 nerdyman
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
React Compare Slider
3 |
Compare two components side-by-side or top-to-toe.
4 |
5 | [](https://codesandbox.io/p/sandbox/github/nerdyman/react-compare-slider/tree/main/docs/example?file=/src/App.tsx:1,1)
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | > [!IMPORTANT]
18 | > This readme is for the [v4 release](https://github.com/nerdyman/react-compare-slider/releases) which is currently in beta (`react-compare-slider@beta`).
19 | >
20 | > See [Version 3](https://github.com/nerdyman/react-compare-slider/tree/v3.1.0) for the latest stable release (`react-compare-slider`).
21 |
22 | ---
23 |
24 | ## Features
25 |
26 | - Supports responsive images and any other React components (`picture`, `video`, `canvas`, `iframe` etc.)
27 | - Supports landscape and portrait orientations
28 | - Accessible – screen reader and keyboard support out of the box
29 | - Simple API
30 | - Unopinionated & fully customizable – optionally use your own components and styles
31 | - Responsive & fluid with intrinsic sizing
32 | - Teeny-tiny, zero dependencies
33 | - Type safe
34 |
35 | ## Demos
36 |
37 | - Storybook: [docs](https://react-compare-slider.vercel.app/?path=/docs/docs-introduction--docs), [demos](https://react-compare-slider.vercel.app/?path=/story/demos), [custom recipes](https://react-compare-slider.vercel.app/?path=/story/recipes), [custom handles](https://react-compare-slider.vercel.app/?path=/story/handles), [`useReactCompareSliderRef`](https://react-compare-slider.vercel.app/?path=/docs/docs-usereactcomparesliderref--docs)
38 | - CodeSandbox: [basic demo](https://codesandbox.io/p/sandbox/github/nerdyman/react-compare-slider/tree/main/docs/example?file=/src/App.tsx:1,1)
39 | - [Local example](./docs/example)
40 |
41 | ## Usage
42 |
43 | ### Install
44 |
45 | ```sh
46 | npm install react-compare-slider
47 | # or
48 | yarn add react-compare-slider
49 | # or
50 | pnpm add react-compare-slider
51 | ```
52 |
53 | ### Basic Image Usage
54 |
55 | You _may_ use `ReactCompareSliderImage` to render images or use your own custom
56 | components.
57 |
58 | ```jsx
59 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider';
60 |
61 | export const Example = () => {
62 | return (
63 | }
65 | itemTwo={ }
66 | />
67 | );
68 | };
69 | ```
70 |
71 | ## Props
72 |
73 | | Prop | Type | Required | Default | Description |
74 | | ---- | ---- | :------: | ------- | ----------- |
75 | | [`boundsPadding`](https://react-compare-slider.vercel.app/?path=/story/demos--bounds-padding) | `number` | | `0` | Padding to limit the slideable bounds in pixels on the X-axis (landscape) or Y-axis (portrait).
76 | | [`browsingContext`](https://react-compare-slider.vercel.app/?path=/story/demos--browsing-context) | `globalThis` | | `globalThis` | Context to bind events to (useful for iframes).
77 | | [`clip`](https://react-compare-slider.vercel.app/?path=/docs/docs-clip--docs) | `` both\|itemOne\|itemTwo `` | | `both` | Whether to clip `itemOne`, `itemTwo` or `both` items.
78 | | [`changePositionOnHover`](https://react-compare-slider.vercel.app/?path=/story/demos--change-position-on-hover) | `boolean` | | `false` | Whether the slider should follow the pointer on hover.
79 | | [`disabled`](https://react-compare-slider.vercel.app/?path=/story/demos--disabled) | `boolean` | | `false` | Whether to disable slider movement (items are still interactable).
80 | | [`handle`](https://react-compare-slider.vercel.app/?path=/story/demos--handle) | `ReactNode` | | `undefined` | Custom handle component.
81 | | `itemOne` | `ReactNode` | ✓ | `undefined` | First component to show in slider.
82 | | `itemTwo` | `ReactNode` | ✓ | `undefined` | Second component to show in slider.
83 | | [`keyboardIncrement`](https://react-compare-slider.vercel.app/?path=/story/demos--keyboard-increment) | `` number\|`${number}%` `` | | `5%` | Percentage or pixel amount to move when the slider handle is focused and keyboard arrow is pressed.
84 | | [`onlyHandleDraggable`](https://react-compare-slider.vercel.app/?path=/story/demos--only-handle-draggable) | `boolean` | | `false` | Whether to only change position when handle is interacted with (useful for touch devices).
85 | | [`onPositionChange`](https://react-compare-slider.vercel.app/?path=/story/demos--on-position-change) | `(position: number) => void` | | `undefined` | Callback on position change, returns current position percentage as argument.
86 | | [`portrait`](https://react-compare-slider.vercel.app/?path=/story/demos--portrait) | `boolean` | | `false` | Whether to use portrait orientation.
87 | | [`position`](https://react-compare-slider.vercel.app/?path=/story/demos--position) | `number` | | `50` | Initial percentage position of divide (`0-100`).
88 | | [`transition`](https://react-compare-slider.vercel.app/?path=/story/demos--transition) | `string` | | `undefined` | Shorthand CSS `transition` property to apply to handle movement. E.g. `.5s ease-in-out`
89 |
90 | [API docs](https://react-compare-slider.vercel.app/?path=/docs/docs-api--docs) for more information.
91 |
92 |
93 |
94 | ## Real World Examples
95 |
96 | Checkout out the [Real World Examples page](https://react-compare-slider.vercel.app/?path=/docs/docs-real-world-examples--docs).
97 |
98 | ## Requirements
99 |
100 | - React 16.8+
101 | - The latest two versions of each major browser are officially supported (at time of publishing)
102 |
--------------------------------------------------------------------------------
/docs/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Thanks for contributing!
4 |
5 | ## Getting Started
6 |
7 | This is a pnpm monorepo, consisting of the main package in [`lib`](../lib/) and
8 | [`storybook`](./storybook/) and [`example`](./example/) packages in the `docs` directory. Don't
9 | worry if you haven't used pnpm or monorepos before, the commands below will set everything up for you.
10 |
11 | If you run Windows please use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install).
12 |
13 | Ensure you're using the Node version specified in [.nvmrc](../.nvmrc) and run the following to
14 | bootstrap the project:
15 |
16 | ```sh
17 | npm run bootstrap
18 | # Also useful but not required, install shell auto completion for pnpm.
19 | pnpm install-completion
20 | ```
21 |
22 | To start the library in watch mode, run the following command:
23 |
24 | ```sh
25 | # Run the library only, note this must run before starting Storybook.
26 | pnpm run --filter react-compare-slider dev
27 | # You can also run the scripts from the directory itself if you prefer.
28 | cd lib
29 | pnpm run dev
30 |
31 | # Run Storybook only.
32 | pnpm run --filter @this/storybook dev
33 | # You can also run the scripts from the directory itself if you prefer.
34 | cd docs/storybook
35 | pnpm run dev
36 |
37 | # Run example only.
38 | pnpm run --filter @this/example dev
39 | # You can also run the scripts from the directory itself if you prefer.
40 | cd docs/example
41 | pnpm run dev
42 | ```
43 |
44 | ## Testing
45 |
46 | If you already have Storybook running, use the following command:
47 |
48 | ```sh
49 | pnpm run test
50 | ```
51 |
52 | If you _don't_ have Storybook running, use the following command:
53 |
54 | ```sh
55 | pnpm run test:ci
56 | ```
57 |
58 | ## Standards
59 |
60 | - Commits use the [Conventional Commits](https://conventionalcommits.org/) standard
61 | - pnpm to manage dependencies
62 | - Use a tool (e.g. nvm or fnm) to use the correct Node.js version
63 | - Prettier & EditorConfig for code style
64 | - ESLint for quality
65 | - Husky for Git hooks
66 |
67 | ## VS Code
68 |
69 | If you're using VS Code please make sure you install the [recommended extensions](../.vscode/extensions.json).
70 |
--------------------------------------------------------------------------------
/docs/example/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../.eslintrc",
3 | "rules": {
4 | "no-console": 0,
5 | "@typescript-eslint/explicit-function-return-type": 0,
6 | "react/react-in-jsx-scope": 0
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/docs/example/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/docs/example/README.md:
--------------------------------------------------------------------------------
1 | # React Compare Slider Demo
2 |
3 | - 🧑💻 [CodeSandbox Demo](https://codesandbox.io/p/sandbox/github/nerdyman/react-compare-slider/tree/main/docs/example?file=/src/App.tsx:1,1)
4 | - 🐙 [GitHub Repo](https://github.com/nerdyman/react-compare-slider)
5 |
6 | ---
7 |
8 | ## React + TypeScript + Vite
9 |
10 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
11 |
12 | Currently, two official plugins are available:
13 |
14 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
15 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
16 |
17 | ### Expanding the ESLint configuration
18 |
19 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
20 |
21 | - Configure the top-level `parserOptions` property like this:
22 |
23 | ```js
24 | parserOptions: {
25 | ecmaVersion: 'latest',
26 | sourceType: 'module',
27 | project: ['./tsconfig.json', './tsconfig.node.json'],
28 | tsconfigRootDir: __dirname,
29 | },
30 | ```
31 |
32 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
33 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
34 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
35 |
--------------------------------------------------------------------------------
/docs/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | React Compare Slider Example
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/docs/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-compare-slider-example",
3 | "license": "MIT",
4 | "version": "0.0.0",
5 | "private": true,
6 | "type": "module",
7 | "repository": {
8 | "type": "git",
9 | "url": "git+https://github.com/nerdyman/react-compare-slider.git",
10 | "directory": "docs/example"
11 | },
12 | "scripts": {
13 | "dev": "vite",
14 | "build": "tsc && vite build",
15 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
16 | "preview": "vite preview"
17 | },
18 | "dependencies": {
19 | "react": "^18.2.0",
20 | "react-compare-slider": "latest",
21 | "react-dom": "^18.2.0"
22 | },
23 | "devDependencies": {
24 | "@types/react": "^18.2.55",
25 | "@types/react-dom": "^18.2.19",
26 | "@typescript-eslint/eslint-plugin": "^6.21.0",
27 | "@typescript-eslint/parser": "^6.21.0",
28 | "@vitejs/plugin-react": "^4.2.1",
29 | "eslint": "^8.56.0",
30 | "eslint-plugin-react-hooks": "^4.6.0",
31 | "eslint-plugin-react-refresh": "^0.4.5",
32 | "typescript": "^5.6.2",
33 | "vite": "^5.4.5"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/docs/example/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/example/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider';
2 |
3 | export default function App() {
4 | return (
5 |
6 |
12 | }
13 | itemTwo={
14 |
28 | }
29 | style={{ width: '100%', height: '50%' }}
30 | />
31 |
38 | }
39 | itemTwo={
40 |
54 | }
55 | style={{ width: '100%', height: '50%' }}
56 | />
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/docs/example/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: system-ui, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | -webkit-text-size-adjust: 100%;
15 | }
16 |
17 | html,
18 | body,
19 | #root {
20 | display: flex;
21 | flex-grow: 1;
22 | width: 100%;
23 | height: 100%;
24 | min-height: 100%;
25 | overflow: hidden;
26 | }
27 |
--------------------------------------------------------------------------------
/docs/example/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react';
2 | import packageJson from 'react-compare-slider/package.json';
3 | import { createRoot } from 'react-dom/client';
4 |
5 | import App from './App';
6 |
7 | import './index.css';
8 |
9 | console.info('react-compare-slider version:', packageJson.version);
10 |
11 | createRoot(document.getElementById('root') as HTMLElement).render(
12 |
13 |
14 | ,
15 | );
16 |
--------------------------------------------------------------------------------
/docs/example/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/docs/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["src"],
24 | "references": [{ "path": "./tsconfig.node.json" }]
25 | }
26 |
--------------------------------------------------------------------------------
/docs/example/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/docs/example/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react';
2 | import { defineConfig } from 'vite';
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | });
8 |
--------------------------------------------------------------------------------
/docs/ssr-tests/README.md:
--------------------------------------------------------------------------------
1 | # SSR Tests
2 |
3 | Super basic test suite to make sure the lib doesn't error server-side.
4 |
5 | ## Usage
6 |
7 | ```sh
8 | pnpm run test
9 | ```
10 |
--------------------------------------------------------------------------------
/docs/ssr-tests/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@this/ssr-tests",
3 | "private": true,
4 | "version": "1.0.0",
5 | "description": "",
6 | "author": "",
7 | "main": "ssr.test.js",
8 | "type": "module",
9 | "license": "MIT",
10 | "scripts": {
11 | "test": "rm -rf ./coverage && NODE_V8_COVERAGE='./coverage' node --experimental-test-coverage --test",
12 | "test-legacy": "node --test"
13 | },
14 | "devDependencies": {
15 | "@types/node": "^22.5.5",
16 | "react": "^18.2.0",
17 | "react-compare-slider": "latest",
18 | "react-dom": "^18.2.0",
19 | "test": "^3.3.0"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/docs/ssr-tests/ssr.test.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | /* eslint no-console: 0 */
3 |
4 | const assert = await import('node:assert');
5 | const { describe, it } = await import('node:test');
6 |
7 | const { createElement, isValidElement } = await import('react');
8 | const { renderToStaticMarkup } = await import('react-dom/server');
9 | // Using npm version of node built-in test lib to allow the test suite to run on node 16.
10 | const { MockTracker } = await import('test/lib/internal/test_runner/mock.js');
11 |
12 | const { ReactCompareSlider, ReactCompareSliderHandle, ReactCompareSliderImage, styleFitContainer } =
13 | await import('react-compare-slider');
14 |
15 | describe('SSR', () => {
16 | it('should render without error', () => {
17 | const mock = new MockTracker();
18 | const mockConsoleError = mock.method(console, 'error');
19 | const mockConsoleWarn = mock.method(console, 'warn');
20 |
21 | const root = createElement(ReactCompareSlider, {
22 | handle: createElement(ReactCompareSliderHandle, {}),
23 | itemOne: createElement(ReactCompareSliderImage, {
24 | alt: 'Example 1',
25 | src: 'example-1.jpg',
26 | style: styleFitContainer({ objectPosition: 'left' }),
27 | }),
28 | itemTwo: createElement(ReactCompareSliderImage, {
29 | alt: 'Example 2',
30 | src: 'example-2.jpg',
31 | style: styleFitContainer({ objectPosition: 'left' }),
32 | }),
33 | });
34 |
35 | /**
36 | * TypeScript errors if assertion isn't assigned.
37 | * @see https://github.com/microsoft/TypeScript/issues/36931
38 | */
39 | let __ = assert.strictEqual(isValidElement(root), true);
40 | __ = assert.strictEqual(renderToStaticMarkup(root).includes('data-rcs="root"'), true);
41 | __ = assert.strictEqual(renderToStaticMarkup(root).includes('src="example-1.jpg"'), true);
42 | __ = assert.strictEqual(renderToStaticMarkup(root).includes('src="example-2.jpg"'), true);
43 | __ = assert.strictEqual(mockConsoleError.mock.calls.length, 0);
44 | __ = assert.strictEqual(mockConsoleWarn.mock.calls.length, 0);
45 |
46 | mock.reset();
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/docs/storybook/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": false,
3 | "extends": "../../.eslintrc",
4 | "parserOptions": {
5 | "project": true
6 | },
7 | "rules": {
8 | "no-console": 0,
9 | "@typescript-eslint/explicit-function-return-type": 0,
10 | "@typescript-eslint/no-explicit-any": "warn",
11 | "react/react-in-jsx-scope": 0
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/docs/storybook/.storybook/hack-coverage.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Storybook won't generate coverage for files outside of its root directory so the lib build script
3 | * copies the `src` dir into the Storybook project root. However, this means the coverage paths for
4 | * the SSR tests and Storybook tests aren't the same. This script updates the Storybook coverage
5 | * JSON to point to the actual `src` directory.
6 | */
7 | const fs = require('node:fs');
8 | const path = require('node:path');
9 |
10 | console.info('[hack-coverage] 🙈 start');
11 |
12 | const coveragePath = path.join(__dirname, '..', 'coverage', 'storybook', 'coverage-storybook.json');
13 |
14 | const coverage = fs
15 | .readFileSync(coveragePath, 'utf8')
16 | .replaceAll('react-compare-slider/docs/storybook/src', 'react-compare-slider/lib/src');
17 |
18 | fs.writeFileSync(coveragePath, coverage);
19 |
20 | console.info('[hack-coverage] 🙈 complete');
21 |
--------------------------------------------------------------------------------
/docs/storybook/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 |
3 | import { resolve } from 'node:path';
4 |
5 | import type { StorybookConfig } from '@storybook/react-vite';
6 | import remarkGfm from 'remark-gfm';
7 | import { mergeConfig } from 'vite';
8 |
9 | const config: StorybookConfig = {
10 | core: {
11 | builder: '@storybook/builder-vite',
12 | disableWhatsNewNotifications: true,
13 | },
14 |
15 | framework: {
16 | name: '@storybook/react-vite',
17 | options: {
18 | strictMode: true,
19 | },
20 | },
21 |
22 | addons: [
23 | '@storybook/addon-essentials',
24 | {
25 | name: '@storybook/addon-docs',
26 | options: {
27 | mdxPluginOptions: {
28 | mdxCompileOptions: {
29 | remarkPlugins: [remarkGfm],
30 | },
31 | },
32 | },
33 | },
34 | '@storybook/addon-links',
35 | '@storybook/addon-interactions',
36 | '@storybook/jest',
37 | '@storybook/addon-coverage',
38 | '@storybook/addon-storysource',
39 | ],
40 |
41 | stories: ['../content/**/*.stories.@(mdx|tsx)'],
42 |
43 | viteFinal: async (config) => {
44 | const libPath = resolve(__dirname, '..', 'src');
45 | console.info(`\n\n[SB CUSTOM] Using lib path: ${libPath}\n\n`);
46 |
47 | const finalConfig = mergeConfig(config, {
48 | resolve: {
49 | alias: {
50 | /**
51 | * @NOTE This alias is needed for Storybook to correctly generate docs.
52 | * Importing from the parent `src` directory does not fully work.
53 | */
54 | 'react-compare-slider': libPath,
55 | },
56 | },
57 | });
58 |
59 | return finalConfig;
60 | },
61 | };
62 |
63 | export default config;
64 |
--------------------------------------------------------------------------------
/docs/storybook/.storybook/manager-head.html:
--------------------------------------------------------------------------------
1 | React Compare Slider
2 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/docs/storybook/.storybook/manager.ts:
--------------------------------------------------------------------------------
1 | import { addons } from '@storybook/addons';
2 | import { ThemeVars } from '@storybook/theming';
3 | import { create } from '@storybook/theming/create';
4 |
5 | import { theme } from './theme';
6 |
7 | /**
8 | * @see https://storybook.js.org/docs/configurations/options-parameter/
9 | */
10 | addons.setConfig({
11 | showAddonsPanel: false,
12 | panelPosition: 'bottom',
13 | theme: create({
14 | ...theme,
15 | brandTitle: 'React Compare Slider',
16 | brandUrl: 'https://github.com/nerdyman/react-compare-slider',
17 | gridCellSize: 12,
18 | } as ThemeVars),
19 | });
20 |
--------------------------------------------------------------------------------
/docs/storybook/.storybook/preview-head.html:
--------------------------------------------------------------------------------
1 |
144 |
--------------------------------------------------------------------------------
/docs/storybook/.storybook/preview.ts:
--------------------------------------------------------------------------------
1 | import type { Parameters } from '@storybook/react';
2 |
3 | import { theme } from './theme';
4 |
5 | import '@storybook/addon-console';
6 |
7 | export const parameters: Parameters = {
8 | layout: 'fullscreen',
9 | controls: {
10 | hideNoControlsWarning: true,
11 | },
12 | docs: {
13 | /** @see https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#docs-page */
14 | docsPage: true,
15 | defaultName: 'Docs',
16 | story: {
17 | inline: true,
18 | },
19 | theme,
20 | },
21 | options: {
22 | showRoots: true,
23 | theme,
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/docs/storybook/.storybook/theme.ts:
--------------------------------------------------------------------------------
1 | import { Theme, themes } from '@storybook/theming';
2 |
3 | export const theme: Partial = {
4 | ...themes.dark,
5 | appBorderRadius: 3,
6 | colorSecondary: '#c86dfc',
7 | barSelectedColor: '#c86dfc',
8 | };
9 |
--------------------------------------------------------------------------------
/docs/storybook/content/00-introduction.stories.mdx:
--------------------------------------------------------------------------------
1 | import { Canvas, Meta, Story } from '@storybook/blocks';
2 | import React from 'react';
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | # React Compare Slider
11 |
12 |
13 |
14 |
15 |
16 |
27 |
28 |
39 |
40 | A lightweight and extensible slider component to compare any two React components in landscape or portrait orientation.
41 | It supports custom images, videos, canvases... and everything else.
42 |
43 |
44 |
45 | ## Features
46 |
47 | - Supports responsive images and any other React components (`picture`, `video`, `canvas`, `iframe` etc.)
48 | - Supports landscape and portrait orientations
49 | - Accessible – includes screen reader and keyboard support
50 | - Simple API
51 | - Unopinionated & fully customizable – optionally use your own components and styles
52 | - Responsive, intrinsic sizing
53 | - [Teeny-tiny](https://bundlephobia.com/result?p=react-compare-slider), zero dependencies
54 | - Type safe
55 |
56 | ## Requirements
57 |
58 | - React 16.8+
59 | - The [latest two versions](https://github.com/nerdyman/react-compare-slider/blob/main/package.json#L55) of each major browser are officially supported
60 |
61 | ## Demos
62 |
63 | Storybook demos are within iframes which can sometimes cause the slider position to not
64 | meet the edges of the container when sliding quickly - this is a browser limitation and
65 | only occurs when the slider is within an iframe.
66 |
67 | ## Real World Examples
68 |
69 | - [Official GOV.UK Coronavirus Dashboard](https://coronavirus.data.gov.uk/details/interactive-map/vaccinations#vaccinations-map-container)
70 | - [Upscayl, Free and Open Source AI Image Upscaler](https://github.com/upscayl/upscayl#free-and-open-source-ai-image-upscaler)
71 | - [Counter-Strike 2 Website](https://www.counter-strike.net/cs2)
72 |
73 | ---
74 |
75 |
View on GitHub
76 |
77 |
78 |
--------------------------------------------------------------------------------
/docs/storybook/content/01-api.stories.mdx:
--------------------------------------------------------------------------------
1 | import { Description, Meta, ArgTypes } from '@storybook/blocks';
2 |
3 | import {
4 | ReactCompareSlider,
5 | ReactCompareSliderHandle,
6 | ReactCompareSliderImage,
7 | styleFitContainer,
8 | useReactCompareSliderRef,
9 | } from 'react-compare-slider';
10 |
11 |
12 |
13 | # API
14 |
15 |
16 |
17 | ## `ReactCompareSlider`
18 |
19 |
20 |
21 | [🔗 Source](https://github.com/nerdyman/react-compare-slider/blob/main/lib/src/ReactCompareSlider.tsx)
22 |
23 | ### Props
24 |
25 |
26 |
27 |
28 | ## `ReactCompareSliderHandle`
29 |
30 |
31 |
32 | [🔗 Source](https://github.com/nerdyman/react-compare-slider/blob/main/lib/src/ReactCompareSliderHandle.tsx)
33 |
34 | ### Props
35 |
36 |
37 |
38 |
39 | ## `ReactCompareSliderImage`
40 |
41 |
42 |
43 | [🔗 Source](https://github.com/nerdyman/react-compare-slider/blob/main/lib/src/ReactCompareSliderImage.tsx)
44 |
45 | ### Props
46 |
47 | `ReactCompareSliderImage` accepts any valid HTML `img` component props.
48 |
49 |
50 | ## `styleFitContainer`
51 |
52 |
53 |
54 | [🔗 Source](https://github.com/nerdyman/react-compare-slider/blob/main/lib/src/utils.ts#L16)
55 |
56 | The `styleFitContainer` utility makes any [replaced element](https://developer.mozilla.org/en-US/docs/Web/CSS/Replaced_element)
57 | fill its parent while maintaining the correct aspect ratio.
58 |
59 | It returns a React `style` object and accepts a CSS object as an argument and
60 | defaults to `object-fit` to `cover`.
61 |
62 | ### Example
63 |
64 | Fill a full width/height container:
65 |
66 | ```jsx
67 | import { styleFitContainer } from 'react-compare-slider';
68 |
69 |
70 |
78 |
79 | ```
80 |
81 | ## `useReactCompareSliderRef`
82 |
83 |
84 |
85 | [🔗 Source](https://github.com/nerdyman/react-compare-slider/blob/main/lib/src/useReactCompareSliderRef.ts)
86 |
87 | The `useReactCompareSliderRef` hook provides a ref to the root element of the slider and exposes the internal
88 | function used to control the position of the slider. It offers performant programmatic control of the slider.
89 |
90 | Check out the [`useReactCompareSliderRef` docs](/story/docs-usereactcomparesliderref--page) more information.
91 |
--------------------------------------------------------------------------------
/docs/storybook/content/01-images.stories.mdx:
--------------------------------------------------------------------------------
1 | import { Canvas, Meta, Story } from '@storybook/blocks';
2 |
3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider';
4 |
5 |
6 |
7 | # Using Images
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | ## `ReactCompareSliderImage`
16 |
17 | Images can be rendered using the `ReactCompareSliderImage` component and/or
18 | by using a custom image component, e.g. `picture` or `img`.
19 |
20 | `ReactCompareSliderImage` is a standalone image (`img`) component which uses the
21 | `styleFitContainer` utility for positioning and fitting. The defaults from `styleFitContainer`
22 | are applied automatically but can be overridden using the `style` prop.
23 |
24 |
25 |
26 | ### `ReactCompareSliderImage` Props
27 |
28 | `ReactCompareSliderImage` is a standard `img` component; it accepts all valid `img` props.
29 |
30 | ### Usage
31 |
32 | ```jsx
33 | import { ReactCompareSliderImage } from 'react-compare-slider';
34 |
35 | /** Standard usage. */
36 |
37 |
38 | /** Override `styleFitContainer` defaults. */
39 |
44 | ```
45 |
46 | ## Live Examples
47 |
48 | Checkout the [Images examples](/story/demos--images).
49 |
--------------------------------------------------------------------------------
/docs/storybook/content/02-bounds-padding.stories.mdx:
--------------------------------------------------------------------------------
1 | import { ArgTypes, Canvas, Meta, Story } from '@storybook/blocks';
2 |
3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider';
4 |
5 |
6 |
7 | # Using `boundsPadding`
8 |
9 | The `boundsPadding` prop allows you to limit the slideable area on the Y-axis
10 | in `portrait` mode or on the X-axis in the default landscape mode. Negative values are treated the
11 | same as `0` and the value **MUST** be supplied as a number in pixels.
12 |
13 | Bounds padding is useful when the slider has other components overlaying it. E.g. in a full
14 | width or height carousel with overlaying navigation buttons.
15 |
16 | In the example below, bounds padding prevents the slider handle from going
17 | within range of pixels of the left or right of the slider specified by the `boundsPadding` prop.
18 | The bounds are automatically applied to the top/bottom or right/left depending on the orientation of the slider.
19 |
20 | ```JSX
21 | boundsPadding={80}
22 | ```
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | ## Live Examples
33 |
34 | Checkout the [Bounds padding examples](/story/demos--bounds-padding).
35 |
--------------------------------------------------------------------------------
/docs/storybook/content/03-browsing-context.stories.mdx:
--------------------------------------------------------------------------------
1 | import { ArgTypes, Canvas, Meta, Story } from '@storybook/blocks';
2 |
3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider';
4 |
5 |
6 |
7 | # Using `browsingContext`
8 |
9 | The `browsingContext` prop allows you to change the target that events are bound to. By default,
10 | `globalThis` (`Window`) is used and will cover 99.999% of use cases. However, if you are using
11 | the library outside of current window context (e.g. an `iframe` or pop-up window) you will notice
12 | that not all the events are caputed by the slider. `browsingContext` can be used to set a different
13 | target to bind events to.
14 |
15 | For example, if you wanted to render the slider in a popup you would use `createPortal` from `react-dom`
16 | with the `document.body` property of the popup as the DOM node for `createPortal` and pass the popup
17 | window context as the `browsingContext` prop.
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | ## Live Examples
28 |
29 | Checkout the [Bounds padding examples](/story/demos--browsing-context).
30 |
--------------------------------------------------------------------------------
/docs/storybook/content/03-change-position-on-hover.stories.mdx:
--------------------------------------------------------------------------------
1 | import { ArgsTable, Canvas, Meta, Story } from '@storybook/blocks';
2 |
3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider';
4 |
5 |
6 |
7 | # Using `changePositionOnHover`
8 |
9 | The `changePositionOnHover` prop allows you to sync the slider position with the position
10 | of the mouse/pointer while the slider is being hovered over.
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | ## Live Examples
21 |
22 | Checkout the [Change Position on Hover examples](/story/demos--change-position-on-hover).
23 |
24 |
--------------------------------------------------------------------------------
/docs/storybook/content/03-clip.stories.mdx:
--------------------------------------------------------------------------------
1 | import { ArgTypes, Canvas, Meta, Story } from '@storybook/blocks';
2 |
3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider';
4 |
5 |
6 |
7 | # Using `clip`
8 |
9 | The `clip` prop allows you to control whether `itemOne`, `itemTwo`, or both items are clipped to
10 | the bounds of the slider.
11 |
12 | This is useful in cases where you have transparent items and want to control which item takes
13 | precedence when the slider is moved.
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | ## Live Examples
24 |
25 | Checkout the [Images example](/story/demos--images).
26 |
--------------------------------------------------------------------------------
/docs/storybook/content/04-disabled.stories.mdx:
--------------------------------------------------------------------------------
1 | import { ArgTypes, Canvas, Meta, Story } from '@storybook/blocks';
2 |
3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider';
4 |
5 |
6 |
7 | # Using `disabled`
8 |
9 | The `disabled` prop prevents the slider from being interacted with by the user and also applies
10 | the appropriate accessibility attributes to the slider controls.
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | ## Live Examples
21 |
22 | Checkout the [Disabled example](/story/demos--disabled).
23 |
--------------------------------------------------------------------------------
/docs/storybook/content/05-handles.stories.mdx:
--------------------------------------------------------------------------------
1 | import { Canvas, ArgTypes, Meta, Story } from '@storybook/blocks';
2 |
3 | import { ReactCompareSliderHandle } from 'react-compare-slider';
4 |
5 |
6 |
7 | # Using Handles
8 |
9 |
10 |
11 | **Note**: You should use a non-interactive elements (`div`, `span`, etc.) for custom `handle`
12 | components as handles are rendered within a `button` by the library.
13 |
14 |
15 |
16 | Custom Handles can be used via the `handle` prop on the main slider component.
17 | If a custom `handle` is not supplied `ReactCompareSliderHandle` will
18 | be used instead.
19 |
20 |
21 |
22 | ## `ReactCompareSliderHandle`
23 |
24 | `ReactCompareSliderHandle` supports the `portrait` prop to automatically flip
25 | itself to match the desired orientation. It also has individual props and class names to
26 | style the lines and button and accepts any valid `div` component props so you can easily
27 | customize it to fit your requirements.
28 |
29 | ### CSS `className`
30 |
31 | - `.__rcs-handle-root` - The root element.
32 | - `.__rcs-handle-button` - The circular element shown in the middle.
33 | - `.__rcs-handle-line` - For each line either side of the button.
34 |
35 |
36 |
37 | ### Props
38 |
39 |
40 |
41 | ### Custom `ReactCompareSliderHandle` usage
42 |
43 | The colors used in `ReactCompareSliderHandle` are inherited from the root element's `color`
44 | property using the `currentColor` keyword (except for `boxShadow`).
45 | To set all colors in sync just change the `style` property or use the `.__rcs-handle-root` class.
46 |
47 |
48 |
49 |
50 |
51 | ### Custom Standalone Handle Usage
52 |
53 |
54 |
55 |
56 |
57 | ## Live Examples
58 |
59 | Check out the [handles examples](/story/handles--inherited-color).
60 |
--------------------------------------------------------------------------------
/docs/storybook/content/06-keyboard-increment.stories.mdx:
--------------------------------------------------------------------------------
1 | import { ArgsTable, Canvas, Meta, Story } from '@storybook/blocks';
2 |
3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider';
4 |
5 |
6 |
7 | # Using `keyboardIncrement`
8 |
9 | The `keyboardIncrement` prop allows you to use the keyboard arrows to increment/decrement the slider
10 | position when the slider `handle` is focused.
11 |
12 | The increment can be either a `number` which evaluates to a pixel value or a `string` value
13 | ending in `%`, e.g. `20%` to move the slider 20% of the total slider width in landscape mode
14 | or height in portrait mode. A percentage is usually preferable as it adapts relatively to the width or
15 | height of the slider, making it behaving consistently across resolutions.
16 |
17 | If you want to effectively disable the keyboard increment, you can set the value to `0`. You can
18 | also set it to `'100%'` to flip between each side immediately.
19 |
20 | ```jsx
21 | // Increment by 20 pixels.
22 |
25 |
26 | // Increment by 20 percent of the slider width or height.
27 |
30 | ```
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | ## Live Examples
41 |
42 | Checkout the [Keyboard Increment examples](/story/demos--keyboard-increment).
43 |
44 |
--------------------------------------------------------------------------------
/docs/storybook/content/07-only-handle-draggable.stories.mdx:
--------------------------------------------------------------------------------
1 | import { ArgTypes, Canvas, Meta, Story } from '@storybook/blocks';
2 |
3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider';
4 |
5 |
6 |
7 | # Using `onlyHandleDraggable`
8 |
9 | The `onlyHandleDraggable` prop moves click, touch and drag from the main slider
10 | container to the handle.
11 |
12 | `onlyHandleDraggable` is useful for full width/height sliders on touch devices
13 | where the user needs to touch the slider container to scroll past it without changing the slider position.
14 | `onlyHandleDraggable` is also useful when comparing interactive components which
15 | have their own pointer events, such as canvas elements.
16 |
17 |
18 |
19 | ## Detecting Touch Devices
20 |
21 | The library doesn't provide a method to detect touch devices as it's simple to
22 | do natively.
23 |
24 | ```js
25 | const isTouchDevice = window.matchMedia('(pointer: coarse)').matches;
26 | ```
27 |
28 | ## Live Examples
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | Also checkout the
45 | [Only Handle Draggable](/story/demos--only-handle-draggable) and
46 | [Detect Touch Devices](/story/recipes--detect-touch-devices) and
47 | [Google Maps](/story/recipes-google-maps--google-maps) demos.
48 |
--------------------------------------------------------------------------------
/docs/storybook/content/08-transition.stories.mdx:
--------------------------------------------------------------------------------
1 | import { Canvas, Meta, Story } from '@storybook/blocks';
2 |
3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider';
4 |
5 |
6 |
7 | # Using `transition`
8 |
9 | The `transition` property applies a CSS transition when the slider is moved. The shorthand CSS
10 | property syntax is used **without** the `transition-property` value. The library sets which property
11 | to apply the transition to internally.
12 |
13 | You may notice that the slider sometimes moves immediately to the new position, this is because the
14 | transition only applies to `pointerdown` events to ensure the dragging movement of the slider is smooth.
15 | This means the transition will be cancelled if `pointerdown` is followed by a `pointermove` event.
16 |
17 | ```jsx
18 |
21 | ```
22 |
23 |
24 |
25 |
26 |
27 | ## Live Examples
28 |
29 | Checkout the [Transition example](/story/demos--transition).
30 |
--------------------------------------------------------------------------------
/docs/storybook/content/09-use-react-compare-slider-ref.stories.mdx:
--------------------------------------------------------------------------------
1 | import { ArgsTable, Canvas, Meta, Story } from '@storybook/blocks';
2 |
3 | import { ReactCompareSlider } from 'react-compare-slider';
4 |
5 |
6 |
7 | # Using the `useReactCompareSliderRef` Hook
8 |
9 |
10 |
11 | **Note**: Properties returned from the hook are only usable _after_ the component has mounted.
12 |
13 |
14 |
15 | The `useReactCompareSliderRef` hook allows you to access the root container as a ref (`rootContainer`) and provides
16 | access to the internal function used to performantly update the slider position (`setPosition`).
17 |
18 | | Property | Type | Description
19 | | :------------------| :---------------------------- | :----------------------------------------------
20 | | `rootContainer` | `HTMLDivElement` | The root container DOM element.
21 | | `handleContainer` | `HTMLButtonElement` | The DOM element of the `handle` parent.
22 | | `setPosition` | `(position: number) => void` | Set the slider position to the given percentage.
23 |
24 |
25 |
26 | `setPosition` offers performant programmatic control of the slider. It is more performant because
27 | it does not trigger a re-render, as opposed to setting the `position` prop which will re-render the
28 | component then call the internal set position function as a side effect.
29 |
30 | Another benefit of `setPosition` is that it can be used to reset the slider position back to
31 | the initial position. This is not possible with the `position` prop.
32 |
33 | ```tsx
34 | const Example = () => {
35 | const reactCompareSliderRef = useReactCompareSliderRef();
36 |
37 | // Safely use the ref properties in an effect or event handler callback.
38 | useEffect(() => {
39 | console.log(reactCompareSliderRef.current.rootContainer); // The root container DOM element.
40 | reactCompareSliderRef.current.setPosition(20); // Set the slider position to 20%.
41 | }, [reactCompareSliderRef]);
42 |
43 | return (
44 |
47 | );
48 | }
49 |
50 | ```
51 |
52 | There's no limitation to `setPosition` usage, you can even use it to link sliders together!
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | ## Live Examples
63 |
64 | Checkout the [useReactCompareSliderRef examples](/story/demos--use-react-compare-slider-ref).
65 |
66 |
--------------------------------------------------------------------------------
/docs/storybook/content/99-real-world-examples.stories.mdx:
--------------------------------------------------------------------------------
1 | import { Meta } from '@storybook/blocks';
2 |
3 |
4 |
5 | # Real-world Examples
6 |
7 | The library is used by many companies and over 1000 open source projects, below are some examples of
8 | how it's being used in the wild.
9 |
10 | - [upscaler.party: Image upscaler and comparator](https://upscaler.party/)
11 | - [removerized: Free AI-Powered Background Remover Tool](https://removerized.tech/)
12 | - [VideoGigaGAN: Detail-rich Video Super-Resolution](https://videogigagan.com/)
13 | - [Upscayl: Free and Open Source AI Image Upscaler](https://github.com/upscayl/upscayl#free-and-open-source-ai-image-upscaler)
14 | - [Counter-Strike 2 Website](https://www.counter-strike.net/cs2#Maps) and [Dota 2 Summer Client Update](https://www.dota2.com/summer2023)
15 | - [RoomGPT](https://github.com/Nutlope/roomGPT)
16 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/00-demos/00-index.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryFn } from '@storybook/react';
2 | import React, { useState } from 'react';
3 | import type { ReactCompareSliderDetailedProps } from 'react-compare-slider';
4 | import {
5 | ReactCompareSlider,
6 | ReactCompareSliderImage,
7 | useReactCompareSliderRef,
8 | } from 'react-compare-slider';
9 | import { createPortal } from 'react-dom';
10 |
11 | import { SLIDER_ROOT_TEST_ID } from '../99-tests/test-utils.test';
12 | import { argTypes, args } from '../config';
13 |
14 | const meta: Meta = {
15 | title: 'Demos',
16 | component: ReactCompareSlider,
17 | args,
18 | argTypes,
19 | };
20 | export default meta;
21 |
22 | export const Images: StoryFn = (props) => {
23 | return (
24 |
31 | }
32 | itemTwo={
33 |
38 | }
39 | />
40 | );
41 | };
42 |
43 | Images.args = {
44 | style: {
45 | width: '100%',
46 | backgroundColor: 'white',
47 | backgroundImage: `
48 | linear-gradient(45deg, #ccc 25%, transparent 25%),
49 | linear-gradient(-45deg, #ccc 25%, transparent 25%),
50 | linear-gradient(45deg, transparent 75%, #ccc 75%),
51 | linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
52 | backgroundSize: `20px 20px`,
53 | backgroundPosition: `0 0, 0 10px, 10px -10px, -10px 0px`,
54 | },
55 | };
56 |
57 | export const BoundsPadding: StoryFn = ({
58 | boundsPadding = 80,
59 | ...props
60 | }) => {
61 | return (
62 |
63 |
71 | }
72 | itemTwo={
73 |
77 | }
78 | style={{ width: '100%', flexGrow: 1 }}
79 | />
80 |
81 | );
82 | };
83 |
84 | BoundsPadding.args = { boundsPadding: 80 };
85 |
86 | export const BrowsingContext: StoryFn = (props) => {
87 | const [browsingContext, setBrowsingContext] = useState(null);
88 | const reactCompareSliderRef = useReactCompareSliderRef();
89 |
90 | return (
91 |
92 | setBrowsingContext(window.open('', '', 'popup,width=200,height=200'))}>
93 | Render in popup
94 |
95 | {browsingContext &&
96 | createPortal(
97 |
104 | }
105 | itemTwo={
106 |
110 | }
111 | ref={reactCompareSliderRef}
112 | browsingContext={browsingContext}
113 | />,
114 | browsingContext.document.body,
115 | )}
116 |
117 | );
118 | };
119 |
120 | BrowsingContext.args = {};
121 |
122 | export const ChangePositionOnHover: StoryFn = ({
123 | changePositionOnHover = true,
124 | ...props
125 | }) => {
126 | return (
127 |
128 |
137 | }
138 | itemTwo={
139 |
143 | }
144 | style={{ width: '100%', flexGrow: 1 }}
145 | />
146 |
147 | );
148 | };
149 |
150 | ChangePositionOnHover.args = { changePositionOnHover: true };
151 |
152 | export const Disabled: StoryFn = ({ disabled, ...props }) => {
153 | return (
154 |
155 |
163 | }
164 | itemTwo={
165 |
169 | }
170 | style={{ width: '100%', flexGrow: 1 }}
171 | />
172 |
173 | );
174 | };
175 |
176 | Disabled.args = { disabled: true };
177 |
178 | export const Handle: StoryFn = (props) => {
179 | const CustomHandle: React.FC = () => {
180 | return (
181 |
209 | );
210 | };
211 |
212 | return (
213 | }
216 | itemOne={
217 |
221 | }
222 | itemTwo={
223 |
227 | }
228 | style={{ width: '100%', height: '100vh' }}
229 | />
230 | );
231 | };
232 |
233 | Handle.args = {};
234 |
235 | export const KeyboardIncrement: StoryFn = (props) => {
236 | return (
237 |
238 |
239 | Info: Click the slider handle then use the keyboard arrows to change the slider
240 | position.
241 |
242 |
243 |
250 | }
251 | itemTwo={
252 |
256 | }
257 | style={{ width: '100%', flexGrow: 1 }}
258 | />
259 |
260 | );
261 | };
262 |
263 | KeyboardIncrement.args = {};
264 |
265 | export const OnlyHandleDraggable: StoryFn = ({
266 | onlyHandleDraggable = true,
267 | ...props
268 | }) => {
269 | return (
270 |
271 |
279 | }
280 | itemTwo={
281 |
285 | }
286 | style={{ width: '100%', flexGrow: 1 }}
287 | />
288 |
289 | );
290 | };
291 |
292 | OnlyHandleDraggable.args = { onlyHandleDraggable: true };
293 |
294 | export const OnPositionChange: StoryFn = (props) => {
295 | const onPositionChange = React.useCallback((position) => {
296 | console.log('[OnPositionChange.onPositionChange]', position);
297 | }, []);
298 |
299 | return (
300 |
301 |
302 | Note: This demo will be slightly laggy when viewing the action logging output in
303 | Storybook Actions tab.
304 |
305 |
306 |
314 | }
315 | itemTwo={
316 |
320 | }
321 | style={{ width: '100%', flexGrow: 1 }}
322 | />
323 |
324 | );
325 | };
326 |
327 | OnPositionChange.args = {};
328 |
329 | export const Portrait: StoryFn = ({
330 | portrait = true,
331 | ...props
332 | }) => (
333 |
341 | }
342 | itemTwo={
343 |
350 | }
351 | />
352 | );
353 |
354 | Portrait.args = {
355 | portrait: true,
356 | style: {
357 | width: '100%',
358 | height: '100vh',
359 | backgroundColor: 'white',
360 | backgroundImage: `
361 | linear-gradient(45deg, #ccc 25%, transparent 25%),
362 | linear-gradient(-45deg, #ccc 25%, transparent 25%),
363 | linear-gradient(45deg, transparent 75%, #ccc 75%),
364 | linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
365 | backgroundSize: `20px 20px`,
366 | backgroundPosition: `0 0, 0 10px, 10px -10px, -10px 0px`,
367 | },
368 | };
369 |
370 | export const Transition: StoryFn = (props) => {
371 | const reactCompareSliderRef = useReactCompareSliderRef();
372 |
373 | React.useEffect(() => {
374 | const fireTransition = async () => {
375 | await new Promise((resolve) =>
376 | setTimeout(() => {
377 | reactCompareSliderRef.current?.setPosition(90);
378 | resolve(true);
379 | }, 750),
380 | );
381 | await new Promise((resolve) =>
382 | setTimeout(() => {
383 | reactCompareSliderRef.current?.setPosition(10);
384 | resolve(true);
385 | }, 750),
386 | );
387 | await new Promise((resolve) =>
388 | setTimeout(() => {
389 | reactCompareSliderRef.current?.setPosition(50);
390 | resolve(true);
391 | }, 750),
392 | );
393 | };
394 |
395 | fireTransition();
396 | }, []);
397 |
398 | return (
399 |
407 | }
408 | itemTwo={
409 |
416 | }
417 | style={{ width: '100%', height: '100vh' }}
418 | />
419 | );
420 | };
421 |
422 | Transition.args = {
423 | position: 50,
424 | transition: '.75s ease-in-out',
425 | style: {
426 | backgroundColor: 'white',
427 | backgroundImage: `
428 | linear-gradient(45deg, #ccc 25%, transparent 25%),
429 | linear-gradient(-45deg, #ccc 25%, transparent 25%),
430 | linear-gradient(45deg, transparent 75%, #ccc 75%),
431 | linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
432 | backgroundSize: `20px 20px`,
433 | backgroundPosition: `0 0, 0 10px, 10px -10px, -10px 0px`,
434 | },
435 | };
436 |
437 | export const Position: StoryFn = ({ position = 25, ...props }) => (
438 |
446 | }
447 | itemTwo={
448 |
452 | }
453 | style={{ width: '100%', height: '100vh' }}
454 | />
455 | );
456 |
457 | Position.args = { position: 25 };
458 |
459 | export const UseReactCompareSliderRef: StoryFn = (props) => {
460 | // We need to know which slider is in control to avoid infinite loops 🤯
461 | const [sliderInControl, setSliderInControl] = React.useState(1);
462 | const slider1Ref = useReactCompareSliderRef();
463 | const slider2Ref = useReactCompareSliderRef();
464 |
465 | const handlePosition1Change = React.useCallback(
466 | (position: number) => {
467 | if (sliderInControl === 1) {
468 | slider2Ref.current?.setPosition(position);
469 | }
470 | },
471 | [slider2Ref, sliderInControl],
472 | );
473 |
474 | const handlePosition2Change = React.useCallback(
475 | (position: number) => {
476 | if (sliderInControl === 2) {
477 | slider1Ref.current?.setPosition(position);
478 | }
479 | },
480 | [slider1Ref, sliderInControl],
481 | );
482 |
483 | return (
484 | <>
485 |
486 | setSliderInControl(1)}
491 | onPointerDown={() => setSliderInControl(1)}
492 | onPositionChange={handlePosition1Change}
493 | itemOne={
494 |
498 | }
499 | itemTwo={
500 |
504 | }
505 | style={{ width: '50%' }}
506 | />
507 | setSliderInControl(2)}
512 | onPointerDown={() => setSliderInControl(2)}
513 | onPositionChange={handlePosition2Change}
514 | itemOne={
515 |
519 | }
520 | itemTwo={
521 |
525 | }
526 | style={{ width: '50%' }}
527 | />
528 |
529 | {
532 | slider1Ref.current.setPosition(props.position!);
533 | slider2Ref.current.setPosition(props.position!);
534 | }}
535 | style={{
536 | position: 'absolute',
537 | left: '50%',
538 | fontSize: '1.5rem',
539 | transform: 'translateX(-50%)',
540 | }}
541 | >
542 | Reset sliders to position
value ({props.position})
543 |
544 |
545 | >
546 | );
547 | };
548 |
549 | UseReactCompareSliderRef.args = {};
550 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/00-demos/02-edge-cases.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryFn } from '@storybook/react';
2 | import type { ReactCompareSliderDetailedProps } from 'react-compare-slider';
3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider';
4 |
5 | import { argTypes, args } from '../config';
6 |
7 | const meta: Meta = {
8 | title: 'Demos/Edge Cases',
9 | component: ReactCompareSlider,
10 | args,
11 | argTypes,
12 | };
13 | export default meta;
14 |
15 | export const Scaled: StoryFn = ({ style, ...props }) => (
16 |
23 | }
24 | itemTwo={
25 |
29 | }
30 | style={{ width: '100%', height: '100vh', ...style }}
31 | />
32 | );
33 |
34 | Scaled.args = {
35 | style: {
36 | transform: 'scale(0.75)',
37 | },
38 | };
39 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/01-recipes/00-index.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryFn } from '@storybook/react';
2 | import React from 'react';
3 | import type { ReactCompareSliderDetailedProps } from 'react-compare-slider';
4 | import {
5 | ReactCompareSlider,
6 | ReactCompareSliderHandle,
7 | ReactCompareSliderImage,
8 | useReactCompareSliderRef,
9 | } from 'react-compare-slider';
10 |
11 | import { argTypes, args } from '../config';
12 |
13 | const meta: Meta = {
14 | title: 'Recipes',
15 | component: ReactCompareSlider,
16 | args,
17 | argTypes,
18 | };
19 | export default meta;
20 |
21 | export const ItemLabels: StoryFn = (props) => {
22 | const [labelOpacity, setLabelOpacity] = React.useState(1);
23 |
24 | const labelStyle = {
25 | fontSize: '1.25rem',
26 | position: 'absolute',
27 | padding: '1rem',
28 | color: 'white',
29 | opacity: labelOpacity,
30 | border: '2px solid white',
31 | borderRadius: '.5rem',
32 | backdropFilter: 'blur(0.25rem) saturate(180%) contrast(80%) brightness(120%)',
33 | WebkitBackdropFilter: 'blur(0.25rem) saturate(180%) contrast(80%) brightness(120%)',
34 | transition: 'opacity 0.25s ease-in-out',
35 | };
36 |
37 | return (
38 | setLabelOpacity(0)}
41 | onPointerUp={() => setLabelOpacity(1)}
42 | itemOne={
43 |
58 | }
59 | itemTwo={
60 |
75 | }
76 | />
77 | );
78 | };
79 |
80 | ItemLabels.args = {
81 | style: {
82 | width: '100%',
83 | height: '100vh',
84 | },
85 | };
86 |
87 | export const HandleLabels: StoryFn = (props) => {
88 | const [labelOpacity, setLabelOpacity] = React.useState(1);
89 |
90 | const labelStyle = {
91 | fontSize: '.75rem',
92 | position: 'absolute',
93 | padding: '.25rem',
94 | color: 'white',
95 | opacity: labelOpacity,
96 | borderRadius: '.25rem',
97 | border: '1px solid white',
98 | backdropFilter: 'blur(0.25rem) saturate(180%) contrast(80%) brightness(120%)',
99 | WebkitBackdropFilter: 'blur(0.25rem) saturate(180%) contrast(80%) brightness(120%)',
100 | backgroundColor: 'rgba(0, 0, 0, 0.5)',
101 | transition: 'opacity 0.25s ease-in-out',
102 | };
103 |
104 | return (
105 | setLabelOpacity(0)}
108 | onPointerUp={() => setLabelOpacity(1)}
109 | itemOne={
110 |
114 | }
115 | itemTwo={
116 |
120 | }
121 | handle={
122 |
123 |
124 |
Label 1
125 |
Label 2
126 |
127 | }
128 | />
129 | );
130 | };
131 |
132 | HandleLabels.args = {
133 | style: {
134 | width: '100%',
135 | height: '100vh',
136 | },
137 | };
138 |
139 | export const DetectTouchDevices: StoryFn = (props) => {
140 | const isTouchDevice = window.matchMedia('(pointer: coarse)').matches;
141 |
142 | return (
143 | <>
144 |
152 | }
153 | itemTwo={
154 |
158 | }
159 | style={{ width: '100%', height: '100vh' }}
160 | />
161 |
175 | Enable onlyHandleDraggable
for touch devices only
176 |
177 | Is touch device: {String(isTouchDevice)}
178 |
179 | >
180 | );
181 | };
182 |
183 | DetectTouchDevices.argTypes = {
184 | onlyHandleDraggable: {
185 | control: {
186 | disable: true,
187 | },
188 | },
189 | };
190 |
191 | DetectTouchDevices.args = {};
192 |
193 | export const WaitForImageLoad: StoryFn = (props) => {
194 | const [loaded, setLoaded] = React.useState(0);
195 | const imagesStyle = {
196 | opacity: loaded === 2 ? 1 : 0,
197 | transition: 'opacity 1s 0.5s ease-in-out',
198 | };
199 |
200 | return (
201 | setLoaded((prev) => prev + 1)}
209 | />
210 | }
211 | itemTwo={
212 | setLoaded((prev) => prev + 1)}
217 | />
218 | }
219 | />
220 | );
221 | };
222 |
223 | WaitForImageLoad.args = {
224 | style: {
225 | width: '100%',
226 | height: '100vh',
227 | backgroundColor: 'black',
228 | backgroundImage: 'radial-gradient(rgba(200, 109, 252, .5), rgba(39, 37, 39, .5))',
229 | },
230 | };
231 |
232 | export const ResetOnPointerLeave: StoryFn = (props) => {
233 | const reactCompareSliderRef = useReactCompareSliderRef();
234 |
235 | return (
236 |
237 | reactCompareSliderRef.current?.setPosition(props.position!)}
240 | ref={reactCompareSliderRef}
241 | itemOne={
242 |
246 | }
247 | itemTwo={
248 |
252 | }
253 | />
254 | Touch device focus trap
255 |
256 | );
257 | };
258 |
259 | ResetOnPointerLeave.args = {
260 | style: {
261 | width: '100%',
262 | },
263 | };
264 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/01-recipes/01-google-maps.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { GoogleMapProps } from '@react-google-maps/api';
2 | import { GoogleMap as GoogleMapBase, useJsApiLoader } from '@react-google-maps/api';
3 | import type { Meta, StoryFn } from '@storybook/react';
4 | import React from 'react';
5 | import type { ReactCompareSliderDetailedProps } from 'react-compare-slider';
6 | import { ReactCompareSlider } from 'react-compare-slider';
7 |
8 | import { argTypes, args } from '../config';
9 |
10 | const meta: Meta = {
11 | title: 'Recipes/Google Maps',
12 | component: ReactCompareSlider,
13 | args,
14 | argTypes,
15 | };
16 | export default meta;
17 |
18 | const useGoogleMap = () => {
19 | const [map, setMap] = React.useState(null);
20 |
21 | const onLoad = React.useCallback(function callback(nextMap) {
22 | setMap(nextMap);
23 | }, []);
24 |
25 | const onUnmount = React.useCallback(function callback() {
26 | setMap(null);
27 | }, []);
28 |
29 | return { onLoad, onUnmount, map };
30 | };
31 |
32 | const GoogleMap: React.FC = ({ isLoaded, ...props }) => {
33 | if (!isLoaded) return null;
34 |
35 | return ;
36 | };
37 |
38 | const sharedMapProps: GoogleMapProps = {
39 | zoom: 14,
40 | tilt: 0,
41 | center: { lat: 54.9754478, lng: -1.6073616 },
42 | mapContainerStyle: {
43 | width: '100%',
44 | height: '100%',
45 | },
46 | };
47 |
48 | export const GoogleMaps: StoryFn = (props) => {
49 | const { isLoaded } = useJsApiLoader({
50 | id: 'google-map-script',
51 | googleMapsApiKey: 'AIzaSyAoRpWSXL16EnnFQqFfkRtfMCKJJTMzvk8',
52 | });
53 |
54 | const mapOne = useGoogleMap();
55 | const mapTwo = useGoogleMap();
56 | const [zoom, setZoom] = React.useState(sharedMapProps.zoom);
57 |
58 | return (
59 | mapTwo?.map?.setCenter(mapOne.map?.getCenter())}
67 | onZoomChanged={() => {
68 | setZoom(mapOne.map?.zoom || sharedMapProps.zoom);
69 | mapTwo?.map?.setCenter(mapOne.map?.getCenter());
70 | }}
71 | zoom={zoom}
72 | />
73 | }
74 | itemTwo={
75 | mapOne?.map?.setCenter(mapTwo.map?.getCenter())}
80 | onZoomChanged={() => {
81 | setZoom(mapTwo.map?.zoom || sharedMapProps.zoom);
82 | mapOne?.map?.setCenter(mapTwo.map?.getCenter());
83 | }}
84 | mapTypeId="satellite"
85 | zoom={zoom}
86 | />
87 | }
88 | />
89 | );
90 | };
91 |
92 | GoogleMaps.args = {
93 | style: { width: '100%', height: '100vh' },
94 | onlyHandleDraggable: true,
95 | };
96 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/02-handles/00-react-compare-slider-handle.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryFn } from '@storybook/react';
2 | import { useEffect } from 'react';
3 | import type { ReactCompareSliderProps } from 'react-compare-slider';
4 | import {
5 | ReactCompareSlider,
6 | ReactCompareSliderHandle,
7 | ReactCompareSliderImage,
8 | useReactCompareSliderRef,
9 | } from 'react-compare-slider';
10 |
11 | import { argTypes, args } from '../config';
12 |
13 | const meta: Meta = {
14 | title: 'Handles',
15 | component: ReactCompareSlider,
16 | args,
17 | argTypes,
18 | };
19 | export default meta;
20 |
21 | export const InheritedColor: StoryFn = ({ portrait, ...props }) => (
22 | }
26 | itemOne={
27 |
32 | }
33 | itemTwo={
34 |
38 | }
39 | style={{ width: '100%', height: '100vh' }}
40 | />
41 | );
42 |
43 | export const IndividualStyles: StoryFn = ({ portrait, ...props }) => (
44 |
60 | }
61 | itemOne={
62 |
67 | }
68 | itemTwo={
69 |
73 | }
74 | style={{ width: '100%', height: '100vh' }}
75 | />
76 | );
77 |
78 | export const HideButton: StoryFn = ({ portrait, ...props }) => (
79 |
89 | }
90 | itemOne={
91 |
96 | }
97 | itemTwo={
98 |
102 | }
103 | style={{ width: '100%', height: '100vh' }}
104 | />
105 | );
106 |
107 | export const HideLines: StoryFn = ({ portrait, ...props }) => (
108 |
122 | }
123 | itemOne={
124 |
129 | }
130 | itemTwo={
131 |
135 | }
136 | style={{ width: '100%', height: '100vh' }}
137 | />
138 | );
139 |
140 | export const OverrideHandleContainerClick: StoryFn = (props) => {
141 | const reactCompareSliderRef = useReactCompareSliderRef();
142 |
143 | useEffect(() => {
144 | /**
145 | * @NOTE The `containerClick` function is defined within the component for simplicity. If you're
146 | * not using any internal state within it, you can move it outside of the component to
147 | * avoid it being redefined on every effect change.
148 | */
149 | const containerClick = (ev: MouseEvent) => {
150 | const container = ev.currentTarget as HTMLButtonElement;
151 |
152 | ev.preventDefault();
153 | ev.stopImmediatePropagation();
154 | container.focus({ preventScroll: true });
155 | container.scrollIntoView({ behavior: 'smooth', block: 'center' });
156 | };
157 |
158 | const handleContainer = reactCompareSliderRef.current.handleContainer!;
159 |
160 | handleContainer.addEventListener('click', containerClick, { capture: true });
161 |
162 | return () => {
163 | handleContainer.removeEventListener('click', containerClick, { capture: true });
164 | };
165 | }, []);
166 |
167 | return (
168 |
169 |
170 |
Click me, then click the slider handle
171 |
172 | The useReactCompareSliderRef
hook exposes the handleContainer
{' '}
173 | property which points to the button
element that contains the{' '}
174 | handle
. By default, when the handleContainer
or any elements
175 | within it are clicked, it focuses the handleContainer
and moves the slider
176 | into view. This is for accessibility but you can override the behaviour as needed. In this
177 | example, instead of plainly focusing the slider, it focuses and smooth scrolls it into
178 | view.
179 |
180 |
181 |
182 | Note that this only occurs when the
183 | handleContainer
or elements within the handleContainer
are
184 | clicked. This is to allow custom itemOne|itemTwo
components to be interacted
185 | with without the slider stealing focus.
186 |
187 |
188 |
197 | }
198 | itemTwo={
199 |
203 | }
204 | style={{ width: '100%', minHeight: '100vh' }}
205 | />
206 |
207 | );
208 | };
209 |
210 | OverrideHandleContainerClick.args = {};
211 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/02-handles/01-custom.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryFn } from '@storybook/react';
2 | import React from 'react';
3 | import type { ReactCompareSliderProps } from 'react-compare-slider';
4 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider';
5 |
6 | import { argTypes, args } from '../config';
7 |
8 | const meta: Meta = {
9 | title: 'Handles/Custom Component',
10 | component: ReactCompareSlider,
11 | args,
12 | argTypes,
13 | };
14 | export default meta;
15 |
16 | export const CustomComponent: StoryFn = (props) => {
17 | const CustomHandle: React.FC = () => {
18 | return (
19 |
47 | );
48 | };
49 |
50 | return (
51 | }
54 | itemOne={
55 |
59 | }
60 | itemTwo={
61 |
65 | }
66 | style={{ width: '100%', height: '100vh' }}
67 | />
68 | );
69 | };
70 |
71 | CustomComponent.args = {};
72 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/99-tests/clip.test.stories.tsx:
--------------------------------------------------------------------------------
1 | import { expect } from '@storybook/jest';
2 | import type { Meta } from '@storybook/react';
3 | import { fireEvent, waitFor, within } from '@storybook/testing-library';
4 | import type { ReactCompareSlider } from 'react-compare-slider';
5 |
6 | import { Template, getArgs } from './test-utils.test';
7 |
8 | const meta: Meta = {
9 | title: 'Tests/Browser/Clip',
10 | };
11 | export default meta;
12 |
13 | export const ClipBoth = Template.bind({});
14 | ClipBoth.args = getArgs({
15 | clip: 'both',
16 | style: { width: 256, height: 256 },
17 | });
18 |
19 | ClipBoth.play = async ({ canvasElement }) => {
20 | const canvas = within(canvasElement);
21 | const slider = canvas.getByRole('slider') as Element;
22 | const sliderRoot = (await canvas.findByTestId(ClipBoth.args?.['data-testid'])) as Element;
23 |
24 | await waitFor(() => expect(slider).toBeInTheDocument());
25 | await waitFor(async () => expect(await canvas.findAllByRole('img')).toHaveLength(2));
26 | await waitFor(() => {
27 | const [itemOne, itemTwo] = Array.from(
28 | sliderRoot.querySelectorAll('[data-rcs="clip-item"]'),
29 | ) as HTMLElement[];
30 | expect(itemOne?.style.clipPath).toBe('inset(0px 50% 0px 0px)');
31 | expect(itemTwo?.style.clipPath).toBe('inset(0px 0px 0px 50%)');
32 | });
33 |
34 | await new Promise((resolve) => setTimeout(resolve, 500));
35 |
36 | await fireEvent.pointerDown(sliderRoot, {
37 | clientX: sliderRoot.clientWidth * 0.75,
38 | clientY: sliderRoot.clientHeight * 0.75,
39 | });
40 |
41 | await new Promise((resolve) => setTimeout(resolve, 500));
42 |
43 | await waitFor(() => {
44 | const [itemOne, itemTwo] = Array.from(
45 | sliderRoot.querySelectorAll('[data-rcs="clip-item"]'),
46 | ) as HTMLElement[];
47 |
48 | expect(itemOne).toBeVisible();
49 | expect(itemTwo).toBeVisible();
50 | expect(itemOne?.style.clipPath).toBe('inset(0px 25% 0px 0px)');
51 | expect(itemTwo?.style.clipPath).toBe('inset(0px 0px 0px 75%)');
52 | });
53 | };
54 |
55 | export const ClipItemOne = Template.bind({});
56 | ClipItemOne.args = getArgs({
57 | clip: 'itemOne',
58 | style: { width: 256, height: 256 },
59 | });
60 |
61 | ClipItemOne.play = async ({ canvasElement }) => {
62 | const canvas = within(canvasElement);
63 | const slider = canvas.getByRole('slider') as Element;
64 | const sliderRoot = (await canvas.findByTestId(ClipBoth.args?.['data-testid'])) as Element;
65 |
66 | await waitFor(() => expect(slider).toBeInTheDocument());
67 | await waitFor(async () => expect(await canvas.findAllByRole('img')).toHaveLength(2));
68 | await waitFor(() => {
69 | const [itemOne, itemTwo] = Array.from(
70 | sliderRoot.querySelectorAll('[data-rcs="clip-item"]'),
71 | ) as HTMLElement[];
72 |
73 | expect(itemOne).toBeVisible();
74 | expect(itemTwo).toBeVisible();
75 | expect(itemOne?.style.clipPath).toBe('inset(0px 50% 0px 0px)');
76 | expect(itemTwo?.style.clipPath).toBe('none');
77 | });
78 |
79 | await new Promise((resolve) => setTimeout(resolve, 500));
80 |
81 | await fireEvent.pointerDown(sliderRoot, {
82 | clientX: sliderRoot.clientWidth * 0.75,
83 | clientY: sliderRoot.clientHeight * 0.75,
84 | });
85 |
86 | await new Promise((resolve) => setTimeout(resolve, 500));
87 |
88 | await waitFor(() => {
89 | const [itemOne, itemTwo] = Array.from(
90 | sliderRoot.querySelectorAll('[data-rcs="clip-item"]'),
91 | ) as HTMLElement[];
92 |
93 | expect(itemOne).toBeVisible();
94 | expect(itemTwo).toBeVisible();
95 | expect(itemOne?.style.clipPath).toBe('inset(0px 25% 0px 0px)');
96 | expect(itemTwo?.style.clipPath).toBe('none');
97 | });
98 | };
99 |
100 | export const ClipItemTwo = Template.bind({});
101 | ClipItemTwo.args = getArgs({
102 | clip: 'itemTwo',
103 | style: { width: 256, height: 256 },
104 | });
105 |
106 | ClipItemTwo.play = async ({ canvasElement }) => {
107 | const canvas = within(canvasElement);
108 | const slider = canvas.getByRole('slider') as Element;
109 | const sliderRoot = (await canvas.findByTestId(ClipBoth.args?.['data-testid'])) as Element;
110 |
111 | await waitFor(() => expect(slider).toBeInTheDocument());
112 | await waitFor(async () => expect(await canvas.findAllByRole('img')).toHaveLength(2));
113 | await waitFor(() => {
114 | const [itemOne, itemTwo] = Array.from(
115 | sliderRoot.querySelectorAll('[data-rcs="clip-item"]'),
116 | ) as HTMLElement[];
117 | expect(itemOne?.style.clipPath).toBe('none');
118 | expect(itemTwo?.style.clipPath).toBe('inset(0px 0px 0px 50%)');
119 | });
120 |
121 | await new Promise((resolve) => setTimeout(resolve, 500));
122 |
123 | await fireEvent.pointerDown(sliderRoot, {
124 | clientX: sliderRoot.clientWidth * 0.25,
125 | clientY: sliderRoot.clientHeight * 0.25,
126 | });
127 |
128 | await new Promise((resolve) => setTimeout(resolve, 500));
129 |
130 | await waitFor(() => {
131 | const [itemOne, itemTwo] = Array.from(
132 | sliderRoot.querySelectorAll('[data-rcs="clip-item"]'),
133 | ) as HTMLElement[];
134 | expect(itemOne?.style.clipPath).toBe('none');
135 | expect(itemTwo?.style.clipPath).toBe('inset(0px 0px 0px 25%)');
136 | });
137 | };
138 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/99-tests/default.test.stories.tsx:
--------------------------------------------------------------------------------
1 | import { expect } from '@storybook/jest';
2 | import type { Meta } from '@storybook/react';
3 | import { waitFor, within } from '@storybook/testing-library';
4 | import type { ReactCompareSlider } from 'react-compare-slider';
5 |
6 | import { Template, getArgs } from './test-utils.test';
7 |
8 | const meta: Meta = {
9 | title: 'Tests/Browser/Default',
10 | };
11 | export default meta;
12 |
13 | /** Test default props. */
14 | export const Default = Template.bind({});
15 | Default.args = getArgs();
16 |
17 | Default.play = async ({ canvasElement }) => {
18 | const canvas = within(canvasElement);
19 | const sliderRoot = (await canvas.findByRole('slider')) as HTMLDivElement;
20 |
21 | // Should have elements on mount.
22 | await waitFor(() => expect(canvas.getByAltText('one')).toBeInTheDocument());
23 | await waitFor(() => expect(canvas.getByAltText('two')).toBeInTheDocument());
24 |
25 | // Should have a11y attributes on mount.
26 | await waitFor(() => expect(sliderRoot).toHaveAttribute('aria-valuemin', '0'));
27 | await waitFor(() => expect(sliderRoot).toHaveAttribute('aria-valuemax', '100'));
28 | await waitFor(() => expect(sliderRoot).toHaveAttribute('aria-valuenow', '50'));
29 | await waitFor(() =>
30 | expect(canvas.queryByLabelText('Drag to move or focus and use arrow keys')).toBeInTheDocument(),
31 | );
32 |
33 | // Should have initial position on mount.
34 | await waitFor(() => expect(Default.args?.onPositionChange).toHaveBeenLastCalledWith(50));
35 | };
36 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/99-tests/disabled.test.stories.tsx:
--------------------------------------------------------------------------------
1 | import { expect } from '@storybook/jest';
2 | import type { Meta } from '@storybook/react';
3 | import { userEvent, waitFor, within } from '@storybook/testing-library';
4 | import type { ReactCompareSlider } from 'react-compare-slider';
5 |
6 | import { Template, getArgs } from './test-utils.test';
7 |
8 | const meta: Meta = {
9 | title: 'Tests/Browser/Disabled',
10 | };
11 | export default meta;
12 |
13 | export const Disabled: typeof Template = (args) => (
14 |
15 |
16 | Button
17 |
18 |
19 |
20 | );
21 |
22 | Disabled.args = getArgs({ style: { width: 200, height: 200 }, disabled: true });
23 |
24 | Disabled.play = async ({ canvasElement }) => {
25 | const user = userEvent.setup();
26 | const canvas = within(canvasElement);
27 | const sliderRoot = canvas.queryByTestId(Disabled.args?.['data-testid']) as Element;
28 |
29 | // Should have elements on mount.
30 | await new Promise((resolve) => setTimeout(resolve, 500));
31 | await waitFor(() => expect(sliderRoot).toBeInTheDocument());
32 |
33 | const testButton = canvas.queryByTestId('test-button') as HTMLElement;
34 |
35 | // Slider should have disabled attribute.
36 | await waitFor(() => expect(canvas.getByRole('slider')).toBeDisabled());
37 |
38 | // Focus the test button.
39 | await user.click(testButton);
40 | await waitFor(() =>
41 | expect((document.activeElement as HTMLElement).getAttribute('data-testid')).toBe('test-button'),
42 | );
43 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
44 |
45 | // Click on the canvas and move pointer - position and focused element should not be slider.
46 | await user.click(sliderRoot);
47 | await waitFor(() =>
48 | expect((document.activeElement as HTMLElement).getAttribute('data-testid')).toBe('test-button'),
49 | );
50 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
51 |
52 | // Click on the handle and move pointer - position and focused element should not be slider.
53 | await user.click(canvas.getByRole('slider'));
54 | await waitFor(() =>
55 | expect((document.activeElement as HTMLElement).getAttribute('data-testid')).toBe('test-button'),
56 | );
57 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
58 |
59 | // Click on the handle and press arrow key - position and focused element should not be slider.
60 | await user.click(canvas.getByRole('slider'));
61 | await waitFor(() =>
62 | expect((document.activeElement as HTMLElement).getAttribute('data-testid')).toBe('test-button'),
63 | );
64 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
65 |
66 | // Move handle right.
67 | await user.click(canvas.getByRole('slider'));
68 | await user.keyboard('{ArrowRight}');
69 | await waitFor(() =>
70 | expect((document.activeElement as HTMLElement).getAttribute('data-testid')).toBe('test-button'),
71 | );
72 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
73 | };
74 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/99-tests/keyboard-interactions.test.stories.tsx:
--------------------------------------------------------------------------------
1 | import { expect } from '@storybook/jest';
2 | import type { Meta } from '@storybook/react';
3 | import { fireEvent, userEvent, waitFor, within } from '@storybook/testing-library';
4 | import type { ReactCompareSlider } from 'react-compare-slider';
5 |
6 | import { Template, getArgs } from './test-utils.test';
7 |
8 | const meta: Meta = {
9 | title: 'Tests/Browser/Keyboard Interactions',
10 | };
11 | export default meta;
12 |
13 | export const KeyboardInteractionsLandscape = Template.bind({});
14 |
15 | KeyboardInteractionsLandscape.args = getArgs({ style: { width: 200, height: 200 } });
16 |
17 | KeyboardInteractionsLandscape.play = async ({ canvasElement }) => {
18 | const user = userEvent.setup();
19 | const canvas = within(canvasElement);
20 | const sliderRoot = canvas.queryByTestId(
21 | KeyboardInteractionsLandscape.args?.['data-testid'],
22 | ) as Element;
23 |
24 | // Should have elements on mount.
25 | await new Promise((resolve) => setTimeout(resolve, 500));
26 | await waitFor(() => expect(sliderRoot).toBeInTheDocument());
27 |
28 | // Focus the handle with tab key.
29 | await user.tab();
30 |
31 | await waitFor(() =>
32 | expect(document.activeElement!.getAttribute('data-rcs')).toBe('handle-container'),
33 | );
34 |
35 | // Unfocus the handle with tab key.
36 | await user.tab({ shift: true });
37 | await waitFor(() =>
38 | expect(document.activeElement!.getAttribute('data-rcs')).not.toBe('handle-container'),
39 | );
40 |
41 | // Focus the handle with mouse click.
42 | await fireEvent.click(canvas.getByRole('slider'), { clientX: 100, clientY: 100 });
43 | await new Promise((resolve) => setTimeout(resolve, 500));
44 |
45 | await waitFor(() =>
46 | expect(document.activeElement!.getAttribute('data-rcs')).toBe('handle-container'),
47 | );
48 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
49 |
50 | // Move handle right.
51 | await user.keyboard('{ArrowRight}');
52 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('55'));
53 |
54 | // Move handle Left.
55 | await user.keyboard('{ArrowLeft}');
56 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
57 |
58 | // Move handle Right.
59 | await user.keyboard('{ArrowUp}');
60 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('55'));
61 |
62 | // Move handle Left.
63 | await user.keyboard('{ArrowDown}');
64 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
65 | };
66 |
67 | export const KeyboardInteractionsPortrait = Template.bind({});
68 | KeyboardInteractionsPortrait.args = getArgs({
69 | portrait: true,
70 | style: { width: 200, height: 200 },
71 | });
72 |
73 | KeyboardInteractionsPortrait.play = async ({ canvasElement }) => {
74 | const user = userEvent.setup();
75 | const canvas = within(canvasElement);
76 | const sliderRoot = canvas.queryByTestId(
77 | KeyboardInteractionsPortrait.args?.['data-testid'],
78 | ) as Element;
79 |
80 | // Should have elements on mount.
81 | await new Promise((resolve) => setTimeout(resolve, 500));
82 | await waitFor(() => expect(sliderRoot).toBeInTheDocument());
83 |
84 | // Focus the handle with tab key.
85 | await user.tab();
86 |
87 | await waitFor(() =>
88 | expect(document.activeElement!.getAttribute('data-rcs')).toBe('handle-container'),
89 | );
90 |
91 | // Unfocus the handle with tab key.
92 | await user.tab({ shift: true });
93 |
94 | await waitFor(() =>
95 | expect(document.activeElement!.getAttribute('data-rcs')).not.toBe('handle-container'),
96 | );
97 |
98 | // Focus the handle with mouse click.
99 | await fireEvent.click(canvas.getByRole('slider'), { clientX: 100, clientY: 100 });
100 | await new Promise((resolve) => setTimeout(resolve, 500));
101 |
102 | await waitFor(() =>
103 | expect((document.activeElement as HTMLElement).getAttribute('data-rcs')).toBe(
104 | 'handle-container',
105 | ),
106 | );
107 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
108 |
109 | // Move handle right.
110 | await user.keyboard('{ArrowRight}');
111 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('45'));
112 |
113 | // Move handle Left.
114 | await user.keyboard('{ArrowLeft}');
115 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
116 |
117 | // Move handle Right.
118 | await user.keyboard('{ArrowUp}');
119 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('45'));
120 |
121 | // Move handle Left.
122 | await user.keyboard('{ArrowDown}');
123 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
124 | };
125 |
126 | export const KeyboardInteractionsPixel = Template.bind({});
127 | KeyboardInteractionsPixel.args = getArgs({
128 | keyboardIncrement: 20,
129 | style: { width: 200, height: 200 },
130 | });
131 |
132 | KeyboardInteractionsPixel.play = async ({ canvasElement }) => {
133 | const user = userEvent.setup();
134 | const canvas = within(canvasElement);
135 | const sliderRoot = canvas.queryByTestId(
136 | KeyboardInteractionsPortrait.args?.['data-testid'],
137 | ) as Element;
138 |
139 | // Should have elements on mount.
140 | await new Promise((resolve) => setTimeout(resolve, 500));
141 | await waitFor(() => expect(sliderRoot).toBeInTheDocument());
142 |
143 | // Focus the handle with tab key.
144 | await user.tab();
145 |
146 | await waitFor(() =>
147 | expect(document.activeElement!.getAttribute('data-rcs')).toBe('handle-container'),
148 | );
149 |
150 | // Unfocus the handle with tab key.
151 | await user.tab({ shift: true });
152 |
153 | await waitFor(() =>
154 | expect(document.activeElement!.getAttribute('data-rcs')).not.toBe('handle-container'),
155 | );
156 |
157 | // Focus the handle with mouse click.
158 | await fireEvent.click(canvas.getByRole('slider'), { clientX: 100, clientY: 100 });
159 | await new Promise((resolve) => setTimeout(resolve, 500));
160 |
161 | await waitFor(() =>
162 | expect((document.activeElement as HTMLElement).getAttribute('data-rcs')).toBe(
163 | 'handle-container',
164 | ),
165 | );
166 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
167 |
168 | // Move handle right.
169 | await user.keyboard('{ArrowRight}');
170 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('60'));
171 |
172 | // Move handle Left.
173 | await user.keyboard('{ArrowLeft}');
174 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
175 |
176 | // Move handle Right.
177 | await user.keyboard('{ArrowUp}');
178 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('60'));
179 |
180 | // Move handle Left.
181 | await user.keyboard('{ArrowDown}');
182 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
183 | };
184 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/99-tests/pointer-interactions.stories.tsx:
--------------------------------------------------------------------------------
1 | import { expect } from '@storybook/jest';
2 | import type { Meta, StoryFn } from '@storybook/react';
3 | import { fireEvent, userEvent, waitFor, within } from '@storybook/testing-library';
4 | import type { ReactCompareSliderDetailedProps } from 'react-compare-slider';
5 | import { ReactCompareSlider } from 'react-compare-slider';
6 |
7 | import { Template, getArgs } from './test-utils.test';
8 |
9 | const meta: Meta = {
10 | title: 'Tests/Browser/Interactions',
11 | };
12 | export default meta;
13 |
14 | export const PointerMovementWithinBounds = Template.bind({ style: { width: 200, height: 200 } });
15 | PointerMovementWithinBounds.args = getArgs({ style: { width: 200, height: 200 } });
16 | PointerMovementWithinBounds.play = async ({ canvasElement }) => {
17 | const canvas = within(canvasElement);
18 | const sliderRoot = (await canvas.findByTestId(
19 | PointerMovementWithinBounds.args?.['data-testid'],
20 | )) as Element;
21 |
22 | // Should have elements on mount.
23 | await waitFor(() => expect(sliderRoot).toBeInTheDocument());
24 |
25 | await new Promise((resolve) => setTimeout(resolve, 500));
26 |
27 | await fireEvent.pointerDown(sliderRoot, {
28 | clientX: sliderRoot.clientWidth * 0.75,
29 | clientY: sliderRoot.clientHeight * 0.75,
30 | });
31 |
32 | await new Promise((resolve) => setTimeout(resolve, 500));
33 |
34 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('75'));
35 | await waitFor(() =>
36 | expect(PointerMovementWithinBounds.args?.onPositionChange).toHaveBeenCalledWith(75),
37 | );
38 |
39 | await new Promise((resolve) => setTimeout(resolve, 500));
40 |
41 | await fireEvent.pointerDown(sliderRoot, {
42 | clientX: sliderRoot.clientWidth,
43 | clientY: sliderRoot.clientHeight,
44 | });
45 |
46 | await new Promise((resolve) => setTimeout(resolve, 500));
47 |
48 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('100'));
49 | await waitFor(() =>
50 | expect(PointerMovementWithinBounds.args?.onPositionChange).toHaveBeenCalledWith(100),
51 | );
52 |
53 | await new Promise((resolve) => setTimeout(resolve, 500));
54 |
55 | await fireEvent.pointerDown(sliderRoot, {
56 | clientX: 10,
57 | clientY: 10,
58 | });
59 |
60 | await new Promise((resolve) => setTimeout(resolve, 500));
61 |
62 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('5'));
63 | await waitFor(() =>
64 | expect(PointerMovementWithinBounds.args?.onPositionChange).toHaveBeenCalledWith(5),
65 | );
66 | };
67 |
68 | /**
69 | * Ensure slider position stops when pointer is not down and moved outside of the root.
70 | */
71 | export const ChangePositionOnHover: StoryFn = (props) => {
72 | return (
73 |
74 |
75 |
76 | );
77 | };
78 | ChangePositionOnHover.args = getArgs({
79 | position: 0,
80 | changePositionOnHover: true,
81 | style: { width: 200, height: 200 },
82 | });
83 | ChangePositionOnHover.play = async ({ canvasElement }) => {
84 | const user = userEvent.setup();
85 | const canvas = within(canvasElement);
86 | const slider = (await canvas.findByRole('slider')) as Element;
87 | const sliderRoot = (await canvas.findByTestId(
88 | ChangePositionOnHover.args?.['data-testid'],
89 | )) as Element;
90 |
91 | await waitFor(() => expect(sliderRoot).toBeInTheDocument());
92 |
93 | await user.click(slider);
94 |
95 | await fireEvent.pointerMove(sliderRoot, {
96 | clientX: sliderRoot.clientWidth * 0.5,
97 | clientY: sliderRoot.clientHeight * 0.5,
98 | });
99 |
100 | await new Promise((resolve) => setTimeout(resolve, 500));
101 |
102 | await waitFor(() => {
103 | expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50');
104 | expect(ChangePositionOnHover.args?.onPositionChange).toHaveBeenCalledWith(50);
105 | });
106 |
107 | // Mouse the pointer outside of the slider.
108 | await fireEvent.pointerMove(sliderRoot, {
109 | clientX: sliderRoot.clientWidth * 1.5,
110 | clientY: sliderRoot.clientHeight * 1.5,
111 | });
112 |
113 | await new Promise((resolve) => setTimeout(resolve, 500));
114 |
115 | await fireEvent.pointerLeave(sliderRoot);
116 |
117 | await new Promise((resolve) => setTimeout(resolve, 500));
118 |
119 | await waitFor(() => {
120 | expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('100');
121 | expect(ChangePositionOnHover.args?.onPositionChange).toHaveBeenCalledWith(100);
122 | });
123 | };
124 |
125 | /**
126 | * Ensure slider position continues to update when pointer is down and moved outside of the root.
127 | */
128 | export const ChangePositionOnHoverPointerDown: StoryFn = (
129 | props,
130 | ) => {
131 | return (
132 |
133 |
134 |
135 | );
136 | };
137 | ChangePositionOnHoverPointerDown.args = getArgs({
138 | changePositionOnHover: true,
139 | style: { width: 200, height: 200 },
140 | });
141 | ChangePositionOnHoverPointerDown.play = async ({ canvasElement }) => {
142 | const canvas = within(canvasElement);
143 | const sliderRoot = (await canvas.findByTestId(
144 | ChangePositionOnHoverPointerDown.args?.['data-testid'],
145 | )) as Element;
146 |
147 | await waitFor(() => expect(sliderRoot).toBeInTheDocument());
148 |
149 | await fireEvent.pointerMove(sliderRoot, {
150 | clientX: sliderRoot.clientWidth * 0.5,
151 | clientY: sliderRoot.clientHeight * 0.5,
152 | });
153 |
154 | await new Promise((resolve) => setTimeout(resolve, 500));
155 |
156 | await waitFor(() => {
157 | expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50');
158 | expect(ChangePositionOnHoverPointerDown.args?.onPositionChange).toHaveBeenCalledWith(50);
159 | });
160 |
161 | await new Promise((resolve) => setTimeout(resolve, 500));
162 |
163 | await fireEvent.pointerDown(sliderRoot, {
164 | clientX: sliderRoot.clientWidth * 0.5,
165 | clientY: sliderRoot.clientHeight * 0.5,
166 | });
167 |
168 | await new Promise((resolve) => setTimeout(resolve, 100));
169 |
170 | // Mouse the pointer outside of the slider.
171 | await fireEvent.pointerMove(sliderRoot, {
172 | clientX: sliderRoot.clientWidth * 1.5,
173 | clientY: sliderRoot.clientHeight * 1.5,
174 | });
175 |
176 | await new Promise((resolve) => setTimeout(resolve, 100));
177 |
178 | await fireEvent.pointerLeave(sliderRoot);
179 |
180 | await new Promise((resolve) => setTimeout(resolve, 500));
181 |
182 | await waitFor(() => {
183 | expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('100');
184 | expect(ChangePositionOnHoverPointerDown.args?.onPositionChange).toHaveBeenCalledWith(100);
185 | });
186 | };
187 |
188 | /**
189 | * Ensure slider 'disconnects' when it loses focus on touch devices.
190 | */
191 | export const TouchEvents: StoryFn = (props) => {
192 | return (
193 |
194 | Clickaroo
195 |
196 |
197 | );
198 | };
199 |
200 | TouchEvents.args = getArgs({
201 | changePositionOnHover: true,
202 | style: { width: 200, height: 200 },
203 | });
204 |
205 | TouchEvents.play = async ({ canvasElement }) => {
206 | const canvas = within(canvasElement);
207 | const sliderRoot = (await canvas.findByTestId(TouchEvents.args?.['data-testid'])) as HTMLElement;
208 |
209 | await waitFor(() => expect(sliderRoot).toBeInTheDocument());
210 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
211 |
212 | await new Promise((resolve) => setTimeout(resolve, 250));
213 |
214 | await fireEvent.pointerDown(sliderRoot, {
215 | clientX: sliderRoot.clientWidth * 0.65,
216 | clientY: sliderRoot.clientHeight * 0.65,
217 | });
218 |
219 | await new Promise((resolve) => setTimeout(resolve, 250));
220 |
221 | await waitFor(() => expect(sliderRoot).toHaveStyle({ cursor: 'ew-resize' }));
222 |
223 | await new Promise((resolve) => setTimeout(resolve, 250));
224 |
225 | await fireEvent.touchEnd(sliderRoot);
226 |
227 | await waitFor(() => expect(sliderRoot).toHaveStyle({ cursor: 'auto' }));
228 | };
229 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/99-tests/position.test.stories.tsx:
--------------------------------------------------------------------------------
1 | import { expect } from '@storybook/jest';
2 | import type { Meta, StoryFn } from '@storybook/react';
3 | import { fireEvent, userEvent, waitFor, within } from '@storybook/testing-library';
4 | import { useState } from 'react';
5 | import type { ReactCompareSliderProps } from 'react-compare-slider';
6 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider';
7 |
8 | import { Template, getArgs } from './test-utils.test';
9 |
10 | const meta: Meta = {
11 | title: 'Tests/Browser/Position',
12 | };
13 | export default meta;
14 |
15 | export const StartAt0 = Template.bind({});
16 | StartAt0.args = getArgs({ position: 0 });
17 |
18 | StartAt0.play = async ({ canvasElement }) => {
19 | const canvas = within(canvasElement);
20 | const slider = canvas.getByRole('slider') as Element;
21 |
22 | await waitFor(() => expect(slider.getAttribute('aria-valuenow')).toBe('0'));
23 | await waitFor(() => expect(window.getComputedStyle(slider).left).toBe('0px'));
24 | };
25 |
26 | export const StartAt100 = Template.bind({});
27 | StartAt100.args = getArgs({ position: 100, style: { width: 256 } });
28 |
29 | StartAt100.play = async ({ canvasElement }) => {
30 | const canvas = within(canvasElement);
31 | const slider = canvas.getByRole('slider') as Element;
32 |
33 | await waitFor(() => expect(slider.getAttribute('aria-valuenow')).toBe('100'));
34 | await waitFor(() => expect(window.getComputedStyle(slider).left).toBe('256px'));
35 | };
36 |
37 | export const PointSamePosition = Template.bind({});
38 | PointSamePosition.args = getArgs({ position: 50, style: { width: 256 } });
39 |
40 | PointSamePosition.play = async ({ canvasElement }) => {
41 | const canvas = within(canvasElement);
42 | const slider = canvas.getByRole('slider') as Element;
43 |
44 | await waitFor(() => expect(slider.getAttribute('aria-valuenow')).toBe('50'));
45 | await waitFor(() => expect(window.getComputedStyle(slider).left).toBe('128px'));
46 |
47 | await fireEvent.pointerDown(slider, { clientX: 128, clientY: 128 });
48 | await waitFor(() => expect(slider.getAttribute('aria-valuenow')).toBe('50'));
49 | await waitFor(() => expect(window.getComputedStyle(slider).left).toBe('128px'));
50 | };
51 |
52 | /**
53 | * Ensure that the slider handle position is at the end of the slider when the position is 100 and
54 | * images do not load immediately.
55 | * @see https://github.com/nerdyman/react-compare-slider/issues/37
56 | *
57 | */
58 | export const LazyImages: StoryFn = (props) => {
59 | return (
60 |
69 | }
70 | itemTwo={
71 |
77 | }
78 | />
79 | );
80 | };
81 | LazyImages.args = getArgs({ position: 100, style: { width: 'auto', height: 'auto' } });
82 | LazyImages.play = async ({ canvasElement }) => {
83 | const canvas = within(canvasElement);
84 | const slider = canvas.getByRole('slider') as HTMLDivElement;
85 |
86 | await waitFor(() => expect(slider.getAttribute('aria-valuenow')).toBe('100'));
87 | await waitFor(() => expect(slider.style.left).toBe('100%'));
88 | };
89 |
90 | /**
91 | * Switch orientation and ensure position is maintained.
92 | */
93 | export const ToggleOrientation: StoryFn = (args) => {
94 | const [portrait, setPortrait] = useState(false);
95 |
96 | return (
97 |
98 |
99 |
110 | setPortrait((prev) => !prev)}>Toggle orientation
111 |
112 |
113 | );
114 | };
115 | ToggleOrientation.args = getArgs({ position: 25, style: { width: 200, height: 200 } });
116 |
117 | ToggleOrientation.play = async ({ canvasElement }) => {
118 | const user = userEvent.setup();
119 | const canvas = within(canvasElement);
120 | const sliderRoot = canvas.queryByTestId(StartAt100.args?.['data-testid']) as Element;
121 |
122 | await waitFor(() => expect(sliderRoot).toBeInTheDocument());
123 |
124 | await user.click(canvas.getByText('Toggle orientation'));
125 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('25'));
126 |
127 | await user.click(canvas.getByText('Toggle orientation'));
128 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('25'));
129 |
130 | fireEvent.pointerDown(sliderRoot, { clientX: 100, clientY: 100 });
131 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
132 |
133 | await user.click(canvas.getByText('Toggle orientation'));
134 | await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50'));
135 | };
136 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/99-tests/react-compare-slider-handle.test.stories.tsx:
--------------------------------------------------------------------------------
1 | import { expect } from '@storybook/jest';
2 | import type { Meta } from '@storybook/react';
3 | import { waitFor, within } from '@storybook/testing-library';
4 | import type { ReactCompareSlider } from 'react-compare-slider';
5 | import { ReactCompareSliderHandle as BaseReactCompareSliderHandle } from 'react-compare-slider';
6 |
7 | import { Template, getArgs } from './test-utils.test';
8 |
9 | const meta: Meta = {
10 | title: 'Tests/Browser/ReactCompareSliderHandle',
11 | };
12 | export default meta;
13 |
14 | /** Test `handle`. */
15 | export const ReactCompareSliderHandle = Template.bind({});
16 |
17 | ReactCompareSliderHandle.args = getArgs({
18 | handle: ,
19 | });
20 |
21 | ReactCompareSliderHandle.play = async ({ canvasElement }) => {
22 | const canvas = within(canvasElement);
23 | const handle = canvas.queryByTestId('handlearoo');
24 |
25 | await waitFor(() => expect(handle).toBeInTheDocument());
26 |
27 | // Lines should inherit color.
28 | await waitFor(() =>
29 | expect(
30 | window.getComputedStyle(handle?.querySelector('.__rcs-handle-line') as HTMLElement)
31 | .backgroundColor,
32 | ).toBe('rgb(255, 0, 0)'),
33 | );
34 |
35 | // Button should inherit color.
36 | await waitFor(() =>
37 | expect(
38 | window.getComputedStyle(handle?.querySelector('.__rcs-handle-button') as HTMLElement).color,
39 | ).toBe('rgb(255, 0, 0)'),
40 | );
41 |
42 | // Arrows should inherit color.
43 | await waitFor(() =>
44 | expect(
45 | window.getComputedStyle(handle?.querySelector('.__rcs-handle-arrow') as HTMLElement).color,
46 | ).toBe('rgb(255, 0, 0)'),
47 | );
48 | };
49 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/99-tests/react-compare-slider-image.test.stories.tsx:
--------------------------------------------------------------------------------
1 | import { expect } from '@storybook/jest';
2 | import type { Meta, StoryFn } from '@storybook/react';
3 | import { waitFor, within } from '@storybook/testing-library';
4 | import type { ReactCompareSlider, ReactCompareSliderImageProps } from 'react-compare-slider';
5 | import {
6 | ReactCompareSliderImage as BaseReactCompareSliderImage,
7 | styleFitContainer,
8 | } from 'react-compare-slider';
9 |
10 | const meta: Meta = {
11 | title: 'Tests/Browser/ReactCompareSliderImage',
12 | };
13 | export default meta;
14 |
15 | /** Default image. */
16 | export const ReactCompareSliderImage: StoryFn = (args) => (
17 |
18 | );
19 |
20 | ReactCompareSliderImage.args = {
21 | alt: 'testaroo',
22 | src: 'https://raw.githubusercontent.com/nerdyman/stuff/main/libs/react-compare-slider/demo-images/e2e-test-1.png',
23 | };
24 |
25 | ReactCompareSliderImage.play = async ({ canvasElement }) => {
26 | const canvas = within(canvasElement);
27 |
28 | await waitFor(() =>
29 | expect(canvas.getByAltText(ReactCompareSliderImage.args!.alt!)).toBeInTheDocument(),
30 | );
31 |
32 | // Ensure default styles have been applied to `ReactCompareSliderImage`.
33 | await waitFor(() =>
34 | expect(canvas.getByAltText(ReactCompareSliderImage.args!.alt!).style).toMatchObject(
35 | styleFitContainer() as Record,
36 | ),
37 | );
38 | };
39 |
40 | /** Default image. */
41 | export const ReactCompareSliderImageCustomStyle = (args) => (
42 |
43 | );
44 |
45 | ReactCompareSliderImageCustomStyle.args = {
46 | alt: 'testaroo',
47 | src: 'https://raw.githubusercontent.com/nerdyman/stuff/main/libs/react-compare-slider/demo-images/e2e-test-1.png',
48 | style: { objectFit: 'fill', objectPosition: 'left center', boxSizing: 'content-box' },
49 | };
50 |
51 | ReactCompareSliderImageCustomStyle.play = async ({ canvasElement }) => {
52 | const canvas = within(canvasElement);
53 |
54 | await waitFor(() =>
55 | expect(canvas.getByAltText(ReactCompareSliderImage.args!.alt!)).toBeInTheDocument(),
56 | );
57 |
58 | // Ensure default styles have been applied to `ReactCompareSliderImage`.
59 | await waitFor(() =>
60 | expect(canvas.getByAltText(ReactCompareSliderImage.args!.alt!).style).toMatchObject(
61 | styleFitContainer({
62 | objectFit: 'fill',
63 | objectPosition: 'left center',
64 | boxSizing: 'content-box',
65 | }),
66 | ),
67 | );
68 | };
69 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/99-tests/right-to-left.test.stories.tsx:
--------------------------------------------------------------------------------
1 | import { expect } from '@storybook/jest';
2 | import type { Meta } from '@storybook/react';
3 | import { waitFor, within } from '@storybook/testing-library';
4 | import type { ReactCompareSlider } from 'react-compare-slider';
5 |
6 | import { Template, getArgs } from './test-utils.test';
7 |
8 | const meta: Meta = {
9 | title: 'Tests/Browser/RightToLeft',
10 | };
11 | export default meta;
12 |
13 | /** Test RTL rendering. */
14 | export const RightToLeft: typeof Template = (args) => (
15 |
16 |
17 |
18 | );
19 |
20 | RightToLeft.args = getArgs({ style: { width: 200, height: 200 }, position: 25 });
21 |
22 | RightToLeft.play = async ({ canvasElement }) => {
23 | const canvas = within(canvasElement);
24 | const sliderRoot = canvas.queryByTestId(RightToLeft.args?.['data-testid']) as HTMLElement;
25 | const slider = canvas.getByRole('slider') as HTMLElement;
26 |
27 | // Should have elements on mount.
28 | await waitFor(() => expect(sliderRoot).toBeInTheDocument());
29 | await waitFor(() => expect(sliderRoot).toBeInTheDocument());
30 |
31 | // Should position slider at 25%.
32 | await waitFor(() => expect(slider).toHaveStyle({ left: '50px' }));
33 | await waitFor(() => expect(slider.getAttribute('aria-valuenow')).toBe('25'));
34 | };
35 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/99-tests/scroll.test.stories.tsx:
--------------------------------------------------------------------------------
1 | import { expect } from '@storybook/jest';
2 | import type { Meta, StoryFn } from '@storybook/react';
3 | import { fireEvent, waitFor, within } from '@storybook/testing-library';
4 | import type { ReactCompareSlider, ReactCompareSliderProps } from 'react-compare-slider';
5 |
6 | import { Template, getArgs } from './test-utils.test';
7 |
8 | const meta: Meta = {
9 | title: 'Tests/Browser/Scroll',
10 | };
11 | export default meta;
12 |
13 | /** Horizontal slider in landscape mode. */
14 | export const Horizontal: StoryFn = (props) => (
15 |
25 |
34 | Scroll right
35 |
36 |
37 |
38 | );
39 | Horizontal.args = getArgs({
40 | style: { width: 200, height: '100%', flexShrink: 0 },
41 | });
42 |
43 | Horizontal.play = async ({ canvasElement }) => {
44 | const canvas = within(canvasElement);
45 | const sliderRoot = canvas.queryByTestId(Horizontal.args?.['data-testid']) as HTMLDivElement;
46 | const slider = canvas.queryByRole('slider') as HTMLDivElement;
47 |
48 | // Should have elements on mount.
49 | await waitFor(() => expect(sliderRoot).toBeInTheDocument());
50 | await waitFor(() => expect(slider).toBeInTheDocument());
51 | await waitFor(() => expect(canvas.getByAltText('one')).toBeInTheDocument());
52 | await waitFor(() => expect(canvas.getByAltText('two')).toBeInTheDocument());
53 | await waitFor(() => expect(slider.getAttribute('aria-valuenow')).toBe('50'));
54 |
55 | // Scroll to slider.
56 | sliderRoot.scrollIntoView();
57 | await new Promise((resolve) => setTimeout(resolve, 100));
58 |
59 | await waitFor(() =>
60 | fireEvent.pointerDown(sliderRoot, {
61 | clientX: 166,
62 | clientY: 100,
63 | }),
64 | );
65 |
66 | // Should match new position.
67 | await waitFor(() => expect(slider.getAttribute('aria-valuenow')).toBe('75'));
68 | };
69 |
70 | /** Vertical slider in portrait mode. */
71 | export const VerticalPortrait: StoryFn = (props) => (
72 |
73 |
Scroll down
74 |
75 |
76 | );
77 | VerticalPortrait.args = getArgs({ portrait: true, style: { width: '100%', height: 200 } });
78 |
79 | VerticalPortrait.play = async ({ canvasElement }) => {
80 | const canvas = within(canvasElement);
81 | const sliderRoot = canvas.queryByTestId(VerticalPortrait.args?.['data-testid']) as HTMLDivElement;
82 | const slider = canvas.queryByRole('slider') as HTMLDivElement;
83 |
84 | // Should have elements on mount.
85 | await waitFor(() => expect(sliderRoot).toBeInTheDocument());
86 | await waitFor(() => expect(slider).toBeInTheDocument());
87 | await waitFor(() => expect(canvas.getByAltText('one')).toBeInTheDocument());
88 | await waitFor(() => expect(canvas.getByAltText('two')).toBeInTheDocument());
89 | await waitFor(() => expect(slider.getAttribute('aria-valuenow')).toBe('50'));
90 |
91 | // Scroll to slider.
92 | sliderRoot.scrollIntoView();
93 | await new Promise((resolve) => setTimeout(resolve, 100));
94 |
95 | await waitFor(() =>
96 | fireEvent.pointerDown(sliderRoot, {
97 | clientX: 100,
98 | clientY: 158,
99 | }),
100 | );
101 |
102 | // Should match new position.
103 | await waitFor(() => expect(slider.getAttribute('aria-valuenow')).toBe('75'));
104 | };
105 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/99-tests/test-utils.test.tsx:
--------------------------------------------------------------------------------
1 | import { jest } from '@storybook/jest';
2 | import type { StoryFn } from '@storybook/react';
3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider';
4 | import type { ReactCompareSliderDetailedProps } from 'react-compare-slider';
5 |
6 | export const Template: StoryFn = (args) => (
7 |
8 | );
9 |
10 | export const SLIDER_ROOT_TEST_ID = 'rcs-root';
11 |
12 | export const getArgs = (
13 | args: Partial = {},
14 | ): Partial> => ({
15 | 'data-testid': SLIDER_ROOT_TEST_ID,
16 | onPositionChange: jest.fn(console.log),
17 | style: { width: '100%', height: '100vh' },
18 | itemOne: (
19 |
23 | ),
24 | itemTwo: (
25 |
29 | ),
30 | ...args,
31 | });
32 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/99-tests/transition.test.stories.tsx:
--------------------------------------------------------------------------------
1 | import { expect } from '@storybook/jest';
2 | import type { Meta } from '@storybook/react';
3 | import { fireEvent, waitFor, within } from '@storybook/testing-library';
4 | import type { ReactCompareSlider } from 'react-compare-slider';
5 |
6 | import { Template, getArgs } from './test-utils.test';
7 |
8 | const meta: Meta = {
9 | title: 'Tests/Browser/Transition',
10 | };
11 | export default meta;
12 |
13 | export const Transition = Template.bind({});
14 | Transition.args = getArgs({
15 | position: 0,
16 | transition: '0.2s ease',
17 | style: { width: 200, height: 200 },
18 | });
19 |
20 | Transition.play = async ({ canvasElement }) => {
21 | const canvas = within(canvasElement);
22 | const sliderRoot = canvas.queryByTestId(Transition.args?.['data-testid']) as Element;
23 |
24 | // Should have elements on mount.
25 | await new Promise((resolve) => setTimeout(resolve, 500));
26 | await waitFor(() => expect(sliderRoot).toBeInTheDocument());
27 |
28 | await new Promise((resolve) =>
29 | setTimeout(() => {
30 | fireEvent.pointerDown(sliderRoot, { clientX: 199, clientY: 25 });
31 | resolve(true);
32 | }, 200),
33 | );
34 | await new Promise((resolve) =>
35 | setTimeout(() => {
36 | fireEvent.pointerDown(sliderRoot, { clientX: 0, clientY: 100 });
37 | resolve(true);
38 | }, 200),
39 | );
40 | await new Promise((resolve) =>
41 | setTimeout(() => {
42 | fireEvent.pointerDown(sliderRoot, { clientX: 100, clientY: 100 });
43 | resolve(true);
44 | }, 200),
45 | );
46 |
47 | // Should have initial position on mount.
48 | await waitFor(() => expect(Transition.args?.onPositionChange).toHaveBeenLastCalledWith(50));
49 | };
50 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/99-tests/use-react-compare-slider-ref.test.stories.tsx:
--------------------------------------------------------------------------------
1 | import { expect, jest } from '@storybook/jest';
2 | import type { Meta, StoryFn } from '@storybook/react';
3 | import { fireEvent, waitFor, within } from '@storybook/testing-library';
4 | import type { ReactCompareSlider } from 'react-compare-slider';
5 | import { useReactCompareSliderRef } from 'react-compare-slider';
6 |
7 | import { UseReactCompareSliderRef as UseReactCompareSliderRefStory } from '../00-demos/00-index.stories';
8 | import { SLIDER_ROOT_TEST_ID, getArgs } from './test-utils.test';
9 |
10 | const meta: Meta = {
11 | title: 'Tests/Browser/UseReactCompareSliderRef',
12 | };
13 | export default meta;
14 |
15 | export const UseReactCompareSliderRef = UseReactCompareSliderRefStory;
16 |
17 | UseReactCompareSliderRef.play = async ({ canvasElement }) => {
18 | const canvas = within(canvasElement);
19 | const slider1 = canvas.queryByTestId(`${SLIDER_ROOT_TEST_ID}-1`) as Element;
20 | const slider2 = canvas.queryByTestId(`${SLIDER_ROOT_TEST_ID}-2`) as Element;
21 |
22 | // Should have elements on mount.
23 | await new Promise((resolve) => setTimeout(resolve, 1000));
24 | await waitFor(() => expect(slider1).toBeInTheDocument());
25 | await waitFor(() => expect(slider2).toBeInTheDocument());
26 |
27 | fireEvent.pointerDown(slider1, {
28 | clientX: slider1.clientWidth * 0.75,
29 | clientY: slider1.clientHeight * 0.75,
30 | });
31 |
32 | await waitFor(() => {
33 | expect(slider1.querySelector('[role="slider"]')?.getAttribute('aria-valuenow')).toBe('75');
34 | expect(slider2.querySelector('[role="slider"]')?.getAttribute('aria-valuenow')).toBe('75');
35 | });
36 |
37 | fireEvent.pointerDown(slider2, {
38 | clientX: slider2.getBoundingClientRect().right,
39 | clientY: slider2.getBoundingClientRect().top,
40 | });
41 |
42 | await waitFor(() => {
43 | expect(slider1.querySelector('[role="slider"]')?.getAttribute('aria-valuenow')).toBe('100');
44 | expect(slider2.querySelector('[role="slider"]')?.getAttribute('aria-valuenow')).toBe('100');
45 | });
46 | };
47 |
48 | export const UseReactCompareSliderRefUninstantied: StoryFn = () => {
49 | console.warn = jest.fn();
50 | const ref = useReactCompareSliderRef();
51 |
52 | ref.current.setPosition(50);
53 |
54 | return Nope
;
55 | };
56 |
57 | UseReactCompareSliderRefUninstantied.args = getArgs();
58 |
59 | UseReactCompareSliderRefUninstantied.play = async () => {
60 | await waitFor(() =>
61 | expect(console.warn).toHaveBeenCalledWith(
62 | '[react-compare-slider] `setPosition` cannot be used until the component has mounted.',
63 | ),
64 | );
65 | };
66 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/99-tests/zero-bounds.test.stories.tsx:
--------------------------------------------------------------------------------
1 | import { expect } from '@storybook/jest';
2 | import type { Meta } from '@storybook/react';
3 | import { userEvent, waitFor, within } from '@storybook/testing-library';
4 | import { useState } from 'react';
5 | import type { ReactCompareSliderDetailedProps } from 'react-compare-slider';
6 | import { ReactCompareSlider } from 'react-compare-slider';
7 |
8 | import { Template, getArgs } from './test-utils.test';
9 |
10 | const meta: Meta = {
11 | title: 'Tests/Browser/ZeroBounds',
12 | };
13 | export default meta;
14 |
15 | /** Rendering items with no width or height. */
16 | export const ZeroBounds = Template.bind({});
17 | ZeroBounds.args = getArgs({
18 | style: { width: 'auto', height: 'auto' },
19 | itemOne:
,
20 | itemTwo:
,
21 | });
22 |
23 | ZeroBounds.play = async ({ canvasElement }) => {
24 | const canvas = within(canvasElement);
25 | const slider = canvas.getByRole('slider') as Element;
26 |
27 | await waitFor(() => expect(slider).toBeInTheDocument());
28 | await waitFor(() => expect(canvas.getByTestId('one')).toBeInTheDocument());
29 | await waitFor(() => expect(canvas.getByTestId('two')).toBeInTheDocument());
30 | await waitFor(() => expect(slider.getAttribute('aria-valuenow')).toBe('50'));
31 | };
32 |
33 | /**
34 | * Rendering items with no width or height the change them to images after rendering.
35 | */
36 | export const ZeroBoundsWithLazyContent = () => {
37 | const [dir, setDir] = useState('ltr');
38 | const [props, setProps] = useState({
39 | position: 0,
40 | portrait: true,
41 | itemOne:
,
42 | itemTwo:
,
43 | });
44 |
45 | const loadContent = async () => {
46 | await new Promise((resolve) => setTimeout(resolve, 1500));
47 | setProps((prev) => ({
48 | ...prev,
49 | position: 100,
50 | itemOne: (
51 |
57 | ),
58 | itemTwo: (
59 |
65 | ),
66 | }));
67 | };
68 |
69 | return (
70 |
71 |
72 | Load content
73 | setProps((prev) => ({ ...prev, portrait: !prev.portrait }))}>
74 | Toggle portrait
75 |
76 | setDir((prev) => (prev === 'ltr' ? 'rtl' : 'ltr'))}>
77 | Toggle direction
78 |
79 |
80 |
81 |
82 | );
83 | };
84 |
85 | ZeroBoundsWithLazyContent.play = async ({ canvasElement }) => {
86 | const user = userEvent.setup();
87 | const canvas = within(canvasElement);
88 | const slider = (await canvas.findByRole('slider')) as Element;
89 | const loadImages = await canvas.findByText('Load content');
90 | const togglePortrait = await canvas.findByText('Toggle portrait');
91 | const toggleDirection = await canvas.findByText('Toggle direction');
92 |
93 | await waitFor(() => expect(slider).toBeInTheDocument());
94 | await waitFor(() => expect(loadImages).toBeInTheDocument());
95 | await waitFor(() => expect(togglePortrait).toBeInTheDocument());
96 | await waitFor(() => expect(toggleDirection).toBeInTheDocument());
97 | await waitFor(() => expect(canvas.getByTestId('one')).toBeInTheDocument());
98 | await waitFor(() => expect(canvas.getByTestId('two')).toBeInTheDocument());
99 | await waitFor(() => expect(slider.getAttribute('aria-valuenow')).toBe('0'));
100 |
101 | await user.click(loadImages);
102 | await waitFor(() => expect(canvas.getByAltText('one')).toBeInTheDocument(), { timeout: 3000 });
103 | await waitFor(() => expect(canvas.getByAltText('two')).toBeInTheDocument());
104 | await waitFor(() => expect(slider.getAttribute('aria-valuenow')).toBe('100'));
105 | await waitFor(() => expect(parseInt(window.getComputedStyle(slider).width)).toBe(128));
106 | await waitFor(() => expect(parseInt(window.getComputedStyle(slider).height)).not.toBe(128));
107 | await waitFor(() => expect(parseInt(window.getComputedStyle(slider).top)).toBe(128));
108 |
109 | await user.click(togglePortrait);
110 | await waitFor(() => expect(slider.getAttribute('aria-valuenow')).toBe('100'));
111 | await waitFor(() => expect(parseInt(window.getComputedStyle(slider).width)).not.toBe(128));
112 | await waitFor(() => expect(parseInt(window.getComputedStyle(slider).height)).toBe(128));
113 | await waitFor(() => expect(parseInt(window.getComputedStyle(slider).left)).toBe(128));
114 |
115 | await user.click(toggleDirection);
116 | await waitFor(() => expect(slider.getAttribute('aria-valuenow')).toBe('100'));
117 | await waitFor(() => expect(parseInt(window.getComputedStyle(slider).width)).not.toBe(128));
118 | await waitFor(() => expect(parseInt(window.getComputedStyle(slider).height)).toBe(128));
119 | await waitFor(() => expect(parseInt(window.getComputedStyle(slider).left)).toBe(128));
120 | };
121 |
--------------------------------------------------------------------------------
/docs/storybook/content/stories/config.ts:
--------------------------------------------------------------------------------
1 | import type { ArgTypes } from '@storybook/react';
2 | import { ReactCompareSliderClipOption, type ReactCompareSliderProps } from 'react-compare-slider';
3 |
4 | /**
5 | * @NOTE These must reflect the default values defined in the `types.ts`.
6 | */
7 | export const args: ReactCompareSliderProps = {
8 | boundsPadding: 0,
9 | changePositionOnHover: false,
10 | clip: ReactCompareSliderClipOption.both,
11 | disabled: false,
12 | handle: undefined,
13 | keyboardIncrement: '5%',
14 | itemOne: undefined,
15 | itemTwo: undefined,
16 | onlyHandleDraggable: false,
17 | onPositionChange: undefined,
18 | portrait: false,
19 | position: 50,
20 | transition: undefined,
21 | };
22 |
23 | export const argTypes: ArgTypes> = {
24 | handle: { control: { type: 'function' } },
25 | itemOne: { control: { type: 'function' } },
26 | itemTwo: { control: { type: 'function' } },
27 | onPositionChange: { control: { type: 'function' } },
28 | position: { control: { type: 'range', min: 0, max: 100 } },
29 | };
30 |
--------------------------------------------------------------------------------
/docs/storybook/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@this/storybook",
3 | "version": "1.0.0",
4 | "private": true,
5 | "description": "React Compare Slider Documentation",
6 | "keywords": [],
7 | "license": "MIT",
8 | "scripts": {
9 | "dev": "pnpm run storybook",
10 | "storybook": "rm -rf ./node_modules/.cache && storybook dev -p 6006 --ci",
11 | "storybook:build": "storybook build",
12 | "test": "rm -rf ./coverage && test-storybook",
13 | "test:coverage": "rm -rf ./coverage && test-storybook --coverage && node ./.storybook/hack-coverage.js"
14 | },
15 | "dependencies": {
16 | "@react-google-maps/api": "^2.19.3",
17 | "@types/react": "^18.2.55",
18 | "@types/react-dom": "^18.2.19",
19 | "react": "^18.2.0",
20 | "react-compare-slider": "workspace:*",
21 | "react-dom": "^18.2.0",
22 | "use-resize-observer": "^9.1.0"
23 | },
24 | "devDependencies": {
25 | "@mdx-js/react": "^2.3.0",
26 | "@storybook/addon-actions": "^7.6.20",
27 | "@storybook/addon-console": "^3.0.0",
28 | "@storybook/addon-coverage": "^1.0.4",
29 | "@storybook/addon-docs": "^7.6.20",
30 | "@storybook/addon-essentials": "^7.6.20",
31 | "@storybook/addon-interactions": "^7.6.20",
32 | "@storybook/addon-links": "^7.6.20",
33 | "@storybook/addon-storysource": "^7.6.20",
34 | "@storybook/blocks": "^7.6.20",
35 | "@storybook/builder-vite": "^7.6.20",
36 | "@storybook/jest": "^0.2.3",
37 | "@storybook/preset-typescript": "^3.0.0",
38 | "@storybook/react": "^7.6.20",
39 | "@storybook/react-vite": "^7.6.20",
40 | "@storybook/test-runner": "^0.16.0",
41 | "@storybook/testing-library": "^0.2.2",
42 | "@storybook/theming": "^7.6.20",
43 | "remark-gfm": "^3.0.1",
44 | "storybook": "^7.6.20",
45 | "tslib": "~2.7.0",
46 | "typescript": "^5.6.2",
47 | "vite": "^5.4.5"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/docs/storybook/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../lib/tsconfig.json",
3 | "include": ["src", "dist", "content"],
4 | "compilerOptions": {
5 | "module": "ESNext",
6 | "moduleResolution": "Node",
7 | "baseUrl": ".",
8 | "rootDir": ".",
9 | "preserveSymlinks": true,
10 | "noImplicitAny": false,
11 | "types": ["node", "react", "react-dom", "@testing-library/jest-dom"]
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/lib/.release-it.json:
--------------------------------------------------------------------------------
1 | {
2 | "git": {
3 | "commitMessage": "chore: release v${version}"
4 | },
5 | "github": {
6 | "autoGenerate": true,
7 | "release": true,
8 | "web": true
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/lib/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-compare-slider",
3 | "version": "4.0.0-0",
4 | "license": "MIT",
5 | "repository": {
6 | "type": "git",
7 | "url": "git+https://github.com/nerdyman/react-compare-slider.git",
8 | "directory": "lib"
9 | },
10 | "homepage": "https://react-compare-slider.vercel.app",
11 | "author": {
12 | "email": "averynerdyman@gmail.com",
13 | "name": "nerdyman",
14 | "url": "https://github.com/nerdyman"
15 | },
16 | "description": "A slider component to compare any two React components in landscape or portrait orientation. It supports custom images, videos... and everything else.",
17 | "keywords": [
18 | "react",
19 | "slider",
20 | "comparison",
21 | "compare",
22 | "image",
23 | "video",
24 | "canvas",
25 | "component",
26 | "image comparison",
27 | "twentytwenty",
28 | "portrait",
29 | "typescript"
30 | ],
31 | "engines": {
32 | "node": ">=18.0.0"
33 | },
34 | "sideEffects": false,
35 | "type": "module",
36 | "main": "dist/index.cjs",
37 | "module": "dist/index.mjs",
38 | "exports": {
39 | "./package.json": "./package.json",
40 | ".": {
41 | "import": {
42 | "types": "./dist/index.d.ts",
43 | "default": "./dist/index.mjs"
44 | },
45 | "require": {
46 | "types": "./dist/index.d.cts",
47 | "default": "./dist/index.cjs"
48 | }
49 | }
50 | },
51 | "files": [
52 | "src",
53 | "dist"
54 | ],
55 | "scripts": {
56 | "dev": "concurrently -k -s first -n \"tsup,ts\" -c \"blue,cyan\" \"tsup --watch\" \"pnpm run check:types --watch --preserveWatchOutput\"",
57 | "build": "pnpm run check:types && NODE_ENV=production tsup",
58 | "release": "pnpm run release:preflight && pnpm run release:publish",
59 | "release:preflight": "cp ../README.md ../LICENSE . && pnpm run -w lint && pnpm run -w test && pnpm run build && pnpm run check",
60 | "release:publish": "release-it",
61 | "check": "concurrently -n \"package,types\" -c \"blue,magenta\" \"pnpm run check:package\" \"pnpm run check:types\"",
62 | "check:package": "attw -P . && publint",
63 | "check:types": "tsc --noEmit"
64 | },
65 | "browserslist": {
66 | "production": [
67 | "last 2 chrome versions",
68 | "last 2 edge versions",
69 | "last 2 firefox versions",
70 | "safari >= 15.6",
71 | "ios >= 15.6",
72 | "not dead",
73 | "not ie > 0",
74 | "not op_mini all"
75 | ],
76 | "development": [
77 | "last 1 chrome version",
78 | "last 1 firefox version",
79 | "last 1 safari version"
80 | ]
81 | },
82 | "peerDependencies": {
83 | "react": ">=16.8",
84 | "react-dom": ">=16.8"
85 | },
86 | "devDependencies": {
87 | "@types/node": "^22.5.5",
88 | "@types/react": "^18.2.55",
89 | "@types/react-dom": "^18.2.19",
90 | "browserslist": "^4.23.3",
91 | "concurrently": "^8.2.2",
92 | "esbuild-plugin-browserslist": "^0.14.0",
93 | "publint": "^0.2.10",
94 | "react": "^18.2.0",
95 | "react-dom": "^18.2.0",
96 | "release-it": "^17.6.0",
97 | "tslib": "~2.7.0",
98 | "tsup": "^8.2.4",
99 | "typescript": "^5.6.2"
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/lib/src/Container.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef } from 'react';
2 | import type { CSSProperties, ComponentPropsWithoutRef, ReactElement } from 'react';
3 |
4 | import type { ReactCompareSliderCommonProps } from './types';
5 |
6 | type ContainerItemProps = ComponentPropsWithoutRef<'div'> &
7 | Pick & {
8 | shouldOverlap?: boolean;
9 | order?: number;
10 | };
11 |
12 | /** Container for clipped item. */
13 | export const ContainerItem = forwardRef(
14 | ({ shouldOverlap, order, style, transition, ...props }, ref): ReactElement => {
15 | const appliedStyle: CSSProperties = {
16 | gridArea: '1 / 1 / 2 / 2',
17 | order,
18 | maxWidth: '100%',
19 | overflow: 'hidden',
20 | boxSizing: 'border-box',
21 | transition: transition ? `clip-path ${transition}` : undefined,
22 | userSelect: 'none',
23 | willChange: 'clip-path, transition',
24 | zIndex: shouldOverlap ? 1 : undefined,
25 | KhtmlUserSelect: 'none',
26 | MozUserSelect: 'none',
27 | WebkitUserSelect: 'none',
28 | ...style,
29 | };
30 |
31 | return
;
32 | },
33 | );
34 |
35 | ContainerItem.displayName = 'ContainerItem';
36 |
37 | type ContainerHandleProps = ComponentPropsWithoutRef<'button'> & ReactCompareSliderCommonProps;
38 |
39 | /** Container to control the handle's position. */
40 | export const ContainerHandle = forwardRef(
41 | ({ children, disabled, portrait, position, transition }, ref): ReactElement => {
42 | const targetAxis = portrait ? 'top' : 'left';
43 |
44 | const style: CSSProperties = {
45 | position: 'absolute',
46 | top: 0,
47 | width: portrait ? '100%' : undefined,
48 | height: portrait ? undefined : '100%',
49 | background: 'none',
50 | border: 0,
51 | padding: 0,
52 | pointerEvents: 'all',
53 | appearance: 'none',
54 | WebkitAppearance: 'none',
55 | MozAppearance: 'none',
56 | zIndex: 1,
57 | outline: 0,
58 | transform: portrait ? `translate3d(0, -50% ,0)` : `translate3d(-50%, 0, 0)`,
59 | transition: transition ? `${targetAxis} ${transition}` : undefined,
60 | };
61 |
62 | return (
63 |
75 | {children}
76 |
77 | );
78 | },
79 | );
80 |
81 | ContainerHandle.displayName = 'ThisHandleContainer';
82 |
--------------------------------------------------------------------------------
/lib/src/ReactCompareSlider.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | forwardRef,
3 | useCallback,
4 | useEffect,
5 | useImperativeHandle,
6 | useRef,
7 | useState,
8 | } from 'react';
9 | import type { CSSProperties, ReactElement } from 'react';
10 |
11 | import { ContainerHandle, ContainerItem } from './Container';
12 | import { ReactCompareSliderHandle } from './ReactCompareSliderHandle';
13 | import {
14 | ReactCompareSliderClipOption,
15 | type ReactCompareSliderDetailedProps,
16 | type UseReactCompareSliderRefReturn,
17 | } from './types';
18 | import type { UseResizeObserverHandlerProps } from './utils';
19 | import { usePrevious } from './utils';
20 | import { KeyboardEventKeys, useEventListener, useResizeObserver } from './utils';
21 |
22 | /** Properties for internal `updateInternalPosition` callback. */
23 | interface UpdateInternalPositionProps {
24 | /** X coordinate to update to (landscape). */
25 | x: number;
26 | /** Y coordinate to update to (portrait). */
27 | y: number;
28 | /** Whether to calculate using page X and Y offsets (required for pointer events). */
29 | isOffset?: boolean;
30 | }
31 |
32 | const EVENT_PASSIVE_PARAMS = { capture: false, passive: true };
33 | const EVENT_CAPTURE_PARAMS = { capture: true, passive: false };
34 |
35 | /**
36 | * Handler for the `handle` container element.
37 | */
38 | const handleContainerClick = (ev: PointerEvent): void => {
39 | ev.preventDefault();
40 | (ev.currentTarget as HTMLButtonElement).focus();
41 | };
42 |
43 | /** Root Comparison slider. */
44 | export const ReactCompareSlider = forwardRef<
45 | UseReactCompareSliderRefReturn,
46 | ReactCompareSliderDetailedProps
47 | >(
48 | (
49 | {
50 | boundsPadding = 0,
51 | browsingContext = globalThis,
52 | changePositionOnHover = false,
53 | clip = ReactCompareSliderClipOption.both,
54 | disabled = false,
55 | handle,
56 | itemOne,
57 | itemTwo,
58 | keyboardIncrement = '5%',
59 | onlyHandleDraggable = false,
60 | onPositionChange,
61 | portrait = false,
62 | position = 50,
63 | style,
64 | transition,
65 | ...props
66 | },
67 | ref,
68 | ): ReactElement => {
69 | /** DOM node of the root element. */
70 | const rootContainerRef = useRef(null);
71 | /** DOM node `itemOne` container. */
72 | const clipContainerOneRef = useRef(null);
73 | /** DOM node of `itemTwo`. */
74 | const clipContainerTwoRef = useRef(null);
75 | /** DOM node of the handle container. */
76 | const handleContainerRef = useRef(null);
77 | /** Current position as a percentage value (initially negative to sync bounds on mount). */
78 | const internalPosition = useRef(position);
79 | /** Whether user is currently dragging. */
80 | const [isDragging, setIsDragging] = useState(false);
81 | /** Whether the `transition` property can be applied. */
82 | const [canTransition, setCanTransition] = useState(true);
83 | /** Whether component has a `window` event binding. */
84 | const hasBrowsingContextBinding = useRef(false);
85 | /** Target container for pointer events. */
86 | const [interactiveTarget, setInteractiveTarget] = useState();
87 | /** The `position` value at *previous* render. */
88 | const previousPosition = usePrevious(position);
89 |
90 | /** Sync the internal position and trigger position change callback if defined. */
91 | const updateInternalPosition = useCallback(
92 | function updateInternal({ x, y, isOffset }: UpdateInternalPositionProps) {
93 | const rootElement = rootContainerRef.current as HTMLDivElement;
94 | const handleElement = handleContainerRef.current as HTMLButtonElement;
95 | const clipElementOne = clipContainerOneRef.current as HTMLDivElement;
96 | const clipElementTwo = clipContainerTwoRef.current as HTMLDivElement;
97 | const { width, height, left, top } = rootElement.getBoundingClientRect();
98 |
99 | // Early out when component has zero bounds.
100 | if (width === 0 || height === 0) {
101 | return;
102 | }
103 |
104 | const pixelPosition = portrait
105 | ? isOffset
106 | ? y - top - browsingContext.scrollY
107 | : y
108 | : isOffset
109 | ? x - left - browsingContext.scrollX
110 | : x;
111 |
112 | /** Next position as percentage. */
113 | const nextPosition = Math.min(
114 | Math.max((pixelPosition / (portrait ? height : width)) * 100, 0),
115 | 100,
116 | );
117 |
118 | const zoomScale = portrait
119 | ? height / (rootElement.offsetHeight || 1)
120 | : width / (rootElement.offsetWidth || 1);
121 |
122 | const boundsPaddingPercentage =
123 | ((boundsPadding * zoomScale) / (portrait ? height : width)) * 100;
124 |
125 | const nextPositionWithBoundsPadding = Math.min(
126 | Math.max(nextPosition, boundsPaddingPercentage * zoomScale),
127 | 100 - boundsPaddingPercentage * zoomScale,
128 | );
129 |
130 | internalPosition.current = nextPosition;
131 | handleElement.setAttribute('aria-valuenow', `${Math.round(internalPosition.current)}`);
132 | handleElement.style.top = portrait ? `${nextPositionWithBoundsPadding}%` : '0';
133 | handleElement.style.left = portrait ? '0' : `${nextPositionWithBoundsPadding}%`;
134 |
135 | const clipBoth = clip === ReactCompareSliderClipOption.both;
136 |
137 | if (clipBoth || clip === ReactCompareSliderClipOption.itemOne) {
138 | clipElementOne.style.clipPath = portrait
139 | ? `inset(0 0 ${100 - nextPositionWithBoundsPadding}% 0)`
140 | : `inset(0 ${100 - nextPositionWithBoundsPadding}% 0 0)`;
141 | } else {
142 | clipElementOne.style.clipPath = 'none';
143 | }
144 |
145 | if (clipBoth || clip === ReactCompareSliderClipOption.itemTwo) {
146 | clipElementTwo.style.clipPath = portrait
147 | ? `inset(${nextPositionWithBoundsPadding}% 0 0 0)`
148 | : `inset(0 0 0 ${nextPositionWithBoundsPadding}%)`;
149 | } else {
150 | clipElementTwo.style.clipPath = 'none';
151 | }
152 |
153 | if (onPositionChange) {
154 | onPositionChange(internalPosition.current);
155 | }
156 | },
157 | [browsingContext, boundsPadding, clip, onPositionChange, portrait],
158 | );
159 |
160 | // Update internal position when other user controllable props change.
161 | useEffect(() => {
162 | const { width, height } = (
163 | rootContainerRef.current as HTMLDivElement
164 | ).getBoundingClientRect();
165 |
166 | // Use current internal position if `position` hasn't changed.
167 | const nextPosition = position === previousPosition ? internalPosition.current : position;
168 |
169 | updateInternalPosition({
170 | x: (width / 100) * nextPosition,
171 | y: (height / 100) * nextPosition,
172 | });
173 | }, [boundsPadding, clip, position, portrait, previousPosition, updateInternalPosition]);
174 |
175 | /** Handle mouse/touch down. */
176 | const handlePointerDown = useCallback(
177 | (ev: PointerEvent) => {
178 | ev.preventDefault();
179 |
180 | // Only handle left mouse button (touch events also use 0).
181 | if (disabled || ev.button !== 0) return;
182 |
183 | updateInternalPosition({ isOffset: true, x: ev.pageX, y: ev.pageY });
184 | setIsDragging(true);
185 | setCanTransition(true);
186 | },
187 | [disabled, updateInternalPosition],
188 | );
189 |
190 | /** Handle mouse/touch move. */
191 | const handlePointerMove = useCallback(
192 | function moveCall(ev: PointerEvent) {
193 | updateInternalPosition({ isOffset: true, x: ev.pageX, y: ev.pageY });
194 | setCanTransition(false);
195 | },
196 | [updateInternalPosition],
197 | );
198 |
199 | /** Handle mouse/touch up. */
200 | const handlePointerUp = useCallback(() => {
201 | setIsDragging(false);
202 | setCanTransition(true);
203 | }, []);
204 |
205 | const handleTouchEnd = useCallback(() => {
206 | setIsDragging(false);
207 | setCanTransition(true);
208 | }, []);
209 |
210 | /** Resync internal position on resize. */
211 | const handleResize: (resizeProps: UseResizeObserverHandlerProps) => void = useCallback(
212 | ({ width, height }) => {
213 | const { width: scaledWidth, height: scaledHeight } = (
214 | rootContainerRef.current as HTMLDivElement
215 | ).getBoundingClientRect();
216 |
217 | updateInternalPosition({
218 | x: ((width / 100) * internalPosition.current * scaledWidth) / width,
219 | y: ((height / 100) * internalPosition.current * scaledHeight) / height,
220 | });
221 | },
222 | [updateInternalPosition],
223 | );
224 |
225 | /** Handle keyboard movment. */
226 | const handleKeydown = useCallback(
227 | (ev: KeyboardEvent) => {
228 | if (!Object.values(KeyboardEventKeys).includes(ev.key as KeyboardEventKeys)) {
229 | return;
230 | }
231 |
232 | ev.preventDefault();
233 | setCanTransition(true);
234 |
235 | const { top, left } = (
236 | handleContainerRef.current as HTMLButtonElement
237 | ).getBoundingClientRect();
238 |
239 | const { width, height } = (
240 | rootContainerRef.current as HTMLDivElement
241 | ).getBoundingClientRect();
242 |
243 | const isPercentage = typeof keyboardIncrement === 'string';
244 | const incrementPercentage = isPercentage
245 | ? parseFloat(keyboardIncrement)
246 | : (keyboardIncrement / width) * 100;
247 |
248 | const isIncrement = portrait
249 | ? ev.key === KeyboardEventKeys.ARROW_LEFT || ev.key === KeyboardEventKeys.ARROW_DOWN
250 | : ev.key === KeyboardEventKeys.ARROW_RIGHT || ev.key === KeyboardEventKeys.ARROW_UP;
251 |
252 | const nextPosition = Math.min(
253 | Math.max(
254 | isIncrement
255 | ? internalPosition.current + incrementPercentage
256 | : internalPosition.current - incrementPercentage,
257 | 0,
258 | ),
259 | 100,
260 | );
261 |
262 | updateInternalPosition({
263 | x: portrait ? left : (width * nextPosition) / 100,
264 | y: portrait ? (height * nextPosition) / 100 : top,
265 | });
266 | },
267 | [keyboardIncrement, portrait, updateInternalPosition],
268 | );
269 |
270 | // Set target container for pointer events.
271 | useEffect(() => {
272 | setInteractiveTarget(
273 | onlyHandleDraggable ? handleContainerRef.current : rootContainerRef.current,
274 | );
275 | }, [onlyHandleDraggable]);
276 |
277 | // Handle hover events on the container.
278 | useEffect(() => {
279 | const containerRef = rootContainerRef.current as HTMLDivElement;
280 |
281 | const handlePointerLeave = (): void => {
282 | if (isDragging) return;
283 | handlePointerUp();
284 | };
285 |
286 | if (changePositionOnHover) {
287 | containerRef.addEventListener('pointermove', handlePointerMove, EVENT_PASSIVE_PARAMS);
288 | containerRef.addEventListener('pointerleave', handlePointerLeave, EVENT_PASSIVE_PARAMS);
289 | }
290 |
291 | return () => {
292 | containerRef.removeEventListener('pointermove', handlePointerMove);
293 | containerRef.removeEventListener('pointerleave', handlePointerLeave);
294 | };
295 | }, [changePositionOnHover, handlePointerMove, handlePointerUp, isDragging]);
296 |
297 | // Allow drag outside of container while pointer is still down.
298 | useEffect(() => {
299 | if (isDragging && !hasBrowsingContextBinding.current) {
300 | browsingContext.addEventListener('pointermove', handlePointerMove, EVENT_PASSIVE_PARAMS);
301 | browsingContext.addEventListener('pointerup', handlePointerUp, EVENT_PASSIVE_PARAMS);
302 | hasBrowsingContextBinding.current = true;
303 | }
304 |
305 | return (): void => {
306 | if (hasBrowsingContextBinding.current) {
307 | browsingContext.removeEventListener('pointermove', handlePointerMove);
308 | browsingContext.removeEventListener('pointerup', handlePointerUp);
309 | hasBrowsingContextBinding.current = false;
310 | }
311 | };
312 | }, [handlePointerMove, handlePointerUp, isDragging, browsingContext]);
313 |
314 | useImperativeHandle(
315 | ref,
316 | () => {
317 | return {
318 | rootContainer: rootContainerRef.current,
319 | handleContainer: handleContainerRef.current,
320 | setPosition(nextPosition): void {
321 | const { width, height } = (
322 | rootContainerRef.current as HTMLDivElement
323 | ).getBoundingClientRect();
324 |
325 | updateInternalPosition({
326 | x: (width / 100) * nextPosition,
327 | y: (height / 100) * nextPosition,
328 | });
329 | },
330 | };
331 | },
332 | [updateInternalPosition],
333 | );
334 |
335 | // Bind resize observer to container.
336 | useResizeObserver(rootContainerRef, handleResize);
337 |
338 | useEventListener(
339 | 'touchend',
340 | handleTouchEnd,
341 | interactiveTarget as HTMLDivElement,
342 | EVENT_CAPTURE_PARAMS,
343 | );
344 |
345 | useEventListener(
346 | 'keydown',
347 | handleKeydown,
348 | handleContainerRef.current as HTMLButtonElement,
349 | EVENT_CAPTURE_PARAMS,
350 | );
351 |
352 | useEventListener(
353 | 'click',
354 | handleContainerClick,
355 | handleContainerRef.current as HTMLButtonElement,
356 | EVENT_CAPTURE_PARAMS,
357 | );
358 |
359 | useEventListener(
360 | 'pointerdown',
361 | handlePointerDown,
362 | interactiveTarget as HTMLDivElement,
363 | EVENT_CAPTURE_PARAMS,
364 | );
365 |
366 | // Use custom handle if requested.
367 | const Handle = handle || ;
368 | const appliedTransition = canTransition ? transition : undefined;
369 |
370 | const rootStyle: CSSProperties = {
371 | position: 'relative',
372 | display: 'grid',
373 | maxWidth: '100%',
374 | maxHeight: '100%',
375 | overflow: 'hidden',
376 | cursor: isDragging ? (portrait ? 'ns-resize' : 'ew-resize') : undefined,
377 | touchAction: 'pan-y',
378 | userSelect: 'none',
379 | KhtmlUserSelect: 'none',
380 | msUserSelect: 'none',
381 | MozUserSelect: 'none',
382 | WebkitUserSelect: 'none',
383 | ...style,
384 | };
385 |
386 | return (
387 |
388 |
393 | {itemOne}
394 |
395 |
396 |
397 | {itemTwo}
398 |
399 |
400 |
407 | {Handle}
408 |
409 |
410 | );
411 | },
412 | );
413 |
414 | ReactCompareSlider.displayName = 'ReactCompareSlider';
415 |
--------------------------------------------------------------------------------
/lib/src/ReactCompareSliderHandle.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { CSSProperties, FC, HtmlHTMLAttributes, ReactElement } from 'react';
3 |
4 | import type { ReactCompareSliderCommonProps } from './types';
5 |
6 | interface ThisArrowProps {
7 | /** Whether to flip the arrow direction. */
8 | flip?: boolean;
9 | }
10 |
11 | const ThisArrow: FC = ({ flip }) => {
12 | const style: CSSProperties = {
13 | width: 0,
14 | height: 0,
15 | borderTop: '8px solid transparent',
16 | borderRight: '10px solid',
17 | borderBottom: '8px solid transparent',
18 | transform: flip ? 'rotate(180deg)' : undefined,
19 | };
20 |
21 | return
;
22 | };
23 |
24 | /** Props for `ReactCompareSliderHandle`. */
25 | export interface ReactCompareSliderHandleProps
26 | extends Pick {
27 | /** Optional styles for handle the button. */
28 | buttonStyle?: CSSProperties;
29 | /** Optional styles for lines either side of the handle button. */
30 | linesStyle?: CSSProperties;
31 | /** Optional styles for the handle root. */
32 | style?: CSSProperties;
33 | }
34 |
35 | /** Default `handle`. */
36 | export const ReactCompareSliderHandle: FC<
37 | ReactCompareSliderHandleProps & HtmlHTMLAttributes
38 | > = ({
39 | className = '__rcs-handle-root',
40 | disabled,
41 | buttonStyle,
42 | linesStyle,
43 | portrait,
44 | style,
45 | ...props
46 | }): ReactElement => {
47 | const _style: CSSProperties = {
48 | display: 'flex',
49 | flexDirection: portrait ? 'row' : 'column',
50 | placeItems: 'center',
51 | height: '100%',
52 | cursor: disabled ? 'not-allowed' : portrait ? 'ns-resize' : 'ew-resize',
53 | pointerEvents: 'none',
54 | color: '#fff',
55 | ...style,
56 | };
57 |
58 | const _linesStyle: CSSProperties = {
59 | flexGrow: 1,
60 | height: portrait ? 2 : '100%',
61 | width: portrait ? '100%' : 2,
62 | backgroundColor: 'currentColor',
63 | pointerEvents: 'auto',
64 | boxShadow: '0 0 4px rgba(0,0,0,.5)',
65 | ...linesStyle,
66 | };
67 |
68 | const _buttonStyle: CSSProperties = {
69 | display: 'grid',
70 | gridAutoFlow: 'column',
71 | gap: 8,
72 | placeContent: 'center',
73 | flexShrink: 0,
74 | width: 56,
75 | height: 56,
76 | borderRadius: '50%',
77 | borderStyle: 'solid',
78 | borderWidth: 2,
79 | pointerEvents: 'auto',
80 | backdropFilter: 'blur(7px)',
81 | WebkitBackdropFilter: 'blur(7px)', // For Safari.
82 | backgroundColor: 'rgba(0, 0, 0, 0.125)',
83 | boxShadow: '0 0 4px rgba(0,0,0,.35)',
84 | transform: portrait ? 'rotate(90deg)' : undefined,
85 | ...buttonStyle,
86 | };
87 |
88 | return (
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | );
98 | };
99 |
--------------------------------------------------------------------------------
/lib/src/ReactCompareSliderImage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { CSSProperties, ImgHTMLAttributes, ReactElement } from 'react';
3 | import { forwardRef } from 'react';
4 |
5 | import { styleFitContainer } from './utils';
6 |
7 | /** Props for `ReactCompareSliderImage`. */
8 | export type ReactCompareSliderImageProps = ImgHTMLAttributes;
9 |
10 | /** `Img` element with defaults from `styleFitContainer` applied. */
11 | export const ReactCompareSliderImage = forwardRef(
12 | ({ style, ...props }, ref): ReactElement => {
13 | const rootStyle: CSSProperties = styleFitContainer(style);
14 |
15 | return ;
16 | },
17 | );
18 |
19 | ReactCompareSliderImage.displayName = 'ReactCompareSliderImage';
20 |
--------------------------------------------------------------------------------
/lib/src/index.ts:
--------------------------------------------------------------------------------
1 | export { ReactCompareSlider } from './ReactCompareSlider';
2 |
3 | export { ReactCompareSliderHandle } from './ReactCompareSliderHandle';
4 | export type { ReactCompareSliderHandleProps } from './ReactCompareSliderHandle';
5 |
6 | export { ReactCompareSliderImage } from './ReactCompareSliderImage';
7 | export type { ReactCompareSliderImageProps } from './ReactCompareSliderImage';
8 |
9 | export {
10 | type ReactCompareSliderDetailedProps,
11 | type ReactCompareSliderProps,
12 | type ReactCompareSliderPropPosition,
13 | type UseReactCompareSliderRefReturn,
14 | ReactCompareSliderClipOption,
15 | type ReactCompareSliderClip,
16 | } from './types';
17 |
18 | export { styleFitContainer } from './utils';
19 | export { useReactCompareSliderRef } from './useReactCompareSliderRef';
20 |
--------------------------------------------------------------------------------
/lib/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { HtmlHTMLAttributes, ReactNode, RefAttributes } from 'react';
2 |
3 | /** Slider position property. */
4 | export type ReactCompareSliderPropPosition = number;
5 |
6 | /** Common props shared between components. */
7 | export interface ReactCompareSliderCommonProps {
8 | /**
9 | * Whether to disable slider movement (items are still interactable).
10 | * @default false
11 | */
12 | disabled?: boolean;
13 |
14 | /**
15 | * Whether to use portrait (vertical) orientation.
16 | * @default false
17 | */
18 | portrait?: boolean;
19 |
20 | /**
21 | * Divider position.
22 | * @default 50
23 | */
24 | position: ReactCompareSliderPropPosition;
25 |
26 | /**
27 | * Shorthand CSS `transition` property to apply to handle movement. The specific CSS property
28 | * to transition **must not** be provided.
29 | * @example '.5s ease-in-out'
30 | */
31 | transition?: string;
32 | }
33 |
34 | export const ReactCompareSliderClipOption = {
35 | both: 'both',
36 | itemOne: 'itemOne',
37 | itemTwo: 'itemTwo',
38 | } as const;
39 |
40 | export type ReactCompareSliderClip =
41 | (typeof ReactCompareSliderClipOption)[keyof typeof ReactCompareSliderClipOption];
42 |
43 | /** Slider component props *without* ref return props. */
44 | export interface ReactCompareSliderRootProps extends Partial {
45 | /**
46 | * Padding in pixels to limit the slideable bounds on the X-axis (landscape) or Y-axis (portrait).
47 | * @default 0
48 | */
49 | boundsPadding?: number;
50 |
51 | /**
52 | * Custom browsing context to use instead of the global `window` object.
53 | * @default globalThis
54 | */
55 | browsingContext?: Window;
56 |
57 | /**
58 | * Whether the slider should follow the pointer on hover.
59 | * @default false
60 | */
61 | changePositionOnHover?: boolean;
62 |
63 | /**
64 | * Whether to clip `itemOne`, `itemTwo` or both items.
65 | * @default 'both'
66 | */
67 | clip?: ReactCompareSliderClip;
68 |
69 | /** Custom handle component. */
70 | handle?: ReactNode;
71 | /** First item to show. */
72 | itemOne: ReactNode;
73 | /** Second item to show. */
74 | itemTwo: ReactNode;
75 |
76 | /**
77 | * Percentage or pixel amount to move when the slider handle is focused and keyboard arrow is pressed.
78 | * @default '5%'
79 | */
80 | keyboardIncrement?: number | `${number}%`;
81 |
82 | /**
83 | * Whether to only change position when handle is interacted with (useful for touch devices).
84 | * @default false
85 | */
86 | onlyHandleDraggable?: boolean;
87 |
88 | /** Callback on position change with position as percentage. */
89 | onPositionChange?: (position: ReactCompareSliderPropPosition) => void;
90 | }
91 |
92 | /** Properties returned by the `useReactCompareSliderRef` hook. */
93 | export type UseReactCompareSliderRefReturn = {
94 | /**
95 | * DOM node of the root container of the slider.
96 | * @NOTE This value is only populated **after** the slider has mounted.
97 | */
98 | rootContainer: HTMLDivElement | null;
99 |
100 | /**
101 | * DOM node of the container of the `handle` component.
102 | * @NOTE This value is only populated **after** the slider has mounted.
103 | */
104 | handleContainer: HTMLButtonElement | null;
105 |
106 | /**
107 | * Set the position of the slider as a percentage between `0` and `100`.
108 | * Updates the slider position after render without triggering re-renders.
109 | * @NOTE This function is only actionable **after** the slider has mounted.
110 | */
111 | setPosition: (position: ReactCompareSliderPropPosition) => void;
112 | };
113 |
114 | /** Slider component props *with* ref return props. */
115 | export type ReactCompareSliderProps = ReactCompareSliderRootProps &
116 | RefAttributes;
117 |
118 | /** `ReactCompareSliderProps` and all valid `div` element props. */
119 | export type ReactCompareSliderDetailedProps = ReactCompareSliderProps &
120 | HtmlHTMLAttributes;
121 |
--------------------------------------------------------------------------------
/lib/src/useReactCompareSliderRef.ts:
--------------------------------------------------------------------------------
1 | import type { MutableRefObject } from 'react';
2 | import { useRef } from 'react';
3 |
4 | import type { UseReactCompareSliderRefReturn } from './types';
5 |
6 | /**
7 | * Control the position and access or modify the DOM elements of the slider.
8 | */
9 | export const useReactCompareSliderRef = (): MutableRefObject =>
10 | useRef({
11 | rootContainer: null,
12 | handleContainer: null,
13 | setPosition: () =>
14 | // eslint-disable-next-line no-console
15 | console.warn(
16 | '[react-compare-slider] `setPosition` cannot be used until the component has mounted.',
17 | ),
18 | });
19 |
--------------------------------------------------------------------------------
/lib/src/utils.ts:
--------------------------------------------------------------------------------
1 | import type { CSSProperties, RefObject } from 'react';
2 | import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
3 |
4 | /** Keyboard `key` events to trigger slider movement. */
5 | export enum KeyboardEventKeys {
6 | ARROW_LEFT = 'ArrowLeft',
7 | ARROW_RIGHT = 'ArrowRight',
8 | ARROW_UP = 'ArrowUp',
9 | ARROW_DOWN = 'ArrowDown',
10 | }
11 |
12 | /**
13 | * Stand-alone CSS utility to make replaced elements (`img`, `video`, etc.) fit their container.
14 | */
15 | export const styleFitContainer = ({
16 | boxSizing = 'border-box',
17 | objectFit = 'cover',
18 | objectPosition = 'center center',
19 | ...props
20 | }: CSSProperties = {}): CSSProperties => ({
21 | display: 'block',
22 | width: '100%',
23 | height: '100%',
24 | maxWidth: '100%',
25 | boxSizing,
26 | objectFit,
27 | objectPosition,
28 | ...props,
29 | });
30 |
31 | /** Store the previous supplied value. */
32 | export const usePrevious = (value: T): T => {
33 | const ref = useRef(value);
34 |
35 | useEffect(() => {
36 | ref.current = value;
37 | });
38 |
39 | return ref.current;
40 | };
41 |
42 | /**
43 | * Event listener binding hook.
44 | * @param eventName - Event to bind to.
45 | * @param handler - Callback handler.
46 | * @param element - Element to bind to.
47 | * @param handlerOptions - Event handler options.
48 | */
49 | export const useEventListener = (
50 | eventName: EventListener['name'],
51 | handler: EventListener['caller'],
52 | element: EventTarget,
53 | handlerOptions: AddEventListenerOptions,
54 | ): void => {
55 | const savedHandler = useRef();
56 |
57 | useEffect(() => {
58 | savedHandler.current = handler;
59 | }, [handler]);
60 |
61 | useEffect(() => {
62 | // Make sure element supports addEventListener.
63 | if (!(element && element.addEventListener)) return;
64 |
65 | // Create event listener that calls handler function stored in ref.
66 | const eventListener: EventListener = (event) =>
67 | savedHandler.current && savedHandler.current(event);
68 |
69 | element.addEventListener(eventName, eventListener, handlerOptions);
70 |
71 | return (): void => {
72 | element.removeEventListener(eventName, eventListener, handlerOptions);
73 | };
74 | }, [eventName, element, handlerOptions]);
75 | };
76 |
77 | /**
78 | * Conditionally use `useLayoutEffect` for client *or* `useEffect` for SSR.
79 | * @see https://github.com/reduxjs/react-redux/blob/89a86805f2fcf9e8fbd2d1dae345ec791de4a71f/src/utils/useIsomorphicLayoutEffect.ts
80 | */
81 | const useIsomorphicLayoutEffect =
82 | typeof window !== 'undefined' &&
83 | typeof window.document !== 'undefined' &&
84 | typeof window.document.createElement !== 'undefined'
85 | ? useLayoutEffect
86 | : useEffect;
87 |
88 | /** Params passed to `useResizeObserver` `handler` function. */
89 | export type UseResizeObserverHandlerProps = DOMRect;
90 |
91 | /**
92 | * Bind resize observer callback to element.
93 | * @param ref - Element to bind to.
94 | * @param handler - Callback for handling entry's bounding rect.
95 | */
96 | export const useResizeObserver = (
97 | ref: RefObject,
98 | handler: (entry: UseResizeObserverHandlerProps) => void,
99 | ): void => {
100 | const observer = useRef();
101 |
102 | const observe = useCallback(() => {
103 | if (ref.current && observer.current) observer.current.observe(ref.current);
104 | }, [ref]);
105 |
106 | // Bind/rebind observer when `handler` changes.
107 | useIsomorphicLayoutEffect(() => {
108 | observer.current = new ResizeObserver(([entry]) => handler(entry!.contentRect));
109 | observe();
110 |
111 | return (): void => {
112 | if (observer.current) observer.current.disconnect();
113 | };
114 | }, [handler, observe]);
115 | };
116 |
--------------------------------------------------------------------------------
/lib/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["src"],
3 | "compilerOptions": {
4 | "lib": ["dom", "ESNext"],
5 | "module": "ES2022",
6 | "moduleResolution": "Bundler",
7 | "importHelpers": true,
8 | "declaration": true,
9 | "sourceMap": true,
10 | "rootDir": "./src",
11 | "strict": true,
12 | "noUncheckedIndexedAccess": true,
13 | "noImplicitAny": true,
14 | "strictNullChecks": true,
15 | "strictFunctionTypes": true,
16 | "strictPropertyInitialization": true,
17 | "noImplicitThis": true,
18 | "alwaysStrict": true,
19 | "noUnusedLocals": false,
20 | "noUnusedParameters": false,
21 | "noImplicitReturns": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "jsx": "react-jsx",
24 | "esModuleInterop": true
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/lib/tsup.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint no-console: 0, @typescript-eslint/explicit-function-return-type: 0 */
2 | import browserslist from 'browserslist';
3 | import { resolveToEsbuildTarget } from 'esbuild-plugin-browserslist';
4 | import type { Options } from 'tsup';
5 | import { defineConfig } from 'tsup';
6 |
7 | const target = resolveToEsbuildTarget(browserslist()) as Options['target'];
8 |
9 | export default defineConfig((options) => ({
10 | clean: !options.watch,
11 | dts: true,
12 | entry: ['src/index.ts'],
13 | format: ['esm', 'cjs'],
14 | outExtension: (context) => {
15 | return {
16 | js: context.format === 'esm' ? '.mjs' : '.cjs',
17 | };
18 | },
19 | minify: !options.watch,
20 | target,
21 | sourcemap: true,
22 | splitting: true,
23 | // Storybook test coverage won't work with files sourced from outside of its root directory, so
24 | // we need to copy the lib into the docs folder.
25 | onSuccess: 'cp -r src ../docs/storybook',
26 | esbuildOptions(esbuild) {
27 | esbuild.banner = {
28 | js: '"use client"',
29 | };
30 | },
31 | }));
32 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@this/root",
3 | "license": "MIT",
4 | "repository": {
5 | "type": "git",
6 | "url": "git+https://github.com/nerdyman/react-compare-slider.git"
7 | },
8 | "homepage": "https://react-compare-slider.vercel.app",
9 | "author": {
10 | "email": "averynerdyman@gmail.com",
11 | "name": "nerdyman",
12 | "url": "https://github.com/nerdyman"
13 | },
14 | "engines": {
15 | "node": ">=18.0.0"
16 | },
17 | "packageManager": "pnpm@8.15.1",
18 | "scripts": {
19 | "bootstrap": "corepack enable && pnpm install --frozen-lockfile",
20 | "clean": "rm -rvf ./node_modules ./docs/*/node_modules ./lib/node_modules ./coverage ./docs/*/coverage ./docs/storybook/storybook-static",
21 | "test": "rm -rf coverage && concurrently -n \"ssr,sb\" -c \"magenta,blue\" \"pnpm run test:ssr\" \"pnpm run test:storybook\"",
22 | "test:ci": "concurrently -k -s first -n \"sb,test\" -c \"magenta,blue\" \"pnpm run --filter @this/storybook storybook:build --quiet && pnpm sirv ./docs/storybook/storybook-static --host 127.0.0.1 --port 6006\" \"pnpm wait-port 6006 && pnpm run test\"",
23 | "test:ssr": "pnpm c8 -o ./coverage/ssr-tests -r text -r lcov node --test ./docs/ssr-tests/ssr.test.mjs",
24 | "test:storybook": "pnpm --filter @this/storybook run test:coverage && pnpm nyc report --reporter=lcov -t ./docs/storybook/coverage/storybook --report-dir ./coverage/storybook",
25 | "lint": "concurrently -n \"eslint,prettier\" -c \"green,magenta\" \"pnpm run lint:eslint\" \"pnpm run lint:prettier\"",
26 | "lint:eslint": "eslint .",
27 | "lint:prettier": "prettier --check .",
28 | "lint-staged": "lint-staged",
29 | "prepare": "husky install"
30 | },
31 | "husky": {
32 | "hooks": {
33 | "pre-commit": "lint-staged && pnpm run test"
34 | }
35 | },
36 | "lint-staged": {
37 | "**/*.{html,css}": [
38 | "pretty-quick --staged"
39 | ],
40 | "**/*.{js,jsx,mjs,ts,tsx}": [
41 | "pretty-quick --staged",
42 | "eslint --fix"
43 | ]
44 | },
45 | "resolutions": {
46 | "react-element-to-jsx-string": "npm:@styled/react-element-to-jsx-string"
47 | },
48 | "devDependencies": {
49 | "@arethetypeswrong/cli": "^0.16.2",
50 | "@ianvs/prettier-plugin-sort-imports": "^3.7.2",
51 | "@types/node": "^22.5.5",
52 | "@typescript-eslint/eslint-plugin": "^6.21.0",
53 | "@typescript-eslint/parser": "^6.21.0",
54 | "c8": "^9.1.0",
55 | "concurrently": "^8.2.2",
56 | "eslint": "^8.56.0",
57 | "eslint-plugin-react": "^7.33.2",
58 | "eslint-plugin-react-hooks": "^4.6.0",
59 | "husky": "^9.0.10",
60 | "lint-staged": "^15.2.2",
61 | "nyc": "^15.1.0",
62 | "prettier": "^2.8.8",
63 | "pretty-quick": "^3.1.3",
64 | "sirv-cli": "^2.0.2",
65 | "wait-port": "^1.1.0"
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - 'lib/**'
3 | - 'docs/**'
4 |
--------------------------------------------------------------------------------