├── .commitlintrc.js ├── .dumi └── global.css ├── .dumirc.ts ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .fatherrc.ts ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── release.yml │ ├── static.yml │ └── test.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .npmrc ├── .prettierignore ├── .prettierrc.js ├── .releaserc.js ├── .stylelintrc.js ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── changelog.md ├── changelog.zh-CN.md ├── examples │ ├── basic.tsx │ ├── cancel.tsx │ ├── circle-item.tsx │ ├── click-to-select.tsx │ ├── item-disabled.tsx │ ├── reset-at-end.tsx │ ├── reset-at-start.tsx │ ├── scroll-container.tsx │ ├── shift-remove.tsx │ ├── sort.tsx │ └── virtual-list.tsx ├── guides │ ├── api.md │ ├── api.zh-CN.md │ ├── basic.md │ ├── basic.zh-CN.md │ ├── cancel.md │ ├── cancel.zh-CN.md │ ├── circle-item.md │ ├── circle-item.zh-CN.md │ ├── click-to-select.md │ ├── click-to-select.zh-CN.md │ ├── item-disabled.md │ ├── item-disabled.zh-CN.md │ ├── reset-at-end.md │ ├── reset-at-end.zh-CN.md │ ├── reset-at-start.md │ ├── reset-at-start.zh-CN.md │ ├── scroll-container.md │ ├── scroll-container.zh-CN.md │ ├── shift-remove.md │ ├── shift-remove.zh-CN.md │ ├── sort.md │ ├── sort.zh-CN.md │ ├── virtual-list.md │ └── virtual-list.zh-CN.md ├── index.md └── index.zh-CN.md ├── package.json ├── pnpm-lock.yaml ├── src ├── Selectable.tsx ├── context.ts ├── hooks │ ├── useContainer.ts │ ├── useEvent.ts │ ├── useLatest.ts │ ├── useLayoutUpdateEffect.ts │ ├── useMergedState.ts │ ├── useScroll.ts │ ├── useSelectable.ts │ └── useUpdateEffect.ts ├── index.ts └── utils.ts ├── tests ├── index.test.tsx └── test-setup.ts ├── tsconfig-check.json ├── tsconfig.json └── vitest.config.ts /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /.dumi/global.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | -------------------------------------------------------------------------------- /.dumirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'dumi'; 2 | import { homepage } from './package.json'; 3 | 4 | const isProd = process.env.NODE_ENV === 'production'; 5 | 6 | const name = 'react-selectable-box'; 7 | 8 | export default defineConfig({ 9 | themeConfig: { 10 | name, 11 | github: homepage, 12 | }, 13 | base: isProd ? `/${name}/` : '/', 14 | publicPath: isProd ? `/${name}/` : '/', 15 | html2sketch: {}, 16 | mfsu: false, 17 | outputPath: '.doc', 18 | locales: [ 19 | { id: 'en-US', name: 'EN' }, 20 | { id: 'zh-CN', name: '中文' }, 21 | ], 22 | }); 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /lambda/mock/** 2 | /scripts 3 | /config 4 | /example 5 | _test_ 6 | __test__ 7 | 8 | /node_modules 9 | jest* 10 | /es 11 | /lib 12 | /docs 13 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@umijs/lint/dist/config/eslint'); 2 | -------------------------------------------------------------------------------- /.fatherrc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'father'; 2 | 3 | export default defineConfig({ 4 | cjs: { output: 'lib' }, 5 | esm: { output: 'es' }, 6 | umd: { output: 'dist', name: 'reactSelectableBox' }, 7 | }); 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Report a Bug 🐛' 3 | about: 'Report a Bug 🐛' 4 | title: '🐛[BUG]' 5 | labels: '🐛 BUG' 6 | assignees: '' 7 | --- 8 | 9 | ### 🐛 Description 10 | 11 | 14 | 15 | ### 💻 Reproduction link 16 | 17 | 18 | 19 | ### 📷 Steps to reproduce 20 | 21 | 26 | 27 | ### 🏞 What is expected? 28 | 29 | 32 | 33 | ### What is actually happening? 34 | 35 | 38 | 39 | ### © Version information 40 | 41 | - react-selectable-fast 42 | - browser 43 | - system 44 | 45 | ### 🚑 Other information 46 | 47 | 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Feature request ✨' 3 | about: 'Feature request ✨' 4 | title: '👑 [Feature]' 5 | labels: '👑 Feature' 6 | assignees: '' 7 | --- 8 | 9 | ### 🥰 Description 10 | 11 | 14 | 15 | ### 🧐 Solution 16 | 17 | 20 | 21 | ### 🚑 Other information 22 | 23 | 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Questions or need help ❓' 3 | about: 'Questions or need help ❓' 4 | title: '🧐[Question]' 5 | labels: '🧐 Question' 6 | assignees: '' 7 | --- 8 | 9 | ### 🧐 Description 10 | 11 | 14 | 15 | ### 💻 Reproduction link 16 | 17 | 18 | 19 | ### 🚑 Other information 20 | 21 | 24 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### 💻 变更类型 | Change Type 2 | 3 | 4 | 5 | - \[ ] ✨ feat 6 | - \[ ] 🐛 fix 7 | - \[ ] 💄 style 8 | - \[ ] 🔨 chore 9 | - \[ ] 📝 docs 10 | 11 | #### 🔀 变更说明 | Description of Change 12 | 13 | 14 | 15 | #### 📝 补充信息 | Additional Information 16 | 17 | 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - alpha 7 | - beta 8 | - rc 9 | 10 | jobs: 11 | test: 12 | name: Test 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Install pnpm 18 | uses: pnpm/action-setup@v2 19 | with: 20 | version: 8 21 | 22 | - name: Setup Node.js environment 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: '18' 26 | 27 | - name: Install deps 28 | run: pnpm install 29 | 30 | - name: Test 31 | run: pnpm run test 32 | 33 | release: 34 | needs: test 35 | name: Release 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v3 39 | 40 | - name: Install pnpm 41 | uses: pnpm/action-setup@v2 42 | with: 43 | version: 8 44 | 45 | - name: Setup Node.js environment 46 | uses: actions/setup-node@v3 47 | with: 48 | node-version: '18' 49 | 50 | - name: Install deps 51 | run: pnpm install 52 | 53 | - name: release 54 | run: pnpm run release 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 58 | -------------------------------------------------------------------------------- /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ['master'] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: 'pages' 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v3 33 | 34 | - name: Setup Node.js environment 35 | uses: actions/setup-node@v3 36 | with: 37 | node-version: '18' 38 | 39 | - name: Install pnpm 40 | uses: pnpm/action-setup@v2 41 | with: 42 | version: 8 43 | 44 | - name: Install deps 45 | run: pnpm install 46 | 47 | - name: Build Pages 48 | run: pnpm run docs:build 49 | 50 | - name: Setup Pages 51 | uses: actions/configure-pages@v3 52 | 53 | - name: Upload artifact 54 | uses: actions/upload-pages-artifact@v1 55 | with: 56 | # Upload entire repository 57 | path: '.doc' 58 | 59 | - name: Deploy to GitHub Pages 60 | id: deployment 61 | uses: actions/deploy-pages@v1 62 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test CI 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - uses: actions/checkout@v3 9 | 10 | - name: Setup Node.js environment 11 | uses: actions/setup-node@v3 12 | with: 13 | node-version: '18' 14 | 15 | - name: Install pnpm 16 | uses: pnpm/action-setup@v2 17 | with: 18 | version: 8 19 | 20 | - name: Get pnpm store directory 21 | id: pnpm-cache 22 | shell: bash 23 | run: | 24 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 25 | 26 | - name: Setup pnpm cache 27 | uses: actions/cache@v3 28 | with: 29 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 30 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 31 | restore-keys: | 32 | ${{ runner.os }}-pnpm-store- 33 | 34 | - name: Install deps 35 | run: pnpm install 36 | 37 | - name: lint 38 | run: pnpm run ci 39 | 40 | - name: Test and coverage 41 | run: pnpm run test:coverage 42 | 43 | - name: Upload coverage to Codecov 44 | uses: codecov/codecov-action@v3 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | **/node_modules 5 | # roadhog-api-doc ignore 6 | /src/utils/request-temp.js 7 | _roadhog-api-doc 8 | 9 | # production 10 | **/dist 11 | /.vscode 12 | /es 13 | /lib 14 | 15 | # misc 16 | .DS_Store 17 | storybook-static 18 | npm-debug.log* 19 | yarn-error.log 20 | 21 | /coverage 22 | .idea 23 | package-lock.json 24 | *bak 25 | .vscode 26 | 27 | # visual studio code 28 | .history 29 | *.log 30 | functions/* 31 | lambda/mock/index.js 32 | .temp/** 33 | 34 | # umi 35 | .dumi/tmp* 36 | .doc 37 | 38 | # screenshot 39 | screenshot 40 | .firebase 41 | example/.temp/* 42 | .eslintcache 43 | techUI* 44 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | resolution-mode=highest 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.svg 2 | .umi 3 | .umi-production 4 | /dist 5 | .dockerignore 6 | .DS_Store 7 | .eslintignore 8 | *.png 9 | *.toml 10 | docker 11 | .editorconfig 12 | Dockerfile* 13 | .gitignore 14 | .prettierignore 15 | LICENSE 16 | .eslintcache 17 | *.lock 18 | yarn-error.log 19 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | pluginSearchDirs: false, 3 | plugins: [ 4 | require.resolve('prettier-plugin-organize-imports'), 5 | require.resolve('prettier-plugin-packagejson'), 6 | ], 7 | printWidth: 100, 8 | proseWrap: 'never', 9 | singleQuote: true, 10 | trailingComma: 'all', 11 | overrides: [ 12 | { 13 | files: '*.md', 14 | options: { 15 | proseWrap: 'preserve', 16 | }, 17 | }, 18 | ], 19 | }; 20 | -------------------------------------------------------------------------------- /.releaserc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | release: { 3 | branches: ['master', 'beta'], 4 | }, 5 | plugins: [ 6 | '@semantic-release/commit-analyzer', 7 | '@semantic-release/release-notes-generator', 8 | [ 9 | '@semantic-release/changelog', 10 | { 11 | changelogFile: 'CHANGELOG.md', 12 | }, 13 | ], 14 | '@semantic-release/npm', 15 | [ 16 | '@semantic-release/github', 17 | { 18 | assets: [], 19 | }, 20 | ], 21 | [ 22 | '@semantic-release/git', 23 | { 24 | assets: ['CHANGELOG.md', 'package.json'], 25 | message: 'chore(release): ${nextRelease.gitTag} [skip ci]', 26 | }, 27 | ], 28 | ], 29 | }; 30 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@umijs/lint/dist/config/stylelint'); 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.10.0](https://github.com/linxianxi/react-selectable-box/compare/v1.9.8...v1.10.0) (2024-11-28) 2 | 3 | ### Features 4 | 5 | - support scroll speed ([#50](https://github.com/linxianxi/react-selectable-box/issues/50)) ([42b0122](https://github.com/linxianxi/react-selectable-box/commit/42b012286e5328947177064cf19c988d6fea6e4a)) 6 | 7 | ## [1.9.8](https://github.com/linxianxi/react-selectable-box/compare/v1.9.7...v1.9.8) (2024-10-18) 8 | 9 | ### Bug Fixes 10 | 11 | - fixed TouchEvent error on Firefox ([#46](https://github.com/linxianxi/react-selectable-box/issues/46)) ([8a5e7bc](https://github.com/linxianxi/react-selectable-box/commit/8a5e7bc5c3b5d93dab81fc2fe942ddb5c81cb3bc)) 12 | 13 | ## [1.9.7](https://github.com/linxianxi/react-selectable-box/compare/v1.9.6...v1.9.7) (2024-10-17) 14 | 15 | ### Bug Fixes 16 | 17 | - click event should be worked on mobile ([#45](https://github.com/linxianxi/react-selectable-box/issues/45)) ([58cdd7e](https://github.com/linxianxi/react-selectable-box/commit/58cdd7e4ac11756c36b0d022221a46f79cf9366d)) 18 | 19 | ## [1.9.6](https://github.com/linxianxi/react-selectable-box/compare/v1.9.5...v1.9.6) (2024-10-17) 20 | 21 | ### Bug Fixes 22 | 23 | - **selectable:** prevent right-click from triggering selection ([#43](https://github.com/linxianxi/react-selectable-box/issues/43)) ([d74ef62](https://github.com/linxianxi/react-selectable-box/commit/d74ef62e5281476ce55d12e38f7ba1322d61655f)) 24 | 25 | ## [1.9.5](https://github.com/linxianxi/react-selectable-box/compare/v1.9.4...v1.9.5) (2024-10-08) 26 | 27 | ### Bug Fixes 28 | 29 | - fixed the mouse scrolling virtual list issue ([#42](https://github.com/linxianxi/react-selectable-box/issues/42)) ([386c074](https://github.com/linxianxi/react-selectable-box/commit/386c0745e2fc0fdadb134b8bc0a7c37b3a4ee1c0)) 30 | 31 | ## [1.9.4](https://github.com/linxianxi/react-selectable-box/compare/v1.9.3...v1.9.4) (2024-06-14) 32 | 33 | ### Bug Fixes 34 | 35 | - onEnd should be correct when items exist ([#38](https://github.com/linxianxi/react-selectable-box/issues/38)) ([e3a1bdc](https://github.com/linxianxi/react-selectable-box/commit/e3a1bdc284895132acb2907da4fc6350e161a9a4)) 36 | 37 | ## [1.9.3](https://github.com/linxianxi/react-selectable-box/compare/v1.9.2...v1.9.3) (2024-06-11) 38 | 39 | ### Bug Fixes 40 | 41 | - scrollContainerOriginPosition should be corrected when effect return ([#37](https://github.com/linxianxi/react-selectable-box/issues/37)) ([49ba781](https://github.com/linxianxi/react-selectable-box/commit/49ba781847184470afbc47d159450219b431c83b)) 42 | 43 | ## [1.9.2](https://github.com/linxianxi/react-selectable-box/compare/v1.9.1...v1.9.2) (2024-06-11) 44 | 45 | ### Bug Fixes 46 | 47 | - use getComputedStyle to get scrollContainer position ([#36](https://github.com/linxianxi/react-selectable-box/issues/36)) ([819a937](https://github.com/linxianxi/react-selectable-box/commit/819a93773712f3ce6525b30b8151ed947e0aa1e8)) 48 | 49 | ## [1.9.1](https://github.com/linxianxi/react-selectable-box/compare/v1.9.0...v1.9.1) (2024-06-07) 50 | 51 | ### Bug Fixes 52 | 53 | - do not select text when selecting ([#35](https://github.com/linxianxi/react-selectable-box/issues/35)) ([427636f](https://github.com/linxianxi/react-selectable-box/commit/427636f26cc62ee79da38e6efb7c37e0677a90d2)) 54 | 55 | # [1.9.0](https://github.com/linxianxi/react-selectable-box/compare/v1.8.2...v1.9.0) (2024-03-12) 56 | 57 | ### Features 58 | 59 | - add event param to onStart ([#34](https://github.com/linxianxi/react-selectable-box/issues/34)) ([1d144d1](https://github.com/linxianxi/react-selectable-box/commit/1d144d10a8c72522c6bcb1f489c6885cd5ae356a)) 60 | 61 | ## [1.8.2](https://github.com/linxianxi/react-selectable-box/compare/v1.8.1...v1.8.2) (2024-02-23) 62 | 63 | ### Bug Fixes 64 | 65 | - isSelecting should be correct when starting box selection ([#30](https://github.com/linxianxi/react-selectable-box/issues/30)) ([9a6b90e](https://github.com/linxianxi/react-selectable-box/commit/9a6b90efcd0072e24a17fcb2a8b21db385fde782)) 66 | 67 | ## [1.8.1](https://github.com/linxianxi/react-selectable-box/compare/v1.8.0...v1.8.1) (2024-02-19) 68 | 69 | ### Bug Fixes 70 | 71 | - compareFn should be used when items exists ([#28](https://github.com/linxianxi/react-selectable-box/issues/28)) ([0902fbc](https://github.com/linxianxi/react-selectable-box/commit/0902fbc48a04c364716478f46e9d098ab77d3147)) 72 | 73 | # [1.8.0](https://github.com/linxianxi/react-selectable-box/compare/v1.7.1...v1.8.0) (2024-02-01) 74 | 75 | ### Features 76 | 77 | - automatically add position to scrollContainer during frame selection ([#27](https://github.com/linxianxi/react-selectable-box/issues/27)) ([b769781](https://github.com/linxianxi/react-selectable-box/commit/b769781a3d8e56e702451d85b78ee8a4bd253f4c)) 78 | 79 | ## [1.7.1](https://github.com/linxianxi/react-selectable-box/compare/v1.7.0...v1.7.1) (2024-01-31) 80 | 81 | ### Bug Fixes 82 | 83 | - add compareFn to Selectable、remove useSelectable compareFn ([#26](https://github.com/linxianxi/react-selectable-box/issues/26)) ([711acce](https://github.com/linxianxi/react-selectable-box/commit/711acce693a5ea593231dfaf996a30278e17fcc2)) 84 | 85 | # [1.7.0](https://github.com/linxianxi/react-selectable-box/compare/v1.6.0...v1.7.0) (2024-01-31) 86 | 87 | ### Features 88 | 89 | - value support any、add compareFn ([#25](https://github.com/linxianxi/react-selectable-box/issues/25)) ([548b38f](https://github.com/linxianxi/react-selectable-box/commit/548b38fd83ceafd4866d2c47229769b5025880fe)) 90 | 91 | # [1.6.0](https://github.com/linxianxi/react-selectable-box/compare/v1.5.0...v1.6.0) (2024-01-26) 92 | 93 | ### Features 94 | 95 | - support custom rule ([#24](https://github.com/linxianxi/react-selectable-box/issues/24)) ([dc4cd07](https://github.com/linxianxi/react-selectable-box/commit/dc4cd07ca7631577b516a6f1c195592ff3a508ef)) 96 | 97 | # [1.5.0](https://github.com/linxianxi/react-selectable-box/compare/v1.4.0...v1.5.0) (2024-01-26) 98 | 99 | ### Features 100 | 101 | - add items to optimize virtual list ([#23](https://github.com/linxianxi/react-selectable-box/issues/23)) ([bbb929d](https://github.com/linxianxi/react-selectable-box/commit/bbb929d8bd28166bdbaa981ac1ad36be52815140)) 102 | 103 | # [1.4.0](https://github.com/linxianxi/react-selectable-box/compare/v1.3.0...v1.4.0) (2024-01-24) 104 | 105 | ### Features 106 | 107 | - add cancel method ([#22](https://github.com/linxianxi/react-selectable-box/issues/22)) ([212911c](https://github.com/linxianxi/react-selectable-box/commit/212911c293db48a1da89f37ba660ea8dd063bfab)) 108 | 109 | # [1.3.0](https://github.com/linxianxi/react-selectable-box/compare/v1.2.0...v1.3.0) (2024-01-18) 110 | 111 | ### Features 112 | 113 | - support dragContainer、scrollContainer, deprecate getContainer ([#19](https://github.com/linxianxi/react-selectable-box/issues/19)) ([77f4acb](https://github.com/linxianxi/react-selectable-box/commit/77f4acba359dc6b51e72a18027bbc8d5d76d6634)) 114 | 115 | # [1.2.0](https://github.com/linxianxi/react-selectable-box/compare/v1.1.1...v1.2.0) (2023-11-23) 116 | 117 | ### Features 118 | 119 | - support automatic scrolling and touch events ([#17](https://github.com/linxianxi/react-selectable-box/issues/17)) ([5e8fe62](https://github.com/linxianxi/react-selectable-box/commit/5e8fe62799555d3b2aeea5c7e1cb864f0b7aac4f)) 120 | 121 | ## [1.1.1](https://github.com/linxianxi/react-selectable-box/compare/v1.1.0...v1.1.1) (2023-09-12) 122 | 123 | ### Bug Fixes 124 | 125 | - add generics to SelectableProps ([#15](https://github.com/linxianxi/react-selectable-box/issues/15)) ([5c04f59](https://github.com/linxianxi/react-selectable-box/commit/5c04f59c983fd150baf09d39c65624fbc9121f52)) 126 | 127 | # [1.1.0](https://github.com/linxianxi/react-selectable-box/compare/v1.0.5...v1.1.0) (2023-09-04) 128 | 129 | ### Features 130 | 131 | - support selectStartRange, remove selectFromInside ([#14](https://github.com/linxianxi/react-selectable-box/issues/14)) ([b7f845f](https://github.com/linxianxi/react-selectable-box/commit/b7f845f0a1f780dbfde95892fb23440494a72d56)) 132 | 133 | ## [1.0.5](https://github.com/linxianxi/react-selectable-box/compare/v1.0.4...v1.0.5) (2023-09-03) 134 | 135 | ### Bug Fixes 136 | 137 | - selectFromInside should be corrected when changed ([#13](https://github.com/linxianxi/react-selectable-box/issues/13)) ([d8a701f](https://github.com/linxianxi/react-selectable-box/commit/d8a701f2a23f21a7c41b6a963ae98a0386082a25)) 138 | 139 | ## [1.0.4](https://github.com/linxianxi/react-selectable-box/compare/v1.0.3...v1.0.4) (2023-09-03) 140 | 141 | ### Bug Fixes 142 | 143 | - fixed some boundary issues with disabled ([#12](https://github.com/linxianxi/react-selectable-box/issues/12)) ([4a0b4b6](https://github.com/linxianxi/react-selectable-box/commit/4a0b4b6ebf703fea201933d17b23faa957ce032a)) 144 | 145 | ## [1.0.3](https://github.com/linxianxi/react-selectable-box/compare/v1.0.2...v1.0.3) (2023-09-03) 146 | 147 | ### Bug Fixes 148 | 149 | - remove mousemove listener in useEffect ([#11](https://github.com/linxianxi/react-selectable-box/issues/11)) ([45f7b58](https://github.com/linxianxi/react-selectable-box/commit/45f7b58f4abadb1511c1dda772bfa3e0f0209771)) 150 | 151 | ## [1.0.2](https://github.com/linxianxi/react-selectable-box/compare/v1.0.1...v1.0.2) (2023-09-02) 152 | 153 | ### Bug Fixes 154 | 155 | - box should not exceed the container ([#10](https://github.com/linxianxi/react-selectable-box/issues/10)) ([6732cd2](https://github.com/linxianxi/react-selectable-box/commit/6732cd2dfcd1b1fb3a3ea4254458fd02fdf7517c)) 156 | 157 | ## [1.0.1](https://github.com/linxianxi/react-selectable-box/compare/v1.0.0...v1.0.1) (2023-08-18) 158 | 159 | ### Bug Fixes 160 | 161 | - start box selection when the area exceeds 1 ([#6](https://github.com/linxianxi/react-selectable-box/issues/6)) ([220f7b7](https://github.com/linxianxi/react-selectable-box/commit/220f7b759f9b98c7e7e1cc4f0aebd4bfadd6a986)) 162 | 163 | # 1.0.0 (2023-08-18) 164 | 165 | ### Features 166 | 167 | - adjust box bg ([#2](https://github.com/linxianxi/react-selectable-box/issues/2)) ([1177f6c](https://github.com/linxianxi/react-selectable-box/commit/1177f6c941c020b90c53cc74fc170ebbd8c33128)) 168 | - base ([#1](https://github.com/linxianxi/react-selectable-box/issues/1)) ([84e742f](https://github.com/linxianxi/react-selectable-box/commit/84e742fcbdce353a4e726b84cbfe239febdce513)) 169 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 linxianxi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |

react-selectable-box

6 | 7 | A React component that allows you to select elements in the drag area using the mouse 8 | 9 | [Changelog](./CHANGELOG.md) · [Report Bug][issues-url] · [Request Feature][issues-url] 10 | 11 | 12 | 13 | [![NPM version][npm-image]][npm-url] [![NPM downloads][download-image]][download-url] [![install size][npm-size]][npm-size-url] 14 | 15 | [![Test CI status][test-ci]][test-ci-url] [![Deploy CI][release-ci]][release-ci-url] [![Coverage][coverage]][codecov-url] 16 | 17 | [![contributors][contributors-shield]][contributors-url] [![forks][forks-shield]][forks-url] [![stargazers][stargazers-shield]][stargazers-url] [![issues][issues-shield]][issues-url] 18 | 19 | [![ docs by dumi][dumi-url]](https://d.umijs.org/) [![Build With father][father-url]](https://github.com/umijs/father/) 20 | 21 | 22 | 23 | [dumi-url]: https://img.shields.io/badge/docs%20by-dumi-blue 24 | [father-url]: https://img.shields.io/badge/build%20with-father-028fe4.svg 25 | 26 | 27 | 28 | [npm-image]: http://img.shields.io/npm/v/react-selectable-box.svg?style=flat-square&color=deepgreen&label=latest 29 | [npm-url]: http://npmjs.org/package/react-selectable-box 30 | [npm-size]: https://img.shields.io/bundlephobia/minzip/react-selectable-box?color=deepgreen&label=gizpped%20size&style=flat-square 31 | [npm-size-url]: https://packagephobia.com/result?p=react-selectable-box 32 | 33 | 34 | 35 | [coverage]: https://codecov.io/gh/linxianxi/react-selectable-box/branch/master/graph/badge.svg 36 | [codecov-url]: https://codecov.io/gh/linxianxi/react-selectable-box/branch/master 37 | 38 | 39 | 40 | [test-ci]: https://github.com/linxianxi/react-selectable-box/workflows/Test%20CI/badge.svg 41 | [release-ci]: https://github.com/linxianxi/react-selectable-box/workflows/Release%20CI/badge.svg 42 | [test-ci-url]: https://github.com/linxianxi/react-selectable-box/actions?query=workflow%3ATest%20CI 43 | [release-ci-url]: https://github.com/linxianxi/react-selectable-box/actions?query=workflow%3ARelease%20CI 44 | [download-image]: https://img.shields.io/npm/dm/react-selectable-box.svg?style=flat-square 45 | [download-url]: https://npmjs.org/package/react-selectable-box 46 | 47 |
48 | 49 | ## Introduction 50 | 51 | A React component that allows you to select elements in the drag area using the mouse 52 | 53 | 54 | 55 | ## Usage 56 | 57 | ### Install 58 | 59 | ```bash 60 | npm i react-selectable-box 61 | // or 62 | yarn add react-selectable-box 63 | // or 64 | pnpm add react-selectable-box 65 | ``` 66 | 67 | ### Docs 68 | 69 | [docs](https://linxianxi.github.io/react-selectable-box/) 70 | 71 | ### Example 72 | 73 | ```typescript 74 | import Selectable, { useSelectable } from 'react-selectable-box'; 75 | 76 | const list: number[] = []; 77 | for (let i = 0; i < 200; i++) { 78 | list.push(i); 79 | } 80 | 81 | const App = () => { 82 | return ( 83 | 84 |
93 | {list.map((i) => ( 94 | 95 | ))} 96 |
97 |
98 | ); 99 | }; 100 | 101 | const Item = ({ value }: { value: number }) => { 102 | const { setNodeRef, isSelected, isAdding } = useSelectable({ 103 | value, 104 | }); 105 | 106 | return ( 107 |
117 | ); 118 | }; 119 | ``` 120 | 121 | 122 | 123 | [contributors-shield]: https://img.shields.io/github/contributors/linxianxi/react-selectable-box.svg?style=flat 124 | [contributors-url]: https://github.com/linxianxi/react-selectable-box/graphs/contributors 125 | 126 | 127 | 128 | [forks-shield]: https://img.shields.io/github/forks/linxianxi/react-selectable-box.svg?style=flat 129 | [forks-url]: https://github.com/linxianxi/react-selectable-box/network/members 130 | 131 | 132 | 133 | [stargazers-shield]: https://img.shields.io/github/stars/linxianxi/react-selectable-box.svg?style=flat 134 | [stargazers-url]: https://github.com/linxianxi/react-selectable-box/stargazers 135 | 136 | 137 | 138 | [issues-shield]: https://img.shields.io/github/issues/linxianxi/react-selectable-box.svg?style=flat 139 | [issues-url]: https://github.com/linxianxi/react-selectable-box/issues/new/choose 140 | 141 | ## Buy me a coffee 142 | 143 | 144 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Changelog 3 | nav: 4 | title: Changelog 5 | order: 999 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/changelog.zh-CN.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 更新记录 3 | nav: 4 | title: 更新记录 5 | order: 999 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/examples/basic.tsx: -------------------------------------------------------------------------------- 1 | import { Descriptions, Radio, Switch } from 'antd'; 2 | import { useState } from 'react'; 3 | import Selectable, { useSelectable } from 'react-selectable-box'; 4 | 5 | const list: number[] = []; 6 | for (let i = 0; i < 200; i++) { 7 | list.push(i); 8 | } 9 | 10 | const Item = ({ value, rule }: { value: number; rule: 'collision' | 'inclusion' }) => { 11 | const { setNodeRef, isSelected, isAdding, isRemoving } = useSelectable({ 12 | value, 13 | rule, 14 | }); 15 | 16 | return ( 17 |
31 | {value} 32 |
33 | ); 34 | }; 35 | 36 | export default () => { 37 | const [value, setValue] = useState([]); 38 | const [mode, setMode] = useState<'add' | 'remove' | 'reverse'>('add'); 39 | const [selectStartRange, setSelectStartRange] = useState<'all' | 'inside' | 'outside'>('all'); 40 | const [disabled, setDisabled] = useState(false); 41 | const [rule, setRule] = useState<'collision' | 'inclusion'>('collision'); 42 | 43 | return ( 44 |
45 | }, 49 | { 50 | label: 'selectStartRange', 51 | children: ( 52 | setSelectStartRange(e.target.value)} 62 | /> 63 | ), 64 | }, 65 | { 66 | label: 'rule', 67 | children: ( 68 | setRule(e.target.value)} 77 | /> 78 | ), 79 | }, 80 | { 81 | label: 'mode', 82 | children: ( 83 | setMode(e.target.value)} 86 | buttonStyle="solid" 87 | optionType="button" 88 | options={[ 89 | { label: 'add', value: 'add' }, 90 | { label: 'remove', value: 'remove' }, 91 | { label: 'reverse', value: 'reverse' }, 92 | ]} 93 | /> 94 | ), 95 | }, 96 | ]} 97 | /> 98 | 99 | document.getElementById('drag-container') as HTMLElement} 104 | selectStartRange={selectStartRange} 105 | onEnd={(selectingValue, { added, removed }) => { 106 | const result = value.concat(added).filter((i) => !removed.includes(i)); 107 | setValue(result); 108 | }} 109 | > 110 |
120 | {list.map((i) => ( 121 | 122 | ))} 123 |
124 |
125 |
126 | ); 127 | }; 128 | -------------------------------------------------------------------------------- /docs/examples/cancel.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import Selectable, { SelectableRef, useSelectable } from 'react-selectable-box'; 3 | 4 | const list: number[] = []; 5 | for (let i = 0; i < 200; i++) { 6 | list.push(i); 7 | } 8 | 9 | const Item = ({ value }: { value: number }) => { 10 | const { setNodeRef, isSelected, isAdding, isRemoving } = useSelectable({ 11 | value, 12 | }); 13 | 14 | return ( 15 |
29 | {value} 30 |
31 | ); 32 | }; 33 | 34 | export default () => { 35 | const [value, setValue] = useState([]); 36 | const selectableRef = useRef(null); 37 | 38 | useEffect(() => { 39 | const onKeyDown = (e: KeyboardEvent) => { 40 | if (e.code === 'Escape') { 41 | selectableRef.current?.cancel(); 42 | } 43 | }; 44 | 45 | document.addEventListener('keydown', onKeyDown); 46 | 47 | return () => { 48 | document.removeEventListener('keydown', onKeyDown); 49 | }; 50 | }, []); 51 | 52 | return ( 53 | document.getElementById('drag-container') as HTMLElement} 57 | onEnd={(selectingValue, { added, removed }) => { 58 | const result = value.concat(added).filter((i) => !removed.includes(i)); 59 | setValue(result); 60 | }} 61 | > 62 |
72 | {list.map((i) => ( 73 | 74 | ))} 75 |
76 |
77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /docs/examples/circle-item.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react'; 2 | import Selectable, { useSelectable } from 'react-selectable-box'; 3 | 4 | const list: number[] = []; 5 | for (let i = 0; i < 200; i++) { 6 | list.push(i); 7 | } 8 | 9 | const checkCircleCollision = ( 10 | circle: { x: number; y: number; radius: number }, 11 | rect: { x: number; y: number; width: number; height: number }, 12 | ) => { 13 | // The point closest to the center of the rectangle 14 | // 矩形距离圆心最近的点 15 | let targetPoint = { x: 0, y: 0 }; 16 | if (circle.x > rect.x + rect.width) { 17 | // If the circle is to the right of the rectangle 18 | // 如果圆形在矩形的右边 19 | targetPoint.x = rect.x + rect.width; 20 | } else if (circle.x < rect.x) { 21 | // If the circle is to the left of the rectangle 22 | // 如果圆形在矩形的左边 23 | targetPoint.x = rect.x; 24 | } else { 25 | // The x in the center of the circle is in the middle of the rectangle 26 | // 圆形中心的x在矩形中间 27 | targetPoint.x = circle.x; 28 | } 29 | if (circle.y > rect.y + rect.height) { 30 | // If the circle is below the rectangle 31 | // 如果圆形在矩形的下边 32 | targetPoint.y = rect.y + rect.height; 33 | } else if (circle.y < rect.y) { 34 | // If the circle is on top of the rectangle 35 | // 如果圆形在矩形的上边 36 | targetPoint.y = rect.y; 37 | } else { 38 | // The y of the center of the circle is in the middle of the rectangle 39 | // 圆形中心的y在矩形中间 40 | targetPoint.y = circle.y; 41 | } 42 | 43 | return ( 44 | Math.sqrt(Math.pow(targetPoint.x - circle.x, 2) + Math.pow(targetPoint.y - circle.y, 2)) < 45 | circle.radius 46 | ); 47 | }; 48 | 49 | const itemSize = 50; 50 | 51 | const Item = ({ value }: { value: number }) => { 52 | const itemRef = useRef(null); 53 | const { setNodeRef, isSelected, isAdding, isRemoving } = useSelectable({ 54 | value, 55 | rule: (_, boxPosition) => { 56 | if (!itemRef.current) { 57 | return false; 58 | } 59 | const itemRect = itemRef.current.getBoundingClientRect(); 60 | const radius = itemSize / 2; 61 | return checkCircleCollision( 62 | { x: itemRect.x + radius, y: itemRect.y + radius, radius }, 63 | { 64 | x: boxPosition.left, 65 | y: boxPosition.top, 66 | width: boxPosition.width, 67 | height: boxPosition.height, 68 | }, 69 | ); 70 | }, 71 | }); 72 | 73 | return ( 74 |
{ 76 | setNodeRef(ref); 77 | itemRef.current = ref; 78 | }} 79 | style={{ 80 | display: 'flex', 81 | justifyContent: 'center', 82 | alignItems: 'center', 83 | color: 'white', 84 | width: itemSize, 85 | height: itemSize, 86 | borderRadius: '50%', 87 | border: isAdding ? '1px solid #1677ff' : undefined, 88 | background: isRemoving ? 'red' : isSelected ? '#1677ff' : '#ccc', 89 | }} 90 | > 91 | {value} 92 |
93 | ); 94 | }; 95 | 96 | export default () => { 97 | const [value, setValue] = useState([]); 98 | 99 | return ( 100 | { 103 | const result = value.concat(added).filter((i) => !removed.includes(i)); 104 | setValue(result); 105 | }} 106 | > 107 |
116 | {list.map((i) => ( 117 | 118 | ))} 119 |
120 |
121 | ); 122 | }; 123 | -------------------------------------------------------------------------------- /docs/examples/click-to-select.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import Selectable, { useSelectable } from 'react-selectable-box'; 3 | 4 | const list: number[] = []; 5 | for (let i = 0; i < 200; i++) { 6 | list.push(i); 7 | } 8 | 9 | const Item = ({ value, onClick }: { value: number; onClick: (isSelected: boolean) => void }) => { 10 | const { setNodeRef, isSelected, isAdding, isRemoving } = useSelectable({ 11 | value, 12 | }); 13 | 14 | return ( 15 |
onClick(isSelected)} 29 | > 30 | {value} 31 |
32 | ); 33 | }; 34 | 35 | export default () => { 36 | const [value, setValue] = useState([]); 37 | 38 | return ( 39 | { 42 | console.log('start'); 43 | }} 44 | onEnd={(selectingValue, { added, removed }) => { 45 | const result = value.concat(added).filter((i) => !removed.includes(i)); 46 | setValue(result); 47 | }} 48 | > 49 |
58 | {list.map((i) => ( 59 | { 63 | if (isSelected) { 64 | setValue(value.filter((val) => val !== i)); 65 | } else { 66 | setValue(value.concat(i)); 67 | } 68 | }} 69 | /> 70 | ))} 71 |
72 |
73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /docs/examples/item-disabled.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import Selectable, { useSelectable } from 'react-selectable-box'; 3 | 4 | const list: number[] = []; 5 | for (let i = 0; i < 200; i++) { 6 | list.push(i); 7 | } 8 | 9 | const Item = ({ value }: { value: number }) => { 10 | const disabled = [46, 47, 48].includes(value); 11 | const { setNodeRef, isSelected, isAdding, isRemoving } = useSelectable({ 12 | value, 13 | disabled, 14 | }); 15 | 16 | return ( 17 |
31 | {value} 32 |
33 | ); 34 | }; 35 | 36 | export default () => { 37 | const [value, setValue] = useState([]); 38 | 39 | return ( 40 | { 43 | const result = value.concat(added).filter((i) => !removed.includes(i)); 44 | setValue(result); 45 | }} 46 | > 47 |
56 | {list.map((i) => ( 57 | 58 | ))} 59 |
60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /docs/examples/reset-at-end.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import Selectable, { useSelectable } from 'react-selectable-box'; 3 | 4 | const list: number[] = []; 5 | for (let i = 0; i < 200; i++) { 6 | list.push(i); 7 | } 8 | 9 | const Item = ({ value }: { value: number }) => { 10 | const { setNodeRef, isSelected, isAdding, isRemoving } = useSelectable({ 11 | value, 12 | }); 13 | 14 | return ( 15 |
29 | {value} 30 |
31 | ); 32 | }; 33 | 34 | export default () => { 35 | const [value, setValue] = useState([]); 36 | 37 | return ( 38 | { 41 | setValue(newValue); 42 | }} 43 | > 44 |
53 | {list.map((i) => ( 54 | 55 | ))} 56 |
57 |
58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /docs/examples/reset-at-start.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import Selectable, { useSelectable } from 'react-selectable-box'; 3 | 4 | const list: number[] = []; 5 | for (let i = 0; i < 200; i++) { 6 | list.push(i); 7 | } 8 | 9 | const Item = ({ value }: { value: number }) => { 10 | const { setNodeRef, isSelected, isAdding, isRemoving } = useSelectable({ 11 | value, 12 | }); 13 | 14 | return ( 15 |
29 | {value} 30 |
31 | ); 32 | }; 33 | 34 | export default () => { 35 | const [value, setValue] = useState([]); 36 | 37 | return ( 38 | { 41 | setValue([]); 42 | }} 43 | onEnd={(newValue) => { 44 | setValue(newValue); 45 | }} 46 | > 47 |
56 | {list.map((i) => ( 57 | 58 | ))} 59 |
60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /docs/examples/scroll-container.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import Selectable, { useSelectable } from 'react-selectable-box'; 3 | 4 | const list: number[] = []; 5 | for (let i = 0; i < 200; i++) { 6 | list.push(i); 7 | } 8 | 9 | const Item = ({ value }: { value: number }) => { 10 | const { setNodeRef, isSelected, isAdding, isRemoving } = useSelectable({ 11 | value, 12 | }); 13 | 14 | return ( 15 |
29 | {value} 30 |
31 | ); 32 | }; 33 | 34 | export default () => { 35 | const [value, setValue] = useState([]); 36 | 37 | return ( 38 | { 41 | const result = value.concat(added).filter((i) => !removed.includes(i)); 42 | setValue(result); 43 | }} 44 | scrollContainer={() => document.getElementById('scroll-container') as HTMLElement} 45 | > 46 |
59 | {list.map((i) => ( 60 | 61 | ))} 62 |
63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /docs/examples/shift-remove.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import Selectable, { useSelectable } from 'react-selectable-box'; 3 | 4 | const list: number[] = []; 5 | for (let i = 0; i < 200; i++) { 6 | list.push(i); 7 | } 8 | 9 | const Item = ({ value }: { value: number }) => { 10 | const { setNodeRef, isSelected, isAdding, isRemoving } = useSelectable({ 11 | value, 12 | }); 13 | 14 | return ( 15 |
29 | {value} 30 |
31 | ); 32 | }; 33 | 34 | export default () => { 35 | const [value, setValue] = useState([]); 36 | const [mode, setMode] = useState<'add' | 'remove'>('add'); 37 | 38 | useEffect(() => { 39 | const onKeyDown = (e: KeyboardEvent) => { 40 | if (e.key === 'Shift') { 41 | setMode('remove'); 42 | } 43 | }; 44 | 45 | const onKeyUp = () => { 46 | setMode('add'); 47 | }; 48 | 49 | document.addEventListener('keydown', onKeyDown); 50 | document.addEventListener('keyup', onKeyUp); 51 | 52 | return () => { 53 | document.removeEventListener('keydown', onKeyDown); 54 | document.removeEventListener('keyup', onKeyUp); 55 | }; 56 | }, []); 57 | 58 | return ( 59 | { 63 | const result = value.concat(added).filter((i) => !removed.includes(i)); 64 | setValue(result); 65 | }} 66 | > 67 |
76 | {list.map((i) => ( 77 | 78 | ))} 79 |
80 |
81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /docs/examples/sort.tsx: -------------------------------------------------------------------------------- 1 | // should use these versions https://github.com/clauderic/dnd-kit/issues/901#issuecomment-1340687113 2 | import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; 3 | import { SortableContext, useSortable } from '@dnd-kit/sortable'; 4 | import { CSS } from '@dnd-kit/utilities'; 5 | import { useState } from 'react'; 6 | import Selectable, { useSelectable } from 'react-selectable-box'; 7 | 8 | const list: string[] = []; 9 | for (let i = 0; i < 200; i++) { 10 | list.push(String(i)); 11 | } 12 | 13 | const Item = ({ 14 | value, 15 | disabled, 16 | onClick, 17 | }: { 18 | value: string; 19 | disabled?: boolean; 20 | onClick: (isSelected: boolean) => void; 21 | }) => { 22 | const { 23 | setNodeRef: setSelectNodeRef, 24 | isSelected, 25 | isAdding, 26 | } = useSelectable({ 27 | value, 28 | }); 29 | 30 | const { 31 | attributes, 32 | listeners, 33 | setNodeRef: setSortNodeRef, 34 | transform, 35 | transition, 36 | isDragging, 37 | } = useSortable({ 38 | id: value, 39 | disabled, 40 | }); 41 | 42 | return ( 43 |
{ 47 | setSelectNodeRef(ref); 48 | setSortNodeRef(ref); 49 | }} 50 | style={{ 51 | display: 'flex', 52 | justifyContent: 'center', 53 | alignItems: 'center', 54 | position: 'relative', 55 | color: 'white', 56 | width: 50, 57 | height: 50, 58 | borderRadius: 4, 59 | transform: CSS.Transform.toString(transform), 60 | transition, 61 | cursor: isSelected ? 'move' : undefined, 62 | border: isAdding ? '1px solid #1677ff' : undefined, 63 | opacity: isDragging ? 0.5 : undefined, 64 | background: isSelected ? '#1677ff' : '#ccc', 65 | }} 66 | onClick={() => onClick(isSelected)} 67 | > 68 | {value} 69 |
70 | ); 71 | }; 72 | 73 | export default () => { 74 | const [value, setValue] = useState([]); 75 | const [items, setItems] = useState(list); 76 | const [beforeSortItems, setBeforeSortItems] = useState([]); 77 | const [activeId, setActiveId] = useState(null); 78 | 79 | const sensors = useSensors( 80 | useSensor(PointerSensor, { 81 | activationConstraint: { 82 | // make item click event available 83 | distance: 1, 84 | }, 85 | }), 86 | ); 87 | 88 | return ( 89 | { 92 | const index = event.active.data.current?.sortable.index as number; 93 | setActiveId(items[index]); 94 | let unSelectItems: string[] = items.filter((item) => !value.includes(item)); 95 | unSelectItems.splice(index, 0, items[index]); 96 | setBeforeSortItems(items); 97 | setItems(unSelectItems); 98 | }} 99 | onDragEnd={(event) => { 100 | setActiveId(null); 101 | if (event.over) { 102 | const index = event.over?.data.current?.sortable.index; 103 | const newItems = items.filter((item) => !value.includes(item)); 104 | const sortValue = beforeSortItems.filter((item) => value.includes(item)); 105 | newItems.splice(index, 0, ...sortValue); 106 | setItems(newItems); 107 | } else { 108 | setItems(beforeSortItems); 109 | } 110 | setValue([]); 111 | }} 112 | > 113 | 114 | { 118 | const result = value.concat(added).filter((i) => !removed.includes(i)); 119 | setValue(result); 120 | }} 121 | > 122 |
132 | {items.map((i) => ( 133 | { 138 | if (isSelected) { 139 | setValue(value.filter((val) => val !== i)); 140 | } else { 141 | setValue(value.concat(i)); 142 | } 143 | }} 144 | /> 145 | ))} 146 | 147 | 148 | {activeId ? ( 149 |
160 | {activeId} 161 |
175 | {value.length} 176 |
177 |
178 | ) : null} 179 |
180 |
181 |
182 |
183 |
184 | ); 185 | }; 186 | -------------------------------------------------------------------------------- /docs/examples/virtual-list.tsx: -------------------------------------------------------------------------------- 1 | import { useVirtualizer } from '@tanstack/react-virtual'; 2 | import React, { useState } from 'react'; 3 | import Selectable, { useSelectable } from 'react-selectable-box'; 4 | 5 | const list: number[] = []; 6 | for (let i = 0; i < 2000; i++) { 7 | list.push(i); 8 | } 9 | 10 | const columnCount = 10; 11 | 12 | const Item = ({ value }: { value: number }) => { 13 | const { setNodeRef, isSelected, isAdding, isRemoving } = useSelectable({ 14 | value, 15 | }); 16 | 17 | return ( 18 |
32 | {value} 33 |
34 | ); 35 | }; 36 | 37 | export default () => { 38 | const [value, setValue] = useState([]); 39 | 40 | const parentRef = React.useRef(null); 41 | 42 | const rowVirtualizer = useVirtualizer({ 43 | count: Math.ceil(list.length / columnCount), 44 | getScrollElement: () => parentRef.current, 45 | estimateSize: () => 50, 46 | gap: 20, 47 | }); 48 | 49 | return ( 50 | document.querySelector('.container') as HTMLElement} 54 | onEnd={(selectingValue, { added, removed }) => { 55 | const result = value.concat(added).filter((i) => !removed.includes(i)); 56 | setValue(result); 57 | }} 58 | > 59 |
67 |
74 | {rowVirtualizer.getVirtualItems().map((virtualItem) => ( 75 |
88 | {list 89 | .slice(virtualItem.index * columnCount, (virtualItem.index + 1) * columnCount) 90 | .map((item) => ( 91 | 92 | ))} 93 |
94 | ))} 95 |
96 |
97 |
98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /docs/guides/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API 3 | group: 4 | title: Usage 5 | order: 0 6 | order: 0 7 | nav: Get Started 8 | --- 9 | 10 | ### Selectable 11 | 12 | | Property | Description | Type | Default | 13 | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------- | 14 | | defaultValue | Initial selected option | any[] | - | 15 | | value | Current selected option | any[] | - | 16 | | disabled | Whether to disable | boolean | false | 17 | | mode | Selection mode | `add` \| `remove` \| `reverse` | `add` | 18 | | items | The collection value of all items, only the virtual list needs to be passed [(FAQ)](#faq) | any[] | - | 19 | | selectStartRange | Where to start with box selection | `all` \| `inside` \| `outside` | `all` | 20 | | scrollSpeed | Scroll speed | number | 4 | 21 | | scrollContainer | Specify the scrolling container | () => HTMLElement | 22 | | dragContainer | Specify the container that can start dragging. If `scrollContainer` is set, please do not set it because the two should be equal in a scrollable container. | () => HTMLElement | scrollContainer | 23 | | boxStyle | Selection box style | React.CSSProperties | - | 24 | | boxClassName | Selection box className | string | - | 25 | | compareFn | Because value supports any type, you may need to define a custom function for comparison. The default is `===` | (item: any, value: any) => boolean | 26 | | onStart | Called when selection starts | (event: MouseEvent \| TouchEvent) => void | - | 27 | | onEnd | Called when selection ends | (selectingValue: any[], { added: any[], removed: any[] }) => void | - | 28 | 29 | ### useSelectable 30 | 31 | ```typescript 32 | const { setNodeRef, isSelected, isAdding, isRemoving, isSelecting, isDragging } = useSelectable({ 33 | value, 34 | disabled, 35 | rule, 36 | }); 37 | ``` 38 | 39 | | Property | Description | Type | Default | 40 | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | 41 | | value | The value of the current selectable element | any | - | 42 | | disabled | Whether to disable | boolean | false | 43 | | rule | Selection rule, collision, inclusion or customization. When customizing, the `boxPosition` is relative to the position of `scrollContainer` | `collision` \| `inclusion` \| ( boxElement: HTMLDivElement, boxPosition: { left: number; top: number; width: number; height: number }) => boolean | `collision` | 44 | | | 45 | 46 | | Property | 说明 | 类型 | 47 | | ----------- | -------------------------------------- | -------------------------------------- | 48 | | setNodeRef | Set the selectable element | (element: HTMLElement \| null) => void | 49 | | isDragging | Whether it is currently dragging | boolean | 50 | | isSelected | Whether it has been selected | boolean | 51 | | isAdding | Whether it is currently being added | boolean | 52 | | isRemoving | Whether it is currently being removed | boolean | 53 | | isSelecting | Whether it is currently being selected | boolean | 54 | 55 | ### Methods 56 | 57 | | Name | Description | Type | 58 | | ------ | ---------------- | ---------- | 59 | | cancel | cancel selection | () => void | 60 | 61 | ### FAQ 62 | 63 | #### 1.When used with a virtual list, passing in `items` ensures that `block` were unmounted during scrolling will still be selected after the selection ends. However, there can be issues when integrating with certain third-party virtual list libraries. A better integration can be achieved with [@tanstack/react-virtual](https://github.com/TanStack/virtual). If you don't mind not passing in items, it is recommended to use [react-virtuoso](https://github.com/petyosi/react-virtuoso), as it is simpler to use. 64 | 65 | not passing `items`: 66 | ![before](https://github.com/user-attachments/assets/4ec33cb8-adf5-44da-8573-9e69486c8cb2) 67 | passing `items`: 68 | ![after](https://github.com/user-attachments/assets/fd60faad-321d-46a4-8aec-c6bda2df2eb1) 69 | -------------------------------------------------------------------------------- /docs/guides/api.zh-CN.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API 3 | group: 4 | title: 快速上手 5 | order: 0 6 | order: 0 7 | nav: 快速上手 8 | --- 9 | 10 | ### Selectable 11 | 12 | | 参数 | 说明 | 类型 | 默认值 | 13 | | ---------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------- | 14 | | defaultValue | 默认已选择的值 | any[] | - | 15 | | value | 受控已选择的值 | any[] | - | 16 | | disabled | 是否禁用 | boolean | false | 17 | | mode | 模式 | `add` \| `remove` \| `reverse` | `add` | 18 | | items | 全部 item 的集合值,只有虚拟列表时要传[(FAQ)](#faq) | any[] | - | 19 | | selectStartRange | 从哪里可以开始进行框选 | `all` \| `inside` \| `outside` | `all` | 20 | | scrollSpeed | 滚动速度 | number | 4 | 21 | | scrollContainer | 指定滚动的容器 | () => HTMLElement | 22 | | dragContainer | 指定可以开始拖拽的容器, 如果设置了 `scrollContainer` 请不要设置,因为在可滚动容器中这两个应该相等 | () => HTMLElement | scrollContainer | 23 | | boxStyle | 框选框的样式 | React.CSSProperties | - | 24 | | boxClassName | 框选框的类名 | string | - | 25 | | compareFn | 因为 value 支持任意类型,所以你可能需要自定义函数进行比较,默认使用 `===` | (item: any, value: any) => boolean | === | 26 | | onStart | 框选开始时触发的事件 | (event: MouseEvent \| TouchEvent) => void | - | 27 | | onEnd | 框选结束时触发的事件 | (selectingValue: any[], { added: any[], removed: any[] }) => void | - | 28 | 29 | ### useSelectable 30 | 31 | ```typescript 32 | const { setNodeRef, isSelected, isAdding, isRemoving, isSelecting, isDragging } = useSelectable({ 33 | value, 34 | disabled, 35 | rule, 36 | }); 37 | ``` 38 | 39 | | 参数 | 说明 | 类型 | 默认值 | 40 | | -------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | 41 | | value | 当前可框选元素的值 | any | - | 42 | | disabled | 是否禁用 | boolean | false | 43 | | rule | 选中规则,碰撞、包含或自定义,自定义时的 `boxPosition` 是相对于 `scrollContainer` 的位置 | `collision` \| `inclusion` \| ( boxElement: HTMLDivElement, boxPosition: { left: number; top: number; width: number; height: number }) => boolean | `collision` | 44 | 45 | | 参数 | 说明 | 类型 | 46 | | ----------- | ------------------ | -------------------------------------- | 47 | | setNodeRef | 设置当前可框选元素 | (element: HTMLElement \| null) => void | 48 | | isDragging | 是否正在框选 | boolean | 49 | | isSelected | 是否已经选中 | boolean | 50 | | isAdding | 当前是否正在添加 | boolean | 51 | | isRemoving | 当前是否正在删除 | boolean | 52 | | isSelecting | 当前是否被框选 | boolean | 53 | 54 | ### 方法 55 | 56 | | 名称 | 说明 | 类型 | 57 | | ------ | -------- | ---------- | 58 | | cancel | 取消选择 | () => void | 59 | 60 | ### FAQ 61 | 62 | #### 1.当配合虚拟列表使用时,传入 `items` 可以使在滚动过程中被卸载的方块在框选结束后也会被选中。但与某些虚拟列表第三方库结合会有问题,配合较好的是 [@tanstack/react-virtual](https://github.com/TanStack/virtual)。如果你不介意这个问题(不传入 `items`),建议使用 [react-virtuoso](https://github.com/petyosi/react-virtuoso),该库使用起来比较简单。 63 | 64 | 不传入 `items`: 65 | ![before](https://github.com/user-attachments/assets/4ec33cb8-adf5-44da-8573-9e69486c8cb2) 66 | 传入 `items`: 67 | ![after](https://github.com/user-attachments/assets/fd60faad-321d-46a4-8aec-c6bda2df2eb1) 68 | -------------------------------------------------------------------------------- /docs/guides/basic.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: basic 3 | group: 4 | title: examples 5 | order: 1 6 | order: 0 7 | --- 8 | 9 | ### Basic 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/basic.zh-CN.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 基本使用 3 | group: 4 | title: 示例 5 | order: 1 6 | order: 0 7 | --- 8 | 9 | ### 基本使用 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/cancel.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: cancel 3 | group: 4 | title: examples 5 | order: 1 6 | order: 9 7 | --- 8 | 9 | ### Press Esc to cancel 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/cancel.zh-CN.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 取消 3 | group: 4 | title: 示例 5 | order: 1 6 | order: 9 7 | --- 8 | 9 | ### 按下 Esc 取消 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/circle-item.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: customize rul(circle item) 3 | group: 4 | title: examples 5 | order: 1 6 | order: 10 7 | --- 8 | 9 | ### Use rule to customize collision rules 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/circle-item.zh-CN.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 自定义规则(圆形 Item) 3 | group: 4 | title: 示例 5 | order: 1 6 | order: 10 7 | --- 8 | 9 | ### 使用 rule 自定义碰撞规则 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/click-to-select.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: click to select 3 | group: 4 | title: examples 5 | order: 1 6 | order: 5 7 | --- 8 | 9 | ### You can add a click event to the item to achieve click selection 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/click-to-select.zh-CN.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 点击进行选择 3 | group: 4 | title: 示例 5 | order: 1 6 | order: 5 7 | --- 8 | 9 | ### 可以添加 onClick 事件实现点击进行选择 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/item-disabled.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: item disabled 3 | group: 4 | title: examples 5 | order: 1 6 | order: 1 7 | --- 8 | 9 | ### Set some items to be unselectable 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/item-disabled.zh-CN.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 单个禁用 3 | group: 4 | title: 示例 5 | order: 1 6 | order: 1 7 | --- 8 | 9 | ### 设置某些项不可选择 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/reset-at-end.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: reset at end 3 | group: 4 | title: examples 5 | order: 1 6 | order: 3 7 | --- 8 | 9 | ### Reset previous selection when box selection ends 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/reset-at-end.zh-CN.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 框选结束时重置 3 | group: 4 | title: 示例 5 | order: 1 6 | order: 3 7 | --- 8 | 9 | ### 框选结束时重置之前的选择 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/reset-at-start.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: reset at start 3 | group: 4 | title: examples 5 | order: 1 6 | order: 2 7 | --- 8 | 9 | ### Reset previous selection when box selection starts 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/reset-at-start.zh-CN.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 框选开始时重置 3 | group: 4 | title: 示例 5 | order: 1 6 | order: 2 7 | --- 8 | 9 | ### 框选开始时重置之前的选择 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/scroll-container.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: scroll-container 3 | group: 4 | title: examples 5 | order: 1 6 | order: 4 7 | --- 8 | 9 | ### You can set the box selection container 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/scroll-container.zh-CN.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 设置框选滚动的容器 3 | group: 4 | title: 示例 5 | order: 1 6 | order: 4 7 | --- 8 | 9 | ### 可以设置框选滚动的容器 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/shift-remove.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: hold shift to remove 3 | group: 4 | title: examples 5 | order: 1 6 | order: 6 7 | --- 8 | 9 | ### hold shift to remove 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/shift-remove.zh-CN.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 按住 shift 框选删除 3 | group: 4 | title: 示例 5 | order: 1 6 | order: 6 7 | --- 8 | 9 | ### 按住 shift 框选进行删除 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/sort.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: sort 3 | group: 4 | title: examples 5 | order: 1 6 | order: 8 7 | --- 8 | 9 | ### Use `dnd-kit` to sort after selecting some options 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/sort.zh-CN.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 排序 3 | group: 4 | title: 示例 5 | order: 1 6 | order: 8 7 | --- 8 | 9 | ### 配合 `dnd-kit` 选择一些选项后进行排序 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/virtual-list.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: virtual-list 3 | group: 4 | title: examples 5 | order: 1 6 | order: 7 7 | --- 8 | 9 | ### Box selection in a virtual list using `@tanstack/react-virtual` 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/guides/virtual-list.zh-CN.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 虚拟列表 3 | group: 4 | title: 示例 5 | order: 1 6 | order: 7 7 | --- 8 | 9 | ### 配合 `@tanstack/react-virtual` 在虚拟列表中进行框选 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | nav: Home 3 | 4 | hero: 5 | title: react-selectable-box 6 | description: A React component that allows you to select elements in the drag area using the mouse 7 | actions: 8 | - text: Get Started 9 | link: /guides/api 10 | - text: Github 11 | link: https://github.com/linxianxi/react-selectable-box 12 | --- 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/index.zh-CN.md: -------------------------------------------------------------------------------- 1 | --- 2 | hero: 3 | title: react-selectable-box 4 | description: 一个可以用鼠标框选元素的 React 组件 5 | actions: 6 | - text: 快速上手 7 | link: /guides/api 8 | - text: Github 9 | link: https://github.com/linxianxi/react-selectable-box 10 | --- 11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-selectable-box", 3 | "version": "1.10.0", 4 | "description": "A React component used hooks that allows you to select elements in the drag area using the mouse", 5 | "keywords": [ 6 | "react", 7 | "selectable", 8 | "selection", 9 | "mouse", 10 | "drag" 11 | ], 12 | "homepage": "https://github.com/linxianxi/react-selectable-box", 13 | "bugs": { 14 | "url": "https://github.com/linxianxi/react-selectable-box/issues/new" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/linxianxi/react-selectable-box.git" 19 | }, 20 | "license": "MIT", 21 | "sideEffects": false, 22 | "main": "lib/index.js", 23 | "module": "es/index.js", 24 | "types": "lib/index.d.ts", 25 | "files": [ 26 | "lib", 27 | "es", 28 | "dist" 29 | ], 30 | "scripts": { 31 | "build": "father build", 32 | "build:watch": "father dev", 33 | "ci": "npm run lint && npm run type-check && npm run doctor && npm run build", 34 | "clean": "rm -rf es lib dist coverage .dumi/tmp .eslintcache .doc", 35 | "dev": "dumi dev", 36 | "docs:build": "dumi build", 37 | "doctor": "father doctor", 38 | "lint": "eslint \"{src,test}/**/*.{js,jsx,ts,tsx}\"", 39 | "prepare": "husky install && npm run setup", 40 | "prepublishOnly": "npm run doctor && npm run build", 41 | "prettier": "prettier -c --write \"**/**\"", 42 | "release": "semantic-release", 43 | "setup": "dumi setup", 44 | "start": "dumi dev", 45 | "test": "vitest --passWithNoTests", 46 | "test:coverage": "vitest run --coverage --passWithNoTests", 47 | "test:update": "vitest -u", 48 | "type-check": "tsc -p tsconfig-check.json" 49 | }, 50 | "lint-staged": { 51 | "*.{md,json}": [ 52 | "prettier --write --no-error-on-unmatched-pattern" 53 | ], 54 | "*.{js,jsx}": [ 55 | "eslint --fix", 56 | "prettier --write" 57 | ], 58 | "*.{ts,tsx}": [ 59 | "eslint --fix", 60 | "prettier --parser=typescript --write" 61 | ] 62 | }, 63 | "devDependencies": { 64 | "@commitlint/cli": "^17", 65 | "@commitlint/config-conventional": "^17", 66 | "@dnd-kit/core": "^5.0.1", 67 | "@dnd-kit/sortable": "^6.0.0", 68 | "@dnd-kit/utilities": "^3.1.0", 69 | "@semantic-release/changelog": "^6.0.3", 70 | "@semantic-release/git": "^10.0.1", 71 | "@tanstack/react-virtual": "^3.8.4", 72 | "@testing-library/jest-dom": "^5", 73 | "@testing-library/react": "^14", 74 | "@types/react": "^18", 75 | "@types/react-dom": "^18", 76 | "@types/react-window": "^1.8.5", 77 | "@types/testing-library__jest-dom": "^5", 78 | "@umijs/lint": "^4", 79 | "@vitest/coverage-v8": "latest", 80 | "antd": "^5.8.3", 81 | "commitlint": "^17", 82 | "concurrently": "^7", 83 | "cross-env": "^7", 84 | "dumi": "^2", 85 | "dumi-theme-antd-style": "^0.29.7", 86 | "eslint": "^8", 87 | "father": "^4", 88 | "husky": "^8", 89 | "jsdom": "^22", 90 | "lint-staged": "^13", 91 | "prettier": "^2", 92 | "prettier-plugin-organize-imports": "^3", 93 | "prettier-plugin-packagejson": "^2", 94 | "react": "^18", 95 | "react-dom": "^18", 96 | "semantic-release": "^21", 97 | "stylelint": "^15", 98 | "typescript": "^5", 99 | "vitest": "latest" 100 | }, 101 | "peerDependencies": { 102 | "react": ">=16.8.0", 103 | "react-dom": ">=16.8.0" 104 | }, 105 | "publishConfig": { 106 | "access": "public", 107 | "registry": "https://registry.npmjs.org" 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Selectable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useRef, useState } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | import { SelectableContext, UnmountItemsInfoType } from './context'; 4 | import useContainer from './hooks/useContainer'; 5 | import useEvent from './hooks/useEvent'; 6 | import useLatest from './hooks/useLatest'; 7 | import useMergedState from './hooks/useMergedState'; 8 | import useScroll from './hooks/useScroll'; 9 | import { checkInRange, getClientXY } from './utils'; 10 | 11 | export interface SelectableProps { 12 | defaultValue?: T[]; 13 | value?: T[]; 14 | /** support virtual */ 15 | items?: T[]; 16 | disabled?: boolean; 17 | children?: React.ReactNode; 18 | mode?: 'add' | 'remove' | 'reverse'; 19 | selectStartRange?: 'all' | 'inside' | 'outside'; 20 | scrollSpeed?: number; 21 | scrollContainer?: () => HTMLElement; 22 | dragContainer?: () => HTMLElement; 23 | boxStyle?: React.CSSProperties; 24 | boxClassName?: string; 25 | compareFn?: (a: T, b: T) => boolean; 26 | onStart?: (event: MouseEvent | TouchEvent) => void; 27 | onEnd?: (selectingValue: T[], changed: { added: T[]; removed: T[] }) => void; 28 | /** 29 | * @deprecated Use scrollContainer instead 30 | */ 31 | getContainer?: () => HTMLElement; 32 | } 33 | 34 | export interface SelectableRef { 35 | cancel: () => void; 36 | } 37 | 38 | function defaultCompareFn(a: T, b: T) { 39 | return a === b; 40 | } 41 | 42 | function Selectable( 43 | { 44 | defaultValue, 45 | value: propsValue, 46 | items, 47 | disabled, 48 | mode = 'add', 49 | children, 50 | selectStartRange = 'all', 51 | scrollSpeed, 52 | getContainer, 53 | scrollContainer: propsScrollContainer, 54 | dragContainer: propsDragContainer, 55 | boxStyle, 56 | boxClassName, 57 | compareFn = defaultCompareFn, 58 | onStart, 59 | onEnd, 60 | }: SelectableProps, 61 | ref: React.ForwardedRef, 62 | ) { 63 | const [isDragging, setIsDragging] = useState(false); 64 | const [startCoords, setStartCoords] = useState({ x: 0, y: 0 }); 65 | const [moveCoords, setMoveCoords] = useState({ x: 0, y: 0 }); 66 | const selectingValue = useRef([]); 67 | const [startTarget, setStartTarget] = useState(null); 68 | const startInside = useRef(false); 69 | const moveClient = useRef({ x: 0, y: 0 }); 70 | const [value, setValue] = useMergedState(defaultValue || [], { 71 | value: propsValue, 72 | }); 73 | const boxRef = useRef(null); 74 | const unmountItemsInfo = useRef>(new Map()); 75 | const scrollInfo = useRef({ scrollTop: 0, scrollLeft: 0 }); 76 | const [isCanceled, setIsCanceled] = useState(false); 77 | 78 | const scrollContainer = useContainer(propsScrollContainer || getContainer); 79 | const dragContainer = useContainer(propsDragContainer || propsScrollContainer || getContainer); 80 | 81 | const { smoothScroll, cancelScroll } = useScroll(scrollSpeed); 82 | const startCoordsRef = useLatest(startCoords); 83 | const isDraggingRef = useLatest(isDragging); 84 | const selectStartRangeRef = useLatest(selectStartRange); 85 | 86 | const top = Math.max(0, Math.min(startCoords.y, moveCoords.y)); 87 | const left = Math.max(0, Math.min(startCoords.x, moveCoords.x)); 88 | const width = isDragging ? Math.abs(startCoords.x - Math.max(0, moveCoords.x)) : 0; 89 | const height = isDragging ? Math.abs(startCoords.y - Math.max(0, moveCoords.y)) : 0; 90 | const boxPosition = useMemo(() => ({ top, left, width, height }), [top, left, width, height]); 91 | 92 | const virtual = !!items; 93 | 94 | if (process.env.NODE_ENV === 'development' && getContainer) { 95 | console.error( 96 | '[react-selectable-box]: getContainer will be deprecated in the future, use scrollContainer instead', 97 | ); 98 | } 99 | 100 | React.useImperativeHandle(ref, () => ({ 101 | cancel: () => { 102 | setIsCanceled(true); 103 | setIsDragging(false); 104 | }, 105 | })); 106 | 107 | const handleStart = useEvent((event: MouseEvent | TouchEvent) => { 108 | onStart?.(event); 109 | }); 110 | 111 | const handleEnd = useEvent(() => { 112 | if (onEnd) { 113 | if (virtual) { 114 | unmountItemsInfo.current.forEach((info, item) => { 115 | if (items.some((i) => compareFn(i, item))) { 116 | const isInRange = checkInRange( 117 | info.rule, 118 | { 119 | width: info.rect.width, 120 | height: info.rect.height, 121 | top: info.rect.top + info.scrollTop - scrollInfo.current.scrollTop, 122 | left: info.rect.left + info.scrollLeft - scrollInfo.current.scrollLeft, 123 | }, 124 | scrollContainer, 125 | boxPosition, 126 | boxRef, 127 | ); 128 | if (isInRange && !info.disabled) { 129 | selectingValue.current.push(item); 130 | } else { 131 | selectingValue.current = selectingValue.current.filter((i) => !compareFn(i, item)); 132 | } 133 | } 134 | }); 135 | } 136 | 137 | const added: T[] = []; 138 | const removed: T[] = []; 139 | 140 | selectingValue.current.forEach((i) => { 141 | if (value?.some((val) => compareFn(val, i))) { 142 | if (mode === 'remove' || mode === 'reverse') { 143 | removed.push(i); 144 | } 145 | } else { 146 | if (mode === 'add' || mode === 'reverse') { 147 | added.push(i); 148 | } 149 | } 150 | }); 151 | 152 | onEnd(selectingValue.current, { added, removed }); 153 | } 154 | }); 155 | 156 | useEffect(() => { 157 | let isMouseDowning = false; 158 | let scrollContainerOriginPosition = ''; 159 | 160 | const reset = () => { 161 | setIsDragging(false); 162 | setIsCanceled(false); 163 | setStartTarget(null); 164 | isMouseDowning = false; 165 | startInside.current = false; 166 | selectingValue.current = []; 167 | }; 168 | 169 | if (disabled || !scrollContainer || !dragContainer || isCanceled) { 170 | reset(); 171 | return; 172 | } 173 | 174 | const onMouseMove = (e: MouseEvent | TouchEvent) => { 175 | if (isMouseDowning) { 176 | // Prevent scroll on mobile 177 | if (window.TouchEvent && e instanceof TouchEvent) { 178 | e.preventDefault(); 179 | } 180 | const { clientX, clientY } = getClientXY(e); 181 | moveClient.current = { x: clientX, y: clientY }; 182 | const { left, top } = scrollContainer.getBoundingClientRect(); 183 | const x = Math.min( 184 | clientX - left + scrollContainer.scrollLeft, 185 | scrollContainer.scrollWidth, 186 | ); 187 | const y = Math.min(clientY - top + scrollContainer.scrollTop, scrollContainer.scrollHeight); 188 | setMoveCoords({ 189 | x, 190 | y, 191 | }); 192 | smoothScroll(e, scrollContainer); 193 | 194 | if (!isDraggingRef.current) { 195 | let shouldDraggingStart = true; 196 | if (selectStartRangeRef.current === 'outside') { 197 | shouldDraggingStart = !startInside.current; 198 | } else if (selectStartRangeRef.current === 'inside') { 199 | shouldDraggingStart = startInside.current; 200 | } 201 | const boxWidth = Math.abs(startCoordsRef.current.x - x); 202 | const boxHeight = Math.abs(startCoordsRef.current.y - y); 203 | // prevent trigger when click too fast 204 | // https://github.com/linxianxi/react-selectable-box/issues/5 205 | if (shouldDraggingStart && (boxWidth > 1 || boxHeight > 1)) { 206 | setIsDragging(true); 207 | scrollContainerOriginPosition = getComputedStyle(scrollContainer).position; 208 | // default position in browser is `static` 209 | if (scrollContainer !== document.body && scrollContainerOriginPosition === 'static') { 210 | scrollContainer.style.position = 'relative'; 211 | } 212 | handleStart(e); 213 | } 214 | } 215 | } 216 | }; 217 | 218 | const scrollListenerElement = scrollContainer === document.body ? document : scrollContainer; 219 | 220 | const onScroll = (e: Event) => { 221 | const target = e.target as HTMLElement; 222 | scrollInfo.current = { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft }; 223 | 224 | if (isDraggingRef.current && scrollContainer) { 225 | const containerRect = scrollContainer.getBoundingClientRect(); 226 | const x = Math.min( 227 | moveClient.current.x - containerRect.left + scrollContainer.scrollLeft, 228 | scrollContainer.scrollWidth, 229 | ); 230 | const y = Math.min( 231 | moveClient.current.y - containerRect.top + scrollContainer.scrollTop, 232 | scrollContainer.scrollHeight, 233 | ); 234 | setMoveCoords({ 235 | x, 236 | y, 237 | }); 238 | } 239 | }; 240 | 241 | const onMouseUp = () => { 242 | window.removeEventListener('mousemove', onMouseMove); 243 | window.removeEventListener('mouseup', onMouseUp); 244 | window.removeEventListener('touchmove', onMouseMove); 245 | window.removeEventListener('touchend', onMouseUp); 246 | 247 | if (isDraggingRef.current) { 248 | scrollContainer.style.position = scrollContainerOriginPosition; 249 | cancelScroll(); 250 | setValue(selectingValue.current); 251 | handleEnd(); 252 | } 253 | reset(); 254 | }; 255 | 256 | const onMouseDown = (e: MouseEvent | TouchEvent) => { 257 | const isMouseEvent = e instanceof MouseEvent; 258 | if (isMouseEvent && e.button !== 0) { 259 | return; 260 | } 261 | 262 | // Disable text selection, but it will prevent default scroll behavior when mouse move, so we used `useScroll` 263 | // And it will prevent click events on mobile devices, so don't trigger it 264 | if (isMouseEvent) { 265 | e.preventDefault(); 266 | } 267 | 268 | isMouseDowning = true; 269 | 270 | if (selectStartRangeRef.current !== 'all') { 271 | setStartTarget(e.target as HTMLElement); 272 | } 273 | 274 | const { clientX, clientY } = getClientXY(e); 275 | const { left, top } = scrollContainer.getBoundingClientRect(); 276 | setStartCoords({ 277 | x: clientX - left + scrollContainer.scrollLeft, 278 | y: clientY - top + scrollContainer.scrollTop, 279 | }); 280 | 281 | window.addEventListener('mousemove', onMouseMove, { passive: false }); 282 | window.addEventListener('mouseup', onMouseUp); 283 | window.addEventListener('touchmove', onMouseMove, { passive: false }); 284 | window.addEventListener('touchend', onMouseUp); 285 | }; 286 | 287 | dragContainer.addEventListener('mousedown', onMouseDown); 288 | dragContainer.addEventListener('touchstart', onMouseDown); 289 | scrollListenerElement.addEventListener('scroll', onScroll); 290 | 291 | return () => { 292 | if (scrollContainerOriginPosition) { 293 | scrollContainer.style.position = scrollContainerOriginPosition; 294 | } 295 | cancelScroll(); 296 | dragContainer.removeEventListener('mousedown', onMouseDown); 297 | dragContainer.removeEventListener('touchstart', onMouseDown); 298 | scrollListenerElement.removeEventListener('scroll', onScroll); 299 | window.removeEventListener('mousemove', onMouseMove); 300 | window.removeEventListener('mouseup', onMouseUp); 301 | window.removeEventListener('touchmove', onMouseMove); 302 | window.removeEventListener('touchend', onMouseUp); 303 | }; 304 | }, [disabled, scrollContainer, dragContainer, isCanceled]); 305 | 306 | const contextValue = useMemo( 307 | () => ({ 308 | value, 309 | selectingValue, 310 | isDragging, 311 | boxPosition, 312 | mode, 313 | scrollContainer, 314 | startTarget, 315 | startInside, 316 | unmountItemsInfo, 317 | virtual, 318 | boxRef, 319 | compareFn, 320 | }), 321 | [ 322 | value, 323 | isDragging, 324 | boxPosition, 325 | mode, 326 | scrollContainer, 327 | startTarget, 328 | unmountItemsInfo, 329 | virtual, 330 | boxRef, 331 | compareFn, 332 | ], 333 | ); 334 | 335 | return ( 336 | 337 | {children} 338 | {isDragging && 339 | scrollContainer && 340 | createPortal( 341 |
, 356 | scrollContainer, 357 | )} 358 | 359 | ); 360 | } 361 | 362 | export default React.forwardRef(Selectable) as ( 363 | props: SelectableProps & React.RefAttributes, 364 | ) => React.ReactElement; 365 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | export type Rule = 4 | | 'collision' 5 | | 'inclusion' 6 | | (( 7 | boxElement: HTMLDivElement, 8 | boxPosition: { left: number; top: number; width: number; height: number }, 9 | ) => boolean); 10 | 11 | export type UnmountItemsInfoType = Map< 12 | T, 13 | { 14 | rect: DOMRect; 15 | scrollTop: number; 16 | scrollLeft: number; 17 | rule: Rule; 18 | disabled?: boolean; 19 | } 20 | >; 21 | 22 | interface ISelectableContext { 23 | selectingValue: React.MutableRefObject; 24 | boxPosition: { top: number; left: number; width: number; height: number }; 25 | isDragging: boolean; 26 | value: T[] | undefined; 27 | mode: 'add' | 'remove' | 'reverse'; 28 | scrollContainer: HTMLElement | null; 29 | startTarget: HTMLElement | null; 30 | startInside: React.MutableRefObject; 31 | unmountItemsInfo: React.MutableRefObject>; 32 | virtual: boolean; 33 | boxRef: React.MutableRefObject; 34 | compareFn: (a: T, b: T) => boolean; 35 | } 36 | 37 | export const SelectableContext = React.createContext>( 38 | {} as ISelectableContext, 39 | ); 40 | 41 | export const useSelectableContext = () => { 42 | const context = useContext(SelectableContext); 43 | if (!context) { 44 | throw new Error('Please put the selectable items in Selectable'); 45 | } 46 | return context; 47 | }; 48 | -------------------------------------------------------------------------------- /src/hooks/useContainer.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | const defaultGetContainer = () => document.body; 4 | 5 | export const getPortalContainer = (getContainer: () => HTMLElement = defaultGetContainer) => { 6 | if (typeof window !== 'undefined') { 7 | return getContainer(); 8 | } 9 | return null; 10 | }; 11 | 12 | export default function useContainer(getContainer?: () => HTMLElement) { 13 | const [container, setContainer] = useState(() => getPortalContainer(getContainer)); 14 | 15 | useEffect(() => { 16 | setContainer(getPortalContainer(getContainer)); 17 | }); 18 | 19 | return container; 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/useEvent.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from 'react'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/ban-types 4 | export default function useEvent(callback: T): T { 5 | const fnRef = useRef(callback); 6 | fnRef.current = callback; 7 | 8 | const memoFn = useCallback(((...args: any) => fnRef.current?.(...args)) as any, []); 9 | 10 | return memoFn; 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/useLatest.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | export default function useLatest(value: T) { 4 | const ref = useRef(value); 5 | ref.current = value; 6 | 7 | return ref; 8 | } 9 | -------------------------------------------------------------------------------- /src/hooks/useLayoutUpdateEffect.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const useInternalLayoutEffect = 4 | typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; 5 | 6 | const useLayoutEffect = ( 7 | callback: (mount: boolean) => void | VoidFunction, 8 | deps?: React.DependencyList, 9 | ) => { 10 | const firstMountRef = React.useRef(true); 11 | 12 | useInternalLayoutEffect(() => { 13 | return callback(firstMountRef.current); 14 | }, deps); 15 | 16 | // We tell react that first mount has passed 17 | useInternalLayoutEffect(() => { 18 | firstMountRef.current = false; 19 | return () => { 20 | firstMountRef.current = true; 21 | }; 22 | }, []); 23 | }; 24 | 25 | const useLayoutUpdateEffect: typeof React.useEffect = (callback, deps) => { 26 | useLayoutEffect((firstMount) => { 27 | if (!firstMount) { 28 | return callback(); 29 | } 30 | }, deps); 31 | }; 32 | 33 | export default useLayoutUpdateEffect; 34 | -------------------------------------------------------------------------------- /src/hooks/useMergedState.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import useEvent from './useEvent'; 3 | import useLayoutUpdateEffect from './useLayoutUpdateEffect'; 4 | 5 | type Updater = (updater: T | ((origin: T) => T)) => void; 6 | 7 | export default function useMergedState( 8 | defaultStateValue: T, 9 | option: { 10 | value: T; 11 | }, 12 | ): [T, Updater] { 13 | const { value } = option; 14 | 15 | const [innerValue, setInnerValue] = useState(() => { 16 | if (value !== undefined) { 17 | return value; 18 | } else { 19 | return defaultStateValue; 20 | } 21 | }); 22 | 23 | const mergedValue = value !== undefined ? value : innerValue; 24 | 25 | useLayoutUpdateEffect(() => { 26 | if (value === undefined) { 27 | setInnerValue(value); 28 | } 29 | }, [value]); 30 | 31 | const triggerChange: Updater = useEvent((updater) => { 32 | setInnerValue(updater); 33 | }); 34 | 35 | return [mergedValue, triggerChange]; 36 | } 37 | -------------------------------------------------------------------------------- /src/hooks/useScroll.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { getClientXY } from '../utils'; 3 | 4 | const DEFAULT_SCROLL_SPEED = 4; 5 | 6 | const EDGE_OFFSET = 1; 7 | 8 | export default function useScroll(scrollSpeed = DEFAULT_SCROLL_SPEED) { 9 | const topRaf = useRef(null); 10 | const bottomRaf = useRef(null); 11 | const leftRaf = useRef(null); 12 | const rightRaf = useRef(null); 13 | 14 | const cancelRaf = (raf: React.MutableRefObject) => { 15 | if (raf.current) { 16 | cancelAnimationFrame(raf.current); 17 | raf.current = null; 18 | } 19 | }; 20 | 21 | const cancelScroll = () => { 22 | cancelRaf(topRaf); 23 | cancelRaf(bottomRaf); 24 | cancelRaf(leftRaf); 25 | cancelRaf(rightRaf); 26 | }; 27 | 28 | useEffect(() => { 29 | return cancelScroll; 30 | }, []); 31 | 32 | const smoothScroll = (e: MouseEvent | TouchEvent, _container: HTMLElement) => { 33 | const container = _container === document.body ? document.documentElement : _container; 34 | const { clientX, clientY } = getClientXY(e); 35 | 36 | // top 37 | if (clientY - EDGE_OFFSET <= 0 || clientY <= container.getBoundingClientRect().top) { 38 | if (!topRaf.current) { 39 | const callback = () => { 40 | if (container.scrollTop > 0) { 41 | topRaf.current = requestAnimationFrame(() => { 42 | container.scrollTop -= scrollSpeed; 43 | callback(); 44 | }); 45 | } 46 | }; 47 | callback(); 48 | } 49 | } else { 50 | cancelRaf(topRaf); 51 | } 52 | 53 | // bottom 54 | if ( 55 | clientY + EDGE_OFFSET >= window.innerHeight || 56 | clientY >= container.getBoundingClientRect().bottom 57 | ) { 58 | if (!bottomRaf.current) { 59 | const callback = () => { 60 | if (container.scrollTop < container.scrollHeight - container.clientHeight) { 61 | bottomRaf.current = requestAnimationFrame(() => { 62 | container.scrollTop += scrollSpeed; 63 | callback(); 64 | }); 65 | } 66 | }; 67 | callback(); 68 | } 69 | } else { 70 | cancelRaf(bottomRaf); 71 | } 72 | 73 | // left 74 | if (clientX - EDGE_OFFSET <= 0 || clientX <= container.getBoundingClientRect().left) { 75 | if (!leftRaf.current) { 76 | const callback = () => { 77 | if (container.scrollLeft > 0) { 78 | leftRaf.current = requestAnimationFrame(() => { 79 | container.scrollLeft -= scrollSpeed; 80 | callback(); 81 | }); 82 | } 83 | }; 84 | callback(); 85 | } 86 | } else { 87 | cancelRaf(leftRaf); 88 | } 89 | 90 | // right 91 | if ( 92 | clientX + EDGE_OFFSET >= window.innerWidth || 93 | clientX >= container.getBoundingClientRect().right 94 | ) { 95 | if (!rightRaf.current) { 96 | const callback = () => { 97 | if (container.scrollLeft < container.scrollWidth - container.clientWidth) { 98 | rightRaf.current = requestAnimationFrame(() => { 99 | container.scrollLeft += scrollSpeed; 100 | callback(); 101 | }); 102 | } 103 | }; 104 | callback(); 105 | } 106 | } else { 107 | cancelRaf(rightRaf); 108 | } 109 | }; 110 | 111 | return { 112 | cancelScroll, 113 | smoothScroll, 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /src/hooks/useSelectable.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; 2 | import { Rule, useSelectableContext } from '../context'; 3 | import { checkInRange } from '../utils'; 4 | import useUpdateEffect from './useUpdateEffect'; 5 | 6 | interface UseSelectableProps { 7 | value: T; 8 | disabled?: boolean; 9 | rule?: Rule; 10 | } 11 | 12 | export default function useSelectable({ 13 | value, 14 | disabled, 15 | rule = 'collision', 16 | }: UseSelectableProps) { 17 | const { 18 | mode, 19 | scrollContainer, 20 | boxPosition, 21 | isDragging, 22 | value: contextValue = [], 23 | startInside, 24 | startTarget, 25 | selectingValue, 26 | unmountItemsInfo, 27 | virtual, 28 | boxRef, 29 | compareFn, 30 | } = useSelectableContext(); 31 | const node = useRef(null); 32 | 33 | const [isInRange, setIsInRange] = useState(false); 34 | 35 | useEffect(() => { 36 | setIsInRange( 37 | checkInRange( 38 | rule, 39 | node.current?.getBoundingClientRect(), 40 | scrollContainer, 41 | boxPosition, 42 | boxRef, 43 | ), 44 | ); 45 | }, [rule, scrollContainer, boxPosition]); 46 | 47 | const isSelected = contextValue.some((i) => compareFn(i, value)); 48 | 49 | const isSelecting = isDragging && !disabled && isInRange; 50 | 51 | const isRemoving = isSelecting && isSelected && (mode === 'remove' || mode === 'reverse'); 52 | 53 | const isAdding = isSelecting && !isSelected && (mode === 'add' || mode === 'reverse'); 54 | 55 | const setNodeRef = useCallback((ref: HTMLElement | null) => { 56 | node.current = ref; 57 | }, []); 58 | 59 | useEffect(() => { 60 | if (startTarget && !startInside.current) { 61 | const contain = node.current?.contains(startTarget); 62 | if (contain) { 63 | startInside.current = true; 64 | } 65 | } 66 | }, [startTarget]); 67 | 68 | useEffect(() => { 69 | if (selectingValue) { 70 | if (isSelecting) { 71 | selectingValue.current.push(value); 72 | } else { 73 | selectingValue.current = selectingValue.current.filter((i) => !compareFn(i, value)); 74 | } 75 | } 76 | }, [isSelecting]); 77 | 78 | // collect item unmount information when virtual 79 | useLayoutEffect(() => { 80 | if (virtual) { 81 | unmountItemsInfo.current.delete(value); 82 | 83 | return () => { 84 | if (node.current && scrollContainer) { 85 | unmountItemsInfo.current.set(value, { 86 | rule, 87 | rect: node.current.getBoundingClientRect(), 88 | disabled, 89 | scrollLeft: scrollContainer.scrollLeft, 90 | scrollTop: scrollContainer.scrollTop, 91 | }); 92 | } 93 | }; 94 | } 95 | }, [virtual]); 96 | 97 | // update disabled when virtual and disabled changed 98 | useUpdateEffect(() => { 99 | if (virtual) { 100 | const info = unmountItemsInfo.current.get(value); 101 | if (info) { 102 | unmountItemsInfo.current.set(value, { ...info, disabled, rule }); 103 | } 104 | } 105 | }, [virtual, disabled, rule]); 106 | 107 | return { 108 | setNodeRef, 109 | isSelecting, 110 | isRemoving, 111 | isSelected, 112 | isAdding, 113 | isDragging, 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /src/hooks/useUpdateEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | function useFirstMountState(): boolean { 4 | const isFirst = useRef(true); 5 | 6 | if (isFirst.current) { 7 | isFirst.current = false; 8 | 9 | return true; 10 | } 11 | 12 | return isFirst.current; 13 | } 14 | 15 | const useUpdateEffect: typeof useEffect = (effect, deps) => { 16 | const isFirstMount = useFirstMountState(); 17 | 18 | useEffect(() => { 19 | if (!isFirstMount) { 20 | return effect(); 21 | } 22 | }, deps); 23 | }; 24 | 25 | export default useUpdateEffect; 26 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Selectable from './Selectable'; 2 | import useSelectable from './hooks/useSelectable'; 3 | 4 | export type { SelectableProps, SelectableRef } from './Selectable'; 5 | 6 | export { useSelectable }; 7 | 8 | export default Selectable; 9 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from './context'; 2 | 3 | export const getClientXY = (e: MouseEvent | TouchEvent) => { 4 | const obj = 'touches' in e ? e.touches[0] : e; 5 | return { 6 | clientX: obj.clientX, 7 | clientY: obj.clientY, 8 | }; 9 | }; 10 | 11 | export const checkInRange = ( 12 | rule: Rule, 13 | rect: { left: number; top: number; width: number; height: number } | undefined, 14 | scrollContainer: HTMLElement | null, 15 | boxRect: { top: number; left: number; width: number; height: number }, 16 | boxRef: React.MutableRefObject, 17 | ) => { 18 | if (typeof rule === 'function' && boxRef.current) { 19 | return rule(boxRef.current, boxRect); 20 | } 21 | 22 | if (!rect || !scrollContainer) { 23 | return false; 24 | } 25 | 26 | const { left: rectLeft, top: rectTop, width: rectWidth, height: rectHeight } = rect; 27 | 28 | const { top: containerTop, left: containerLeft } = scrollContainer.getBoundingClientRect(); 29 | const scrollLeft = scrollContainer.scrollLeft; 30 | const scrollTop = scrollContainer.scrollTop; 31 | 32 | const { top: boxTop, left: boxLeft, width: boxWidth, height: boxHeight } = boxRect; 33 | 34 | if (rule === 'collision') { 35 | return ( 36 | rectLeft - containerLeft + scrollLeft < boxLeft + boxWidth && 37 | rectLeft + rectWidth - containerLeft + scrollLeft > boxLeft && 38 | rectTop - containerTop + scrollTop < boxTop + boxHeight && 39 | rectTop + rectHeight - containerTop + scrollTop > boxTop 40 | ); 41 | } 42 | 43 | return ( 44 | rectLeft - containerLeft + scrollLeft >= boxLeft && 45 | rectLeft + rectWidth - containerLeft + scrollLeft <= boxLeft + boxWidth && 46 | rectTop - containerTop + scrollTop >= boxTop && 47 | rectTop + rectHeight - containerTop + scrollTop <= boxTop + boxHeight 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /tests/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import Selectable, { useSelectable } from '../src'; 3 | 4 | const list: string[] = []; 5 | for (let i = 0; i < 10; i++) { 6 | list.push(String(i)); 7 | } 8 | 9 | const Item = ({ value }: { value: string }) => { 10 | const { setNodeRef, isSelected, isAdding, isRemoving } = useSelectable({ 11 | value, 12 | }); 13 | 14 | return ( 15 |
25 | ); 26 | }; 27 | 28 | describe('Selectable', () => { 29 | it('render', async () => { 30 | const { container, unmount } = render( 31 | 32 |
43 | {list.map((i) => ( 44 | 45 | ))} 46 |
47 |
, 48 | ); 49 | expect(container.querySelector('#wrapper')).toBeInTheDocument(); 50 | unmount(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/test-setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | 3 | /* eslint-disable global-require */ 4 | if (typeof window !== 'undefined') { 5 | /** 6 | * ref: https://github.com/vitest-dev/vitest/issues/821#issuecomment-1046954558 7 | * ref: https://github.com/ant-design/ant-design/issues/18774 8 | */ 9 | if (!window.matchMedia) { 10 | Object.defineProperty(global.window, 'matchMedia', { 11 | value: vi.fn((query) => ({ 12 | matches: query.includes('max-width'), 13 | addListener: () => {}, 14 | addEventListener: () => {}, 15 | removeListener: () => {}, 16 | removeEventListener: () => {}, 17 | })), 18 | }); 19 | } 20 | // ref: https://github.com/NickColley/jest-axe/issues/147#issuecomment-758804533 21 | const { getComputedStyle } = window; 22 | window.getComputedStyle = (elt) => getComputedStyle(elt); 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig-check.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "tests", ".dumi/**/*", ".dumirc.ts", "*.ts", "docs"], 3 | "compilerOptions": { 4 | "strict": true, 5 | "declaration": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "target": "ES2015", 9 | "moduleResolution": "Node", 10 | "resolveJsonModule": true, 11 | "jsx": "react-jsx", 12 | "baseUrl": ".", 13 | "types": ["vitest/globals"], 14 | "paths": { 15 | "@@/*": [".dumi/tmp/*"], 16 | "@/*": ["src"], 17 | "react-selectable-box": ["src"], 18 | "react-selectable-box/*": ["src/*", "*"] 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import { name } from './package.json'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | setupFiles: './tests/test-setup.ts', 7 | environment: 'jsdom', 8 | globals: true, 9 | alias: { 10 | '@': './src', 11 | [name]: './src', 12 | }, 13 | coverage: { 14 | reporter: ['text', 'text-summary', 'json', 'lcov'], 15 | include: ['src/**/*'], 16 | }, 17 | }, 18 | }); 19 | --------------------------------------------------------------------------------