├── .codebeatignore ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── codeql-config.yml ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ └── main.yml ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── prepare-commit-msg ├── .npmrc ├── .nvmrc ├── .prettierrc.mjs ├── .storybook ├── index.css ├── main.ts ├── manager.ts ├── preview.ts └── test-runner.ts ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── commitlint.config.cjs ├── cypress.config.ts ├── cypress ├── e2e │ └── index.cy.ts ├── fixtures │ └── example.json ├── plugins │ └── index.js ├── support │ ├── commands.ts │ ├── e2e.ts │ └── index.js └── tsconfig.json ├── docs ├── assets │ ├── css │ │ └── main.css │ ├── images │ │ ├── icons.png │ │ ├── icons@2x.png │ │ ├── widgets.png │ │ └── widgets@2x.png │ └── js │ │ ├── main.js │ │ └── search.js ├── classes │ ├── _scrollmenu_.scrollmenu.html │ ├── _wrapper_.arrowwrapper.html │ └── _wrapper_.innerwrapper.html ├── globals.html ├── index.html ├── interfaces │ ├── _scrollmenu_.draghistoryentry.html │ ├── _scrollmenu_.menustate.html │ ├── _types_.item.html │ ├── _types_.menuprops.html │ ├── _types_.refobject.html │ ├── _wrapper_.arrowwrapperprops.html │ ├── _wrapper_.innerstyleprops.html │ ├── _wrapper_.innerwrapperprops.html │ └── _wrapper_.innerwrapperstate.html └── modules │ ├── _defautsettings_.html │ ├── _index_.html │ ├── _scrollmenu_.html │ ├── _types_.html │ ├── _utils_.html │ └── _wrapper_.html ├── example-cra ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.test.tsx │ ├── App.tsx │ ├── index.css │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ └── setupTests.ts └── tsconfig.json ├── example-nextjs ├── .gitignore ├── .nvmrc ├── README.md ├── app │ ├── favicon.ico │ ├── globals.css │ ├── helpers │ │ ├── DragManager.ts │ │ ├── index.ts │ │ ├── usePreventBodyScroll │ │ │ └── index.ts │ │ └── usePrevious.ts │ ├── layout.tsx │ ├── page.module.css │ └── page.tsx ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── public │ ├── next.svg │ └── vercel.svg └── tsconfig.json ├── hireBadge.svg ├── jest.config.cjs ├── jest.init.mjs ├── package-lock.json ├── package.json ├── rollup.config.js ├── sample.gif ├── snapshotResolver.cjs ├── src ├── ItemsMap │ ├── ItemsMap.test.ts │ ├── ItemsMap.ts │ └── index.ts ├── Observer │ ├── Observer.test.ts │ ├── Observer.ts │ └── index.ts ├── components │ ├── Item │ │ ├── Item.test.tsx │ │ ├── Item.tsx │ │ └── index.ts │ ├── MenuItems │ │ ├── MenuItems.test.tsx │ │ ├── MenuItems.tsx │ │ └── index.ts │ ├── ScrollContainer │ │ ├── ScrollContainer.test.tsx │ │ ├── ScrollContainer.tsx │ │ └── index.ts │ └── index.ts ├── constants.ts ├── context.ts ├── createApi.test.ts ├── createApi.ts ├── getItemsPos.test.ts ├── getItemsPos.ts ├── helpers.test.tsx ├── helpers.tsx ├── hooks │ ├── useIntersectionObserver.test.ts │ ├── useIntersectionObserver.ts │ ├── useIsomorphicLayoutEffect.ts │ ├── useItemsChanged.test.tsx │ ├── useItemsChanged.tsx │ ├── useMenuVisible.tsx │ ├── useOnCb.test.ts │ ├── useOnCb.ts │ ├── usePrevious.test.ts │ └── usePrevious.ts ├── index.test.tsx ├── index.test.tsx.snap ├── index.tsx ├── settings.ts ├── slidingWindow │ ├── index.ts │ ├── slidingWindow.test.ts │ └── slidingWindow.ts ├── styles.css ├── testUtils.ts └── types.ts ├── stories ├── .eslintrc.cjs ├── 0_Simple │ ├── Simple.source.tsx │ └── Simple.stories.tsx ├── 1_Vertical │ ├── Vertical.source.tsx │ └── Vertical.stories.tsx ├── 2_OneItemScroll │ ├── OneItemScroll.source.tsx │ └── OneItemScroll.stories.tsx ├── 3_OneItem │ ├── OneItem.source.tsx │ └── OneItem.stories.tsx ├── 4_MouseDrag │ ├── MouseDrag.source.tsx │ └── MouseDrag.stories.tsx ├── 5_1_ScrollToItem │ ├── ScrollToItem.source.tsx │ └── ScrollToItem.stories.tsx ├── 5_Save_restore_position │ ├── Position.source.tsx │ └── Position.stories.tsx ├── 6_Items_animation │ ├── Items_animation.source.tsx │ └── Items_animation.stories.tsx ├── 7_progress │ ├── Progress.source.tsx │ └── Progress.stories.tsx ├── 8_PreventBodyScroll │ ├── PreventBodyScroll.source.tsx │ └── PreventBodyScroll.stories.tsx ├── 991_SwipeDesktop │ ├── SwipeDesktop.source.tsx │ └── SwipeDesktop.stories.tsx ├── 99_performance │ ├── Performance.source.tsx │ └── Performance.stories.tsx ├── 9_AddItems │ ├── AddItems.source.tsx │ └── AddItems.stories.tsx ├── BottomArrows │ ├── BottomArrows.source.tsx │ └── BottomArrows.stories.tsx ├── Docs.mdx ├── MobileSwipeOnly │ ├── MobileSwipeOnly.source.tsx │ └── MobileSwipeOnly.stories.tsx ├── RTL │ ├── RTL.source.tsx │ └── RTL.stories.tsx ├── SizeWrapper.tsx ├── availableImports.ts ├── setupEditor.ts └── test.tsx ├── styleMock.js ├── tea.yaml ├── test-runner-jest.config.js ├── tsconfig-monaco.json ├── tsconfig.json └── tsconfig.stories.json /.codebeatignore: -------------------------------------------------------------------------------- 1 | docs/** 2 | *.test.ts 3 | *.test.tsx 4 | src/**/*.test.ts 5 | src/**/*.test.tsx 6 | example-nextjs/** 7 | example-cra/** 8 | 9 | cypress/** 10 | cypress.config.* 11 | coverage/** 12 | .yarn/** 13 | .vscode/** 14 | .husky/** 15 | .github/** 16 | types/** 17 | dist/** 18 | stories/** 19 | .storybook/** 20 | 21 | *.js 22 | 23 | !src/** 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | quote_type = single 12 | max_line_length = 80 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2023": true, 4 | "shared-node-browser": true, 5 | "jest/globals": true 6 | }, 7 | "extends": [ 8 | "plugin:maintainable/recommended", 9 | "plugin:maintainable/react", 10 | "plugin:storybook/recommended", 11 | "plugin:cypress/recommended", 12 | "plugin:jest/recommended" 13 | ], 14 | "plugins": ["cypress", "jest"], 15 | "overrides": [ 16 | { 17 | "files": ["stories/**"], 18 | "rules": { 19 | "sonarjs/cognitive-complexity": ["error", 10], 20 | "no-secrets/no-secrets": "off" 21 | } 22 | }, 23 | { 24 | "files": ["package.json", "example-nextjs/app/page.tsx"], 25 | "rules": { 26 | "max-lines": "off" 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [asmyshlyaev177] 4 | patreon: asmyshlyaev177 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Link to codesandbox where can see the bug or steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - Browser [e.g. chrome 89, safari 14] 28 | - OS: [e.g. iOS 14.7] 29 | - Mobile device (eg iPhone, Galaxy etc) 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/codeql-config.yml: -------------------------------------------------------------------------------- 1 | name: "My CodeQL config" 2 | 3 | paths: 4 | - src 5 | paths-ignore: 6 | - src/node_modules 7 | - '**/*.test.js' 8 | - '**/*.test.jsx' 9 | - '**/*.test.ts' 10 | - '**/*.test.tsx' 11 | - '**/*.spec.js' 12 | - '**/*.spec.jsx' 13 | - '**/*.spec.ts' 14 | - '**/*.spec.tsx' -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Use `allow` to specify which dependencies to maintain 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "npm" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | target-branch: "main" 10 | open-pull-requests-limit: 0 11 | allow: 12 | - dependency-type: "production" -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '25 12 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v4 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v3 47 | with: 48 | languages: ${{ matrix.language }} 49 | config-file: ./.github/codeql-config.yml 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 54 | 55 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 56 | # If this step fails, then you should remove it and run the build manually (see below) 57 | - name: Autobuild 58 | uses: github/codeql-action/autobuild@v3 59 | 60 | # ℹ️ Command-line programs to run using the OS shell. 61 | # 📚 https://git.io/JvXDl 62 | 63 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 64 | # and modify them (or add more) to build your code if your project 65 | # uses a compiled language 66 | 67 | #- run: | 68 | # make bootstrap 69 | # make release 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v3 73 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test and deploy 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | branches: [master] 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | build-test: 14 | runs-on: ubuntu-20.04 15 | container: cypress/included:cypress-13.4.0-node-20.9.0-chrome-118.0.5993.88-1-ff-118.0.2-edge-118.0.2088.46-1 16 | 17 | env: 18 | CYPRESS_CACHE_FOLDER: ../cypress_cache 19 | NODE_OPTIONS: --max-old-space-size=4096 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | CI: true 22 | WIREIT_LOGGER: "simple" 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Use Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version-file: '.nvmrc' 29 | check-latest: true 30 | cache: 'npm' 31 | cache-dependency-path: 'package-lock.json' 32 | 33 | - uses: google/wireit@setup-github-actions-caching/v1 34 | 35 | - name: Install 36 | run: | 37 | mkdir -p ../cypress_cache && \ 38 | npm run setup && \ 39 | chmod -R 777 ../cypress_cache && \ 40 | wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | tee /etc/apt/trusted.gpg.d/google.asc >/dev/null && \ 41 | apt update && \ 42 | npx cross-env HOME=/root npx playwright install chromium firefox webkit --with-deps && \ 43 | chmod -R 777 /root/.cache/ms-playwright && \ 44 | apt install -y curl 45 | 46 | - name: Types and linter check 47 | run: npm run test:lint 48 | 49 | - name: Unit tests 50 | run: npm run test:unit -- --coverage --coverageReporters="lcov" 51 | 52 | - name: Run codacy-coverage-reporter 53 | uses: codacy/codacy-coverage-reporter-action@v1.3.0 54 | continue-on-error: true 55 | with: 56 | project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} 57 | coverage-reports: coverage/lcov.info 58 | 59 | - name: E2E tests builded version 60 | run: npm run test:e2e 61 | - uses: actions/upload-artifact@v4 62 | if: failure() 63 | with: 64 | name: cypress 65 | path: cypress 66 | 67 | - name: E2E tests dev version 68 | run: npm run test:e2e:dev 69 | 70 | - name: Storybook tests 71 | run: npx cross-env HOME=/root npm run test:storybook-ci 72 | 73 | deploy-storybook: 74 | needs: build-test 75 | runs-on: ubuntu-20.04 76 | if: github.ref_name == 'master' 77 | steps: 78 | - uses: actions/checkout@v4 79 | - uses: actions/setup-node@v4 80 | with: 81 | node-version-file: '.nvmrc' 82 | cache: 'npm' 83 | - name: Install 84 | run: npm run setup 85 | - name: Build storybook 86 | run: npm run build-storybook 87 | - name: Deploy storybook 88 | uses: peaceiris/actions-gh-pages@v3 89 | if: github.ref_name == 'master' 90 | with: 91 | github_token: ${{ secrets.GITHUB_TOKEN }} 92 | publish_dir: ./storybook-static 93 | 94 | # publish: 95 | # needs: build-test 96 | # runs-on: ubuntu-20.04 97 | # if: github.ref_name == 'master' 98 | # env: 99 | # NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 100 | # steps: 101 | # - uses: actions/checkout@v4 102 | # - uses: actions/setup-node@v4 103 | # with: 104 | # node-version-file: '.nvmrc' 105 | # cache: 'npm' 106 | # registry-url: 'https://registry.npmjs.org' 107 | # - name: Install 108 | # run: npm run setup 109 | # - run: npm run build 110 | # - uses: JS-DevTools/npm-publish@v3 111 | # with: 112 | # token: ${{ secrets.NPM_TOKEN }} 113 | # provenance: true 114 | # registry: 'https://registry.npmjs.org' 115 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | build 5 | coverage 6 | yarn-error.log 7 | 8 | **/.yarn/* 9 | !**/.yarn/patches 10 | !**/.yarn/releases 11 | !**/.yarn/plugins 12 | !**/.yarn/sdks 13 | !**/.yarn/versions 14 | **/.pnp.* 15 | 16 | cypress/videos 17 | cypress/screenshots 18 | cypress_cache 19 | 20 | .wireit 21 | 22 | stories/index.d.ts 23 | storybook-static 24 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | exec < /dev/tty && npx cz --hook || true 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry = "https://registry.npmjs.com/" 2 | legacy-peer-deps = true 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 2 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | const config = { 3 | editorConfig: true 4 | }; 5 | 6 | export default config; 7 | -------------------------------------------------------------------------------- /.storybook/index.css: -------------------------------------------------------------------------------- 1 | div { 2 | box-sizing: border-box; 3 | -moz-box-sizing: border-box; 4 | -webkit-box-sizing: border-box; 5 | } 6 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-webpack5'; 2 | import { 3 | getCodeEditorStaticDirs, 4 | getExtraStaticDir, 5 | } from 'storybook-addon-code-editor/getStaticDirs'; 6 | 7 | const config: StorybookConfig = { 8 | core: { 9 | builder: '@storybook/builder-webpack5', 10 | }, 11 | stories: [ 12 | '../stories/**/*.mdx', 13 | '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)', 14 | ], 15 | addons: [ 16 | 'storybook-addon-code-editor', 17 | '@storybook/addon-interactions', 18 | '@storybook/addon-links', 19 | { 20 | name: '@storybook/addon-essentials', 21 | options: { 22 | actions: false, 23 | controls: false, 24 | }, 25 | }, 26 | // '@storybook/addon-onboarding', 27 | ], 28 | managerHead: (head) => ` 29 | 44 | ${head} 45 | `, 46 | staticDirs: [ 47 | ...getCodeEditorStaticDirs(), 48 | getExtraStaticDir('monaco-editor/esm'), 49 | ], 50 | framework: { 51 | name: '@storybook/react-webpack5', 52 | options: { 53 | builder: { 54 | useSWC: true, 55 | }, 56 | }, 57 | }, 58 | docs: { 59 | autodocs: 'tag', 60 | }, 61 | }; 62 | export default config; 63 | -------------------------------------------------------------------------------- /.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/manager-api'; 2 | 3 | addons.setConfig({ 4 | isFullscreen: false, 5 | showNav: true, 6 | showPanel: true, 7 | panelPosition: 'right', 8 | enableShortcuts: false, 9 | showToolbar: true, 10 | theme: undefined, 11 | selectedPanel: undefined, 12 | initialActive: 'sidebar', 13 | sidebar: { 14 | showRoots: false, 15 | collapsedRoots: ['other'], 16 | }, 17 | toolbar: { 18 | title: { hidden: false }, 19 | zoom: { hidden: false }, 20 | eject: { hidden: false }, 21 | copy: { hidden: false }, 22 | fullscreen: { hidden: false }, 23 | }, 24 | }); 25 | 26 | 27 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from "@storybook/react"; 2 | import 'normalize.css'; 3 | import './index.css' 4 | 5 | const preview: Preview = { 6 | parameters: { 7 | actions: { argTypesRegex: "^on[A-Z].*" }, 8 | // controls: { 9 | // matchers: { 10 | // color: /(background|color)$/i, 11 | // date: /Date$/i, 12 | // }, 13 | // }, 14 | }, 15 | }; 16 | 17 | export default preview; 18 | -------------------------------------------------------------------------------- /.storybook/test-runner.ts: -------------------------------------------------------------------------------- 1 | // .storybook/test-runner.ts 2 | import { TestRunnerConfig, waitForPageReady } from '@storybook/test-runner'; 3 | 4 | const config: TestRunnerConfig = { 5 | // async postVisit(page, context) { 6 | // // use the test-runner utility to wait for fonts to load, etc. 7 | // await waitForPageReady(page); 8 | 9 | // // by now, we know that the page is fully loaded 10 | // }, 11 | }; 12 | export default config; 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | Any meaningful contribution will be appreciated. 4 | 5 | Try to write a good code with tests(not 100% covered is ok, no need complex and fragile tests only for coverage sake). 6 | 7 | Please try to leave code cleaner that it was before you touch it(or at least not worse). Don't make things overcomplicated. 8 | 9 | Feel free to ask questions, report bugs or create PR. But don't create duplicates. 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Make sure that what you trying to fix really a bug, or if adding feature that is good way to solve your task. 2 | 3 | 1. Fork the repo and install stuff: 4 | 5 | - Run `npm run setup` in root folder (it will install all deps and do other required steps) 6 | - Run `npm run demo` for run the demo project in watch mode 7 | 8 | 2. Write code! Add some feature or fix bug. 9 | 10 | 3. Check that all tests passed(unit and e2e) and add tests for your code. 11 | You can run unit tests with `npm run test:unit` and cypress tests `npm run test:e2e` 12 | 13 | 4. Update readme and example (if needed) 14 | 15 | 5. Make commit and Pull Request, review, approval and merge. 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Alex - https://github.com/asmyshlyaev177 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | [comment]: # (What it's about. Adding feature or fixing a bug) 3 | # PR title 4 | 5 | [comment]: # (Description of changes) 6 | ## Proposed Changes 7 | - 8 | 9 | [comment]: # (Fill out if it change or break existing API) 10 | ## Breaking Changes 11 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | modifyObstructiveCode: false, 5 | 6 | retries: { 7 | runMode: 2, 8 | openMode: 0, 9 | }, 10 | 11 | e2e: { 12 | baseUrl: 'http://localhost:3003', 13 | setupNodeEvents(on, config) { 14 | // implement node event listeners here 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /cypress/e2e/index.cy.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable cypress/no-unnecessary-waiting */ 2 | /* eslint-disable jest/expect-expect */ 3 | /* eslint-disable sonarjs/no-duplicate-string */ 4 | /* eslint-disable max-nested-callbacks */ 5 | 6 | const waitShort = 450; 7 | 8 | type Cy = Cypress.cy & CyEventEmitter; 9 | type Direction = 'Left' | 'Right'; 10 | 11 | describe('Scrolling menu', () => { 12 | it('Scroll forward and backward and check cards and arrows visibility', () => { 13 | cy.viewport(650, 768); 14 | 15 | cy.visit('/'); 16 | cy.waitUntil(() => cy.contains('test0').should('be.visible')); 17 | 18 | cy.wait(500); 19 | 20 | checkArrow({ cy, direction: 'Left', visible: false }); 21 | checkArrow({ cy, direction: 'Right', visible: true }); 22 | 23 | checkCards({ cy, visible: ['0', '1', '2'] }); 24 | 25 | scrollNext({ cy }); 26 | checkCards({ cy, visible: ['3', '4', '5'] }); 27 | 28 | scrollNext({ cy }); 29 | checkCards({ cy, visible: ['6', '7', '8'] }); 30 | 31 | scrollNext({ cy }); 32 | checkCards({ cy, visible: ['7', '8', '9'] }); 33 | 34 | cy.log('Last items'); 35 | 36 | scrollPrev({ cy }); 37 | checkCards({ cy, visible: ['4', '5', '6'] }); 38 | 39 | scrollPrev({ cy }); 40 | checkCards({ cy, visible: ['1', '2', '3'] }); 41 | 42 | scrollPrev({ cy }); 43 | checkCards({ cy, visible: ['0', '1', '2'] }); 44 | 45 | cy.log('First items'); 46 | }); 47 | 48 | describe('menu visibility', () => { 49 | it('When Menu hidden should not update arrows', () => { 50 | cy.viewport(650, 768); 51 | 52 | cy.visit('/'); 53 | cy.wait(300); 54 | cy.waitUntil(() => cy.contains('test0').should('be.visible')); 55 | 56 | checkArrow({ cy, direction: 'Left', visible: false }); 57 | 58 | cy.scrollTo(0, 400); 59 | 60 | cy.wait(300); 61 | checkArrow({ cy, direction: 'Left', visible: false }); 62 | }); 63 | 64 | it('should handle scroll when Menu partially hidden', () => { 65 | cy.viewport(650, 768); 66 | 67 | cy.visit('/'); 68 | cy.waitUntil(() => cy.contains('test0').should('be.visible')); 69 | 70 | cy.wait(300); 71 | checkArrow({ cy, direction: 'Left', visible: false }); 72 | 73 | cy.scrollTo(0, 450); 74 | cy.wait(300); 75 | 76 | scrollNext({ cy }); 77 | cy.wait(300); 78 | 79 | checkCards({ cy, visible: ['3', '4', '5'] }); 80 | 81 | checkArrow({ cy, direction: 'Left', visible: true }); 82 | checkArrow({ cy, direction: 'Right', visible: true }); 83 | 84 | scrollNext({ cy }); 85 | cy.wait(300); 86 | checkCards({ cy, visible: ['6', '7', '8'] }); 87 | checkArrow({ cy, direction: 'Left', visible: true }); 88 | checkArrow({ cy, direction: 'Right', visible: true }); 89 | 90 | scrollNext({ cy }); 91 | cy.wait(300); 92 | checkCards({ cy, visible: ['7', '8', '9'] }); 93 | checkArrow({ cy, direction: 'Left', visible: true }); 94 | checkArrow({ cy, direction: 'Right', visible: false }); 95 | }); 96 | }); 97 | }); 98 | 99 | function scrollPrev({ cy }: { cy: Cy }) { 100 | return getArrow({ cy, direction: 'Left' }).click(); 101 | } 102 | 103 | function scrollNext({ cy }: { cy: Cy }) { 104 | return getArrow({ cy, direction: 'Right' }).click(); 105 | } 106 | 107 | function checkCards({ cy, visible: _visible }: { cy: Cy; visible: string[] }) { 108 | cy.log('CHECK_CARDS'); 109 | const visible = _visible.map((id) => `test${id}`); 110 | 111 | cy.get('.card[data-visible=true]').should('have.length', visible.length); 112 | return cy.waitUntil(() => 113 | cy 114 | .wrap(visible) 115 | .each((id) => cy.get(`[data-cy=${id}]:contains("visible: true")`)), 116 | ); 117 | } 118 | 119 | function checkArrow({ 120 | direction = 'Left', 121 | visible = true, 122 | cy, 123 | }: { 124 | cy: Cy; 125 | direction: Direction; 126 | visible: boolean; 127 | }) { 128 | cy.log('CHECK_ARROWS'); 129 | cy.wait(waitShort); 130 | return getArrow({ cy, direction }) 131 | .should(`${visible ? '' : 'not.'}be.visible`) 132 | .should(`${visible ? 'not.' : ''}be.disabled`); 133 | } 134 | 135 | function getArrow({ 136 | cy, 137 | direction = 'Left', 138 | }: { 139 | cy: Cy; 140 | direction: Direction; 141 | }) { 142 | return cy.get(`button:contains("${direction}")`); 143 | } 144 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | // eslint-disable-next-line no-unused-vars 19 | module.exports = (on, config) => { 20 | // `on` is used to hook into various events Cypress emits 21 | // `config` is the resolved Cypress config 22 | } 23 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } 38 | 39 | import 'cypress-wait-until'; 40 | 41 | // Cypress.on('window:before:load', (window) => { 42 | // window.document.head.insertAdjacentHTML( 43 | // 'beforeend', 44 | // ` 45 | // 51 | // `, 52 | // ); 53 | // }); 54 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["es2017", "dom"], 5 | "types": ["cypress"] 6 | }, 7 | "include": ["**/*.ts"] 8 | } -------------------------------------------------------------------------------- /docs/assets/images/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asmyshlyaev177/react-horizontal-scrolling-menu/a2d76479be195b0475f6931051c63d80c5d9eedd/docs/assets/images/icons.png -------------------------------------------------------------------------------- /docs/assets/images/icons@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asmyshlyaev177/react-horizontal-scrolling-menu/a2d76479be195b0475f6931051c63d80c5d9eedd/docs/assets/images/icons@2x.png -------------------------------------------------------------------------------- /docs/assets/images/widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asmyshlyaev177/react-horizontal-scrolling-menu/a2d76479be195b0475f6931051c63d80c5d9eedd/docs/assets/images/widgets.png -------------------------------------------------------------------------------- /docs/assets/images/widgets@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asmyshlyaev177/react-horizontal-scrolling-menu/a2d76479be195b0475f6931051c63d80c5d9eedd/docs/assets/images/widgets@2x.png -------------------------------------------------------------------------------- /example-cra/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /example-cra/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /example-cra/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-cra", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.17.0", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "@types/jest": "^27.5.2", 10 | "@types/node": "^16.18.79", 11 | "@types/react": "^18.2.52", 12 | "@types/react-dom": "^18.2.18", 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0", 15 | "react-horizontal-scrolling-menu": "file:../", 16 | "react-scripts": "5.0.1", 17 | "styled-jss": "^2.2.3", 18 | "typescript": "^4.9.5", 19 | "web-vitals": "^2.1.4" 20 | }, 21 | "scripts": { 22 | "start": "cross-env BROWSER=none cross-env PORT=3003 react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test", 25 | "eject": "react-scripts eject" 26 | }, 27 | "eslintConfig": { 28 | "extends": [ 29 | "react-app", 30 | "react-app/jest" 31 | ] 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | }, 45 | "devDependencies": { 46 | "cross-env": "^7.0.3" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /example-cra/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asmyshlyaev177/react-horizontal-scrolling-menu/a2d76479be195b0475f6931051c63d80c5d9eedd/example-cra/public/favicon.ico -------------------------------------------------------------------------------- /example-cra/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example-cra/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asmyshlyaev177/react-horizontal-scrolling-menu/a2d76479be195b0475f6931051c63d80c5d9eedd/example-cra/public/logo192.png -------------------------------------------------------------------------------- /example-cra/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asmyshlyaev177/react-horizontal-scrolling-menu/a2d76479be195b0475f6931051c63d80c5d9eedd/example-cra/public/logo512.png -------------------------------------------------------------------------------- /example-cra/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /example-cra/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /example-cra/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const utils = render(); 7 | expect(utils.container).toBeTruthy() 8 | }); 9 | -------------------------------------------------------------------------------- /example-cra/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | ScrollMenu, 4 | VisibilityContext, 5 | type publicApiType, 6 | } from 'react-horizontal-scrolling-menu'; 7 | import 'react-horizontal-scrolling-menu/dist/styles.css'; 8 | // @ts-ignore 9 | import styled from 'styled-jss'; 10 | 11 | function App() { 12 | return ( 13 |
14 | 15 |
16 | ); 17 | } 18 | 19 | export default App; 20 | 21 | export function SimpleExample() { 22 | const [items] = React.useState(() => getItems()); 23 | const [selected, setSelected] = React.useState([]); 24 | 25 | const isItemSelected = (id: string): boolean => 26 | !!selected.find((el) => el === id); 27 | 28 | const handleItemClick = (itemId: string) => { 29 | const itemSelected = isItemSelected(itemId); 30 | 31 | setSelected((currentSelected: string[]) => 32 | itemSelected 33 | ? currentSelected.filter((el) => el !== itemId) 34 | : currentSelected.concat(itemId) 35 | ); 36 | }; 37 | 38 | return ( 39 | 40 | 45 | {items.map(({ id }) => ( 46 | handleItemClick(id)} 51 | selected={isItemSelected(id)} 52 | /> 53 | ))} 54 | 55 | 56 | ); 57 | } 58 | 59 | const NoScrollbar = styled('div')({ 60 | '& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': { 61 | display: 'none', 62 | }, 63 | '& .react-horizontal-scrolling-menu--scroll-container': { 64 | scrollbarWidth: 'none', 65 | '-ms-overflow-style': 'none', 66 | }, 67 | }); 68 | 69 | function LeftArrow() { 70 | const visibility = React.useContext(VisibilityContext); 71 | const isFirstItemVisible = visibility.useIsVisible('first', true); 72 | 73 | return ( 74 | visibility.scrollPrev()} 77 | testId="left-arrow" 78 | > 79 | Left 80 | 81 | ); 82 | } 83 | 84 | function RightArrow() { 85 | const visibility = React.useContext(VisibilityContext); 86 | const isLastItemVisible = visibility.useIsVisible('last', false); 87 | 88 | return ( 89 | visibility.scrollNext()} 92 | testId="right-arrow" 93 | > 94 | Right 95 | 96 | ); 97 | } 98 | 99 | function Arrow({ 100 | children, 101 | disabled, 102 | onClick, 103 | className, 104 | testId, 105 | }: { 106 | children: React.ReactNode; 107 | disabled: boolean; 108 | onClick: VoidFunction; 109 | className?: string; 110 | testId: string; 111 | }) { 112 | return ( 113 | 119 | {children} 120 | 121 | ); 122 | } 123 | const ArrowButton = styled('button')({ 124 | cursor: 'pointer', 125 | display: 'flex', 126 | flexDirection: 'column', 127 | justifyContent: 'center', 128 | marginBottom: '2px', 129 | opacity: (props: { disabled: boolean }) => (props.disabled ? '0' : '1'), 130 | userSelect: 'none', 131 | borderRadius: '6px', 132 | borderWidth: '1px', 133 | }); 134 | 135 | function Card({ 136 | onClick, 137 | selected, 138 | title, 139 | itemId, 140 | }: { 141 | onClick: (context: publicApiType) => void; 142 | selected: boolean; 143 | title: string; 144 | itemId: string; 145 | }) { 146 | const visibility = React.useContext(VisibilityContext); 147 | const visible = visibility.useIsVisible(itemId, true); 148 | 149 | return ( 150 | onClick(visibility)} 153 | onKeyDown={(ev: React.KeyboardEvent) => { 154 | ev.code === 'Enter' && onClick(visibility); 155 | }} 156 | data-testid="card" 157 | role="button" 158 | tabIndex={0} 159 | className="card" 160 | visible={visible} 161 | selected={selected} 162 | > 163 |
164 |
{title}
165 |
visible: {JSON.stringify(visible)}
166 |
selected: {JSON.stringify(!!selected)}
167 |
168 |
169 | 170 | ); 171 | } 172 | const CardBody = styled('div')({ 173 | border: '1px solid', 174 | display: 'inline-block', 175 | margin: '0 10px', 176 | width: '160px', 177 | userSelect: 'none', 178 | borderRadius: '8px', 179 | overflow: 'hidden', 180 | 181 | '& .header': { 182 | backgroundColor: 'white', 183 | }, 184 | 185 | '& .visible': { 186 | backgroundColor: (props: { visible: boolean }) => (props.visible ? 'transparent' : 'gray'), 187 | }, 188 | 189 | '& .background': { 190 | backgroundColor: (props: { visible: boolean, selected: string }) => (props.selected ? 'green' : 'bisque'), 191 | height: '200px', 192 | }, 193 | }); 194 | 195 | const getId = (index: number) => `${'test'}${index}`; 196 | 197 | const getItems = () => 198 | Array(10) 199 | .fill(0) 200 | .map((_, ind) => ({ id: getId(ind) })); 201 | 202 | function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void { 203 | // NOTE: no good standart way to distinguish touchpad scrolling gestures 204 | // but can assume that gesture will affect X axis, mouse scroll only Y axis 205 | // of if deltaY too small probably is it touchpad 206 | const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15; 207 | 208 | if (isThouchpad) { 209 | ev.stopPropagation(); 210 | return; 211 | } 212 | 213 | if (ev.deltaY < 0) { 214 | apiObj.scrollNext(); 215 | } else { 216 | apiObj.scrollPrev(); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /example-cra/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | * { 16 | box-sizing: border-box; 17 | padding: 0; 18 | margin: 0; 19 | } 20 | -------------------------------------------------------------------------------- /example-cra/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | 16 | // If you want to start measuring performance in your app, pass a function 17 | // to log results (for example: reportWebVitals(console.log)) 18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 19 | reportWebVitals(); 20 | -------------------------------------------------------------------------------- /example-cra/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example-cra/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /example-cra/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /example-cra/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /example-nextjs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /example-nextjs/.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /example-nextjs/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /example-nextjs/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asmyshlyaev177/react-horizontal-scrolling-menu/a2d76479be195b0475f6931051c63d80c5d9eedd/example-nextjs/app/favicon.ico -------------------------------------------------------------------------------- /example-nextjs/app/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --max-width: 1100px; 3 | --border-radius: 12px; 4 | --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', 5 | 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', 6 | 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; 7 | 8 | --foreground-rgb: 0, 0, 0; 9 | --background-start-rgb: 214, 219, 220; 10 | --background-end-rgb: 255, 255, 255; 11 | 12 | --primary-glow: conic-gradient( 13 | from 180deg at 50% 50%, 14 | #16abff33 0deg, 15 | #0885ff33 55deg, 16 | #54d6ff33 120deg, 17 | #0071ff33 160deg, 18 | transparent 360deg 19 | ); 20 | --secondary-glow: radial-gradient( 21 | rgba(255, 255, 255, 1), 22 | rgba(255, 255, 255, 0) 23 | ); 24 | 25 | --tile-start-rgb: 239, 245, 249; 26 | --tile-end-rgb: 228, 232, 233; 27 | --tile-border: conic-gradient( 28 | #00000080, 29 | #00000040, 30 | #00000030, 31 | #00000020, 32 | #00000010, 33 | #00000010, 34 | #00000080 35 | ); 36 | 37 | --callout-rgb: 238, 240, 241; 38 | --callout-border-rgb: 172, 175, 176; 39 | --card-rgb: 180, 185, 188; 40 | --card-border-rgb: 131, 134, 135; 41 | } 42 | 43 | @media (prefers-color-scheme: dark) { 44 | :root { 45 | --foreground-rgb: 255, 255, 255; 46 | --background-start-rgb: 0, 0, 0; 47 | --background-end-rgb: 0, 0, 0; 48 | 49 | --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); 50 | --secondary-glow: linear-gradient( 51 | to bottom right, 52 | rgba(1, 65, 255, 0), 53 | rgba(1, 65, 255, 0), 54 | rgba(1, 65, 255, 0.3) 55 | ); 56 | 57 | --tile-start-rgb: 2, 13, 46; 58 | --tile-end-rgb: 2, 5, 19; 59 | --tile-border: conic-gradient( 60 | #ffffff80, 61 | #ffffff40, 62 | #ffffff30, 63 | #ffffff20, 64 | #ffffff10, 65 | #ffffff10, 66 | #ffffff80 67 | ); 68 | 69 | --callout-rgb: 20, 20, 20; 70 | --callout-border-rgb: 108, 108, 108; 71 | --card-rgb: 100, 100, 100; 72 | --card-border-rgb: 200, 200, 200; 73 | } 74 | } 75 | 76 | * { 77 | box-sizing: border-box; 78 | padding: 0; 79 | margin: 0; 80 | } 81 | 82 | html, 83 | body { 84 | max-width: 100vw; 85 | overflow-x: hidden; 86 | } 87 | 88 | body { 89 | color: rgb(var(--foreground-rgb)); 90 | background: linear-gradient( 91 | to bottom, 92 | transparent, 93 | rgb(var(--background-end-rgb)) 94 | ) 95 | rgb(var(--background-start-rgb)); 96 | } 97 | 98 | a { 99 | color: inherit; 100 | text-decoration: none; 101 | } 102 | 103 | @media (prefers-color-scheme: dark) { 104 | html { 105 | color-scheme: dark; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /example-nextjs/app/helpers/DragManager.ts: -------------------------------------------------------------------------------- 1 | export class DragManager { 2 | clicked: boolean; 3 | dragging: boolean; 4 | position: number; 5 | 6 | constructor() { 7 | this.clicked = false; 8 | this.dragging = false; 9 | this.position = 0; 10 | } 11 | 12 | public dragStart = (ev: React.MouseEvent) => { 13 | this.position = ev.clientX; 14 | this.clicked = true; 15 | }; 16 | 17 | public dragStop = () => { 18 | window.requestAnimationFrame(() => { 19 | this.dragging = false; 20 | this.clicked = false; 21 | }); 22 | }; 23 | 24 | public dragMove = (ev: React.MouseEvent, cb: (posDiff: number) => void) => { 25 | const newDiff = this.position - ev.clientX; 26 | 27 | const movedEnough = Math.abs(newDiff) > 5; 28 | 29 | if (this.clicked && movedEnough) { 30 | this.dragging = true; 31 | } 32 | 33 | if (this.dragging && movedEnough) { 34 | this.position = ev.clientX; 35 | cb(newDiff); 36 | } 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /example-nextjs/app/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './usePreventBodyScroll'; 2 | export * from './DragManager'; 3 | export * from './usePrevious'; 4 | -------------------------------------------------------------------------------- /example-nextjs/app/helpers/usePreventBodyScroll/index.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function usePreventBodyScroll() { 4 | const preventDefault = React.useCallback((ev: Event) => { 5 | ev?.preventDefault?.(); 6 | }, []); 7 | 8 | const enableScroll = React.useCallback(() => { 9 | document && document.removeEventListener('wheel', preventDefault, false); 10 | }, [preventDefault]); 11 | const disableScroll = React.useCallback(() => { 12 | document && 13 | document.addEventListener('wheel', preventDefault, { 14 | passive: false, 15 | }); 16 | }, [preventDefault]); 17 | 18 | React.useEffect(() => { 19 | return enableScroll; 20 | }, [enableScroll]); 21 | 22 | return { disableScroll, enableScroll }; 23 | } 24 | 25 | export default usePreventBodyScroll; 26 | -------------------------------------------------------------------------------- /example-nextjs/app/helpers/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function usePrevious(value: T) { 4 | const ref = React.useRef(); 5 | 6 | React.useEffect(() => { 7 | ref.current = value; 8 | }, [value]); 9 | 10 | return ref.current; 11 | } 12 | 13 | export default usePrevious; 14 | -------------------------------------------------------------------------------- /example-nextjs/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from 'next/font/google'; 2 | import React from 'react'; 3 | 4 | import type { Metadata } from 'next'; 5 | import './globals.css'; 6 | 7 | const inter = Inter({ subsets: ['latin'] }); 8 | 9 | export const metadata: Metadata = { 10 | title: 'Create Next App', 11 | description: 'Generated by create next app', 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: { 17 | children: React.ReactNode; 18 | }) { 19 | return ( 20 | 21 | {/* 22 | 26 | */} 27 | {children} 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /example-nextjs/app/page.module.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asmyshlyaev177/react-horizontal-scrolling-menu/a2d76479be195b0475f6931051c63d80c5d9eedd/example-nextjs/app/page.module.css -------------------------------------------------------------------------------- /example-nextjs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /example-nextjs/next.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // const MillionCompiler = require('@million/lint'); 3 | 4 | /** @type {import('next').NextConfig} */ 5 | const nextConfig = { 6 | output: "export", 7 | reactStrictMode: true 8 | } 9 | 10 | // module.exports = MillionCompiler.next()(nextConfig); 11 | module.exports = nextConfig; 12 | -------------------------------------------------------------------------------- /example-nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev -p 3003", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "serve": "serve -s -p 3003 out" 11 | }, 12 | "dependencies": { 13 | "@formkit/auto-animate": "^0.8.1", 14 | "lodash": "^4.17.21", 15 | "next": "^14.2.5", 16 | "react": "^18.3.1", 17 | "react-dom": "^18.3.1", 18 | "react-horizontal-scrolling-menu": "file:../" 19 | }, 20 | "devDependencies": { 21 | "@million/lint": "^0.0.68", 22 | "@types/lodash": "^4.14.200", 23 | "@types/node": "^20", 24 | "@types/react": "^18", 25 | "@types/react-dom": "^18", 26 | "eslint": "^8", 27 | "eslint-config-next": "^14.2.5", 28 | "popmotion": "^11.0.5", 29 | "serve": "^14.2.1", 30 | "stylefire": "^7.0.3", 31 | "typescript": "^5" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /example-nextjs/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example-nextjs/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example-nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2016", 4 | "lib": ["dom", "dom.iterable", "ES2016", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /hireBadge.svg: -------------------------------------------------------------------------------- 1 | For: HireForHire 2 | -------------------------------------------------------------------------------- /jest.init.mjs: -------------------------------------------------------------------------------- 1 | require('@testing-library/dom'); 2 | require('@testing-library/jest-dom'); 3 | require('jest-environment-jsdom'); 4 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import ignore from 'rollup-plugin-ignore'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | import postcss from 'rollup-plugin-postcss'; 6 | import sourcemaps from 'rollup-plugin-sourcemaps'; 7 | import typescript from '@rollup/plugin-typescript'; 8 | import filesize from 'rollup-plugin-filesize'; 9 | import pkg from './package.json'; 10 | 11 | const input = 'src/index.tsx'; 12 | 13 | const isProduction = !process.env.IS_DEVELOPMENT; 14 | const sourcemap = !isProduction; 15 | const clearScreen = { watch: { clearScreen: false } }; 16 | 17 | const external = [ 18 | ...Object.keys(pkg.peerDependencies || {}), 19 | (id) => /^react$|^react-dom$|^@babel\/runtime/.test(id), 20 | ]; 21 | const plugins = [ 22 | ignore(['fs', 'net', 'react', 'react-dom', 'prop-types', 'PropTypes']), 23 | resolve({ 24 | include: ['node_modules/**'], 25 | }), 26 | typescript({ sourceMap: false, tsconfig: './tsconfig.json' }), 27 | commonjs(), 28 | postcss({ 29 | extract: 'styles.css', 30 | modules: false, 31 | minimize: true, 32 | use: ['sass'], 33 | }), 34 | 35 | !isProduction && sourcemaps(), 36 | isProduction && terser(), 37 | filesize(), 38 | ].filter(Boolean); 39 | 40 | export default [ 41 | // browser-friendly UMD build 42 | { 43 | input, 44 | output: { 45 | name: 'react-horizontal-scrolling-menu', 46 | file: pkg.unpkg, 47 | format: 'umd', 48 | sourcemap, 49 | globals: { 50 | react: 'React', 51 | 'react-dom': 'ReactDOM', 52 | 'prop-types': 'PropTypes', 53 | }, 54 | }, 55 | plugins, 56 | external, 57 | ...clearScreen, 58 | }, 59 | 60 | // CommonJS (for Node) and ES module (for bundlers) build. 61 | // (We could have three entries in the configuration array 62 | // instead of two, but it's quicker to generate multiple 63 | // builds from a single configuration where possible, using 64 | // an array for the `output` option, where we can specify 65 | // `file` and `format` for each target) 66 | { 67 | input, 68 | plugins, 69 | external, 70 | output: [ 71 | // { file: pkg.main, format: 'cjs', sourcemap }, 72 | { file: pkg.module, format: 'es', sourcemap }, 73 | ], 74 | ...clearScreen, 75 | }, 76 | ]; 77 | -------------------------------------------------------------------------------- /sample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asmyshlyaev177/react-horizontal-scrolling-menu/a2d76479be195b0475f6931051c63d80c5d9eedd/sample.gif -------------------------------------------------------------------------------- /snapshotResolver.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testPathForConsistencyCheck: 'some/example.test.js', 3 | 4 | resolveSnapshotPath: (testPath, snapshotExtension) => 5 | testPath.replace(/\.test\.([tj]sx?)/, `.test.$1${snapshotExtension}`), 6 | 7 | resolveTestPath: (snapshotFilePath, snapshotExtension) => 8 | snapshotFilePath.replace(snapshotExtension, ''), 9 | }; 10 | -------------------------------------------------------------------------------- /src/ItemsMap/ItemsMap.ts: -------------------------------------------------------------------------------- 1 | import { Observer, type ObsFn } from '../Observer'; 2 | import { events } from '../constants'; 3 | 4 | import type { IOItem, Item, ItemId, visibleElements, EventKey } from '../types'; 5 | 6 | export class ItemsMap extends Map { 7 | observer: Observer; 8 | firstRun: boolean; 9 | 10 | constructor() { 11 | super(); 12 | this.observer = new Observer(); 13 | this.firstRun = true; 14 | } 15 | 16 | public subscribe = (key: EventKey, value: ObsFn) => { 17 | return this.observer.subscribe(key, value); 18 | }; 19 | 20 | public unsubscribe = (key: EventKey, fn: ObsFn) => { 21 | return this.observer.unsubscribe(key, fn); 22 | }; 23 | 24 | private isEdgeItem = ({ 25 | key, 26 | value, 27 | first = this.first(), 28 | last = this.last(), 29 | }: { 30 | key: EventKey; 31 | value: IOItem; 32 | first?: IOItem; 33 | last?: IOItem; 34 | }) => { 35 | const result: [EventKey, IOItem][] = []; 36 | if (key === first?.key) { 37 | result.push([events.first, value]); 38 | } else if (key === last?.key) { 39 | result.push([events.last, value]); 40 | } 41 | return result; 42 | }; 43 | 44 | private edgeItemsCheck = (items: Item[]) => { 45 | const first = this.first(); 46 | const last = this.last(); 47 | const firstItem = items.find(([key]) => key === first?.key); 48 | const result: [EventKey, IOItem][] = []; 49 | if (firstItem) { 50 | result.push([events.first, firstItem[1]]); 51 | } 52 | const lastItem = items.find(([key]) => key === last?.key); 53 | if (lastItem) { 54 | result.push([events.last, lastItem[1]]); 55 | } 56 | return result; 57 | }; 58 | 59 | public toArr = () => this.sort([...this]); 60 | 61 | public toItems = (): visibleElements => this.toArr().map(([key]) => key); 62 | 63 | public sort = (arr: Item[]) => 64 | arr.sort(([, IOItemA], [, IOItemB]) => +IOItemA.index - +IOItemB.index); 65 | 66 | set = (_key: ItemId, value: IOItem): this => { 67 | const key = String(_key) as ItemId; 68 | const payload: [EventKey, IOItem][] = [[key, value]]; 69 | 70 | super.set(key, value); 71 | 72 | payload.push( 73 | ...this.isEdgeItem({ 74 | key, 75 | value, 76 | first: this.first(), 77 | last: this.last(), 78 | }), 79 | ); 80 | this.observer.updateBatch(payload); 81 | 82 | return this; 83 | }; 84 | 85 | // NOTE: Intersection Observer will fire first batch with all items 86 | public setBatch = (_entries: Array) => { 87 | if (this.firstRun) { 88 | this.observer.update(events.onInit); 89 | } 90 | const entries = [..._entries]; 91 | 92 | this.sort(entries).forEach(([itemId, ioitem]) => { 93 | super.set(String(itemId), ioitem); 94 | }); 95 | entries.push(...this.edgeItemsCheck(entries)); 96 | 97 | this.observer.updateBatch(entries, !this.firstRun); 98 | 99 | this.firstRun = false; 100 | return this; 101 | }; 102 | 103 | public first = (): IOItem | undefined => this.toArr()[0]?.[1]; 104 | 105 | public last = (): IOItem | undefined => this.toArr().slice(-1)?.[0]?.[1]; 106 | 107 | public filter = ( 108 | predicate: (value: Item, index: number, array: Item[]) => boolean, 109 | ): Item[] => this.toArr().filter(predicate); 110 | 111 | public find = ( 112 | predicate: (value: Item, index: number, obj: Item[]) => boolean, 113 | ): Item | undefined => this.toArr().find(predicate); 114 | 115 | public findIndex = ( 116 | predicate: (value: Item, index: number, obj: Item[]) => unknown, 117 | ): number => this.toArr().findIndex(predicate); 118 | 119 | public getCurrentPos = (item: ItemId | IOItem): [Item[], number] => { 120 | const arr = this.toArr(); 121 | const current = arr.findIndex( 122 | ([itemId, ioitem]) => itemId === item || ioitem === item, 123 | ); 124 | return [arr, current]; 125 | }; 126 | 127 | public prev = (item: ItemId | IOItem): IOItem | undefined => { 128 | const [arr, current] = this.getCurrentPos(item); 129 | return current !== -1 ? arr[current - 1]?.[1] : undefined; 130 | }; 131 | 132 | public next = (item: ItemId | IOItem): IOItem | undefined => { 133 | const [arr, current] = this.getCurrentPos(item); 134 | return current !== -1 ? arr[current + 1]?.[1] : undefined; 135 | }; 136 | 137 | public getVisible = () => this.filter((value: Item) => value[1].visible); 138 | } 139 | -------------------------------------------------------------------------------- /src/ItemsMap/index.ts: -------------------------------------------------------------------------------- 1 | export { ItemsMap } from './ItemsMap'; 2 | -------------------------------------------------------------------------------- /src/Observer/Observer.test.ts: -------------------------------------------------------------------------------- 1 | import { events } from '../constants'; 2 | import { IOItem } from '../types'; 3 | 4 | import { EventPayload, Observer } from './Observer'; 5 | 6 | const key = 'test'; 7 | const fn = jest.fn(); 8 | const item = {} as IOItem; 9 | 10 | let observer: Observer; 11 | describe('Observer', () => { 12 | beforeEach(() => { 13 | jest.clearAllMocks(); 14 | observer = new Observer(); 15 | }); 16 | 17 | it('should construct new instance', () => { 18 | expect(new Observer()).toBeTruthy(); 19 | }); 20 | 21 | describe('subscribe', () => { 22 | it('should add new subscriptions', () => { 23 | const key = 'test'; 24 | const fn = jest.fn(); 25 | const fn2 = jest.fn(); 26 | observer.subscribe(key, fn); 27 | observer.subscribe('test2', fn); 28 | observer.subscribe('test2', fn2); 29 | expect(observer.observers.size).toEqual(2); 30 | expect([...observer.observers.entries()]).toStrictEqual([ 31 | [key, [fn]], 32 | ['test2', [fn, fn2]], 33 | ]); 34 | }); 35 | }); 36 | 37 | describe('unsubscribe', () => { 38 | it('should remove subscription', () => { 39 | observer.subscribe(key, fn); 40 | expect(observer.observers.size).toEqual(1); 41 | observer.unsubscribe(key, fn); 42 | expect(observer.observers.size).toEqual(0); 43 | }); 44 | 45 | it('should keep others subscriptions', () => { 46 | observer.subscribe(key, fn); 47 | observer.subscribe('test2', () => fn()); 48 | observer.unsubscribe(key, fn); 49 | expect(observer.observers.size).toEqual(1); 50 | }); 51 | }); 52 | 53 | describe('update', () => { 54 | it('should emit single update to observers', () => { 55 | observer.subscribe(key, fn); 56 | const fn2 = jest.fn(); 57 | observer.subscribe('test2', fn2); 58 | 59 | observer.update(key, item); 60 | 61 | expect(fn).toHaveBeenCalledTimes(1); 62 | expect(fn).toHaveBeenNthCalledWith(1, item); 63 | expect(fn2).not.toHaveBeenCalled(); 64 | }); 65 | 66 | it('should emit onUpdate event', () => { 67 | observer.subscribe(key, fn); 68 | const onUpdate = jest.fn(); 69 | observer.subscribe(events.onUpdate, onUpdate); 70 | 71 | observer.update(key, item); 72 | 73 | expect(onUpdate).toHaveBeenCalledTimes(1); 74 | }); 75 | }); 76 | 77 | describe('updateBatch', () => { 78 | it('should emit batch of updates to observers', () => { 79 | observer.subscribe(key, fn); 80 | const fn2 = jest.fn(); 81 | observer.subscribe('test2', fn2); 82 | 83 | const updates = [ 84 | [key, item], 85 | [key, { test: true }], 86 | ['test3', item], 87 | ] as EventPayload[]; 88 | observer.updateBatch(updates); 89 | 90 | expect(fn).toHaveBeenCalledTimes(2); 91 | expect(fn).toHaveBeenNthCalledWith(1, item); 92 | expect(fn).toHaveBeenNthCalledWith(2, { test: true }); 93 | expect(fn2).not.toHaveBeenCalled(); 94 | }); 95 | 96 | it('should emit onUpdate event', () => { 97 | observer.subscribe(key, fn); 98 | const fn2 = jest.fn(); 99 | observer.subscribe(events.onUpdate, fn2); 100 | 101 | const updates = [ 102 | [key, item], 103 | [key, { test: true }], 104 | ['test3', item], 105 | ] as EventPayload[]; 106 | observer.updateBatch(updates); 107 | 108 | expect(fn).toHaveBeenCalledTimes(2); 109 | expect(fn2).toHaveBeenCalledTimes(1); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /src/Observer/Observer.ts: -------------------------------------------------------------------------------- 1 | import { events } from '../constants'; 2 | 3 | import type { EventKey, IOItem } from '../types'; 4 | 5 | export type ObsFn = (val?: IOItem) => void; 6 | 7 | export type EventPayload = [key: EventKey, value?: IOItem]; 8 | 9 | export class Observer { 10 | observers: Map; 11 | 12 | constructor() { 13 | this.observers = new Map(); 14 | } 15 | 16 | public subscribe = (key: EventKey, fn: ObsFn) => { 17 | this.observers.set(key, (this.observers.get(key) || []).concat(fn)); 18 | }; 19 | 20 | public unsubscribe = (key: EventKey, fn: ObsFn) => { 21 | const newArr = (this.observers.get(key) || []).filter((el) => el !== fn); 22 | if (newArr.length) { 23 | this.observers.set(key, newArr); 24 | } else { 25 | this.observers.delete(key); 26 | } 27 | }; 28 | 29 | private emitUpdates = (key: EventPayload[0], value?: EventPayload[1]) => { 30 | const cbs = this.observers.get(key) || []; 31 | cbs?.forEach((cb) => cb(value)); 32 | }; 33 | 34 | public updateBatch = (entries: EventPayload[], onUpdate = true) => { 35 | if (entries.length) { 36 | entries.forEach(([key, value]) => this.emitUpdates(key, value)); 37 | onUpdate && this.emitUpdates(events.onUpdate); 38 | } 39 | }; 40 | 41 | public update = (key: EventPayload[0], value?: EventPayload[1]) => { 42 | this.emitUpdates(key, value); 43 | this.emitUpdates(events.onUpdate); 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/Observer/index.ts: -------------------------------------------------------------------------------- 1 | export { Observer } from './Observer'; 2 | export type { ObsFn } from './Observer'; 3 | -------------------------------------------------------------------------------- /src/components/Item/Item.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import React from 'react'; 3 | 4 | import { itemClassName } from '../../constants'; 5 | 6 | import Item, { type Props } from './Item'; 7 | 8 | import type { Refs } from '../../types'; 9 | 10 | const setup = ({ children, className, id, index, refs }: Props) => { 11 | return render( 12 | 13 | {children} 14 | , 15 | ); 16 | }; 17 | 18 | describe('Item', () => { 19 | const className = `${itemClassName} item-custom`; 20 | 21 | test('should pass data-key data-index and className attrs', () => { 22 | const id = 'test1'; 23 | const index = 1; 24 | const refs = {}; 25 | const { container } = setup({ 26 | className, 27 | id, 28 | index, 29 | refs, 30 | }); 31 | 32 | const child = container.firstChild as HTMLElement; 33 | expect(child.getAttribute('data-key')).toEqual(id); 34 | expect(child.getAttribute('data-index')).toEqual(String(index)); 35 | expect(child.getAttribute('class')).toEqual(className); 36 | }); 37 | 38 | test('should assign ref to refs', () => { 39 | const id = 'test1'; 40 | const index = 1; 41 | const refs: Refs = {}; 42 | setup({ className, id, index, refs }); 43 | 44 | expect(Object.keys(refs)).toHaveLength(1); 45 | expect(refs[index].current).toBeInTheDocument(); 46 | }); 47 | 48 | test('should render children', () => { 49 | const id = 'child1'; 50 | const index = 1; 51 | const refs: Refs = {}; 52 | const text = 'text123'; 53 | const { findByTestId, findByText } = setup({ 54 | children:
{text}
, 55 | className, 56 | id, 57 | index, 58 | refs, 59 | }); 60 | 61 | expect(findByTestId(id)).toBeTruthy(); 62 | expect(findByText(text)).toBeTruthy(); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/components/Item/Item.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { dataKeyAttribute, dataIndexAttribute } from '../../constants'; 4 | 5 | import type { Refs, ItemId } from '../../types'; 6 | 7 | export type Props = { 8 | id: ItemId; 9 | index: number; 10 | refs: Refs; 11 | children?: React.ReactNode; 12 | className: string; 13 | }; 14 | 15 | function Item({ children, className, id, index, refs }: Props) { 16 | const ref = React.useRef(null); 17 | refs[String(index)] = ref; 18 | 19 | return ( 20 |
25 | {children} 26 |
27 | ); 28 | } 29 | 30 | export default React.memo(Item); 31 | -------------------------------------------------------------------------------- /src/components/Item/index.ts: -------------------------------------------------------------------------------- 1 | import Item, { type Props as _Props } from './Item'; 2 | 3 | export type Props = _Props; 4 | 5 | export default Item; 6 | -------------------------------------------------------------------------------- /src/components/MenuItems/MenuItems.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/no-conditional-expect */ 2 | import { render } from '@testing-library/react'; 3 | import React from 'react'; 4 | 5 | import { type Props as ItemProps } from '../Item'; 6 | 7 | import MenuItems from './MenuItems'; 8 | 9 | import type { Refs } from '../../types'; 10 | 11 | jest.mock('../Item', () => ({ className, id, index, refs }: ItemProps) => ( 12 |
13 | Item 14 |
15 | )); 16 | 17 | const items = ['test1', 'test2']; 18 | const children = items.map((item) => { 19 | const itemId = { itemId: item }; 20 | return ( 21 |
22 | {item} 23 |
24 | ); 25 | }); 26 | 27 | type mockProps = { 28 | refs: Refs; 29 | itemClassName?: string; 30 | }; 31 | const setup = ({ refs, itemClassName }: mockProps) => { 32 | return render( 33 | 34 | {children} 35 | , 36 | ); 37 | }; 38 | 39 | describe('MenuItems', () => { 40 | test('should render children', () => { 41 | const refs = { test0: { current: 'test123' } } as unknown as Refs; 42 | const itemClassName = 'item-123'; 43 | const { container } = setup({ 44 | itemClassName, 45 | refs, 46 | }); 47 | 48 | const renderedChildren = container.childNodes; 49 | expect(renderedChildren).toHaveLength(2); 50 | 51 | renderedChildren.forEach((_child, ind) => { 52 | const child = _child as HTMLElement; 53 | const item = items[ind]; 54 | 55 | expect(child.getAttribute('id')).toEqual(item); 56 | expect(+child.getAttribute('data-index')!).toEqual( 57 | +item.replace(/\D/g, '') - 1, 58 | ); 59 | expect(child.childNodes).toHaveLength(1); 60 | expect(child).toHaveClass( 61 | `react-horizontal-scrolling-menu--item ${itemClassName}`, 62 | ); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/components/MenuItems/MenuItems.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { itemClassName, emptyStr } from '../../constants'; 4 | import { getItemId } from '../../helpers'; 5 | import Item from '../Item'; 6 | 7 | import type { ItemType, Refs } from '../../types'; 8 | 9 | export type Props = { 10 | children?: ItemType | ItemType[]; 11 | refs: Refs; 12 | itemClassName?: string; 13 | }; 14 | 15 | function MenuItems({ 16 | children, 17 | itemClassName: _itemClassName = emptyStr, 18 | refs, 19 | }: Props) { 20 | const childArray = React.Children.toArray(children).filter(Boolean); 21 | 22 | const itemClass = React.useMemo( 23 | () => `${itemClassName} ${_itemClassName}`, 24 | [_itemClassName], 25 | ); 26 | return childArray.map((child, index: number) => { 27 | const id = getItemId(child); 28 | 29 | return ( 30 | 31 | {child} 32 | 33 | ); 34 | }); 35 | } 36 | 37 | export default MenuItems; 38 | -------------------------------------------------------------------------------- /src/components/MenuItems/index.ts: -------------------------------------------------------------------------------- 1 | import MenuItems, { type Props as _Props } from './MenuItems'; 2 | 3 | export type Props = _Props; 4 | 5 | export default MenuItems; 6 | -------------------------------------------------------------------------------- /src/components/ScrollContainer/ScrollContainer.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from '@testing-library/react'; 2 | import React from 'react'; 3 | 4 | import { scrollContainerClassName } from '../../constants'; 5 | 6 | import ScrollContainer, { type Props } from './ScrollContainer'; 7 | 8 | const _containerRef = { current: null }; 9 | const _scrollRef = { current: null }; 10 | 11 | const setup = ({ 12 | className, 13 | scrollRef = _scrollRef, 14 | onScroll, 15 | containerRef = _containerRef, 16 | }: Omit, 'scrollRef'> & { 17 | containerRef?: Props['containerRef']; 18 | scrollRef?: Props['scrollRef']; 19 | } = {}) => { 20 | return render( 21 | 27 | Child 28 | , 29 | ); 30 | }; 31 | 32 | describe('ScrollContainer', () => { 33 | beforeEach(() => { 34 | _containerRef.current = null; 35 | _scrollRef.current = null; 36 | }); 37 | 38 | describe('className', () => { 39 | test('default', () => { 40 | const { container } = setup(); 41 | 42 | expect(container.firstChild).toHaveClass(scrollContainerClassName); 43 | }); 44 | 45 | test('custom', () => { 46 | const className = 'test123'; 47 | 48 | const { container } = setup({ className }); 49 | 50 | expect(container.firstChild).toHaveClass(scrollContainerClassName); 51 | expect(container.firstChild).toHaveClass(className); 52 | }); 53 | }); 54 | 55 | test('should render children and use ref', () => { 56 | const scrollRef: React.Ref = { current: null }; 57 | const { container, getByText } = setup({ scrollRef }); 58 | 59 | expect(scrollRef.current).toEqual(container.firstChild); 60 | expect(getByText('Child')).toBeTruthy(); 61 | }); 62 | 63 | test('should fire onScroll', () => { 64 | const onScroll = jest.fn(); 65 | const { container } = setup({ onScroll }); 66 | 67 | expect(onScroll).toHaveBeenCalledTimes(0); 68 | act(() => { 69 | fireEvent.scroll(container.firstChild as Node); 70 | }); 71 | 72 | expect(onScroll).toHaveBeenCalledTimes(1); 73 | }); 74 | 75 | test('should pass containerRef', () => { 76 | const scrollRef: React.Ref = { current: null }; 77 | const containerRef: React.Ref = { current: null }; 78 | const { container, getByText } = setup({ scrollRef, containerRef }); 79 | 80 | expect(scrollRef.current).toEqual(container.firstChild); 81 | expect(containerRef.current).toEqual(container.firstChild); 82 | expect(getByText('Child')).toBeTruthy(); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/components/ScrollContainer/ScrollContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { scrollContainerClassName, emptyStr } from '../../constants'; 4 | import { isMutableRef } from '../../helpers'; 5 | import { RefType } from '../../types'; 6 | 7 | export type Props = { 8 | className?: string; 9 | children?: React.ReactNode; 10 | onScroll?: (event: React.UIEvent) => void; 11 | scrollRef: RefType; 12 | containerRef: RefType; 13 | }; 14 | 15 | function ScrollContainer({ 16 | className: _className = emptyStr, 17 | children, 18 | onScroll = () => void 0, 19 | scrollRef, 20 | containerRef, 21 | }: Props) { 22 | const scrollContainerClass = React.useMemo( 23 | () => `${scrollContainerClassName} ${_className}`, 24 | [_className], 25 | ); 26 | 27 | const setRefs = React.useCallback( 28 | (elem: HTMLDivElement) => { 29 | if (isMutableRef(scrollRef)) { 30 | scrollRef.current = elem; 31 | } else { 32 | scrollRef(elem); 33 | } 34 | if (isMutableRef(containerRef)) { 35 | containerRef.current = elem; 36 | } else { 37 | containerRef(elem); 38 | } 39 | }, 40 | [scrollRef, containerRef], 41 | ); 42 | 43 | return ( 44 |
45 | {children} 46 |
47 | ); 48 | } 49 | 50 | export default ScrollContainer; 51 | -------------------------------------------------------------------------------- /src/components/ScrollContainer/index.ts: -------------------------------------------------------------------------------- 1 | import ScrollContainer from './ScrollContainer'; 2 | 3 | export default ScrollContainer; 4 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | import MenuItems from './MenuItems'; 2 | 3 | export default MenuItems; 4 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const rootClassName = 'react-horizontal-scrolling-menu'; 2 | 3 | export const itemClassName = `${rootClassName}--item`; 4 | export const scrollContainerClassName = `${rootClassName}--scroll-container`; 5 | export const wrapperClassName = `${rootClassName}--wrapper`; 6 | 7 | export const innerWrapperClassName = `${rootClassName}--inner-wrapper`; 8 | export const headerClassName = `${rootClassName}--header`; 9 | export const arrowLeftClassName = `${rootClassName}--arrow-left`; 10 | export const arrowRightClassName = `${rootClassName}--arrow-right`; 11 | export const footerClassName = `${rootClassName}--footer`; 12 | 13 | export const id = 'itemId'; 14 | 15 | export const dataKeyAttribute = 'data-key'; 16 | export const dataIndexAttribute = 'data-index'; 17 | 18 | export const events = { 19 | first: 'first', 20 | last: 'last', 21 | onInit: 'onInit', 22 | onUpdate: 'onUpdate', 23 | } as const; 24 | 25 | export const emptyStr = ''; 26 | 27 | export const emptyRef = { current: null }; 28 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { type publicApiType } from './createApi'; 4 | 5 | export const VisibilityContext = React.createContext( 6 | {} as publicApiType, 7 | ); 8 | -------------------------------------------------------------------------------- /src/getItemsPos.test.ts: -------------------------------------------------------------------------------- 1 | import getItemsPos from './getItemsPos'; 2 | 3 | describe('getItemsPos', () => { 4 | describe('should return first, center and last items', () => { 5 | test('5 items', () => { 6 | const items = ['test5', 'test6', 'test7', 'test8', 'test9']; 7 | 8 | expect(getItemsPos(items)).toEqual({ 9 | first: 'test5', 10 | center: 'test7', 11 | last: 'test9', 12 | }); 13 | }); 14 | 15 | test('4 items', () => { 16 | const items = ['test5', 'test6', 'test7', 'test8']; 17 | 18 | expect(getItemsPos(items)).toEqual({ 19 | first: 'test5', 20 | center: 'test7', 21 | last: 'test8', 22 | }); 23 | 24 | expect(getItemsPos(items)).toEqual({ 25 | first: 'test5', 26 | center: 'test7', 27 | last: 'test8', 28 | }); 29 | }); 30 | 31 | test('1 item', () => { 32 | const items = ['test5']; 33 | 34 | expect(getItemsPos(items)).toEqual({ 35 | first: 'test5', 36 | center: 'test5', 37 | last: 'test5', 38 | }); 39 | }); 40 | }); 41 | 42 | test('should return empty items array if there is no items', () => { 43 | expect(getItemsPos([])).toEqual({}); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/getItemsPos.ts: -------------------------------------------------------------------------------- 1 | import type { visibleElements } from './types'; 2 | 3 | const getItemsPos = (items: visibleElements) => { 4 | const centerIndex = Math.floor(items.length / 2); 5 | const center = items[centerIndex]; 6 | 7 | return { 8 | first: items?.[0], 9 | center, 10 | last: items.slice(-1)?.[0], 11 | }; 12 | }; 13 | 14 | export default getItemsPos; 15 | -------------------------------------------------------------------------------- /src/helpers.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import scrollIntoView from 'smooth-scroll-into-view-if-needed'; 3 | 4 | import { 5 | id as itemId, 6 | emptyStr, 7 | dataKeyAttribute, 8 | dataIndexAttribute, 9 | } from './constants'; 10 | import { observerOptions } from './settings'; 11 | 12 | import type { 13 | IOItem, 14 | Item, 15 | ItemOrElement, 16 | ItemId, 17 | Refs, 18 | ScrollBehaviorArg, 19 | scrollToItemOptions, 20 | } from './types'; 21 | 22 | export const getNodesFromRefs = (refs: Refs): HTMLElement[] => { 23 | const result = Object.values(refs) 24 | .map((el) => el.current) 25 | .filter(Boolean); 26 | 27 | return result as HTMLElement[]; 28 | }; 29 | 30 | export function observerEntriesToItems( 31 | entries: IntersectionObserverEntry[], 32 | options: typeof observerOptions, 33 | ): Item[] { 34 | return [...entries].map((entry) => { 35 | const target = entry.target as HTMLElement; 36 | const key = String(target?.dataset?.key ?? emptyStr); 37 | const index = String(target?.dataset?.index ?? emptyStr); 38 | 39 | return [ 40 | key, 41 | { 42 | index, 43 | key, 44 | entry, 45 | visible: entry.intersectionRatio >= options.ratio, 46 | }, 47 | ]; 48 | }); 49 | } 50 | 51 | // eslint-disable-next-line max-params 52 | function scrollToItem( 53 | item: ItemOrElement, 54 | behavior?: ScrollBehaviorArg, 55 | inline?: ScrollLogicalPosition, 56 | block?: ScrollLogicalPosition, 57 | rest?: Omit, 58 | noPolyfill?: boolean, 59 | ): void { 60 | const _item = (item as IOItem)?.entry?.target || item; 61 | const _behavior = behavior || 'smooth'; 62 | 63 | if (!_item) { 64 | return void 0; 65 | } 66 | 67 | const params = { 68 | behavior: _behavior as unknown as ScrollBehavior, 69 | inline: inline || 'end', 70 | block: block || 'nearest', 71 | }; 72 | 73 | return noPolyfill 74 | ? _item.scrollIntoView(params) 75 | : scrollIntoView(_item, { 76 | ...rest, 77 | ...params, 78 | }); 79 | } 80 | 81 | export { scrollToItem }; 82 | 83 | export const getItemElementById = (id: ItemId) => 84 | document.querySelector(`[${dataKeyAttribute}='${id}']`); 85 | 86 | export const getItemElementByIndex = (id: ItemId) => 87 | document.querySelector(`[${dataIndexAttribute}='${id}']`); 88 | 89 | export function getElementOrConstructor( 90 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 91 | Elem: React.FC | React.ReactNode | React.MemoExoticComponent, 92 | ): React.JSX.Element | null { 93 | return ( 94 | (React.isValidElement(Elem) && Elem) || 95 | (typeof Elem === 'function' && ) || 96 | // @ts-expect-error temporary solution for React.memo 97 | (!!Elem && typeof Elem === 'object' && ) || 98 | null 99 | ); 100 | } 101 | 102 | export const getItemId = (item: React.ReactNode) => 103 | String( 104 | (item as React.JSX.Element)?.props?.[itemId] || 105 | ((item as React.JSX.Element)?.key || emptyStr).replace(/^\.\$/, emptyStr), 106 | ); 107 | 108 | export function isMutableRef( 109 | elem: React.MutableRefObject | React.RefCallback | React.LegacyRef, 110 | ): elem is React.MutableRefObject { 111 | return !!elem && Object.prototype.hasOwnProperty.call(elem, 'current'); 112 | } 113 | -------------------------------------------------------------------------------- /src/hooks/useIntersectionObserver.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | import { mocked } from 'jest-mock'; 3 | 4 | import { ItemsMap } from '../ItemsMap'; 5 | import { observerEntriesToItems } from '../helpers'; 6 | import { observerOptions } from '../settings'; 7 | import { MockedObserver, traceMethodCalls } from '../testUtils'; 8 | 9 | import useIntersectionObserver from './useIntersectionObserver'; 10 | 11 | import type { IntersectionObserverCB, MockedCalls } from '../testUtils'; 12 | import type { Refs, Item } from '../types'; 13 | 14 | jest.mock('../helpers', () => ({ 15 | __esModule: true, 16 | ...jest.requireActual('../helpers'), 17 | observerEntriesToItems: jest.fn(), 18 | })); 19 | 20 | describe('useIntersectionObserver', () => { 21 | let observer: MockedObserver | null; 22 | let mockedObserverCalls: MockedCalls = {}; 23 | beforeEach(() => { 24 | Object.defineProperty(window, 'IntersectionObserver', { 25 | writable: true, 26 | value: jest.fn().mockImplementation(function TrackMock( 27 | cb: IntersectionObserverCB, 28 | options: IntersectionObserverInit, 29 | ) { 30 | observer = traceMethodCalls( 31 | new MockedObserver(cb, options), 32 | mockedObserverCalls, 33 | ) as unknown as MockedObserver; 34 | 35 | return observer; 36 | }), 37 | }); 38 | }); 39 | afterEach(() => { 40 | observer = null; 41 | mockedObserverCalls = {}; 42 | }); 43 | 44 | test('should observe items from refs', () => { 45 | const items = new ItemsMap(); 46 | const itemsChanged = ''; 47 | const options = observerOptions; 48 | const refs: Refs = { 49 | el1: { current: document.createElement('div') }, 50 | el2: { current: document.createElement('div') }, 51 | }; 52 | const props = { items, itemsChanged, options, refs }; 53 | 54 | renderHook(() => useIntersectionObserver(props)); 55 | 56 | // called observe on refs 57 | expect(mockedObserverCalls.observe[0]).toEqual(refs.el1.current); 58 | expect(mockedObserverCalls.observe[1]).toEqual(refs.el2.current); 59 | }); 60 | 61 | test('should set entries to ItemsMap', () => { 62 | const observerMock = mocked(observerEntriesToItems, { shallow: true }); 63 | 64 | const items = { setBatch: jest.fn() } as unknown as ItemsMap; 65 | const itemsChanged = ''; 66 | const options = observerOptions; 67 | const refs: Refs = {}; 68 | const props = { 69 | items, 70 | itemsChanged, 71 | options, 72 | refs, 73 | }; 74 | 75 | observerMock.mockReturnValueOnce([]); 76 | renderHook(() => useIntersectionObserver(props)); 77 | 78 | const itemsToEntries = (items: { key: string; visible: boolean }[]) => 79 | items.map( 80 | (el, index) => 81 | [ 82 | el.key, 83 | { 84 | key: el.key, 85 | entry: {} as IntersectionObserverEntry, 86 | visible: el.visible, 87 | index: String(index), 88 | }, 89 | ] as Item, 90 | ); 91 | 92 | // observer entries cbs 93 | const visibilityStateHistory = [ 94 | [ 95 | { key: 'item1', visible: true }, 96 | { key: 'item2', visible: true }, 97 | ], 98 | [ 99 | { key: 'item1', visible: false }, 100 | { key: 'item2', visible: true }, 101 | { key: 'item3', visible: true }, 102 | ], 103 | ]; 104 | 105 | const mockedObserver = observer as unknown as MockedObserver; 106 | 107 | observerMock.mockReturnValueOnce(itemsToEntries(visibilityStateHistory[0])); 108 | 109 | // trigger cb on observer 110 | const entriesMock1 = [] as IntersectionObserverEntry[]; 111 | mockedObserver.fire(entriesMock1); 112 | expect(items.setBatch).toHaveBeenCalledTimes(1); 113 | expect(items.setBatch).toHaveBeenNthCalledWith(1, entriesMock1); 114 | 115 | const entriesMock2 = itemsToEntries( 116 | visibilityStateHistory[1], 117 | ) as unknown as IntersectionObserverEntry[]; 118 | 119 | observerMock.mockReturnValueOnce(entriesMock2 as unknown as Item[]); 120 | mockedObserver.fire(entriesMock2); 121 | 122 | expect(items.setBatch).toHaveBeenCalledTimes(2); 123 | expect(items.setBatch).toHaveBeenNthCalledWith(2, [ 124 | ['item1', { entry: {}, index: '0', key: 'item1', visible: true }], 125 | ['item2', { entry: {}, index: '1', key: 'item2', visible: true }], 126 | ]); 127 | 128 | mockedObserver.fire(entriesMock2); 129 | expect(items.setBatch).toHaveBeenCalledTimes(3); 130 | expect(items.setBatch).toHaveBeenNthCalledWith(3, [ 131 | ['item1', { entry: {}, index: '0', key: 'item1', visible: false }], 132 | ['item2', { entry: {}, index: '1', key: 'item2', visible: true }], 133 | ['item3', { entry: {}, index: '2', key: 'item3', visible: true }], 134 | ]); 135 | }); 136 | 137 | test('should call disconnect', () => { 138 | const items = new ItemsMap(); 139 | const itemsChanged = ''; 140 | const options = observerOptions; 141 | const refs: Refs = { 142 | el1: { current: document.createElement('div') }, 143 | el2: { current: document.createElement('div') }, 144 | }; 145 | const props = { 146 | items, 147 | itemsChanged, 148 | options, 149 | refs, 150 | menuVisible: { current: true }, 151 | }; 152 | 153 | const { unmount } = renderHook(() => useIntersectionObserver(props)); 154 | 155 | expect(mockedObserverCalls.disconnect).toBeFalsy(); 156 | unmount(); 157 | expect(mockedObserverCalls.disconnect).toEqual([]); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /src/hooks/useIntersectionObserver.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ItemsMap } from '../ItemsMap'; 4 | import { getNodesFromRefs, observerEntriesToItems } from '../helpers'; 5 | import { observerOptions } from '../settings'; 6 | 7 | import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; 8 | 9 | import type { Refs } from '../types'; 10 | 11 | interface Props { 12 | items: ItemsMap; 13 | itemsChanged: string; 14 | options: typeof observerOptions; 15 | refs: Refs; 16 | } 17 | 18 | function useIntersectionObserver({ 19 | items, 20 | itemsChanged, 21 | refs, 22 | options, 23 | }: Props) { 24 | const observer: { current?: IntersectionObserver } = React.useRef(); 25 | 26 | const ioCb = React.useCallback( 27 | (entries: IntersectionObserverEntry[]) => { 28 | items.setBatch(observerEntriesToItems(entries, options)); 29 | }, 30 | [items, options], 31 | ); 32 | 33 | useIsomorphicLayoutEffect(() => { 34 | const elements = getNodesFromRefs(refs); 35 | const observerInstance = 36 | observer.current || new IntersectionObserver(ioCb, options); 37 | observer.current = observerInstance; 38 | elements.forEach((elem) => observerInstance.observe(elem)); 39 | 40 | return () => { 41 | observerInstance.disconnect(); 42 | observer.current = undefined; 43 | }; 44 | }, [ioCb, itemsChanged, options, refs]); 45 | } 46 | 47 | export default useIntersectionObserver; 48 | -------------------------------------------------------------------------------- /src/hooks/useIsomorphicLayoutEffect.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const useIsomorphicLayoutEffect = 4 | typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; 5 | 6 | export default useIsomorphicLayoutEffect; 7 | -------------------------------------------------------------------------------- /src/hooks/useItemsChanged.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import React from 'react'; 3 | 4 | import { ItemsMap } from '../ItemsMap'; 5 | import { id as itemId } from '../constants'; 6 | import { type IOItem } from '../types'; 7 | 8 | import useItemsChanged from './useItemsChanged'; 9 | 10 | const TestComponent = ({ 11 | menuItems, 12 | items, 13 | }: { 14 | menuItems?: React.JSX.Element[]; 15 | items: ItemsMap; 16 | }) => { 17 | const hash = useItemsChanged(menuItems, items); 18 | 19 | return
{JSON.stringify(hash)}
; 20 | }; 21 | 22 | describe('useItemsChanged', () => { 23 | const getChildren = (keys: string[]) => 24 | keys.map((key) => { 25 | const props = { [itemId]: key }; 26 | 27 | return ( 28 |
29 | {key} 30 |
31 | ); 32 | }); 33 | 34 | describe('should return hash based on children', () => { 35 | test('empty string if there is no children', () => { 36 | const utils = render( 37 | , 38 | ); 39 | 40 | const hash = JSON.parse(utils.getByTestId('hash').textContent!); 41 | 42 | expect(hash).toEqual(''); 43 | }); 44 | 45 | test('should return hash when have children', () => { 46 | const childrenKeys = ['child1', 'chidl2']; 47 | 48 | const children = getChildren(childrenKeys); 49 | 50 | const utils = render( 51 | , 52 | ); 53 | 54 | const hash = JSON.parse(utils.getByTestId('hash').textContent!); 55 | expect(hash).toEqual(childrenKeys.join('')); 56 | 57 | const newChildrenKeys = childrenKeys.concat('child3'); 58 | const newChildren = getChildren(newChildrenKeys); 59 | utils.rerender( 60 | , 61 | ); 62 | 63 | const newHash = JSON.parse(utils.getByTestId('hash').textContent!); 64 | expect(newHash).toEqual(newChildrenKeys.join('')); 65 | }); 66 | }); 67 | 68 | describe('when child removed', () => { 69 | test('should remove child from ItemsMap', () => { 70 | const childrenKeys = ['child1', 'chidl2', 'child3']; 71 | const itemsMap = new ItemsMap(); 72 | 73 | const children = getChildren(childrenKeys); 74 | 75 | const utils = render( 76 | , 77 | ); 78 | 79 | const hash = JSON.parse(utils.getByTestId('hash').textContent!); 80 | expect(hash).toEqual(childrenKeys.join('')); 81 | 82 | childrenKeys.forEach((key) => { 83 | itemsMap.set(key, { key } as IOItem); 84 | }); 85 | expect(itemsMap.toItems()).toEqual(childrenKeys); 86 | 87 | const removeFirst = (arr: T[]) => arr.slice(1); 88 | 89 | const newChildren = removeFirst(children); 90 | 91 | utils.rerender( 92 | , 93 | ); 94 | 95 | expect(itemsMap.toItems()).toEqual(['chidl2', 'child3']); 96 | 97 | const newHash = JSON.parse(utils.getByTestId('hash').textContent!); 98 | expect(newHash).toEqual(removeFirst(childrenKeys).join('')); 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/hooks/useItemsChanged.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ItemsMap } from '../ItemsMap'; 4 | import { emptyStr } from '../constants'; 5 | import { getItemId } from '../helpers'; 6 | 7 | import type { ItemId, ItemType } from '../types'; 8 | 9 | const getItemsIdFromChildren = ( 10 | children: ItemType | ItemType[] | undefined, 11 | ): ItemId[] => React.Children.toArray(children).map(getItemId).filter(Boolean); 12 | 13 | function useItemsChanged( 14 | menuItems: ItemType | ItemType[] | undefined, 15 | items: ItemsMap, 16 | ): string { 17 | const [hash, setHash] = React.useState(emptyStr); 18 | 19 | const domNodes = React.useMemo( 20 | () => getItemsIdFromChildren(menuItems), 21 | [menuItems], 22 | ); 23 | 24 | React.useEffect(() => { 25 | const hash = domNodes.filter(Boolean).join(emptyStr); 26 | 27 | const allItems = items.toItems(); 28 | const removed = allItems.filter((item) => !domNodes.includes(item)); 29 | removed.forEach((item) => { 30 | items.delete(item); 31 | }); 32 | 33 | setHash(hash); 34 | }, [domNodes, items]); 35 | 36 | return hash; 37 | } 38 | 39 | export default useItemsChanged; 40 | -------------------------------------------------------------------------------- /src/hooks/useMenuVisible.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; 4 | 5 | export const useMenuVisible = ( 6 | menuRef: { current: HTMLDivElement | null }, 7 | ratio: number, 8 | ) => { 9 | const menuVisible = React.useRef(true); 10 | 11 | const threshold = React.useMemo( 12 | () => [ratio - 0.05, ratio - 0.01, ratio, ratio + 0.01, ratio + 0.05], 13 | [ratio], 14 | ); 15 | const ioCb = React.useCallback( 16 | (entries: IntersectionObserverEntry[]) => { 17 | menuVisible.current = entries?.[0]?.intersectionRatio >= ratio; 18 | }, 19 | [ratio], 20 | ); 21 | 22 | useIsomorphicLayoutEffect(() => { 23 | const observerInstance = new IntersectionObserver(ioCb, { 24 | threshold, 25 | rootMargin: '0px', 26 | }); 27 | if (menuRef.current) { 28 | observerInstance.observe(menuRef.current); 29 | } 30 | 31 | return () => observerInstance.disconnect(); 32 | }, [menuVisible, menuRef, ioCb, threshold]); 33 | 34 | return menuVisible; 35 | }; 36 | -------------------------------------------------------------------------------- /src/hooks/useOnCb.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | 3 | import { publicApiType } from '../createApi'; 4 | 5 | import { useOnCb } from './useOnCb'; 6 | 7 | const cbs: { [index: string]: (() => void) | undefined } = { 8 | onInit: undefined, 9 | onUpdate: undefined, 10 | }; 11 | const context = { 12 | items: { 13 | subscribe: jest.fn((event: string, cb: () => void) => { 14 | cbs[event] = cb; 15 | }), 16 | unsubscribe: jest.fn((event: string, cb: () => void) => { 17 | cbs[event] = cb; 18 | }), 19 | }, 20 | } as unknown as publicApiType; 21 | const onInit = jest.fn(); 22 | const onUpdate = jest.fn(); 23 | const props = { context, onInit, onUpdate }; 24 | 25 | describe('useOnCb', () => { 26 | beforeEach(() => { 27 | jest.clearAllMocks(); 28 | cbs.onInit = undefined; 29 | cbs.onUpdate = undefined; 30 | }); 31 | 32 | it('should subscribe to events', () => { 33 | renderHook(() => useOnCb(props)); 34 | 35 | expect(context.items.subscribe).toHaveBeenCalledTimes(2); 36 | expect(cbs.onInit).toEqual(expect.any(Function)); 37 | expect(cbs.onUpdate).toEqual(expect.any(Function)); 38 | }); 39 | 40 | it('should fire onInit with context', () => { 41 | renderHook(() => useOnCb(props)); 42 | 43 | expect(context.items.subscribe).toHaveBeenCalledTimes(2); 44 | 45 | cbs?.onInit?.(); 46 | 47 | expect(onInit).toHaveBeenCalledTimes(1); 48 | expect(onInit).toHaveBeenNthCalledWith(1, context); 49 | }); 50 | 51 | it('should fire onUpdate with context', () => { 52 | renderHook(() => useOnCb(props)); 53 | 54 | expect(context.items.subscribe).toHaveBeenCalledTimes(2); 55 | 56 | cbs?.onUpdate?.(); 57 | 58 | expect(onUpdate).toHaveBeenCalledTimes(1); 59 | expect(onUpdate).toHaveBeenNthCalledWith(1, context); 60 | }); 61 | 62 | it('should unsubscribe on unmount', () => { 63 | const { unmount } = renderHook(() => useOnCb(props)); 64 | 65 | unmount(); 66 | 67 | expect(context.items.unsubscribe).toHaveBeenCalledTimes(2); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/hooks/useOnCb.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { events } from '../constants'; 4 | import { publicApiType } from '../createApi'; 5 | 6 | interface Props { 7 | context: publicApiType; 8 | onInit: (context: publicApiType) => void; 9 | onUpdate: (context: publicApiType) => void; 10 | } 11 | 12 | export const useOnCb = ({ context, onInit, onUpdate }: Props) => { 13 | const onInitCb = React.useCallback(() => onInit(context), [onInit, context]); 14 | const onUpdateCb = React.useCallback( 15 | () => onUpdate(context), 16 | [onUpdate, context], 17 | ); 18 | const { items } = context; 19 | 20 | React.useEffect(() => { 21 | items.subscribe(events.onInit, onInitCb); 22 | items.subscribe(events.onUpdate, onUpdateCb); 23 | 24 | return () => { 25 | items.unsubscribe(events.onInit, onInitCb); 26 | items.unsubscribe(events.onUpdate, onUpdateCb); 27 | }; 28 | }, [items, onInitCb, onUpdateCb]); 29 | }; 30 | -------------------------------------------------------------------------------- /src/hooks/usePrevious.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | 3 | import usePrevious from './usePrevious'; 4 | 5 | describe('usePrevious', () => { 6 | test('should return previous value', () => { 7 | const values = ['test1', 'test2', 'test3']; 8 | 9 | const { result, rerender } = renderHook(usePrevious, { 10 | initialProps: values[0], 11 | }); 12 | expect(result.current).toEqual(undefined); 13 | 14 | rerender(values[1]); 15 | expect(result.current).toEqual(values[0]); 16 | 17 | rerender(values[2]); 18 | expect(result.current).toEqual(values[1]); 19 | 20 | rerender(values[0]); 21 | expect(result.current).toEqual(values[2]); 22 | 23 | rerender(values[1]); 24 | expect(result.current).toEqual(values[0]); 25 | 26 | rerender(values[2]); 27 | expect(result.current).toEqual(values[1]); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function usePrevious(value: T) { 4 | const ref = React.useRef(); 5 | 6 | React.useEffect(() => { 7 | ref.current = value; 8 | }, [value]); 9 | 10 | return ref.current; 11 | } 12 | 13 | export default usePrevious; 14 | -------------------------------------------------------------------------------- /src/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ScrollMenu Children, arrows, header and footer Header and footer 1`] = ` 4 |
5 |
8 |
11 |
14 | Header 15 |
16 |
17 |
20 |
23 |
26 |
31 |
34 | {"itemId":"test1","isFirstItemVisible":false,"isLastItemVisible":false,"menuVisible":{"current":true}} 35 |
36 |
37 |
42 |
45 | {"itemId":"test2","isFirstItemVisible":false,"isLastItemVisible":false,"menuVisible":{"current":true}} 46 |
47 |
48 |
49 |
52 |
53 | 62 |
63 |
64 | `; 65 | 66 | exports[`ScrollMenu Children, arrows, header and footer LeftArrow, ScrollContainer, MenuItems, RightArrow 1`] = ` 67 |
68 |
71 |
74 |
77 |
80 | 86 |
87 |
90 |
95 |
98 | {"itemId":"test1","isFirstItemVisible":false,"isLastItemVisible":false,"menuVisible":{"current":true}} 99 |
100 |
101 |
106 |
109 | {"itemId":"test2","isFirstItemVisible":false,"isLastItemVisible":false,"menuVisible":{"current":true}} 110 |
111 |
112 |
113 |
116 | 122 |
123 |
124 | 128 |
129 | `; 130 | 131 | exports[`ScrollMenu should render without props 1`] = ` 132 |
133 |
136 |
139 |
142 |
145 |
148 |
153 |
156 | {"itemId":"test1","isFirstItemVisible":false,"isLastItemVisible":false,"menuVisible":{"current":true}} 157 |
158 |
159 |
164 |
167 | {"itemId":"test2","isFirstItemVisible":false,"isLastItemVisible":false,"menuVisible":{"current":true}} 168 |
169 |
170 |
171 |
174 |
175 | 179 |
180 | `; 181 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | type IntersectionObserverEntryVisibilityRatio = number; 2 | 3 | interface IntersectionObserverOptions extends IntersectionObserverInit { 4 | ratio: IntersectionObserverEntryVisibilityRatio; 5 | } 6 | 7 | export const observerOptions: IntersectionObserverOptions = { 8 | ratio: 0.9, 9 | rootMargin: '5px', 10 | threshold: [0.05, 0.5, 0.75, 0.95], 11 | }; 12 | -------------------------------------------------------------------------------- /src/slidingWindow/index.ts: -------------------------------------------------------------------------------- 1 | export { slidingWindow } from './slidingWindow'; 2 | -------------------------------------------------------------------------------- /src/slidingWindow/slidingWindow.ts: -------------------------------------------------------------------------------- 1 | import type { visibleElements } from '../types'; 2 | 3 | export function prevGroup( 4 | allItems: visibleElements, 5 | visibleElements: visibleElements, 6 | ): visibleElements { 7 | const firstIndex = allItems.findIndex( 8 | (item) => item === visibleElements?.[0], 9 | ); 10 | 11 | const count = visibleElements.length; 12 | 13 | const _nextGroupFirstItem = firstIndex - count; 14 | 15 | const isEnd = _nextGroupFirstItem < 0; 16 | 17 | const nextGroupFirstItem = isEnd ? 0 : _nextGroupFirstItem; 18 | const prev = allItems.slice(nextGroupFirstItem, isEnd ? count : firstIndex); 19 | 20 | // when have prev items 21 | if (prev.length === count) { 22 | return prev; 23 | // when no prev group 24 | } else { 25 | return allItems.slice(firstIndex, count); 26 | } 27 | } 28 | 29 | export function nextGroup( 30 | allItems: visibleElements, 31 | visibleElements: visibleElements, 32 | ): visibleElements { 33 | const lastIndex = allItems.findIndex( 34 | (item) => item === visibleElements.slice(-1)?.[0], 35 | ); 36 | 37 | const count = visibleElements.length; 38 | 39 | const _nextGroupLastItem = lastIndex + count + 1; 40 | 41 | const isEnd = _nextGroupLastItem > allItems.length - 1; 42 | 43 | const nextGroupLastItem = isEnd ? allItems.length - 1 : _nextGroupLastItem; 44 | const next = allItems.slice( 45 | isEnd ? nextGroupLastItem - count + 1 : lastIndex + 1, 46 | nextGroupLastItem, 47 | ); 48 | 49 | // when have next items 50 | if (next.length === count) { 51 | return next; 52 | // when no next group 53 | } else { 54 | return allItems.slice(allItems.length - count, allItems.length + count); 55 | } 56 | } 57 | 58 | export function slidingWindow( 59 | allItems: visibleElements, 60 | visibleElements: visibleElements, 61 | ): { 62 | prev: () => visibleElements; 63 | next: () => visibleElements; 64 | } { 65 | return { 66 | prev: () => { 67 | return prevGroup(allItems, visibleElements); 68 | }, 69 | next: () => { 70 | return nextGroup(allItems, visibleElements); 71 | }, 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .react-horizontal-scrolling-menu--scroll-container { 2 | display: flex; 3 | height: max-content; 4 | overflow-y: hidden; 5 | position: relative; 6 | width: 100%; 7 | } 8 | 9 | .react-horizontal-scrolling-menu--scroll-container.rtl { 10 | direction: rtl; 11 | } 12 | 13 | .react-horizontal-scrolling-menu--inner-wrapper { 14 | display: flex; 15 | overflow-y: hidden; 16 | } 17 | 18 | .react-horizontal-scrolling-menu--wrapper { 19 | display: flex; 20 | flex-direction: column; 21 | } 22 | 23 | .react-horizontal-scrolling-menu--header, 24 | .react-horizontal-scrolling-menu--footer { 25 | width: 100%; 26 | } 27 | 28 | .react-horizontal-scrolling-menu--arrow-left, 29 | .react-horizontal-scrolling-menu--arrow-right { 30 | display: flex; 31 | } 32 | -------------------------------------------------------------------------------- /src/testUtils.ts: -------------------------------------------------------------------------------- 1 | type IntersectionObserverCB = (arg1: IntersectionObserverEntry[]) => void; 2 | 3 | export type { IntersectionObserverCB }; 4 | 5 | export class MockedObserver { 6 | cb: IntersectionObserverCB; 7 | options: IntersectionObserverInit; 8 | elements: HTMLElement[]; 9 | 10 | constructor(cb: IntersectionObserverCB, options: IntersectionObserverInit) { 11 | this.cb = cb; 12 | this.options = options; 13 | this.elements = []; 14 | } 15 | 16 | unobserve(elem: HTMLElement): void { 17 | this.elements = this.elements.filter((en) => en !== elem); 18 | } 19 | 20 | observe(elem: HTMLElement): void { 21 | this.elements = [...new Set(this.elements.concat(elem))]; 22 | } 23 | 24 | disconnect(): void { 25 | this.elements = []; 26 | } 27 | 28 | fire(arr: IntersectionObserverEntry[]): void { 29 | this.cb(arr); 30 | } 31 | } 32 | 33 | export type MockedCalls = Record; 34 | 35 | export function traceMethodCalls(obj: object, calls: MockedCalls = {}) { 36 | const handler: ProxyHandler = { 37 | get(target, propKey, receiver) { 38 | const targetValue = Reflect.get(target, propKey, receiver); 39 | if (typeof targetValue === 'function') { 40 | return function (...args: unknown[]) { 41 | calls[propKey] = (calls[propKey] || []).concat(args); 42 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 43 | // @ts-ignore 44 | return targetValue.apply(this, args); 45 | }; 46 | } else { 47 | return targetValue; 48 | } 49 | }, 50 | }; 51 | return new Proxy(obj, handler); 52 | } 53 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'scroll-into-view-if-needed'; 2 | 3 | import { events } from './constants'; 4 | 5 | export type ItemId = string; 6 | 7 | export interface IOItem { 8 | index: string; 9 | key: ItemId; 10 | entry: IntersectionObserverEntry; 11 | visible: boolean; 12 | } 13 | 14 | export type Event = (typeof events)[keyof typeof events]; 15 | 16 | export type EventKey = Event | ItemId; 17 | 18 | export type Item = [itemId: ItemId, observerEntry: IOItem]; 19 | 20 | export type visibleElements = ItemId[]; 21 | 22 | export type RefType = 23 | | React.MutableRefObject 24 | | React.RefCallback; 25 | 26 | export interface Refs { 27 | [key: ItemId]: React.MutableRefObject; 28 | } 29 | 30 | export type ItemType = React.ReactElement<{ 31 | /** 32 | Required. id for every item, should be unique 33 | */ 34 | itemId: ItemId; 35 | }>; 36 | 37 | export type CustomScrollBehavior = Options; 38 | 39 | export type ScrollBehaviorArg = ScrollBehavior | CustomScrollBehavior; 40 | 41 | export interface scrollToItemOptions { 42 | boundary?: HTMLElement | null; 43 | duration?: number; 44 | behavior: ScrollBehaviorArg; 45 | } 46 | 47 | export type ItemOrElement = IOItem | Element | undefined; 48 | -------------------------------------------------------------------------------- /stories/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'plugin:maintainable/recommended', 4 | 'plugin:maintainable/react', 5 | 'plugin:storybook/recommended', 6 | 'plugin:jest/recommended', 7 | ], 8 | plugins: ['jest'], 9 | parserOptions: { 10 | ecmaFeatures: { 11 | jsx: true, 12 | }, 13 | ecmaVersion: 2023, 14 | sourceType: 'module', 15 | allowImportExportEverywhere: true, 16 | project: './tsconfig.stories.json', 17 | }, 18 | rules: { 19 | 'sonarjs/cognitive-complexity': ['error', 10], 20 | "no-secrets/no-secrets": "off" 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /stories/0_Simple/Simple.source.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-jss'; 3 | 4 | import { 5 | ScrollMenu, 6 | VisibilityContext, 7 | type publicApiType, 8 | } from 'react-horizontal-scrolling-menu'; 9 | 10 | import 'react-horizontal-scrolling-menu/dist/styles.css'; 11 | 12 | export function SimpleExample() { 13 | const [items] = React.useState(() => getItems()); 14 | const [selected, setSelected] = React.useState([]); 15 | 16 | const isItemSelected = (id: string): boolean => 17 | !!selected.find((el) => el === id); 18 | 19 | const handleItemClick = (itemId: string) => { 20 | const itemSelected = isItemSelected(itemId); 21 | 22 | setSelected((currentSelected: string[]) => 23 | itemSelected 24 | ? currentSelected.filter((el) => el !== itemId) 25 | : currentSelected.concat(itemId), 26 | ); 27 | }; 28 | 29 | return ( 30 |
31 |
32 | 33 | 38 | {items.map(({ id }) => ( 39 | handleItemClick(id)} 44 | selected={isItemSelected(id)} 45 | /> 46 | ))} 47 | 48 | 49 |
50 | 51 |
52 | filler 53 |
54 |
55 | ); 56 | } 57 | 58 | export default SimpleExample; 59 | 60 | const NoScrollbar = styled('div')({ 61 | '& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': { 62 | display: 'none', 63 | }, 64 | '& .react-horizontal-scrolling-menu--scroll-container': { 65 | scrollbarWidth: 'none', 66 | '-ms-overflow-style': 'none', 67 | }, 68 | }); 69 | 70 | function LeftArrow() { 71 | const visibility = React.useContext(VisibilityContext); 72 | 73 | const disabled = visibility.useLeftArrowVisible(); 74 | 75 | return ( 76 | visibility.scrollPrev()} 79 | testId="left-arrow" 80 | > 81 | Left 82 | 83 | ); 84 | } 85 | 86 | function RightArrow() { 87 | const visibility = React.useContext(VisibilityContext); 88 | 89 | const disabled = visibility.useRightArrowVisible(); 90 | 91 | return ( 92 | visibility.scrollNext()} 95 | testId="right-arrow" 96 | > 97 | Right 98 | 99 | ); 100 | } 101 | 102 | function Arrow({ 103 | children, 104 | disabled, 105 | onClick, 106 | className, 107 | testId, 108 | }: { 109 | children: React.ReactNode; 110 | disabled: boolean; 111 | onClick: VoidFunction; 112 | className?: string; 113 | testId: string; 114 | }) { 115 | return ( 116 | 122 | {children} 123 | 124 | ); 125 | } 126 | const ArrowButton = styled('button')({ 127 | cursor: 'pointer', 128 | display: 'flex', 129 | flexDirection: 'column', 130 | justifyContent: 'center', 131 | marginBottom: '2px', 132 | opacity: (props) => (props.disabled ? '0' : '1'), 133 | userSelect: 'none', 134 | borderRadius: '6px', 135 | borderWidth: '1px', 136 | }); 137 | 138 | function Card({ 139 | onClick, 140 | selected, 141 | title, 142 | itemId, 143 | }: { 144 | onClick: (context: publicApiType) => void; 145 | selected: boolean; 146 | title: string; 147 | itemId: string; 148 | }) { 149 | const visibility = React.useContext(VisibilityContext); 150 | const isVisible = visibility.useIsVisible(itemId, true); 151 | 152 | return ( 153 | onClick(visibility)} 156 | onKeyDown={(ev: React.KeyboardEvent) => { 157 | ev.code === 'Enter' && onClick(visibility); 158 | }} 159 | data-testid="card" 160 | role="button" 161 | tabIndex={0} 162 | className="card" 163 | visible={isVisible} 164 | selected={selected} 165 | > 166 |
167 |
{title}
168 |
visible: {JSON.stringify(isVisible)}
169 |
selected: {JSON.stringify(!!selected)}
170 |
171 |
172 | 173 | ); 174 | } 175 | const CardBody = styled('div')({ 176 | border: '1px solid', 177 | display: 'inline-block', 178 | margin: '0 10px', 179 | width: '160px', 180 | userSelect: 'none', 181 | borderRadius: '8px', 182 | overflow: 'hidden', 183 | 184 | '& .header': { 185 | backgroundColor: 'white', 186 | }, 187 | 188 | '& .visible': { 189 | backgroundColor: (props) => (props.visible ? 'transparent' : 'gray'), 190 | }, 191 | 192 | '& .background': { 193 | backgroundColor: (props) => (props.selected ? 'green' : 'bisque'), 194 | height: '200px', 195 | }, 196 | }); 197 | 198 | const getId = (index: number) => `${'test'}${index}`; 199 | 200 | const getItems = () => 201 | Array(10) 202 | .fill(0) 203 | .map((_, ind) => ({ id: getId(ind) })); 204 | 205 | function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void { 206 | // NOTE: no good standart way to distinguish touchpad scrolling gestures 207 | // but can assume that gesture will affect X axis, mouse scroll only Y axis 208 | // of if deltaY too small probably is it touchpad 209 | const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15; 210 | 211 | if (isThouchpad) { 212 | ev.stopPropagation(); 213 | return; 214 | } 215 | 216 | if (ev.deltaY < 0) { 217 | apiObj.scrollNext(); 218 | } else { 219 | apiObj.scrollPrev(); 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /stories/0_Simple/Simple.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { type Meta } from '@storybook/react'; 3 | import React from 'react'; 4 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 5 | 6 | import { ScrollMenu } from '../../src/index'; 7 | import { SizeWrapper } from '../SizeWrapper'; 8 | import { availableImports } from '../availableImports'; 9 | import { setupEditor } from '../setupEditor'; 10 | import { ScrollTest } from '../test'; 11 | 12 | import Example from './Simple.source'; 13 | // @ts-ignore 14 | import ExampleRaw from './Simple.source.tsx?raw'; 15 | 16 | const meta: Meta = { 17 | title: 'Examples/Simple', 18 | component: Example, 19 | decorators: [ 20 | (Story) => ( 21 | 22 | 23 | 24 | ), 25 | ], 26 | }; 27 | 28 | export default meta; 29 | 30 | export const Simple = createLiveEditStory({ 31 | code: ExampleRaw, 32 | availableImports, 33 | modifyEditor: setupEditor, 34 | }); 35 | 36 | export const Test = ScrollTest(); 37 | -------------------------------------------------------------------------------- /stories/1_Vertical/Vertical.source.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | ScrollMenu, 4 | VisibilityContext, 5 | type publicApiType, 6 | } from 'react-horizontal-scrolling-menu'; 7 | import 'react-horizontal-scrolling-menu/dist/styles.css'; 8 | import styled from 'styled-jss'; 9 | 10 | const NoScrollbar = styled('div')({ 11 | '& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': { 12 | display: 'none', 13 | }, 14 | // NOTE: also need to set on parent: 15 | // display: 'flex' and position: 'relative' 16 | '& .react-horizontal-scrolling-menu--wrapper': { 17 | height: '100%', 18 | }, 19 | 20 | '& .react-horizontal-scrolling-menu--scroll-container': { 21 | height: 'initial', 22 | scrollbarWidth: 'none', 23 | '-ms-overflow-style': 'none', 24 | overflowY: 'auto', 25 | flexDirection: 'column', 26 | }, 27 | }); 28 | 29 | export function VerticalExample() { 30 | const [items] = React.useState(() => getItems()); 31 | const [selected, setSelected] = React.useState([]); 32 | 33 | const isItemSelected = (id: string): boolean => 34 | !!selected.find((el) => el === id); 35 | 36 | const handleItemClick = (itemId: string) => { 37 | const itemSelected = isItemSelected(itemId); 38 | 39 | setSelected((currentSelected: string[]) => 40 | itemSelected 41 | ? currentSelected.filter((el) => el !== itemId) 42 | : currentSelected.concat(itemId), 43 | ); 44 | }; 45 | 46 | return ( 47 | 48 | 49 | {items.map(({ id }) => ( 50 | handleItemClick(id)} 55 | selected={isItemSelected(id)} 56 | /> 57 | ))} 58 | 59 | 60 | ); 61 | } 62 | 63 | export default VerticalExample; 64 | 65 | function UpArrow() { 66 | const visibility = React.useContext(VisibilityContext); 67 | const isFirstItemVisible = visibility.useIsVisible('first', true); 68 | 69 | return ( 70 | visibility.scrollPrev(undefined, undefined, 'end')} 73 | testId="up-arrow" 74 | > 75 | Up 76 | 77 | ); 78 | } 79 | 80 | function DownArrow() { 81 | const visibility = React.useContext(VisibilityContext); 82 | const isLastItemVisible = visibility.useIsVisible('last', false); 83 | 84 | return ( 85 | visibility.scrollNext(undefined, undefined, 'start')} 88 | testId="down-arrow" 89 | > 90 | Down 91 | 92 | ); 93 | } 94 | 95 | function Arrow({ 96 | children, 97 | disabled, 98 | onClick, 99 | className, 100 | testId, 101 | }: { 102 | children: React.ReactNode; 103 | disabled: boolean; 104 | onClick: VoidFunction; 105 | className?: string; 106 | testId: string; 107 | }) { 108 | return ( 109 | 115 | {children} 116 | 117 | ); 118 | } 119 | const ArrowButton = styled('button')({ 120 | cursor: 'pointer', 121 | display: 'flex', 122 | flexDirection: 'column', 123 | justifyContent: 'center', 124 | marginBottom: '2px', 125 | opacity: (props) => (props.disabled ? '0' : '1'), 126 | userSelect: 'none', 127 | borderRadius: '6px', 128 | borderWidth: '1px', 129 | }); 130 | 131 | function Card({ 132 | onClick, 133 | selected, 134 | title, 135 | itemId, 136 | }: { 137 | onClick: (context: publicApiType) => void; 138 | selected: boolean; 139 | title: string; 140 | itemId: string; 141 | }) { 142 | const visibility = React.useContext(VisibilityContext); 143 | const isVisible = visibility.useIsVisible(itemId, true); 144 | 145 | return ( 146 | onClick(visibility)} 149 | onKeyDown={(ev: React.KeyboardEvent) => { 150 | ev.code === 'Enter' && onClick(visibility); 151 | }} 152 | data-testid="card" 153 | role="button" 154 | tabIndex={0} 155 | className="card" 156 | visible={isVisible} 157 | selected={selected} 158 | > 159 |
160 |
{title}
161 |
visible: {JSON.stringify(isVisible)}
162 |
selected: {JSON.stringify(!!selected)}
163 |
164 |
165 | 166 | ); 167 | } 168 | const CardBody = styled('div')({ 169 | border: '1px solid', 170 | display: 'inline-block', 171 | margin: '0 10px', 172 | width: '160px', 173 | userSelect: 'none', 174 | borderRadius: '8px', 175 | overflow: 'hidden', 176 | 177 | '& .header': { 178 | backgroundColor: 'white', 179 | }, 180 | 181 | '& .visible': { 182 | backgroundColor: (props) => (props.visible ? 'transparent' : 'gray'), 183 | }, 184 | 185 | '& .background': { 186 | backgroundColor: (props) => (props.selected ? 'green' : 'bisque'), 187 | height: '125px', 188 | }, 189 | }); 190 | 191 | const getId = (index: number) => `${'test'}${index}`; 192 | 193 | const getItems = () => 194 | Array(10) 195 | .fill(0) 196 | .map((_, ind) => ({ id: getId(ind) })); 197 | -------------------------------------------------------------------------------- /stories/1_Vertical/Vertical.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import React from 'react'; 3 | import type { Meta } from '@storybook/react'; 4 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 5 | import styled from 'styled-jss'; 6 | 7 | import { setupEditor } from '../setupEditor'; 8 | import { availableImports } from '../availableImports'; 9 | import { ScrollMenu } from '../../src/index'; 10 | import { ScrollTest, upArrowSelector, downArrowSelector } from '../test'; 11 | 12 | // @ts-ignore 13 | import ExampleRaw from './Vertical.source.tsx?raw'; 14 | import Example from './Vertical.source'; 15 | 16 | const meta: Meta = { 17 | title: 'Examples/Vertical', 18 | component: Example, 19 | decorators: [ 20 | (Story) => ( 21 | 22 | 23 | 24 | ), 25 | ], 26 | }; 27 | 28 | const SizeWrapper = styled('div')({ 29 | maxWidth: '300px', 30 | maxHeight: '670px', 31 | display: 'flex', 32 | position: 'relative', 33 | }); 34 | 35 | export default meta; 36 | 37 | export const Vertical = createLiveEditStory({ 38 | code: ExampleRaw, 39 | availableImports, 40 | modifyEditor: setupEditor, 41 | }); 42 | 43 | export const Test = ScrollTest({ 44 | leftArrow: upArrowSelector, 45 | rightArrow: downArrowSelector, 46 | }); 47 | -------------------------------------------------------------------------------- /stories/2_OneItemScroll/OneItemScroll.source.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | ScrollMenu, 4 | VisibilityContext, 5 | type publicApiType, 6 | } from 'react-horizontal-scrolling-menu'; 7 | import 'react-horizontal-scrolling-menu/dist/styles.css'; 8 | import styled from 'styled-jss'; 9 | 10 | export function OneItemScroll() { 11 | const [items] = React.useState(() => getItems()); 12 | const [selected, setSelected] = React.useState([]); 13 | 14 | const isItemSelected = (id: string): boolean => 15 | !!selected.find((el) => el === id); 16 | 17 | const handleItemClick = (itemId: string) => { 18 | const itemSelected = isItemSelected(itemId); 19 | 20 | setSelected((currentSelected: string[]) => 21 | itemSelected 22 | ? currentSelected.filter((el) => el !== itemId) 23 | : currentSelected.concat(itemId), 24 | ); 25 | }; 26 | 27 | return ( 28 | 29 | 34 | {items.map(({ id }) => ( 35 | handleItemClick(id)} 40 | selected={isItemSelected(id)} 41 | /> 42 | ))} 43 | 44 | 45 | ); 46 | } 47 | 48 | export default OneItemScroll; 49 | 50 | function LeftArrow() { 51 | const visibility = React.useContext(VisibilityContext); 52 | const isFirstItemVisible = visibility.useIsVisible('first', true); 53 | 54 | // NOTE: Look here 55 | const onClick = () => 56 | visibility.scrollToItem(visibility.getPrevElement(), 'smooth', 'start'); 57 | 58 | return ( 59 | 60 | Left 61 | 62 | ); 63 | } 64 | 65 | function RightArrow() { 66 | const visibility = React.useContext(VisibilityContext); 67 | const isLastItemVisible = visibility.useIsVisible('last', false); 68 | 69 | // NOTE: Look here 70 | const onClick = () => 71 | visibility.scrollToItem(visibility.getNextElement(), 'smooth', 'end'); 72 | 73 | return ( 74 | 75 | Right 76 | 77 | ); 78 | } 79 | 80 | const NoScrollbar = styled('div')({ 81 | '& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': { 82 | display: 'none', 83 | }, 84 | '& .react-horizontal-scrolling-menu--scroll-container': { 85 | scrollbarWidth: 'none', 86 | '-ms-overflow-style': 'none', 87 | }, 88 | }); 89 | 90 | function Arrow({ 91 | children, 92 | disabled, 93 | onClick, 94 | className, 95 | testId, 96 | }: { 97 | children: React.ReactNode; 98 | disabled: boolean; 99 | onClick: VoidFunction; 100 | className?: string; 101 | testId: string; 102 | }) { 103 | return ( 104 | 110 | {children} 111 | 112 | ); 113 | } 114 | const ArrowButton = styled('button')({ 115 | cursor: 'pointer', 116 | display: 'flex', 117 | flexDirection: 'column', 118 | justifyContent: 'center', 119 | marginBottom: '2px', 120 | opacity: (props) => (props.disabled ? '0' : '1'), 121 | userSelect: 'none', 122 | borderRadius: '6px', 123 | borderWidth: '1px', 124 | }); 125 | 126 | function Card({ 127 | onClick, 128 | selected, 129 | title, 130 | itemId, 131 | }: { 132 | onClick: (context: publicApiType) => void; 133 | selected: boolean; 134 | title: string; 135 | itemId: string; 136 | }) { 137 | const visibility = React.useContext(VisibilityContext); 138 | const isVisible = visibility.useIsVisible(itemId, true); 139 | 140 | return ( 141 | onClick(visibility)} 144 | onKeyDown={(ev: React.KeyboardEvent) => { 145 | ev.code === 'Enter' && onClick(visibility); 146 | }} 147 | data-testid="card" 148 | role="button" 149 | tabIndex={0} 150 | className="card" 151 | visible={isVisible} 152 | selected={selected} 153 | > 154 |
155 |
{title}
156 |
visible: {JSON.stringify(isVisible)}
157 |
selected: {JSON.stringify(!!selected)}
158 |
159 |
160 | 161 | ); 162 | } 163 | const CardBody = styled('div')({ 164 | border: '1px solid', 165 | display: 'inline-block', 166 | margin: '0 10px', 167 | width: '160px', 168 | userSelect: 'none', 169 | borderRadius: '8px', 170 | overflow: 'hidden', 171 | 172 | '& .header': { 173 | backgroundColor: 'white', 174 | }, 175 | 176 | '& .visible': { 177 | backgroundColor: (props) => (props.visible ? 'transparent' : 'gray'), 178 | }, 179 | 180 | '& .background': { 181 | backgroundColor: (props) => (props.selected ? 'green' : 'bisque'), 182 | height: '200px', 183 | }, 184 | }); 185 | 186 | const getId = (index: number) => `${'test'}${index}`; 187 | 188 | const getItems = () => 189 | Array(10) 190 | .fill(0) 191 | .map((_, ind) => ({ id: getId(ind) })); 192 | 193 | function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void { 194 | // NOTE: no good standart way to distinguish touchpad scrolling gestures 195 | // but can assume that gesture will affect X axis, mouse scroll only Y axis 196 | // of if deltaY too small probably is it touchpad 197 | const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15; 198 | 199 | if (isThouchpad) { 200 | ev.stopPropagation(); 201 | return; 202 | } 203 | 204 | if (ev.deltaY < 0) { 205 | apiObj.scrollNext(); 206 | } else { 207 | apiObj.scrollPrev(); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /stories/2_OneItemScroll/OneItemScroll.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { expect } from '@storybook/jest'; 3 | import { within } from '@storybook/testing-library'; 4 | import React from 'react'; 5 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 6 | 7 | import { ScrollMenu } from '../../src/index'; 8 | import { SizeWrapper } from '../SizeWrapper'; 9 | import { availableImports } from '../availableImports'; 10 | import { setupEditor } from '../setupEditor'; 11 | import { TestObj, leftArrowSelector, rightArrowSelector } from '../test'; 12 | 13 | // @ts-ignore 14 | import Example from './OneItemScroll.source'; 15 | import ExampleRaw from './OneItemScroll.source.tsx?raw'; 16 | 17 | import type { Meta } from '@storybook/react'; 18 | 19 | const meta: Meta = { 20 | title: 'Examples/OneItemScroll', 21 | component: Example, 22 | decorators: [ 23 | (Story) => ( 24 | 25 | 26 | 27 | ), 28 | ], 29 | }; 30 | 31 | export default meta; 32 | 33 | export const OneItemScroll = createLiveEditStory({ 34 | code: ExampleRaw, 35 | availableImports, 36 | modifyEditor: setupEditor, 37 | }); 38 | 39 | export const Test = { 40 | play: async ({ canvasElement }) => { 41 | const canvas = within(canvasElement); 42 | const testObj = new TestObj(canvas, { 43 | leftArrow: leftArrowSelector, 44 | rightArrow: rightArrowSelector, 45 | }); 46 | await testObj.wait(); 47 | 48 | await testObj.arrowsVisible({ left: false, right: true }); 49 | 50 | await testObj.clickNext(); 51 | await testObj.wait(); 52 | await testObj.cardHidden('test0'); 53 | expect(await testObj.getVisibleCardsKeys()).toEqual([ 54 | 'test1', 55 | 'test2', 56 | 'test3', 57 | ]); 58 | await testObj.arrowsVisible({ left: true, right: true }); 59 | 60 | await testObj.clickNext(); 61 | await testObj.wait(); 62 | await testObj.cardHidden('test1'); 63 | expect(await testObj.getVisibleCardsKeys()).toEqual([ 64 | 'test2', 65 | 'test3', 66 | 'test4', 67 | ]); 68 | await testObj.arrowsVisible({ left: true, right: true }); 69 | 70 | await testObj.clickPrev(); 71 | await testObj.wait(); 72 | await testObj.cardHidden('test4'); 73 | await testObj.arrowsVisible({ left: true, right: true }); 74 | expect(await testObj.getVisibleCardsKeys()).toEqual([ 75 | 'test1', 76 | 'test2', 77 | 'test3', 78 | ]); 79 | 80 | await testObj.clickPrev(); 81 | await testObj.wait(); 82 | await testObj.cardHidden('test3'); 83 | await testObj.arrowsVisible({ left: false, right: true }); 84 | expect(await testObj.getVisibleCardsKeys()).toEqual([ 85 | 'test0', 86 | 'test1', 87 | 'test2', 88 | ]); 89 | }, 90 | }; 91 | -------------------------------------------------------------------------------- /stories/3_OneItem/OneItem.source.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-jss'; 3 | 4 | import { 5 | ScrollMenu, 6 | VisibilityContext, 7 | type publicApiType, 8 | } from 'react-horizontal-scrolling-menu'; 9 | 10 | import 'react-horizontal-scrolling-menu/dist/styles.css'; 11 | 12 | const WideItems = styled('div')({ 13 | '& .react-horizontal-scrolling-menu--item ': { 14 | minWidth: '100%', 15 | display: 'flex', 16 | justifyContent: 'center', 17 | }, 18 | }); 19 | 20 | export function OneItem() { 21 | const [items] = React.useState(() => getItems()); 22 | const [selected, setSelected] = React.useState([]); 23 | 24 | const isItemSelected = (id: string): boolean => 25 | !!selected.find((el) => el === id); 26 | 27 | const handleItemClick = (itemId: string) => { 28 | const itemSelected = isItemSelected(itemId); 29 | 30 | setSelected((currentSelected: string[]) => 31 | itemSelected 32 | ? currentSelected.filter((el) => el !== itemId) 33 | : currentSelected.concat(itemId), 34 | ); 35 | }; 36 | 37 | return ( 38 | 39 | 40 | 45 | {items.map(({ id }) => ( 46 | handleItemClick(id)} 51 | selected={isItemSelected(id)} 52 | /> 53 | ))} 54 | 55 | 56 | 57 | ); 58 | } 59 | 60 | export default OneItem; 61 | 62 | function LeftArrow() { 63 | const visibility = React.useContext(VisibilityContext); 64 | 65 | const disabled = visibility.useLeftArrowVisible(); 66 | 67 | return ( 68 | visibility.scrollPrev()} 71 | testId="left-arrow" 72 | > 73 | Left 74 | 75 | ); 76 | } 77 | 78 | function RightArrow() { 79 | const visibility = React.useContext(VisibilityContext); 80 | 81 | const disabled = visibility.useRightArrowVisible(); 82 | 83 | return ( 84 | visibility.scrollNext()} 87 | testId="right-arrow" 88 | > 89 | Right 90 | 91 | ); 92 | } 93 | 94 | const NoScrollbar = styled('div')({ 95 | '& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': { 96 | display: 'none', 97 | }, 98 | '& .react-horizontal-scrolling-menu--scroll-container': { 99 | scrollbarWidth: 'none', 100 | '-ms-overflow-style': 'none', 101 | }, 102 | }); 103 | 104 | function Arrow({ 105 | children, 106 | disabled, 107 | onClick, 108 | className, 109 | testId, 110 | }: { 111 | children: React.ReactNode; 112 | disabled: boolean; 113 | onClick: VoidFunction; 114 | className?: string; 115 | testId: string; 116 | }) { 117 | return ( 118 | 124 | {children} 125 | 126 | ); 127 | } 128 | const ArrowButton = styled('button')({ 129 | cursor: 'pointer', 130 | display: 'flex', 131 | flexDirection: 'column', 132 | justifyContent: 'center', 133 | marginBottom: '2px', 134 | opacity: (props) => (props.disabled ? '0' : '1'), 135 | userSelect: 'none', 136 | borderRadius: '6px', 137 | borderWidth: '1px', 138 | }); 139 | 140 | function Card({ 141 | onClick, 142 | selected, 143 | title, 144 | itemId, 145 | }: { 146 | onClick: (context: publicApiType) => void; 147 | selected: boolean; 148 | title: string; 149 | itemId: string; 150 | }) { 151 | const visibility = React.useContext(VisibilityContext); 152 | const isVisible = visibility.useIsVisible(itemId, true); 153 | 154 | return ( 155 | onClick(visibility)} 158 | onKeyDown={(ev: React.KeyboardEvent) => { 159 | ev.code === 'Enter' && onClick(visibility); 160 | }} 161 | data-testid="card" 162 | role="button" 163 | tabIndex={0} 164 | className="card" 165 | visible={isVisible} 166 | selected={selected} 167 | > 168 |
169 |
{title}
170 |
visible: {JSON.stringify(isVisible)}
171 |
selected: {JSON.stringify(!!selected)}
172 |
173 |
174 | 175 | ); 176 | } 177 | const CardBody = styled('div')({ 178 | border: '1px solid', 179 | display: 'inline-block', 180 | margin: '0 10px', 181 | width: '160px', 182 | userSelect: 'none', 183 | borderRadius: '8px', 184 | overflow: 'hidden', 185 | 186 | '& .header': { 187 | backgroundColor: 'white', 188 | }, 189 | 190 | '& .visible': { 191 | backgroundColor: (props) => (props.visible ? 'transparent' : 'gray'), 192 | }, 193 | 194 | '& .background': { 195 | backgroundColor: (props) => (props.selected ? 'green' : 'bisque'), 196 | height: '200px', 197 | }, 198 | }); 199 | 200 | const getId = (index: number) => `${'test'}${index}`; 201 | 202 | const getItems = () => 203 | Array(10) 204 | .fill(0) 205 | .map((_, ind) => ({ id: getId(ind) })); 206 | 207 | function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void { 208 | // NOTE: no good standart way to distinguish touchpad scrolling gestures 209 | // but can assume that gesture will affect X axis, mouse scroll only Y axis 210 | // of if deltaY too small probably is it touchpad 211 | const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15; 212 | 213 | if (isThouchpad) { 214 | ev.stopPropagation(); 215 | return; 216 | } 217 | 218 | if (ev.deltaY < 0) { 219 | apiObj.scrollNext(); 220 | } else { 221 | apiObj.scrollPrev(); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /stories/3_OneItem/OneItem.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import React from 'react'; 3 | import type { Meta } from '@storybook/react'; 4 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 5 | import { within } from '@storybook/testing-library'; 6 | import { expect } from '@storybook/jest'; 7 | 8 | import { setupEditor } from '../setupEditor'; 9 | import { availableImports } from '../availableImports'; 10 | import { ScrollMenu } from '../../src/index'; 11 | import { TestObj, leftArrowSelector, rightArrowSelector } from '../test'; 12 | import { SizeWrapper } from '../SizeWrapper'; 13 | 14 | // @ts-ignore 15 | import ExampleRaw from './OneItem.source.tsx?raw'; 16 | import Example from './OneItem.source'; 17 | 18 | const meta: Meta = { 19 | title: 'Examples/OneItem', 20 | component: Example, 21 | decorators: [ 22 | (Story) => ( 23 | 24 | 25 | 26 | ), 27 | ], 28 | }; 29 | 30 | export default meta; 31 | 32 | export const OneItem = createLiveEditStory({ 33 | code: ExampleRaw, 34 | availableImports, 35 | modifyEditor: setupEditor, 36 | }); 37 | 38 | export const Test = { 39 | play: async ({ canvasElement }) => { 40 | const canvas = within(canvasElement); 41 | const testObj = new TestObj(canvas, { 42 | leftArrow: leftArrowSelector, 43 | rightArrow: rightArrowSelector, 44 | }); 45 | await testObj.wait(); 46 | 47 | await testObj.arrowsVisible({ left: false, right: true }); 48 | expect(await testObj.getVisibleCardsKeys(1)).toEqual(['test0']); 49 | 50 | await testObj.clickNext(); 51 | await testObj.wait(); 52 | await testObj.cardHidden('test0'); 53 | expect(await testObj.getVisibleCardsKeys(1)).toEqual(['test1']); 54 | await testObj.arrowsVisible({ left: true, right: true }); 55 | 56 | await testObj.clickNext(); 57 | await testObj.wait(); 58 | await testObj.cardHidden('test1'); 59 | expect(await testObj.getVisibleCardsKeys(1)).toEqual(['test2']); 60 | await testObj.arrowsVisible({ left: true, right: true }); 61 | 62 | await testObj.clickPrev(); 63 | await testObj.wait(); 64 | await testObj.cardHidden('test2'); 65 | await testObj.arrowsVisible({ left: true, right: true }); 66 | expect(await testObj.getVisibleCardsKeys(1)).toEqual(['test1']); 67 | 68 | await testObj.clickPrev(); 69 | await testObj.wait(); 70 | await testObj.cardHidden('test1'); 71 | await testObj.arrowsVisible({ left: false, right: true }); 72 | expect(await testObj.getVisibleCardsKeys(1)).toEqual(['test0']); 73 | }, 74 | }; 75 | -------------------------------------------------------------------------------- /stories/4_MouseDrag/MouseDrag.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { expect } from '@storybook/jest'; 3 | import { within } from '@storybook/testing-library'; 4 | import React from 'react'; 5 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 6 | 7 | import { ScrollMenu } from '../../src/index'; 8 | import { SizeWrapper } from '../SizeWrapper'; 9 | import { availableImports } from '../availableImports'; 10 | import { setupEditor } from '../setupEditor'; 11 | import { ScrollTest, TestObj, drag } from '../test'; 12 | 13 | // @ts-ignore 14 | import Example from './MouseDrag.source'; 15 | import ExampleRaw from './MouseDrag.source.tsx?raw'; 16 | 17 | import type { Meta } from '@storybook/react'; 18 | 19 | const meta: Meta = { 20 | title: 'Examples/MouseDrag', 21 | component: Example, 22 | decorators: [ 23 | (Story) => ( 24 | 25 | 26 | 27 | ), 28 | ], 29 | }; 30 | 31 | export default meta; 32 | 33 | export const MouseDrag = createLiveEditStory({ 34 | code: ExampleRaw, 35 | availableImports, 36 | modifyEditor: setupEditor, 37 | }); 38 | 39 | export const Test = ScrollTest(); 40 | 41 | export const TestDrag = { 42 | play: async ({ canvasElement }) => { 43 | const canvas = within(canvasElement); 44 | const testObj = new TestObj(canvas, { leftArrow: '', rightArrow: '' }); 45 | await testObj.wait(); 46 | 47 | const lastCard = (await testObj.getVisibleCards()).slice(-1)[0]; 48 | expect(await testObj.getSelectedCardsKeys()).toHaveLength(0); 49 | await lastCard.click(); 50 | expect(await testObj.getSelectedCards()).toHaveLength(1); 51 | 52 | await drag(lastCard, { delta: { x: -350, y: 0 } }); 53 | await testObj.wait(); 54 | 55 | expect(await testObj.getVisibleCardsKeys()).toEqual([ 56 | 'test2', 57 | 'test3', 58 | 'test4', 59 | ]); 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /stories/5_1_ScrollToItem/ScrollToItem.source.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-jss'; 3 | 4 | import { 5 | ScrollMenu, 6 | VisibilityContext, 7 | type publicApiType, 8 | } from 'react-horizontal-scrolling-menu'; 9 | 10 | import 'react-horizontal-scrolling-menu/dist/styles.css'; 11 | 12 | export function ScrollToItem() { 13 | const [items] = React.useState(() => getItems()); 14 | const [selected, setSelected] = React.useState([]); 15 | 16 | const isItemSelected = (id: string): boolean => 17 | !!selected.find((el) => el === id); 18 | 19 | const handleItemClick = (itemId: string) => { 20 | const itemSelected = isItemSelected(itemId); 21 | 22 | setSelected((currentSelected: string[]) => 23 | itemSelected 24 | ? currentSelected.filter((el) => el !== itemId) 25 | : currentSelected.concat(itemId), 26 | ); 27 | }; 28 | 29 | const apiRef = React.useRef(null); 30 | 31 | // TODO: fix bug with items 32 | React.useEffect(() => { 33 | if (!apiRef.current) return () => {}; 34 | 35 | const id = setTimeout(() => { 36 | const itemsList = [...apiRef.current.items.toItems()]; 37 | const itemKey = itemsList.find((el) => el.includes(5)); 38 | const item = apiRef.current.getItemById(itemKey); 39 | // const item = apiRef.current.getItemByIndex(5) // or by index 40 | apiRef.current.scrollToItem(item, 'auto', 'start'); 41 | }, 100); 42 | 43 | return () => clearTimeout(id); 44 | }, [apiRef.current]); 45 | 46 | return ( 47 | 53 | {items.map(({ id }) => ( 54 | handleItemClick(id)} 59 | selected={isItemSelected(id)} 60 | /> 61 | ))} 62 | 63 | ); 64 | } 65 | 66 | export default ScrollToItem; 67 | 68 | function LeftArrow() { 69 | const visibility = React.useContext(VisibilityContext); 70 | 71 | const disabled = visibility.useLeftArrowVisible(); 72 | 73 | return ( 74 | visibility.scrollPrev()} 77 | testId="left-arrow" 78 | > 79 | Left 80 | 81 | ); 82 | } 83 | 84 | function RightArrow() { 85 | const visibility = React.useContext(VisibilityContext); 86 | 87 | const disabled = visibility.useRightArrowVisible(); 88 | 89 | return ( 90 | visibility.scrollNext()} 93 | testId="right-arrow" 94 | > 95 | Right 96 | 97 | ); 98 | } 99 | 100 | function Arrow({ 101 | children, 102 | disabled, 103 | onClick, 104 | className, 105 | testId, 106 | }: { 107 | children: React.ReactNode; 108 | disabled: boolean; 109 | onClick: VoidFunction; 110 | className?: string; 111 | testId: string; 112 | }) { 113 | return ( 114 | 120 | {children} 121 | 122 | ); 123 | } 124 | const ArrowButton = styled('button')({ 125 | cursor: 'pointer', 126 | display: 'flex', 127 | flexDirection: 'column', 128 | justifyContent: 'center', 129 | marginBottom: '2px', 130 | opacity: (props) => (props.disabled ? '0' : '1'), 131 | userSelect: 'none', 132 | borderRadius: '6px', 133 | borderWidth: '1px', 134 | }); 135 | 136 | function Card({ 137 | onClick, 138 | selected, 139 | title, 140 | itemId, 141 | }: { 142 | onClick: (context: publicApiType) => void; 143 | selected: boolean; 144 | title: string; 145 | itemId: string; 146 | }) { 147 | const visibility = React.useContext(VisibilityContext); 148 | const isVisible = visibility.useIsVisible(itemId, true); 149 | 150 | return ( 151 | onClick(visibility)} 154 | onKeyDown={(ev: React.KeyboardEvent) => { 155 | ev.code === 'Enter' && onClick(visibility); 156 | }} 157 | data-testid="card" 158 | role="button" 159 | tabIndex={0} 160 | className="card" 161 | visible={isVisible} 162 | selected={selected} 163 | > 164 |
165 |
{title}
166 |
visible: {JSON.stringify(isVisible)}
167 |
selected: {JSON.stringify(!!selected)}
168 |
169 |
170 | 171 | ); 172 | } 173 | const CardBody = styled('div')({ 174 | border: '1px solid', 175 | display: 'inline-block', 176 | margin: '0 10px', 177 | width: '160px', 178 | userSelect: 'none', 179 | borderRadius: '8px', 180 | overflow: 'hidden', 181 | 182 | '& .header': { 183 | backgroundColor: 'white', 184 | }, 185 | 186 | '& .visible': { 187 | backgroundColor: (props) => (props.visible ? 'transparent' : 'gray'), 188 | }, 189 | 190 | '& .background': { 191 | backgroundColor: (props) => (props.selected ? 'green' : 'bisque'), 192 | height: '200px', 193 | }, 194 | }); 195 | 196 | const getId = (index: number) => `${'test'}${index}`; 197 | 198 | const getItems = () => 199 | Array(10) 200 | .fill(0) 201 | .map((_, ind) => ({ id: getId(ind) })); 202 | 203 | function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void { 204 | // NOTE: no good standart way to distinguish touchpad scrolling gestures 205 | // but can assume that gesture will affect X axis, mouse scroll only Y axis 206 | // of if deltaY too small probably is it touchpad 207 | const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15; 208 | 209 | if (isThouchpad) { 210 | ev.stopPropagation(); 211 | return; 212 | } 213 | 214 | if (ev.deltaY < 0) { 215 | apiObj.scrollNext(); 216 | } else { 217 | apiObj.scrollPrev(); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /stories/5_1_ScrollToItem/ScrollToItem.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { type Meta } from '@storybook/react'; 3 | import React from 'react'; 4 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 5 | 6 | import { ScrollMenu } from '../../src/index'; 7 | import { SizeWrapper } from '../SizeWrapper'; 8 | import { availableImports } from '../availableImports'; 9 | import { setupEditor } from '../setupEditor'; 10 | 11 | import Example from './ScrollToItem.source'; 12 | // @ts-ignore 13 | import ExampleRaw from './ScrollToItem.source.tsx?raw'; 14 | 15 | const meta: Meta = { 16 | title: 'Examples/ScrollToItem', 17 | component: Example, 18 | decorators: [ 19 | (Story) => ( 20 | 21 | 22 | 23 | ), 24 | ], 25 | }; 26 | 27 | export default meta; 28 | 29 | export const ScrollToItem = createLiveEditStory({ 30 | code: ExampleRaw, 31 | availableImports, 32 | modifyEditor: setupEditor, 33 | }); 34 | -------------------------------------------------------------------------------- /stories/5_Save_restore_position/Position.source.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-jss'; 3 | 4 | import { 5 | ScrollMenu, 6 | VisibilityContext, 7 | type publicApiType, 8 | } from 'react-horizontal-scrolling-menu'; 9 | 10 | import 'react-horizontal-scrolling-menu/dist/styles.css'; 11 | 12 | export function Position() { 13 | const [items] = React.useState(() => getItems()); 14 | const [selected, setSelected] = React.useState([]); 15 | 16 | const isItemSelected = (id: string): boolean => 17 | !!selected.find((el) => el === id); 18 | 19 | const handleItemClick = (itemId: string) => { 20 | const itemSelected = isItemSelected(itemId); 21 | 22 | setSelected((currentSelected: string[]) => 23 | itemSelected 24 | ? currentSelected.filter((el) => el !== itemId) 25 | : currentSelected.concat(itemId), 26 | ); 27 | }; 28 | 29 | const { getPosition, setPosition, reset } = usePosition(); 30 | const savePos = React.useCallback( 31 | (api: publicApiType) => { 32 | setPosition(api.scrollContainer.current.scrollLeft); 33 | }, 34 | [setPosition], 35 | ); 36 | const restorePosition = React.useCallback( 37 | (api: publicApiType) => { 38 | api.scrollContainer.current.scrollLeft = getPosition(); 39 | }, 40 | [getPosition], 41 | ); 42 | 43 | const [key, setKey] = React.useState(() => String(Math.random())); 44 | const reload = React.useCallback(() => setKey(String(Math.random())), []); 45 | 46 | return ( 47 | <> 48 | 56 | {items.map(({ id }) => ( 57 | handleItemClick(id)} 62 | selected={isItemSelected(id)} 63 | /> 64 | ))} 65 | 66 |
67 | 70 | 73 |
74 | 75 | ); 76 | } 77 | 78 | const usePosition = () => { 79 | React.useEffect(() => { 80 | window.history.scrollRestoration = 'manual'; 81 | }, []); 82 | 83 | const setPosition = React.useCallback((pos: number | string) => { 84 | sessionStorage.setItem('position', String(pos)); 85 | }, []); 86 | const getPosition = () => +(sessionStorage.getItem('position') || 0); 87 | const reset = React.useCallback( 88 | () => sessionStorage.removeItem('position'), 89 | [], 90 | ); 91 | 92 | return { getPosition, setPosition, reset }; 93 | }; 94 | 95 | export default Position; 96 | 97 | function LeftArrow() { 98 | const visibility = React.useContext(VisibilityContext); 99 | 100 | const disabled = visibility.useLeftArrowVisible(); 101 | 102 | return ( 103 | visibility.scrollPrev()} 106 | testId="left-arrow" 107 | > 108 | Left 109 | 110 | ); 111 | } 112 | 113 | function RightArrow() { 114 | const visibility = React.useContext(VisibilityContext); 115 | 116 | const disabled = visibility.useRightArrowVisible(); 117 | 118 | return ( 119 | visibility.scrollNext()} 122 | testId="right-arrow" 123 | > 124 | Right 125 | 126 | ); 127 | } 128 | 129 | function Arrow({ 130 | children, 131 | disabled, 132 | onClick, 133 | className, 134 | testId, 135 | }: { 136 | children: React.ReactNode; 137 | disabled: boolean; 138 | onClick: VoidFunction; 139 | className?: string; 140 | testId: string; 141 | }) { 142 | return ( 143 | 149 | {children} 150 | 151 | ); 152 | } 153 | const ArrowButton = styled('button')({ 154 | cursor: 'pointer', 155 | display: 'flex', 156 | flexDirection: 'column', 157 | justifyContent: 'center', 158 | marginBottom: '2px', 159 | opacity: (props) => (props.disabled ? '0' : '1'), 160 | userSelect: 'none', 161 | borderRadius: '6px', 162 | borderWidth: '1px', 163 | }); 164 | 165 | function Card({ 166 | onClick, 167 | selected, 168 | title, 169 | itemId, 170 | }: { 171 | onClick: (context: publicApiType) => void; 172 | selected: boolean; 173 | title: string; 174 | itemId: string; 175 | }) { 176 | const visibility = React.useContext(VisibilityContext); 177 | const isVisible = visibility.useIsVisible(itemId, true); 178 | 179 | return ( 180 | onClick(visibility)} 183 | onKeyDown={(ev: React.KeyboardEvent) => { 184 | ev.code === 'Enter' && onClick(visibility); 185 | }} 186 | data-testid="card" 187 | role="button" 188 | tabIndex={0} 189 | className="card" 190 | visible={isVisible} 191 | selected={selected} 192 | > 193 |
194 |
{title}
195 |
visible: {JSON.stringify(isVisible)}
196 |
selected: {JSON.stringify(!!selected)}
197 |
198 |
199 | 200 | ); 201 | } 202 | const CardBody = styled('div')({ 203 | border: '1px solid', 204 | display: 'inline-block', 205 | margin: '0 10px', 206 | width: '160px', 207 | userSelect: 'none', 208 | borderRadius: '8px', 209 | overflow: 'hidden', 210 | 211 | '& .header': { 212 | backgroundColor: 'white', 213 | }, 214 | 215 | '& .visible': { 216 | backgroundColor: (props) => (props.visible ? 'transparent' : 'gray'), 217 | }, 218 | 219 | '& .background': { 220 | backgroundColor: (props) => (props.selected ? 'green' : 'bisque'), 221 | height: '200px', 222 | }, 223 | }); 224 | 225 | const getId = (index: number) => `${'test'}${index}`; 226 | 227 | const getItems = () => 228 | Array(10) 229 | .fill(0) 230 | .map((_, ind) => ({ id: getId(ind) })); 231 | 232 | function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void { 233 | // NOTE: no good standart way to distinguish touchpad scrolling gestures 234 | // but can assume that gesture will affect X axis, mouse scroll only Y axis 235 | // of if deltaY too small probably is it touchpad 236 | const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15; 237 | 238 | if (isThouchpad) { 239 | ev.stopPropagation(); 240 | return; 241 | } 242 | 243 | if (ev.deltaY < 0) { 244 | apiObj.scrollNext(); 245 | } else { 246 | apiObj.scrollPrev(); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /stories/5_Save_restore_position/Position.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import React from 'react'; 3 | import type { Meta } from '@storybook/react'; 4 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 5 | import { within, userEvent } from '@storybook/testing-library'; 6 | import { expect } from '@storybook/jest'; 7 | 8 | import { setupEditor } from '../setupEditor'; 9 | import { availableImports } from '../availableImports'; 10 | import { ScrollMenu } from '../../src/index'; 11 | import { 12 | ScrollTest, 13 | TestObj, 14 | leftArrowSelector, 15 | rightArrowSelector, 16 | } from '../test'; 17 | import { SizeWrapper } from '../SizeWrapper'; 18 | 19 | // @ts-ignore 20 | import ExampleRaw from './Position.source.tsx?raw'; 21 | import Example from './Position.source'; 22 | 23 | const meta: Meta = { 24 | title: 'Examples/Position', 25 | component: Example, 26 | decorators: [ 27 | (Story) => ( 28 | 29 | 30 | 31 | ), 32 | ], 33 | }; 34 | 35 | export default meta; 36 | 37 | export const Position = createLiveEditStory({ 38 | code: ExampleRaw, 39 | availableImports, 40 | modifyEditor: setupEditor, 41 | }); 42 | 43 | export const Test = ScrollTest(); 44 | 45 | export const PosTest = { 46 | play: async ({ canvasElement }) => { 47 | const canvas = within(canvasElement); 48 | const testObj = new TestObj(canvas, { 49 | leftArrow: leftArrowSelector, 50 | rightArrow: rightArrowSelector, 51 | }); 52 | await testObj.wait(); 53 | await userEvent.click(canvas.getByTestId('reset')); 54 | await userEvent.click(canvas.getByTestId('reload')); 55 | await testObj.isReady(); 56 | 57 | await testObj.clickNext(); 58 | await testObj.wait(); 59 | expect(await testObj.getVisibleCardsKeys()).toEqual([ 60 | 'test3', 61 | 'test4', 62 | 'test5', 63 | ]); 64 | 65 | await userEvent.click(canvas.getByTestId('reload')); 66 | await testObj.isReady(); 67 | await testObj.wait(); 68 | 69 | expect(await testObj.getVisibleCardsKeys()).toEqual([ 70 | 'test3', 71 | 'test4', 72 | 'test5', 73 | ]); 74 | }, 75 | }; 76 | -------------------------------------------------------------------------------- /stories/6_Items_animation/Items_animation.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import React from 'react'; 3 | import type { Meta } from '@storybook/react'; 4 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 5 | 6 | import { setupEditor } from '../setupEditor'; 7 | import { availableImports } from '../availableImports'; 8 | import { ScrollMenu } from '../../src/index'; 9 | 10 | // @ts-ignore 11 | import ExampleRaw from './Items_animation.source.tsx?raw'; 12 | import Example from './Items_animation.source'; 13 | 14 | const meta: Meta = { 15 | title: 'Examples/ItemsAnimation', 16 | component: Example, 17 | decorators: [(Story) => ], 18 | }; 19 | 20 | export default meta; 21 | 22 | export const ItemsAnimation = createLiveEditStory({ 23 | code: ExampleRaw, 24 | availableImports, 25 | modifyEditor: setupEditor, 26 | }); 27 | -------------------------------------------------------------------------------- /stories/7_progress/Progress.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-statements */ 2 | /* eslint-disable sonarjs/no-duplicate-string */ 3 | import { expect } from '@storybook/jest'; 4 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 5 | import { within, userEvent } from '@storybook/testing-library'; 6 | import React from 'react'; 7 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 8 | 9 | import { ScrollMenu } from '../../src/index'; 10 | import { SizeWrapper } from '../SizeWrapper'; 11 | import { availableImports } from '../availableImports'; 12 | import { setupEditor } from '../setupEditor'; 13 | import { TestObj, leftArrowSelector, rightArrowSelector } from '../test'; 14 | 15 | import Example from './Progress.source'; 16 | // @ts-ignore 17 | import ExampleRaw from './Progress.source.tsx?raw'; 18 | 19 | import type { Meta } from '@storybook/react'; 20 | 21 | const meta: Meta = { 22 | title: 'Examples/Progress', 23 | component: Example, 24 | decorators: [ 25 | (Story) => ( 26 | 27 | 28 | 29 | ), 30 | ], 31 | }; 32 | 33 | export default meta; 34 | 35 | export const Progress = createLiveEditStory({ 36 | code: ExampleRaw, 37 | availableImports, 38 | modifyEditor: setupEditor, 39 | }); 40 | 41 | export const Test = { 42 | play: async ({ canvasElement }) => { 43 | const canvas = within(canvasElement); 44 | const testObj = new TestObj(canvas, { 45 | leftArrow: leftArrowSelector, 46 | rightArrow: rightArrowSelector, 47 | }); 48 | await testObj.wait(); 49 | expect(await testObj.getVisibleCardsKeys()).toEqual([ 50 | 'test0', 51 | 'test1', 52 | 'test2', 53 | ]); 54 | 55 | expect(await canvas.getByTestId('items-left').textContent).toEqual('0'); 56 | expect(await canvas.getByTestId('items-right').textContent).toEqual('27'); 57 | 58 | expect(await canvas.queryAllByTestId(/page-/)).toHaveLength(10); 59 | await testObj.wait(); 60 | const activePages = await canvas 61 | .queryAllByTestId(/page-/) 62 | .filter((el) => el.className.includes('active')); 63 | expect(activePages[0].textContent).toEqual('1'); 64 | 65 | await userEvent.click(canvas.getByTestId('page-5')); 66 | await testObj.wait(); 67 | await testObj.wait(); 68 | 69 | expect(await canvas.queryAllByTestId(/page-/)).toHaveLength(10); 70 | expect(await testObj.getVisibleCardsKeys()).toEqual([ 71 | 'test12', 72 | 'test13', 73 | 'test14', 74 | ]); 75 | 76 | const activePages1 = await canvas 77 | .queryAllByTestId(/page-/) 78 | .filter((el) => el.className.includes('active')); 79 | expect(activePages1[0].textContent).toEqual('5'); 80 | 81 | expect(await canvas.getByTestId('items-left').textContent).toEqual('12'); 82 | expect(await canvas.getByTestId('items-right').textContent).toEqual('15'); 83 | 84 | await testObj.wait(); 85 | await userEvent.click(canvas.getByTestId('page-10')); 86 | 87 | await testObj.wait(); 88 | expect(await canvas.queryAllByTestId(/page-/)).toHaveLength(10); 89 | expect(await testObj.getVisibleCardsKeys()).toEqual([ 90 | 'test27', 91 | 'test28', 92 | 'test29', 93 | ]); 94 | 95 | await testObj.wait(1200); 96 | const activePages2 = await canvas 97 | .queryAllByTestId(/page-/) 98 | .filter((el) => el.className.includes('active')); 99 | expect(activePages2[0].textContent).toEqual('10'); 100 | 101 | expect(await canvas.getByTestId('items-left').textContent).toEqual('27'); 102 | expect(await canvas.getByTestId('items-right').textContent).toEqual('0'); 103 | }, 104 | }; 105 | -------------------------------------------------------------------------------- /stories/8_PreventBodyScroll/PreventBodyScroll.source.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-jss'; 3 | 4 | import { 5 | ScrollMenu, 6 | VisibilityContext, 7 | type publicApiType, 8 | } from 'react-horizontal-scrolling-menu'; 9 | 10 | import 'react-horizontal-scrolling-menu/dist/styles.css'; 11 | 12 | function usePreventBodyScroll() { 13 | const preventDefault = React.useCallback((ev: Event) => { 14 | ev?.preventDefault?.(); 15 | }, []); 16 | 17 | const enableScroll = React.useCallback(() => { 18 | document && document.removeEventListener('wheel', preventDefault, false); 19 | }, [preventDefault]); 20 | const disableScroll = React.useCallback(() => { 21 | document && 22 | document.addEventListener('wheel', preventDefault, { 23 | passive: false, 24 | }); 25 | }, [preventDefault]); 26 | 27 | React.useEffect(() => { 28 | return enableScroll; 29 | }, [enableScroll]); 30 | 31 | return { disableScroll, enableScroll }; 32 | } 33 | 34 | export function PreventBodyScroll() { 35 | const { disableScroll, enableScroll } = usePreventBodyScroll(); 36 | 37 | const [items] = React.useState(() => getItems()); 38 | const [selected, setSelected] = React.useState([]); 39 | 40 | const isItemSelected = (id: string): boolean => 41 | !!selected.find((el) => el === id); 42 | 43 | const handleItemClick = (itemId: string) => { 44 | const itemSelected = isItemSelected(itemId); 45 | 46 | setSelected((currentSelected: string[]) => 47 | itemSelected 48 | ? currentSelected.filter((el) => el !== itemId) 49 | : currentSelected.concat(itemId), 50 | ); 51 | }; 52 | 53 | return ( 54 |
55 |
56 | 57 | 62 | {items.map(({ id }) => ( 63 | handleItemClick(id)} 68 | selected={isItemSelected(id)} 69 | /> 70 | ))} 71 | 72 | 73 |
74 |
75 | ); 76 | } 77 | 78 | export default PreventBodyScroll; 79 | 80 | const NoScrollbar = styled('div')({ 81 | '& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': { 82 | display: 'none', 83 | }, 84 | '& .react-horizontal-scrolling-menu--scroll-container': { 85 | scrollbarWidth: 'none', 86 | '-ms-overflow-style': 'none', 87 | }, 88 | }); 89 | 90 | function LeftArrow() { 91 | const visibility = React.useContext(VisibilityContext); 92 | 93 | const disabled = visibility.useLeftArrowVisible(); 94 | 95 | return ( 96 | visibility.scrollPrev()} 99 | testId="left-arrow" 100 | > 101 | Left 102 | 103 | ); 104 | } 105 | 106 | function RightArrow() { 107 | const visibility = React.useContext(VisibilityContext); 108 | 109 | const disabled = visibility.useRightArrowVisible(); 110 | 111 | return ( 112 | visibility.scrollNext()} 115 | testId="right-arrow" 116 | > 117 | Right 118 | 119 | ); 120 | } 121 | 122 | function Arrow({ 123 | children, 124 | disabled, 125 | onClick, 126 | className, 127 | testId, 128 | }: { 129 | children: React.ReactNode; 130 | disabled: boolean; 131 | onClick: VoidFunction; 132 | className?: string; 133 | testId: string; 134 | }) { 135 | return ( 136 | 142 | {children} 143 | 144 | ); 145 | } 146 | const ArrowButton = styled('button')({ 147 | cursor: 'pointer', 148 | display: 'flex', 149 | flexDirection: 'column', 150 | justifyContent: 'center', 151 | marginBottom: '2px', 152 | opacity: (props) => (props.disabled ? '0' : '1'), 153 | userSelect: 'none', 154 | borderRadius: '6px', 155 | borderWidth: '1px', 156 | }); 157 | 158 | function Card({ 159 | onClick, 160 | selected, 161 | title, 162 | itemId, 163 | }: { 164 | onClick: (context: publicApiType) => void; 165 | selected: boolean; 166 | title: string; 167 | itemId: string; 168 | }) { 169 | const visibility = React.useContext(VisibilityContext); 170 | const isVisible = visibility.useIsVisible(itemId, true); 171 | 172 | return ( 173 | onClick(visibility)} 176 | onKeyDown={(ev: React.KeyboardEvent) => { 177 | ev.code === 'Enter' && onClick(visibility); 178 | }} 179 | data-testid="card" 180 | role="button" 181 | tabIndex={0} 182 | className="card" 183 | visible={isVisible} 184 | selected={selected} 185 | > 186 |
187 |
{title}
188 |
visible: {JSON.stringify(isVisible)}
189 |
selected: {JSON.stringify(!!selected)}
190 |
191 |
192 | 193 | ); 194 | } 195 | const CardBody = styled('div')({ 196 | border: '1px solid', 197 | display: 'inline-block', 198 | margin: '0 10px', 199 | width: '160px', 200 | userSelect: 'none', 201 | borderRadius: '8px', 202 | overflow: 'hidden', 203 | 204 | '& .header': { 205 | backgroundColor: 'white', 206 | }, 207 | 208 | '& .visible': { 209 | backgroundColor: (props) => (props.visible ? 'transparent' : 'gray'), 210 | }, 211 | 212 | '& .background': { 213 | backgroundColor: (props) => (props.selected ? 'green' : 'bisque'), 214 | height: '200px', 215 | }, 216 | }); 217 | 218 | const getId = (index: number) => `${'test'}${index}`; 219 | 220 | const getItems = () => 221 | Array(10) 222 | .fill(0) 223 | .map((_, ind) => ({ id: getId(ind) })); 224 | 225 | function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void { 226 | // NOTE: no good standart way to distinguish touchpad scrolling gestures 227 | // but can assume that gesture will affect X axis, mouse scroll only Y axis 228 | // of if deltaY too small probably is it touchpad 229 | const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15; 230 | 231 | if (isThouchpad) { 232 | ev.stopPropagation(); 233 | return; 234 | } 235 | 236 | if (ev.deltaY < 0) { 237 | apiObj.scrollNext(); 238 | } else { 239 | apiObj.scrollPrev(); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /stories/8_PreventBodyScroll/PreventBodyScroll.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import React from 'react'; 3 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 4 | 5 | import { ScrollMenu } from '../../src/index'; 6 | import { SizeWrapper } from '../SizeWrapper'; 7 | import { availableImports } from '../availableImports'; 8 | import { setupEditor } from '../setupEditor'; 9 | import { ScrollTest } from '../test'; 10 | 11 | import Example from './PreventBodyScroll.source'; 12 | // @ts-ignore 13 | import ExampleRaw from './PreventBodyScroll.source.tsx?raw'; 14 | 15 | import type { Meta } from '@storybook/react'; 16 | 17 | const meta: Meta = { 18 | title: 'Examples/PreventBodyScroll', 19 | component: Example, 20 | decorators: [ 21 | (Story) => ( 22 | 23 | 24 | 25 | ), 26 | ], 27 | }; 28 | 29 | export default meta; 30 | 31 | export const PreventBodyScroll = createLiveEditStory({ 32 | code: ExampleRaw, 33 | availableImports, 34 | modifyEditor: setupEditor, 35 | }); 36 | 37 | export const Test = ScrollTest(); 38 | -------------------------------------------------------------------------------- /stories/991_SwipeDesktop/SwipeDesktop.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { expect } from '@storybook/jest'; 3 | import { within } from '@storybook/testing-library'; 4 | import React from 'react'; 5 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 6 | 7 | import { ScrollMenu } from '../../src/index'; 8 | import { SizeWrapper } from '../SizeWrapper'; 9 | import { availableImports } from '../availableImports'; 10 | import { setupEditor } from '../setupEditor'; 11 | import { TestObj, drag } from '../test'; 12 | 13 | // @ts-ignore 14 | import Example from './SwipeDesktop.source'; 15 | import ExampleRaw from './SwipeDesktop.source.tsx?raw'; 16 | 17 | import type { Meta } from '@storybook/react'; 18 | 19 | const meta: Meta = { 20 | title: 'Examples/SwipeDesktop', 21 | component: Example, 22 | decorators: [ 23 | (Story) => ( 24 | 25 | 26 | 27 | ), 28 | ], 29 | }; 30 | 31 | export default meta; 32 | 33 | export const SwipeDesktop = createLiveEditStory({ 34 | code: ExampleRaw, 35 | availableImports, 36 | modifyEditor: setupEditor, 37 | }); 38 | 39 | export const Test = { 40 | play: async ({ canvasElement }) => { 41 | const canvas = within(canvasElement); 42 | const testObj = new TestObj(canvas, { leftArrow: '', rightArrow: '' }); 43 | await testObj.wait(); 44 | 45 | const lastCard = (await testObj.getVisibleCards()).slice(-1)[0]; 46 | 47 | await drag(lastCard, { delta: { x: -100, y: 0 }, duration: 150, steps: 5 }); 48 | await testObj.wait(); 49 | expect(await testObj.getVisibleCardsKeys()).toEqual([ 50 | 'test3', 51 | 'test4', 52 | 'test5', 53 | ]); 54 | 55 | await drag(lastCard, { delta: { x: 100, y: 0 }, duration: 150, steps: 5 }); 56 | await testObj.wait(); 57 | expect(await testObj.getVisibleCardsKeys()).toEqual([ 58 | 'test0', 59 | 'test1', 60 | 'test2', 61 | ]); 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /stories/99_performance/Performance.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Meta } from '@storybook/react'; 3 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 4 | 5 | import { setupEditor } from '../setupEditor'; 6 | import { availableImports } from '../availableImports'; 7 | import { ScrollMenu } from '../../src/index'; 8 | 9 | // @ts-expect-error import 10 | import ExampleRaw from './Performance.source.tsx?raw'; 11 | import Example from './Performance.source'; 12 | 13 | const meta: Meta = { 14 | title: 'Examples/Performance', 15 | component: Example, 16 | decorators: [(Story) => ], 17 | }; 18 | 19 | export default meta; 20 | 21 | export const Performance = createLiveEditStory({ 22 | code: ExampleRaw, 23 | availableImports, 24 | modifyEditor: setupEditor, 25 | }); 26 | -------------------------------------------------------------------------------- /stories/9_AddItems/AddItems.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { type Meta } from '@storybook/react'; 3 | import React from 'react'; 4 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 5 | 6 | import { ScrollMenu } from '../../src/index'; 7 | import { SizeWrapper } from '../SizeWrapper'; 8 | import { availableImports } from '../availableImports'; 9 | import { setupEditor } from '../setupEditor'; 10 | 11 | import Example from './AddItems.source'; 12 | // @ts-ignore 13 | import ExampleRaw from './AddItems.source.tsx?raw'; 14 | 15 | const meta: Meta = { 16 | title: 'Examples/AddItems', 17 | component: Example, 18 | decorators: [ 19 | (Story) => ( 20 | 21 | 22 | 23 | ), 24 | ], 25 | }; 26 | 27 | export default meta; 28 | 29 | export const AddItems = createLiveEditStory({ 30 | code: ExampleRaw, 31 | availableImports, 32 | modifyEditor: setupEditor, 33 | }); 34 | -------------------------------------------------------------------------------- /stories/BottomArrows/BottomArrows.source.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-jss'; 3 | 4 | import { 5 | ScrollMenu, 6 | VisibilityContext, 7 | type publicApiType, 8 | } from 'react-horizontal-scrolling-menu'; 9 | 10 | import 'react-horizontal-scrolling-menu/dist/styles.css'; 11 | 12 | export function BottomArrows() { 13 | const [items] = React.useState(() => getItems()); 14 | const [selected, setSelected] = React.useState([]); 15 | 16 | const isItemSelected = (id: string): boolean => 17 | !!selected.find((el) => el === id); 18 | 19 | const handleItemClick = (itemId: string) => { 20 | const itemSelected = isItemSelected(itemId); 21 | 22 | setSelected((currentSelected: string[]) => 23 | itemSelected 24 | ? currentSelected.filter((el) => el !== itemId) 25 | : currentSelected.concat(itemId), 26 | ); 27 | }; 28 | 29 | return ( 30 | 31 | 32 | {items.map(({ id }) => ( 33 | handleItemClick(id)} 38 | selected={isItemSelected(id)} 39 | /> 40 | ))} 41 | 42 | 43 | ); 44 | } 45 | export default BottomArrows; 46 | 47 | const NoScrollbar = styled('div')({ 48 | '& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': { 49 | display: 'none', 50 | }, 51 | '& .react-horizontal-scrolling-menu--scroll-container': { 52 | scrollbarWidth: 'none', 53 | '-ms-overflow-style': 'none', 54 | }, 55 | }); 56 | 57 | const Arrows = () => ( 58 |
65 | Some other content 66 |
67 | 68 |
69 |
70 | ); 71 | 72 | function LeftArrow() { 73 | const visibility = React.useContext(VisibilityContext); 74 | 75 | const disabled = visibility.useLeftArrowVisible(); 76 | 77 | return ( 78 | visibility.scrollPrev()} 81 | testId="left-arrow" 82 | > 83 | Left 84 | 85 | ); 86 | } 87 | 88 | function RightArrow() { 89 | const visibility = React.useContext(VisibilityContext); 90 | 91 | const disabled = visibility.useRightArrowVisible(); 92 | 93 | return ( 94 | visibility.scrollNext()} 97 | testId="right-arrow" 98 | > 99 | Right 100 | 101 | ); 102 | } 103 | 104 | function Arrow({ 105 | children, 106 | disabled, 107 | onClick, 108 | className, 109 | testId, 110 | }: { 111 | children: React.ReactNode; 112 | disabled: boolean; 113 | onClick: (context: publicApiType) => void; 114 | className?: string; 115 | testId: string; 116 | }) { 117 | return ( 118 | 124 | {children} 125 | 126 | ); 127 | } 128 | const ArrowButton = styled('button')({ 129 | cursor: 'pointer', 130 | display: 'flex', 131 | flexDirection: 'column', 132 | justifyContent: 'center', 133 | marginBottom: '2px', 134 | opacity: (props) => (props.disabled ? '0' : '1'), 135 | userSelect: 'none', 136 | borderRadius: '6px', 137 | borderWidth: '1px', 138 | }); 139 | 140 | function Card({ 141 | onClick, 142 | selected, 143 | title, 144 | itemId, 145 | }: { 146 | onClick: (context: publicApiType) => void; 147 | selected: boolean; 148 | title: string; 149 | itemId: string; 150 | }) { 151 | const visibility = React.useContext(VisibilityContext); 152 | const isVisible = visibility.useIsVisible(itemId, true); 153 | 154 | return ( 155 | onClick(visibility)} 158 | onKeyDown={(ev: React.KeyboardEvent) => { 159 | ev.code === 'Enter' && onClick(visibility); 160 | }} 161 | data-testid="card" 162 | role="button" 163 | tabIndex={0} 164 | className="card" 165 | visible={isVisible} 166 | selected={selected} 167 | > 168 |
169 |
{title}
170 |
visible: {JSON.stringify(isVisible)}
171 |
selected: {JSON.stringify(!!selected)}
172 |
173 |
174 | 175 | ); 176 | } 177 | const CardBody = styled('div')({ 178 | border: '1px solid', 179 | display: 'inline-block', 180 | margin: '0 10px', 181 | width: '160px', 182 | userSelect: 'none', 183 | borderRadius: '8px', 184 | overflow: 'hidden', 185 | 186 | '& .header': { 187 | backgroundColor: 'white', 188 | }, 189 | 190 | '& .visible': { 191 | backgroundColor: (props) => (props.visible ? 'transparent' : 'gray'), 192 | }, 193 | 194 | '& .background': { 195 | backgroundColor: (props) => (props.selected ? 'green' : 'bisque'), 196 | height: '200px', 197 | }, 198 | }); 199 | 200 | const getId = (index: number) => `${'test'}${index}`; 201 | 202 | const getItems = () => 203 | Array(10) 204 | .fill(0) 205 | .map((_, ind) => ({ id: getId(ind) })); 206 | 207 | function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void { 208 | // NOTE: no good standart way to distinguish touchpad scrolling gestures 209 | // but can assume that gesture will affect X axis, mouse scroll only Y axis 210 | // of if deltaY too small probably is it touchpad 211 | const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15; 212 | 213 | if (isThouchpad) { 214 | ev.stopPropagation(); 215 | return; 216 | } 217 | 218 | if (ev.deltaY < 0) { 219 | apiObj.scrollNext(); 220 | } else { 221 | apiObj.scrollPrev(); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /stories/BottomArrows/BottomArrows.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import React from 'react'; 3 | import type { Meta } from '@storybook/react'; 4 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 5 | 6 | import { setupEditor } from '../setupEditor'; 7 | import { availableImports } from '../availableImports'; 8 | import { ScrollMenu } from '../../src/index'; 9 | import { ScrollTest } from '../test'; 10 | import { SizeWrapper } from '../SizeWrapper'; 11 | 12 | // @ts-ignore 13 | import ExampleRaw from './BottomArrows.source.tsx?raw'; 14 | import Example from './BottomArrows.source'; 15 | 16 | const meta: Meta = { 17 | title: 'Examples/BottomArrows', 18 | component: Example, 19 | decorators: [ 20 | (Story) => ( 21 | 22 | 23 | 24 | ), 25 | ], 26 | }; 27 | 28 | export default meta; 29 | 30 | export const BottomArrows = createLiveEditStory({ 31 | code: ExampleRaw, 32 | availableImports, 33 | modifyEditor: setupEditor, 34 | }); 35 | 36 | export const Test = ScrollTest(); 37 | -------------------------------------------------------------------------------- /stories/Docs.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/blocks'; 2 | import Markdown from 'react-markdown' 3 | import remarkGfm from 'remark-gfm' 4 | import README from '../README.md' 5 | 6 | {/* {(() => { 7 | console.log('debug' ) 8 | })()} */} 9 | 10 | 11 | 12 | 13 |
14 |
15 | # Docs 16 |
17 |
18 | 19 | ## Quick start 20 | 21 | Add library to your project 22 | 23 | ``` 24 | npm install --save react-horizontal-scrolling-menu 25 | ``` 26 | 27 | Start by simple example and use this documentation as a reference 28 | 29 | You can ask for help or propose improvements at library's github 30 | 31 | 32 | 33 | {README 34 | .match(/\<\!\-\-\s?DOCS_START\s?\-\-\>(.*)\<\!\-\-\s?DOCS_END\s?\-\-\>/s)[0] 35 | .replace('', '') 36 | .replace('', '')} 37 | 38 | 39 | 54 | -------------------------------------------------------------------------------- /stories/MobileSwipeOnly/MobileSwipeOnly.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import React from 'react'; 3 | import type { Meta } from '@storybook/react'; 4 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 5 | 6 | import { setupEditor } from '../setupEditor'; 7 | import { availableImports } from '../availableImports'; 8 | import { ScrollMenu } from '../../src/index'; 9 | import { ScrollTest } from '../test'; 10 | import { SizeWrapper } from '../SizeWrapper'; 11 | 12 | // @ts-ignore 13 | import ExampleRaw from './MobileSwipeOnly.source.tsx?raw'; 14 | import Example from './MobileSwipeOnly.source'; 15 | 16 | const meta: Meta = { 17 | title: 'Examples/MobileSwipeOnly', 18 | component: Example, 19 | decorators: [ 20 | (Story) => ( 21 | 22 | 23 | 24 | ), 25 | ], 26 | }; 27 | 28 | export default meta; 29 | 30 | export const MobileSwipeOnly = createLiveEditStory({ 31 | code: ExampleRaw, 32 | availableImports, 33 | modifyEditor: setupEditor, 34 | }); 35 | 36 | export const Test = ScrollTest(); 37 | -------------------------------------------------------------------------------- /stories/RTL/RTL.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { expect } from '@storybook/jest'; 3 | import { within } from '@storybook/testing-library'; 4 | import React from 'react'; 5 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 6 | 7 | import { ScrollMenu } from '../../src/index'; 8 | import { SizeWrapper } from '../SizeWrapper'; 9 | import { availableImports } from '../availableImports'; 10 | import { setupEditor } from '../setupEditor'; 11 | import { 12 | scrollSmokeTest, 13 | TestObj, 14 | leftArrowSelector, 15 | rightArrowSelector, 16 | } from '../test'; 17 | 18 | // @ts-ignore 19 | import Example from './RTL.source'; 20 | import ExampleRaw from './RTL.source.tsx?raw'; 21 | 22 | import type { Canvas } from '../test'; 23 | import type { Meta } from '@storybook/react'; 24 | 25 | const meta: Meta = { 26 | title: 'Examples/RTL', 27 | component: Example, 28 | decorators: [ 29 | (Story) => ( 30 | 31 | 32 | 33 | ), 34 | ], 35 | }; 36 | 37 | export default meta; 38 | 39 | export const RTL = createLiveEditStory({ 40 | code: ExampleRaw, 41 | availableImports, 42 | modifyEditor: setupEditor, 43 | }); 44 | 45 | export const TestRTL = { 46 | play: async ({ canvasElement }) => { 47 | const canvas = within(canvasElement) as Canvas; 48 | const testObj = new TestObj(canvas, { 49 | leftArrow: rightArrowSelector, 50 | rightArrow: leftArrowSelector, 51 | }); 52 | expect(await canvas.getByLabelText('RTL')).toBeChecked(); 53 | await testObj.isReady(); 54 | await testObj.wait(); 55 | 56 | await scrollSmokeTest(testObj); 57 | }, 58 | }; 59 | 60 | // Another test to make sure it works with noPolyfill=true 61 | export const TestNonRTL = { 62 | play: async ({ canvasElement }) => { 63 | const canvas = within(canvasElement) as Canvas; 64 | const testObj = new TestObj(canvas, { 65 | leftArrow: leftArrowSelector, 66 | rightArrow: rightArrowSelector, 67 | }); 68 | 69 | await canvas.getByLabelText('RTL').click(); 70 | expect(await canvas.getByLabelText('RTL')).not.toBeChecked(); 71 | await testObj.isReady(); 72 | await testObj.wait(); 73 | 74 | await scrollSmokeTest(testObj); 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /stories/SizeWrapper.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-jss'; 2 | 3 | export const SizeWrapper = styled('div')({ 4 | maxWidth: '650px', 5 | maxHeight: '400px', 6 | }); 7 | -------------------------------------------------------------------------------- /stories/availableImports.ts: -------------------------------------------------------------------------------- 1 | import * as formkit from '@formkit/auto-animate/react'; 2 | import React from 'react'; 3 | import styled from 'styled-jss'; 4 | import * as useHooks from 'usehooks-ts'; 5 | 6 | // @ts-expect-error err 7 | import * as styles from '../dist/styles.css'; 8 | import * as Lib from '../src/index'; 9 | 10 | export const availableImports = { 11 | react: React, 12 | 'react-horizontal-scrolling-menu': Lib, 13 | 'styled-jss': styled, 14 | 'react-horizontal-scrolling-menu/dist/styles.css': styles, 15 | '@formkit/auto-animate/react': formkit, 16 | 'usehooks-ts': useHooks, 17 | }; 18 | -------------------------------------------------------------------------------- /stories/setupEditor.ts: -------------------------------------------------------------------------------- 1 | import { createLiveEditStory } from 'storybook-addon-code-editor'; 2 | // @ts-expect-error raw import 3 | import * as Types from './index.d.ts?raw'; 4 | 5 | type args = Parameters< 6 | // @ts-expect-error some error 7 | Parameters[0]['modifyEditor'] 8 | >; 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 11 | export function setupEditor(monaco: args[0], _editor: args[1]) { 12 | monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ 13 | noSemanticValidation: false, 14 | noSyntaxValidation: false, 15 | }); 16 | monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ 17 | target: monaco.languages.typescript.ScriptTarget.ES2016, 18 | allowNonTsExtensions: true, 19 | moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, // NodeJs 20 | module: monaco.languages.typescript.ModuleKind.AMD, 21 | allowSyntheticDefaultImports: true, 22 | jsx: 2, 23 | esModuleInterop: true, 24 | }); 25 | 26 | monaco.languages.typescript.typescriptDefaults.addExtraLib( 27 | `declare module "react-horizontal-scrolling-menu" { ${Types.default} }`, 28 | ); 29 | 30 | monaco.editor.setTheme('vs-dark'); 31 | } 32 | -------------------------------------------------------------------------------- /styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /tea.yaml: -------------------------------------------------------------------------------- 1 | # https://tea.xyz/what-is-this-file 2 | --- 3 | version: 1.0.0 4 | codeOwners: 5 | - '0x0fDc8a4c4FFaEC09422B1d6B2722D060B3D20EA0' 6 | quorum: 1 7 | -------------------------------------------------------------------------------- /test-runner-jest.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const { getJestConfig } = require('@storybook/test-runner'); 3 | 4 | // The default Jest configuration comes from @storybook/test-runner 5 | const testRunnerConfig = getJestConfig(); 6 | 7 | /** 8 | * @type {import('@jest/types').Config.InitialOptions} 9 | */ 10 | module.exports = { 11 | ...testRunnerConfig, 12 | roots: ['stories'], 13 | testTimeout: 120 * 1000, 14 | /** Add your own overrides below, and make sure 15 | * to merge testRunnerConfig properties with your own 16 | * @see https://jestjs.io/docs/configuration 17 | */ 18 | }; 19 | -------------------------------------------------------------------------------- /tsconfig-monaco.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2016", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNext'. */ 4 | "module": "amd", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 5 | "lib": [ 6 | "ES2016", 7 | "dom.iterable", 8 | "ESNext", 9 | "DOM" 10 | ], 11 | "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 12 | 13 | "allowJs": true, /* Allow javascript files to be compiled. */ 14 | "checkJs": true, /* Report errors in .js files. */ 15 | "jsx": "react", 16 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 17 | "emitDeclarationOnly": true, 18 | "declarationMap": false, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 19 | "sourceMap": true, /* Generates corresponding '.map' file. */ 20 | "outDir": "./", /* Redirect output structure to the directory. */ 21 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 22 | "downlevelIteration": false, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | "isolatedModules": false, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | /* Strict Type-Checking Options */ 25 | "strict": true, /* Enable all strict type-checking options. */ 26 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 27 | "types": ["node", "jest", "@testing-library/dom", "@testing-library/jest-dom", "jest-environment-jsdom", "@jest/globals"], /* Type declaration files to be included in compilation. */ 28 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 29 | // "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 30 | "skipLibCheck": true 31 | }, 32 | "include": [ 33 | "src" 34 | ], 35 | "exclude": [ 36 | "node_modules", "**/*.test.ts", "**/*.test.tsx", "**/stories/**" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | /* Basic Options */ 5 | // "incremental": true, 6 | /* Enable incremental compilation */ 7 | "target": "ES2016" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNext'. */, 8 | "module": "ES2022" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | "lib": ["ES2016", "ES2022", "dom.iterable", "ESNext", "DOM"], 10 | "allowJs": true /* Allow javascript files to be compiled. */, 11 | "checkJs": true /* Report errors in .js files. */, 12 | "jsx": "react", 13 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 14 | "declaration": true /* Generates corresponding '.d.ts' file. */, 15 | "declarationDir": "./types", 16 | "declarationMap": false /* Generates a sourcemap for each corresponding '.d.ts' file. */, 17 | "sourceMap": true /* Generates corresponding '.map' file. */, 18 | // "outFile": "./", /* Concatenate and emit output to single file. */ 19 | "outDir": "./" /* Redirect output structure to the directory. */, 20 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 21 | // "composite": true, /* Enable project compilation */ 22 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 23 | "removeComments": false /* Do not emit comments to output. */, 24 | // "noEmit": true, /* Do not emit outputs. */ 25 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 26 | "downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */, 27 | "isolatedModules": true /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */, 28 | /* Strict Type-Checking Options */ 29 | "strict": true /* Enable all strict type-checking options. */, 30 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 31 | "strictNullChecks": true /* Enable strict null checks. */, 32 | "strictFunctionTypes": true /* Enable strict checking of function types. */, 33 | "strictBindCallApply": true /* Enable strict 'bind', 'call', and 'apply' methods on functions. */, 34 | "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, 35 | "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 36 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 37 | /* Additional Checks */ 38 | "noUnusedLocals": true /* Report errors on unused locals. */, 39 | "noUnusedParameters": true /* Report errors on unused parameters. */, 40 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 44 | /* Module Resolution Options */ 45 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 49 | // "typeRoots": [], /* List of folders to include type definitions from. */ 50 | "types": [ 51 | "node", 52 | "jest", 53 | "@testing-library/dom", 54 | "@testing-library/jest-dom", 55 | "jest-environment-jsdom", 56 | "@jest/globals" 57 | ] /* Type declaration files to be included in compilation. */, 58 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */, 59 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 60 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 61 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 62 | /* Source Map Options */ 63 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 64 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 65 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 66 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 67 | /* Experimental Options */ 68 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 69 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 70 | /* Advanced Options */ 71 | "skipLibCheck": true /* Skip type checking of declaration files. */, 72 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 73 | }, 74 | "include": ["src"], 75 | "exclude": ["node_modules"] 76 | } 77 | -------------------------------------------------------------------------------- /tsconfig.stories.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2016" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNext'. */, 4 | "module": "ES2022" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 5 | "lib": ["ES2016", "ES2022", "dom.iterable", "ESNext", "DOM"], 6 | "allowJs": true /* Allow javascript files to be compiled. */, 7 | "checkJs": true /* Report errors in .js files. */, 8 | "jsx": "react", 9 | "declaration": true /* Generates corresponding '.d.ts' file. */, 10 | "declarationDir": "./types", 11 | "declarationMap": false /* Generates a sourcemap for each corresponding '.d.ts' file. */, 12 | "sourceMap": true /* Generates corresponding '.map' file. */, 13 | "outDir": "./" /* Redirect output structure to the directory. */, 14 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 15 | "removeComments": false /* Do not emit comments to output. */, 16 | "downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */, 17 | "isolatedModules": true /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */, 18 | "strict": true /* Enable all strict type-checking options. */, 19 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 20 | "strictNullChecks": true /* Enable strict null checks. */, 21 | "strictFunctionTypes": true /* Enable strict checking of function types. */, 22 | "strictBindCallApply": true /* Enable strict 'bind', 'call', and 'apply' methods on functions. */, 23 | "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, 24 | "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 25 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 26 | "noUnusedLocals": true /* Report errors on unused locals. */, 27 | "noUnusedParameters": true /* Report errors on unused parameters. */, 28 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 29 | "moduleResolution": "node", 30 | "types": [ 31 | "node", 32 | "jest", 33 | "@testing-library/dom", 34 | "@testing-library/jest-dom", 35 | "jest-environment-jsdom", 36 | "@jest/globals" 37 | ], 38 | "allowSyntheticDefaultImports": true, 39 | "esModuleInterop": true, 40 | "skipLibCheck": true, 41 | "forceConsistentCasingInFileNames": true 42 | }, 43 | "include": ["stories"], 44 | "exclude": [".eslintrc.*"] 45 | } 46 | --------------------------------------------------------------------------------