16 |
17 | ## ✨ Features
18 |
19 | - Near 60 fps in 6x CPU slowdown on 2.3 GHz Quad-Core Intel Core i7
20 | - Simple API. It doesn't actually select items; just draws the selection box and passes you coordinates so you can determine that (we provided a utility to help though)
21 | - Fully built in TypeScript
22 | - Unit and e2e tested
23 | - Actively battle-tested in a [production-scale application](https://air.inc)
24 |
25 | ## Install
26 |
27 | ```bash
28 | npm install --save @air/react-drag-to-select
29 | ```
30 | ```bash
31 | yarn add @air/react-drag-to-select
32 | ```
33 |
34 | ## Usage
35 |
36 | ```tsx
37 | import { useSelectionContainer } from '@air/react-drag-to-select'
38 |
39 | const App = () => {
40 | const { DragSelection } = useSelectionContainer();
41 |
42 | return (
43 |
44 |
45 |
Selectable element
46 |
47 | )
48 | }
49 |
50 | ```
51 |
52 | Check out this codesandbox for a complete working example: https://codesandbox.io/s/billowing-lake-rzhid4
53 |
54 | ## useSelectionContainer arguments
55 |
56 | |Name|Required|Type|Default|Description|
57 | |----|--------|----|-------|-----------|
58 | |`onSelectionStart`|No|`() => void`||Method called when selection starts (mouse is down and moved)|
59 | |`onSelectionEnd`|No|`() => void`||Method called when selection ends (mouse is up)
60 | |`onSelectionChange`|Yes|`(box: Box) => void`||Method called when selection moves|
61 | |`isEnabled`|No|`boolean`|`true`|If false, selection does not fire|
62 | |`eventsElement`|No|`Window`, `HTMLElement` or `null`|`window`|Element to listen mouse events|
63 | |`selectionProps`|No|`React.HTMLAttributes`||Props of selection - you can pass style here as shown below|
64 | |`shouldStartSelecting`|No|`() => boolean`|`undefined`|If supplied, this callback is fired on mousedown and can be used to prevent selection from starting. This is useful when you want to prevent certain areas of your application from being able to be selected. Returning true will enable selection and returning false will prevent selection from starting.|
65 |
66 | ## Selection styling
67 |
68 | To style the selection box, pass `selectionProps: { style }` prop:
69 |
70 | ```tsx
71 | const { DragSelection } = useSelectionContainer({
72 | ...,
73 | selectionProps: {
74 | style: {
75 | border: '2px dashed purple',
76 | borderRadius: 4,
77 | backgroundColor: 'brown',
78 | opacity: 0.5,
79 | },
80 | },
81 | });
82 | ```
83 |
84 | The default style for the selection box is
85 | ```ts
86 | {
87 | border: '1px solid #4C85D8',
88 | background: 'rgba(155, 193, 239, 0.4)',
89 | position: `absolute`,
90 | zIndex: 99,
91 | }
92 | ```
93 |
94 | ## Disabling selecting in certain areas
95 |
96 |
97 |
98 |
99 |
100 | Sometimes you want to disable a user being able to start selecting in a certain area. You can use the `shouldStartSelecting` prop for this.
101 |
102 | ```tsx
103 | const { DragSelection } = useSelectionContainer({
104 | shouldStartSelecting: (target) => {
105 | /**
106 | * In this example, we're preventing users from selecting in elements
107 | * that have a data-disableselect attribute on them or one of their parents
108 | */
109 | if (target instanceof HTMLElement) {
110 | let el = target;
111 | while (el.parentElement && !el.dataset.disableselect) {
112 | el = el.parentElement;
113 | }
114 | return el.dataset.disableselect !== "true";
115 | }
116 |
117 | /**
118 | * If the target doesn't exist, return false
119 | * This would most likely not happen. It's really a TS safety check
120 | */
121 | return false;
122 | }
123 | });
124 | ```
125 |
126 | See full example here: https://codesandbox.io/s/exciting-rubin-xxf6r0
127 |
128 | ## Scrolling
129 |
130 | Because we use the mouse position to calculate the selection box's coordinates, if your `` is inside of an area that scrolls, you'll need to make some adjustments on your end. Our library can't inherently know which parent is being scrolled nor of it's position inside of the scrolling parent (if there are other sibling elements above it).
131 |
132 | How this is solved on your end is modifiying the `left` (for horizontal scrolling) and `top` (for vertical scrolling) of the `selectionBox` that is passed to `handleSelectionChange`. See the [`onSelectionChange` in the example](https://github.com/AirLabsTeam/react-drag-to-select/blob/main/example/src/App.tsx#L20) for an idea of how to do this.
133 |
134 |
135 |
--------------------------------------------------------------------------------
/cypress.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'cypress';
2 |
3 | export default defineConfig({
4 | e2e: {
5 | baseUrl: 'http://localhost:3000',
6 | setupNodeEvents(on, config) {
7 | // implement node event listeners here
8 | }
9 | }
10 | });
11 |
--------------------------------------------------------------------------------
/cypress/e2e/selecting.cy.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | describe('react-drag-to-select example', () => {
4 | beforeEach(() => {
5 | cy.visit('/');
6 | });
7 |
8 | it('can select some items', () => {
9 | cy.get('.container')
10 | .trigger('mousedown', 10, 10, {
11 | eventConstructor: 'MouseEvent',
12 | })
13 | .trigger('mousemove', 400, 150, {
14 | eventConstructor: 'MouseEvent',
15 | })
16 | .trigger('mouseup');
17 |
18 | for (let index = 0; index < 16; index++) {
19 | if (index < 3) {
20 | cy.get(`.element[data-testid="grid-cell-${index}"]`).should('have.class', 'selected');
21 | } else {
22 | cy.get(`.element[data-testid="grid-cell-${index}"]`).should('not.have.class', 'selected');
23 | }
24 | }
25 | });
26 |
27 | it('can select some items after scrolling', { scrollBehavior: false }, () => {
28 | cy.viewport(500, 200);
29 |
30 | cy.get('.element[data-testid="grid-cell-8"]').scrollIntoView()
31 |
32 | cy.get('.container', { force: true })
33 | .trigger('mousedown', 10, 320, {
34 | eventConstructor: 'MouseEvent',
35 | force: true
36 | })
37 | .trigger('mousemove', 320, 325, {
38 | eventConstructor: 'MouseEvent',
39 | force: true
40 | })
41 | .trigger('mouseup')
42 |
43 | for (let index = 0; index < 16; index++) {
44 | if (index > 7 && index < 11) {
45 | cy.get(`.element[data-testid="grid-cell-${index}"]`).should('have.class', 'selected');
46 | } else {
47 | cy.get(`.element[data-testid="grid-cell-${index}"]`).should('not.have.class', 'selected');
48 | }
49 | }
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/cypress/support/commands.ts:
--------------------------------------------------------------------------------
1 | ///
2 | // ***********************************************
3 | // This example commands.ts shows you how to
4 | // create various custom commands and overwrite
5 | // existing commands.
6 | //
7 | // For more comprehensive examples of custom
8 | // commands please read more here:
9 | // https://on.cypress.io/custom-commands
10 | // ***********************************************
11 | //
12 | //
13 | // -- This is a parent command --
14 | // Cypress.Commands.add('login', (email, password) => { ... })
15 | //
16 | //
17 | // -- This is a child command --
18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
19 | //
20 | //
21 | // -- This is a dual command --
22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
23 | //
24 | //
25 | // -- This will overwrite an existing command --
26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
27 | //
28 | // declare global {
29 | // namespace Cypress {
30 | // interface Chainable {
31 | // login(email: string, password: string): Chainable
32 | // drag(subject: string, options?: Partial): Chainable
33 | // dismiss(subject: string, options?: Partial): Chainable
34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable
35 | // }
36 | // }
37 | // }
--------------------------------------------------------------------------------
/cypress/support/e2e.ts:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/e2e.ts is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js using ES2015 syntax:
17 | import './commands'
18 |
19 | // Alternatively you can use CommonJS syntax:
20 | // require('./commands')
--------------------------------------------------------------------------------
/example/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | This example was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
2 |
3 | It is linked to the react-drag-to-select package in the parent directory for development purposes.
4 |
5 | You can run `yarn install` and then `yarn start` to test your package.
6 |
--------------------------------------------------------------------------------
/example/assets/air.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AirLabsTeam/react-drag-to-select/f8fcc1e0156dd575881e593f41a115a2fa78169d/example/assets/air.png
--------------------------------------------------------------------------------
/example/assets/disable-select-example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AirLabsTeam/react-drag-to-select/f8fcc1e0156dd575881e593f41a115a2fa78169d/example/assets/disable-select-example.gif
--------------------------------------------------------------------------------
/example/assets/example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AirLabsTeam/react-drag-to-select/f8fcc1e0156dd575881e593f41a115a2fa78169d/example/assets/example.gif
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "example",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@air/react-drag-to-select": "file:..",
7 | "@testing-library/jest-dom": "^5.16.4",
8 | "@testing-library/react": "^13.3.0",
9 | "@testing-library/user-event": "^13.5.0",
10 | "@types/jest": "^27.5.2",
11 | "@types/node": "^16.11.45",
12 | "@types/react": "^18.0.15",
13 | "@types/react-dom": "^18.0.6",
14 | "react": "file:../node_modules/react",
15 | "react-dom": "file:../node_modules/react-dom",
16 | "react-style-object-to-css": "file:../node_modules/react-style-object-to-css",
17 | "typescript": "^4.7.4",
18 | "web-vitals": "^2.1.4"
19 | },
20 | "devDependencies": {
21 | "react-scripts": "5.0.1"
22 | },
23 | "scripts": {
24 | "start": "react-scripts start",
25 | "build": "react-scripts build",
26 | "test": "react-scripts test",
27 | "eject": "react-scripts eject"
28 | },
29 | "eslintConfig": {
30 | "extends": [
31 | "react-app",
32 | "react-app/jest"
33 | ]
34 | },
35 | "browserslist": {
36 | "production": [
37 | ">0.2%",
38 | "not dead",
39 | "not op_mini all"
40 | ],
41 | "development": [
42 | "last 1 chrome version",
43 | "last 1 firefox version",
44 | "last 1 safari version"
45 | ]
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/example/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AirLabsTeam/react-drag-to-select/f8fcc1e0156dd575881e593f41a115a2fa78169d/example/public/favicon.ico
--------------------------------------------------------------------------------
/example/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/example/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/example/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/example/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | height: 40vmin;
7 | pointer-events: none;
8 | }
9 |
10 | @media (prefers-reduced-motion: no-preference) {
11 | .App-logo {
12 | animation: App-logo-spin infinite 20s linear;
13 | }
14 | }
15 |
16 | .App-header {
17 | background-color: #282c34;
18 | min-height: 100vh;
19 | display: flex;
20 | flex-direction: column;
21 | align-items: center;
22 | justify-content: center;
23 | font-size: calc(10px + 2vmin);
24 | color: white;
25 | }
26 |
27 | .App-link {
28 | color: #61dafb;
29 | }
30 |
31 | @keyframes App-logo-spin {
32 | from {
33 | transform: rotate(0deg);
34 | }
35 | to {
36 | transform: rotate(360deg);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/example/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react';
2 | import './App.css';
3 | import { Box, boxesIntersect, useSelectionContainer } from '@air/react-drag-to-select';
4 |
5 | function App() {
6 | const [selectionBox, setSelectionBox] = useState();
7 | const [selectedIndexes, setSelectedIndexes] = useState([]);
8 | const selectableItems = useRef([]);
9 | const elementsContainerRef = useRef(null);
10 |
11 | const { DragSelection } = useSelectionContainer({
12 | eventsElement: document.getElementById('root'),
13 | onSelectionChange: (box) => {
14 | /**
15 | * Here we make sure to adjust the box's left and top with the scroll position of the window
16 | * @see https://github.com/AirLabsTeam/react-drag-to-select/#scrolling
17 | */
18 | const scrollAwareBox = {
19 | ...box,
20 | top: box.top + window.scrollY,
21 | left: box.left + window.scrollX,
22 | };
23 |
24 | setSelectionBox(scrollAwareBox);
25 | const indexesToSelect: number[] = [];
26 | selectableItems.current.forEach((item, index) => {
27 | if (boxesIntersect(scrollAwareBox, item)) {
28 | indexesToSelect.push(index);
29 | }
30 | });
31 |
32 | setSelectedIndexes(indexesToSelect);
33 | },
34 | onSelectionStart: () => {},
35 | onSelectionEnd: () => {},
36 | selectionProps: {
37 | style: {
38 | border: '2px dashed purple',
39 | borderRadius: 4,
40 | backgroundColor: 'brown',
41 | opacity: 0.5,
42 | },
43 | },
44 | shouldStartSelecting: (target) => {
45 | // do something with target to determine if the user should start selecting
46 |
47 | return true;
48 | },
49 | });
50 |
51 | useEffect(() => {
52 | if (elementsContainerRef.current) {
53 | Array.from(elementsContainerRef.current.children).forEach((item) => {
54 | const { left, top, width, height } = item.getBoundingClientRect();
55 | selectableItems.current.push({
56 | left,
57 | top,
58 | width,
59 | height,
60 | });
61 | });
62 | }
63 | }, []);
64 |
65 | return (
66 |