├── .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 | 
67 | passing `items`:
68 | 
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 | 
66 | 传入 `items`:
67 | 
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 |
--------------------------------------------------------------------------------