├── .babelrc ├── .circleci └── config.yml ├── .eslintignore ├── .eslintrc.json ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── github-pages.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── .storybook ├── main.ts └── manager.ts ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── bili.config.ts ├── package.json ├── scripts └── copy-build-files.js ├── src ├── helpers.test.ts ├── helpers.ts ├── hooks.tsx ├── index.tsx └── types.ts ├── stories ├── helpers │ └── index.ts ├── interactive-avatars │ └── index.stories.tsx ├── simple-grid │ └── index.stories.tsx ├── simple-horizontal-list │ └── index.stories.tsx ├── simple-vertical-list │ └── index.stories.tsx ├── with-drop-target │ └── index.stories.tsx ├── with-knobs │ └── index.stories.tsx └── with-locked-axis │ └── index.stories.tsx ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"], 3 | "plugins": [ 4 | ["@babel/plugin-proposal-class-properties", { "loose": true }], 5 | "@babel/plugin-proposal-object-rest-spread" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: cypress/base:10 6 | environment: 7 | ## this enables colors in the output 8 | TERM: xterm 9 | auth: 10 | username: $DOCKER_HUB_USERNAME 11 | password: $DOCKER_HUB_PASSWORD 12 | steps: 13 | - checkout 14 | - restore_cache: # special step to restore the dependency cache 15 | key: dependency-cache-{{ checksum "yarn.lock" }} 16 | - run: 17 | name: Setup Dependencies 18 | command: yarn install --frozen-lockfile 19 | - save_cache: # special step to save the dependency cache 20 | key: dependency-cache-{{ checksum "yarn.lock" }} 21 | paths: 22 | - ~/.cache ## cache both yarn and Cypress 23 | - run: 24 | name: Run linting 25 | command: | 26 | yarn lint 27 | - run: 28 | name: Run unit tests 29 | command: | 30 | yarn unit 31 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | docs/.cache 2 | dist 3 | .yalc 4 | webpack.config.js -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:@typescript-eslint/recommended", 4 | "react-app", 5 | "prettier", 6 | "prettier/@typescript-eslint", 7 | "prettier/react" 8 | ], 9 | "plugins": ["@typescript-eslint/eslint-plugin"], 10 | "rules": { 11 | "@typescript-eslint/explicit-function-return-type": 0, 12 | "@typescript-eslint/ban-ts-ignore": 0, 13 | "@typescript-eslint/explicit-module-boundary-types": 0, 14 | "@typescript-eslint/ban-types": 0 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to react-easy-sort 2 | 3 | **Working on your first Pull Request?** You can learn how from this _free_ series [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github) 4 | 5 | ## Setting Up the project locally 6 | 7 | To install the project you need to have `yarn` and `node` 8 | 9 | 1. [Fork](https://help.github.com/articles/fork-a-repo/) the project, clone your fork: 10 | 11 | ``` 12 | # Clone your fork 13 | git clone https://github.com//react-easy-sort.git 14 | 15 | # Navigate to the newly cloned directory 16 | cd react-easy-sort 17 | ``` 18 | 19 | 2. `yarn` to install dependencies 20 | 3. `yarn start` to start the example app 21 | 22 | > Tip: Keep your `main` branch pointing at the original repository and make 23 | > pull requests from branches on your fork. To do this, run: 24 | > 25 | > ``` 26 | > git remote add upstream git@github.com:ValentinH/react-easy-sort.git 27 | > git fetch upstream 28 | > git branch --set-upstream-to=upstream/main main 29 | > ``` 30 | > 31 | > This will add the original repository as a "remote" called "upstream," 32 | > Then fetch the git information from that remote, then set your local `main` 33 | > branch to use the upstream main branch whenever you run `git pull`. 34 | > Then you can make all of your pull request branches based on this `main` 35 | > branch. Whenever you want to update your version of `main`, do a regular 36 | > `git pull`. 37 | 38 | ## Submitting a Pull Request 39 | 40 | Please go through existing issues and pull requests to check if somebody else is already working on it. 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | - Please create a CodeSandbox that demonstrates your issue. You can fork this example: https://codesandbox.io/s/react-easy-sort-grid-demo-87ev9. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | 22 | - If you have ideas how to implement your new feature, please share! 23 | 24 | - If you know any examples online that already implement such functionality, please share a link. 25 | -------------------------------------------------------------------------------- /.github/workflows/github-pages.yml: -------------------------------------------------------------------------------- 1 | name: Github pages deployment 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | 10 | jobs: 11 | deploy-docs: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Use Node.js 16.x 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: 16.x 20 | 21 | # setup cache 22 | - name: Get yarn cache directory path 23 | id: yarn-cache-dir-path 24 | run: echo "::set-output name=dir::$(yarn cache dir)" 25 | - uses: actions/cache@v2 26 | id: yarn-cache 27 | with: 28 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 29 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 30 | restore-keys: | 31 | ${{ runner.os }}-yarn- 32 | 33 | - name: Install modules 34 | run: yarn 35 | 36 | - name: Build storybook 37 | run: yarn build-storybook 38 | 39 | - name: Deploy to GitHub Pages 40 | uses: JamesIves/github-pages-deploy-action@4.0.0 41 | with: 42 | branch: gh-pages # The branch the action should deploy to. 43 | folder: docs # The folder the action should deploy. 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | docs 4 | .idea 5 | *.iml 6 | .yalc 7 | yalc.lock 8 | yarn-error.log 9 | .DS_Store -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "es5" 6 | } -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../stories/**/*.stories.mdx', '../stories/**/*.stories.@(js|jsx|ts|tsx)'], 3 | addons: ['@storybook/addon-essentials', '@storybook/addon-links'], 4 | } 5 | -------------------------------------------------------------------------------- /.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/addons' 2 | import { create } from '@storybook/theming' 3 | 4 | const theme = create({ 5 | base: 'light', 6 | brandTitle: 'react-easy-sort', 7 | }) 8 | 9 | addons.setConfig({ 10 | theme, 11 | }) 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | You can see the changelog on the [releases page](../../releases). 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at valentin@hervi.eu. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Valentin Hervieu 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 | # react-easy-sort 2 | 3 | A React component to sort items in lists or grids 4 | 5 | [![version][version-badge]][package] [![Monthly downloads][npmstats-badge]][npmstats] ![gzip size][brotli-badge] [![MIT License][license-badge]][license] [![PRs Welcome][prs-badge]][prs] 6 | 7 | ![react-easy-sort-demo](https://user-images.githubusercontent.com/2678610/107036435-f27fbb00-67b9-11eb-8e3f-72a000586d35.gif) 8 | 9 | The goal of this component is to allow sorting elements with drag and drop. 10 | 11 | It is mobile friendly by default. It doesn't block scrolling the page when swiping inside it: 12 | the user needs to press an item during at least 200ms to start the drag gesture. 13 | 14 | On non-touch devices, the drag gesture only starts after moving an element by at least one pixel. 15 | This is done to avoid blocking clicks on clickable elements inside an item. 16 | 17 | ## Features 18 | 19 | - Supports horizontal and vertical lists 20 | - Supports grid layouts 21 | - Mobile-friendly 22 | - IE11 support 🙈 23 | 24 | ## Demo 25 | 26 | Check out the examples: 27 | 28 | - [Example with grid layout](https://codesandbox.io/s/react-easy-sort-grid-demo-87ev9) 29 | - [Example with vertical list layout](https://codesandbox.io/s/react-easy-sort-vertical-list-demo-njg4i) 30 | - [Example with horizontal list layout](https://codesandbox.io/s/react-easy-sort-horizontal-list-demo-69b3k) 31 | - [Interactive avatars demo](https://codesandbox.io/s/react-easy-sort-images-demo-486qk) 32 | - [Example with custom knobs](https://codesandbox.io/s/react-easy-sort-custom-knob-demo-ij37h) 33 | 34 | ## Installation 35 | 36 | ```shell 37 | yarn add react-easy-sort 38 | ``` 39 | 40 | or 41 | 42 | ```shell 43 | npm install react-easy-sort --save 44 | ``` 45 | 46 | ## Basic usage 47 | 48 | ```js 49 | import SortableList, { SortableItem } from 'react-easy-sort' 50 | import { arrayMoveImmutable } from 'array-move' 51 | 52 | const App = () => { 53 | const [items, setItems] = React.useState(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']) 54 | 55 | const onSortEnd = (oldIndex: number, newIndex: number) => { 56 | setItems((array) => arrayMoveImmutable(array, oldIndex, newIndex)) 57 | } 58 | 59 | return ( 60 | 61 | {items.map((item) => ( 62 | 63 |
{item}
64 |
65 | ))} 66 |
67 | ) 68 | } 69 | ``` 70 | 71 | ## Props 72 | 73 | ### SortableList 74 | 75 | | Name | Description | Type | Default | 76 | | ------------------------ | :----------------------------------------------------------: | :--------------------------------------------: | --------------: | 77 | | **as** | Determines html tag for the container element | `keyof JSX.IntrinsicElements` | `div` | 78 | | **onSortEnd\*** | Called when the user finishes a sorting gesture. | `(oldIndex: number, newIndex: number) => void` | - | 79 | | **draggedItemClassName** | Class applied to the item being dragged | `string` | - | 80 | | **lockAxis** | Determines if an axis should be locked | `'x'` or `'y'` | | 81 | | **allowDrag** | Determines whether items can be dragged | `boolean` | `true` | 82 | | **customHolderRef** | Ref of an element to use as a container for the dragged item | `React.RefObject` | `document.body` | 83 | | **dropTarget** | React element to use as a dropTarget | `ReactNode` | | 84 | 85 | ### SortableItem 86 | 87 | This component doesn't take any other props than its child. This child should be a single React element that can receives a ref. If you pass a component as a child, it needs to be wrapped with `React.forwardRef()`. 88 | 89 | ### SortableKnob 90 | 91 | You can use this component if you don't want the whole item to be draggable but only a specific area of it. 92 | 93 | ```js 94 | import SortableList, { SortableItem, SortableKnob } from 'react-easy-sort' 95 | import arrayMove from 'array-move' 96 | 97 | const App = () => { 98 | const [items, setItems] = React.useState(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']) 99 | 100 | const onSortEnd = (oldIndex: number, newIndex: number) => { 101 | setItems((array) => arrayMove(array, oldIndex, newIndex)) 102 | } 103 | 104 | return ( 105 | 106 | {items.map((item) => ( 107 | 108 |
109 | 110 |
Drag me
111 |
112 | {item} 113 |
114 |
115 | ))} 116 |
117 | ) 118 | } 119 | ``` 120 | 121 | This component doesn't take any other props than its child. This child should be a single React element that can receive a ref. If you pass a component as a child, it needs to be wrapped with `React.forwardRef()`. 122 | 123 | ## Recommended CSS rules 124 | 125 | To disable browser default behaviors that can interfer with the dragging experience, we recommend adding the following declarations on the "items": 126 | 127 | - `user-select: none;`: disable the selection of content inside the item (the blue box) 128 | - `pointer-events: none;`: required for some browsers if your items contain images (see the [Interactive avatars demo](https://codesandbox.io/s/react-easy-sort-images-demo-486qk)) 129 | 130 | ## Development 131 | 132 | ```shell 133 | yarn 134 | yarn start 135 | ``` 136 | 137 | Now, open `http://localhost:3001/index.html` and start hacking! 138 | 139 | ## License 140 | 141 | [MIT](https://github.com/ValentinH/react-easy-sort/blob/master/LICENSE) 142 | 143 | ## Maintainers 144 | 145 | This project is maintained by Valentin Hervieu. 146 | 147 | This project was originally part of [@ricardo-ch](https://github.com/ricardo-ch/) organisation because I (Valentin) was working at Ricardo. 148 | After leaving this company, they gracefully accepted to transfer the project to me. ❤️ 149 | 150 | ## Alternatives 151 | 152 | - https://github.com/clauderic/react-sortable-hoc : before creating this library, I was using it and it was also supporting grid layouts. However, we had a lot of errors reported to our Sentry and this project was not maintained anymore. 153 | - https://github.com/atlassian/react-beautiful-dnd: another great library for sorting items. However, it doesn't support grid layouts (as of 2021-02-05). 154 | 155 | [npm]: https://www.npmjs.com/ 156 | [node]: https://nodejs.org 157 | [version-badge]: https://img.shields.io/npm/v/react-easy-sort.svg?style=flat-square 158 | [package]: https://www.npmjs.com/package/react-easy-sort 159 | [downloads-badge]: https://img.shields.io/npm/dm/react-easy-sort.svg?style=flat-square 160 | [npmstats]: https://npm-stat.com/charts.html?package=react-easy-sort&from=2021-02-01 161 | [npmstats-badge]: https://img.shields.io/npm/dm/react-easy-sort.svg?style=flat-square 162 | [brotli-badge]: http://img.badgesize.io/https://unpkg.com/react-easy-sort/umd/react-easy-sort.min.js?compression=brotli&style=flat-square&1 163 | [license-badge]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square 164 | [license]: https://github.com/ValentinH/react-easy-sort/blob/main/LICENSE 165 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square 166 | [prs]: http://makeapullrequest.com 167 | -------------------------------------------------------------------------------- /bili.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from 'bili' 2 | 3 | const config: Config = { 4 | input: { 5 | index: 'src/index.tsx', 6 | }, 7 | output: { 8 | format: ['cjs', 'umd', 'umd-min', 'esm'], 9 | moduleName: 'ReactEasySort', 10 | sourceMap: true, 11 | }, 12 | globals: { 13 | react: 'React', 14 | }, 15 | extendConfig(config, { format }) { 16 | if (format.startsWith('umd')) { 17 | config.output.fileName = 'umd/react-easy-sort[min].js' 18 | } 19 | if (format === 'esm') { 20 | config.output.fileName = '[name].module.js' 21 | } 22 | return config 23 | }, 24 | env: { 25 | NODE_ENV: 'production', 26 | }, 27 | } 28 | 29 | export default config 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-easy-sort", 3 | "version": "1.6.0", 4 | "description": "A React component to sort items in lists or grids", 5 | "homepage": "https://ValentinH.github.io/react-easy-sort/", 6 | "keywords": [ 7 | "react", 8 | "sort", 9 | "sortable", 10 | "sortable grid", 11 | "sortable list" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/ValentinH/react-easy-sort" 16 | }, 17 | "publishConfig": { 18 | "registry": "https://registry.npmjs.org/" 19 | }, 20 | "engines": { 21 | "node": ">=16" 22 | }, 23 | "author": "Valentin Hervieu", 24 | "license": "MIT", 25 | "sideEffects": false, 26 | "scripts": { 27 | "start": "yarn storybook", 28 | "version": "yarn && yarn build", 29 | "build": "yarn-or-npm bili && yarn-or-npm build:copy-files", 30 | "test": "yarn-or-npm unit && yarn-or-npm lint && yarn-or-npm lint:ts", 31 | "unit": "jest src", 32 | "unit:watch": "jest --watchAll src", 33 | "lint": "eslint --ext .ts,.tsx ./src", 34 | "lint:ts": "tsc --noEmit", 35 | "prebuild": "rimraf dist", 36 | "build:copy-files": "babel-node ./scripts/copy-build-files.js", 37 | "prepublishOnly": "yarn-or-npm build", 38 | "precommit": "lint-staged", 39 | "format": "prettier --write src/**/*.ts* stories/**/*.ts*", 40 | "npm:publish": "np --contents=dist", 41 | "storybook": "start-storybook -p 6006", 42 | "build-storybook": "build-storybook -o ./docs" 43 | }, 44 | "peerDependencies": { 45 | "react": ">=16.4.0", 46 | "react-dom": ">=16.4.0" 47 | }, 48 | "dependencies": { 49 | "array-move": "^3.0.1", 50 | "tslib": "2.0.1" 51 | }, 52 | "devDependencies": { 53 | "@babel/core": "^7.3.4", 54 | "@babel/node": "^7.7.7", 55 | "@babel/plugin-proposal-class-properties": "^7.3.4", 56 | "@babel/plugin-proposal-object-rest-spread": "^7.3.4", 57 | "@babel/preset-env": "^7.3.4", 58 | "@babel/preset-react": "^7.0.0", 59 | "@material-ui/core": "^4.11.3", 60 | "@material-ui/icons": "^4.11.2", 61 | "@storybook/addon-actions": "^6.3.8", 62 | "@storybook/addon-essentials": "^6.3.8", 63 | "@storybook/addon-links": "^6.3.8", 64 | "@storybook/react": "^6.3.8", 65 | "@types/jest": "^26.0.14", 66 | "@types/react": "^16.9.17", 67 | "@types/react-dom": "^16.9.4", 68 | "@typescript-eslint/eslint-plugin": "4.2.0", 69 | "@typescript-eslint/parser": "4.2.0", 70 | "all-contributors-cli": "^6.4.0", 71 | "babel-eslint": "10.x", 72 | "babel-loader": "^8.0.5", 73 | "bili": "^5.0.5", 74 | "css-loader": "^4.3.0", 75 | "eslint": "7.9.0", 76 | "eslint-config-prettier": "^6.9.0", 77 | "eslint-config-react-app": "^5.1.0", 78 | "eslint-plugin-flowtype": "5.2.0", 79 | "eslint-plugin-import": "2.x", 80 | "eslint-plugin-jsx-a11y": "6.x", 81 | "eslint-plugin-react": "7.x", 82 | "eslint-plugin-react-hooks": "4.1.2", 83 | "jest": "26.4.2", 84 | "lint-staged": "^10.4.0", 85 | "np": "^8.0.4", 86 | "prettier": "^2.1.2", 87 | "query-string": "^6.1.0", 88 | "raw-loader": "^4.0.1", 89 | "react": "^16.4.1", 90 | "react-dom": "^16.4.1", 91 | "rimraf": "^3.0.0", 92 | "rollup-plugin-typescript2": "^0.27.1", 93 | "start-server-and-test": "^1.4.1", 94 | "style-loader": "^1.2.1", 95 | "ts-jest": "^26.4.0", 96 | "ts-loader": "^8.0.4", 97 | "typescript": "^4.0.3", 98 | "yarn-or-npm": "^3.0.1" 99 | }, 100 | "lint-staged": { 101 | "*.+(ts|tsx|js|css)": [ 102 | "prettier --write", 103 | "git add" 104 | ] 105 | }, 106 | "jest": { 107 | "preset": "ts-jest" 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /scripts/copy-build-files.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console, import/no-extraneous-dependencies */ 2 | import path from 'path' 3 | import fse from 'fs-extra' 4 | 5 | async function copyFile(file) { 6 | const buildPath = path.resolve(__dirname, '../dist/', path.basename(file.to || file.from)) 7 | await fse.copy(file.from, buildPath) 8 | console.log(`Copied ${file.from} to ${buildPath}`) 9 | } 10 | 11 | async function createPackageFile() { 12 | const packageData = await fse.readFile(path.resolve(__dirname, '../package.json'), 'utf8') 13 | const newPackageData = { 14 | ...JSON.parse(packageData), 15 | scripts: undefined, 16 | devDependencies: undefined, 17 | jest: undefined, 18 | 'lint-staged': undefined, 19 | main: './index.js', 20 | 'umd:main': './umd/react-easy-sort.js', 21 | unpkg: './umd/react-easy-sort.js', 22 | jsdelivr: './umd/react-easy-sort.js', 23 | module: './index.module.js', 24 | 'jsnext:main': './index.module.js', 25 | 'react-native': './index.module.js', 26 | types: './index.d.ts', 27 | exports: { 28 | '.': { 29 | import: './index.module.js', 30 | require: './index.js', 31 | types: './index.d.ts', 32 | }, 33 | }, 34 | } 35 | const buildPath = path.resolve(__dirname, '../dist/package.json') 36 | 37 | await fse.writeFile(buildPath, JSON.stringify(newPackageData, null, 2), 'utf8') 38 | console.log(`Created package.json in ${buildPath}`) 39 | 40 | return newPackageData 41 | } 42 | 43 | async function run() { 44 | await Promise.all([{ from: './README.md' }].map((file) => copyFile(file))) 45 | await createPackageFile() 46 | } 47 | 48 | run() 49 | -------------------------------------------------------------------------------- /src/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import * as helpers from './helpers' 2 | 3 | const getRect = ({ 4 | left, 5 | top, 6 | width, 7 | height, 8 | }: { 9 | left: number 10 | top: number 11 | width: number 12 | height: number 13 | }): DOMRect => ({ 14 | top, 15 | left, 16 | width, 17 | height, 18 | right: left + width, 19 | bottom: top + height, 20 | x: left, 21 | y: top, 22 | toJSON: () => '', 23 | }) 24 | 25 | describe('SortableList helpers', () => { 26 | describe('findItemIndexAtPosition', () => { 27 | it('should return 0 if point is in the first item', () => { 28 | const point = { x: 20, y: 20 } 29 | const rects: DOMRect[] = [ 30 | getRect({ left: 10, top: 10, width: 100, height: 100 }), 31 | getRect({ left: 120, top: 10, width: 100, height: 100 }), 32 | getRect({ left: 230, top: 10, width: 100, height: 100 }), 33 | ] 34 | 35 | const index = helpers.findItemIndexAtPosition(point, rects) 36 | 37 | expect(index).toEqual(0) 38 | }) 39 | 40 | it('should return 2 if point is in the last item', () => { 41 | const point = { x: 300, y: 50 } 42 | const rects: DOMRect[] = [ 43 | getRect({ left: 10, top: 10, width: 100, height: 100 }), 44 | getRect({ left: 120, top: 10, width: 100, height: 100 }), 45 | getRect({ left: 230, top: 10, width: 100, height: 100 }), 46 | ] 47 | 48 | const index = helpers.findItemIndexAtPosition(point, rects) 49 | 50 | expect(index).toEqual(2) 51 | }) 52 | it('should return the right index if point is inside an item positioned in a grid', () => { 53 | const point = { x: 100, y: 200 } 54 | const rects: DOMRect[] = [ 55 | getRect({ left: 10, top: 10, width: 100, height: 100 }), 56 | getRect({ left: 120, top: 10, width: 100, height: 100 }), 57 | getRect({ left: 10, top: 120, width: 100, height: 100 }), 58 | getRect({ left: 120, top: 120, width: 100, height: 100 }), 59 | ] 60 | 61 | const index = helpers.findItemIndexAtPosition(point, rects) 62 | 63 | expect(index).toEqual(2) 64 | }) 65 | 66 | it('should return -1 if point is not inside any items and fallbackToClosest is false', () => { 67 | const point = { x: 150, y: -20 } 68 | const rects: DOMRect[] = [ 69 | getRect({ left: 10, top: 10, width: 100, height: 100 }), 70 | getRect({ left: 120, top: 10, width: 100, height: 100 }), 71 | getRect({ left: 230, top: 10, width: 100, height: 100 }), 72 | ] 73 | 74 | const index = helpers.findItemIndexAtPosition(point, rects, { fallbackToClosest: false }) 75 | 76 | expect(index).toEqual(-1) 77 | }) 78 | it('should the closest index if point is not inside any items and fallbackToClosest is true', () => { 79 | const point = { x: 150, y: -20 } 80 | const rects: DOMRect[] = [ 81 | getRect({ left: 10, top: 10, width: 100, height: 100 }), 82 | getRect({ left: 120, top: 10, width: 100, height: 100 }), 83 | getRect({ left: 230, top: 10, width: 100, height: 100 }), 84 | ] 85 | 86 | const index = helpers.findItemIndexAtPosition(point, rects, { fallbackToClosest: true }) 87 | 88 | expect(index).toEqual(1) 89 | }) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Point } from './types' 2 | 3 | /** 4 | * This function check if a given point is inside of the items rect. 5 | * If it's not inside any rect, it will return the index of the closest rect 6 | */ 7 | export const findItemIndexAtPosition = ( 8 | { x, y }: Point, 9 | itemsRect: DOMRect[], 10 | { fallbackToClosest = false } = {} 11 | ): number => { 12 | let smallestDistance = 10000 13 | let smallestDistanceIndex = -1 14 | for (let index = 0; index < itemsRect.length; index += 1) { 15 | const rect = itemsRect[index] 16 | // if it's inside the rect, we return the current index directly 17 | if (x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom) { 18 | return index 19 | } 20 | if (fallbackToClosest) { 21 | // otherwise we compute the distance and update the smallest distance index if needed 22 | const itemCenterX = (rect.left + rect.right) / 2 23 | const itemCenterY = (rect.top + rect.bottom) / 2 24 | 25 | const distance = Math.sqrt(Math.pow(x - itemCenterX, 2) + Math.pow(y - itemCenterY, 2)) // ** 2 operator is not supported on IE11 26 | if (distance < smallestDistance) { 27 | smallestDistance = distance 28 | smallestDistanceIndex = index 29 | } 30 | } 31 | } 32 | return smallestDistanceIndex 33 | } 34 | -------------------------------------------------------------------------------- /src/hooks.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { Point } from './types' 4 | 5 | const getMousePoint = (e: MouseEvent | React.MouseEvent): Point => ({ 6 | x: Number(e.clientX), 7 | y: Number(e.clientY), 8 | }) 9 | 10 | const getTouchPoint = (touch: Touch | React.Touch): Point => ({ 11 | x: Number(touch.clientX), 12 | y: Number(touch.clientY), 13 | }) 14 | 15 | const getPointInContainer = (point: Point, containerTopLeft: Point): Point => { 16 | return { 17 | x: point.x - containerTopLeft.x, 18 | y: point.y - containerTopLeft.y, 19 | } 20 | } 21 | 22 | const preventDefault = (event: Event) => { 23 | event.preventDefault() 24 | } 25 | 26 | const disableContextMenu = () => { 27 | window.addEventListener('contextmenu', preventDefault, { capture: true, passive: false }) 28 | } 29 | 30 | const enableContextMenu = () => { 31 | window.removeEventListener('contextmenu', preventDefault) 32 | } 33 | 34 | export type OnStartArgs = { point: Point; pointInWindow: Point } 35 | export type OnMoveArgs = { point: Point; pointInWindow: Point } 36 | 37 | type UseDragProps = { 38 | onStart?: (args: OnStartArgs) => void 39 | onMove?: (args: OnMoveArgs) => void 40 | onEnd?: () => void 41 | allowDrag?: boolean 42 | containerRef: React.MutableRefObject 43 | knobs?: HTMLElement[] 44 | } 45 | 46 | export const useDrag = ({ 47 | onStart, 48 | onMove, 49 | onEnd, 50 | allowDrag = true, 51 | containerRef, 52 | knobs, 53 | }: UseDragProps) => { 54 | // contains the top-left coordinates of the container in the window. Set on drag start and used in drag move 55 | const containerPositionRef = React.useRef({ x: 0, y: 0 }) 56 | // on touch devices, we only start the drag gesture after pressing the item 200ms. 57 | // this ref contains the timer id to be able to cancel it 58 | const handleTouchStartTimerRef = React.useRef(undefined) 59 | // on non-touch device, we don't call onStart on mouse down but on the first mouse move 60 | // we do this to let the user clicks on clickable element inside the container 61 | // this means that the drag gesture actually starts on the fist move 62 | const isFirstMoveRef = React.useRef(false) 63 | // see https://twitter.com/ValentinHervieu/status/1324407814970920968 64 | // we do this so that the parent doesn't have to use `useCallback()` for these callbacks 65 | const callbacksRef = React.useRef({ onStart, onMove, onEnd }) 66 | 67 | // instead of relying on hacks to know if the device is a touch device or not, 68 | // we track this using an onTouchStart listener on the document. (see https://codeburst.io/the-only-way-to-detect-touch-with-javascript-7791a3346685) 69 | const [isTouchDevice, setTouchDevice] = React.useState(false) 70 | 71 | React.useEffect(() => { 72 | callbacksRef.current = { onStart, onMove, onEnd } 73 | }, [onStart, onMove, onEnd]) 74 | 75 | const cancelTouchStart = () => { 76 | if (handleTouchStartTimerRef.current) { 77 | window.clearTimeout(handleTouchStartTimerRef.current) 78 | } 79 | } 80 | 81 | const saveContainerPosition = React.useCallback(() => { 82 | if (containerRef.current) { 83 | const bounds = containerRef.current.getBoundingClientRect() 84 | containerPositionRef.current = { x: bounds.left, y: bounds.top } 85 | } 86 | }, [containerRef]) 87 | 88 | const onDrag = React.useCallback((pointInWindow: Point) => { 89 | const point = getPointInContainer(pointInWindow, containerPositionRef.current) 90 | if (callbacksRef.current.onMove) { 91 | callbacksRef.current.onMove({ pointInWindow, point }) 92 | } 93 | }, []) 94 | 95 | const onMouseMove = React.useCallback( 96 | (e: MouseEvent) => { 97 | // if this is the first move, we trigger the onStart logic 98 | if (isFirstMoveRef.current) { 99 | isFirstMoveRef.current = false 100 | const pointInWindow = getMousePoint(e) 101 | const point = getPointInContainer(pointInWindow, containerPositionRef.current) 102 | if (callbacksRef.current.onStart) { 103 | callbacksRef.current.onStart({ point, pointInWindow }) 104 | } 105 | } 106 | // otherwise, we do the normal move logic 107 | else { 108 | onDrag(getMousePoint(e)) 109 | } 110 | }, 111 | [onDrag] 112 | ) 113 | 114 | const onTouchMove = React.useCallback( 115 | (e: TouchEvent) => { 116 | if (e.cancelable) { 117 | // Prevent the whole page from scrolling 118 | e.preventDefault() 119 | onDrag(getTouchPoint(e.touches[0])) 120 | } else { 121 | // if the event is not cancelable, it means the browser is currently scrolling 122 | // which cannot be interrupted. Thus we cancel the drag gesture. 123 | document.removeEventListener('touchmove', onTouchMove) 124 | if (callbacksRef.current.onEnd) { 125 | callbacksRef.current.onEnd() 126 | } 127 | } 128 | }, 129 | [onDrag] 130 | ) 131 | 132 | const onMouseUp = React.useCallback(() => { 133 | isFirstMoveRef.current = false 134 | document.removeEventListener('mousemove', onMouseMove) 135 | document.removeEventListener('mouseup', onMouseUp) 136 | if (callbacksRef.current.onEnd) { 137 | callbacksRef.current.onEnd() 138 | } 139 | }, [onMouseMove]) 140 | 141 | const onTouchEnd = React.useCallback(() => { 142 | document.removeEventListener('touchmove', onTouchMove) 143 | document.removeEventListener('touchend', onTouchEnd) 144 | enableContextMenu() 145 | if (callbacksRef.current.onEnd) { 146 | callbacksRef.current.onEnd() 147 | } 148 | }, [onTouchMove]) 149 | 150 | const onMouseDown = React.useCallback( 151 | (e: React.MouseEvent) => { 152 | if (e.button !== 0) { 153 | // we don't want to handle clicks other than left ones 154 | return 155 | } 156 | 157 | if (knobs?.length && !knobs.find((knob) => knob.contains(e.target as Node))) { 158 | return 159 | } 160 | 161 | document.addEventListener('mousemove', onMouseMove) 162 | document.addEventListener('mouseup', onMouseUp) 163 | 164 | saveContainerPosition() 165 | 166 | // mark the next move as being the first one 167 | isFirstMoveRef.current = true 168 | }, 169 | [onMouseMove, onMouseUp, saveContainerPosition, knobs] 170 | ) 171 | 172 | const handleTouchStart = React.useCallback( 173 | (point: Point, pointInWindow: Point) => { 174 | document.addEventListener('touchmove', onTouchMove, { capture: false, passive: false }) 175 | document.addEventListener('touchend', onTouchEnd) 176 | disableContextMenu() 177 | 178 | if (callbacksRef.current.onStart) { 179 | callbacksRef.current.onStart({ point, pointInWindow }) 180 | } 181 | }, 182 | [onTouchEnd, onTouchMove] 183 | ) 184 | 185 | const onTouchStart = React.useCallback( 186 | (e: TouchEvent) => { 187 | if (knobs?.length && !knobs.find((knob) => knob.contains(e.target as Node))) { 188 | return 189 | } 190 | 191 | saveContainerPosition() 192 | 193 | const pointInWindow = getTouchPoint(e.touches[0]) 194 | const point = getPointInContainer(pointInWindow, containerPositionRef.current) 195 | 196 | // we wait 120ms to start the gesture to be sure that the user 197 | // is not trying to scroll the page 198 | handleTouchStartTimerRef.current = window.setTimeout( 199 | () => handleTouchStart(point, pointInWindow), 200 | 120 201 | ) 202 | }, 203 | [handleTouchStart, saveContainerPosition, knobs] 204 | ) 205 | 206 | const detectTouchDevice = React.useCallback(() => { 207 | setTouchDevice(true) 208 | document.removeEventListener('touchstart', detectTouchDevice) 209 | }, []) 210 | 211 | // if the user is scrolling on mobile, we cancel the drag gesture 212 | const touchScrollListener = React.useCallback(() => { 213 | cancelTouchStart() 214 | }, []) 215 | 216 | React.useLayoutEffect(() => { 217 | if (isTouchDevice) { 218 | const container = containerRef.current 219 | 220 | if (allowDrag) { 221 | container?.addEventListener('touchstart', onTouchStart, { capture: true, passive: false }) 222 | // we are adding this touchmove listener to cancel drag if user is scrolling 223 | // however, it's also important to have a touchmove listener always set 224 | // with non-capture and non-passive option to prevent an issue on Safari 225 | // with e.preventDefault (https://github.com/atlassian/react-beautiful-dnd/issues/1374) 226 | document.addEventListener('touchmove', touchScrollListener, { 227 | capture: false, 228 | passive: false, 229 | }) 230 | document.addEventListener('touchend', touchScrollListener, { 231 | capture: false, 232 | passive: false, 233 | }) 234 | } 235 | 236 | return () => { 237 | container?.removeEventListener('touchstart', onTouchStart, { capture: true }) 238 | document.removeEventListener('touchmove', touchScrollListener, { capture: false }) 239 | document.removeEventListener('touchend', touchScrollListener, { capture: false }) 240 | document.removeEventListener('touchmove', onTouchMove) 241 | document.removeEventListener('touchend', onTouchEnd) 242 | enableContextMenu() 243 | cancelTouchStart() 244 | } 245 | } 246 | // if non-touch device 247 | document.addEventListener('touchstart', detectTouchDevice) 248 | return () => { 249 | document.removeEventListener('touchstart', detectTouchDevice) 250 | document.removeEventListener('mousemove', onMouseMove) 251 | document.removeEventListener('mouseup', onMouseUp) 252 | } 253 | }, [ 254 | isTouchDevice, 255 | allowDrag, 256 | detectTouchDevice, 257 | onMouseMove, 258 | onTouchMove, 259 | touchScrollListener, 260 | onTouchEnd, 261 | onMouseUp, 262 | containerRef, 263 | onTouchStart, 264 | ]) 265 | 266 | // on touch devices, we cannot attach the onTouchStart directly via React: 267 | // Touch handlers must be added with {passive: false} to be cancelable. 268 | // https://developers.google.com/web/updates/2017/01/scrolling-intervention 269 | return isTouchDevice ? {} : { onMouseDown } 270 | } 271 | 272 | type UseDropTargetProps = Partial<{ 273 | show: (sourceRect: DOMRect) => void 274 | hide: () => void 275 | setPosition: (index: number, itemsRect: DOMRect[], lockAxis?: 'x' | 'y') => void 276 | render: () => React.ReactElement 277 | }> 278 | 279 | export const useDropTarget = (content?: React.ReactNode): UseDropTargetProps => { 280 | const dropTargetRef = React.useRef(null) 281 | 282 | if (!content) { 283 | return {} 284 | } 285 | 286 | const show = (sourceRect: DOMRect) => { 287 | if (dropTargetRef.current) { 288 | dropTargetRef.current.style.width = `${sourceRect.width}px` 289 | dropTargetRef.current.style.height = `${sourceRect.height}px` 290 | dropTargetRef.current.style.opacity = '1' 291 | dropTargetRef.current.style.visibility = 'visible' 292 | } 293 | } 294 | 295 | const hide = () => { 296 | if (dropTargetRef.current) { 297 | dropTargetRef.current.style.opacity = '0' 298 | dropTargetRef.current.style.visibility = 'hidden' 299 | } 300 | } 301 | 302 | const setPosition = (index: number, itemsRect: DOMRect[], lockAxis?: 'x' | 'y') => { 303 | if (dropTargetRef.current) { 304 | const sourceRect = itemsRect[index] 305 | const newX = lockAxis === 'y' ? sourceRect.left : itemsRect[index].left 306 | const newY = lockAxis === 'x' ? sourceRect.top : itemsRect[index].top 307 | 308 | dropTargetRef.current.style.transform = `translate3d(${newX}px, ${newY}px, 0px)` 309 | } 310 | } 311 | 312 | const DropTargetWrapper = (): React.ReactElement => ( 313 |
325 | {content} 326 |
327 | ) 328 | 329 | return { 330 | show, 331 | hide, 332 | setPosition, 333 | render: DropTargetWrapper, 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import arrayMove from 'array-move' 2 | import React, { HTMLAttributes } from 'react' 3 | 4 | import { findItemIndexAtPosition } from './helpers' 5 | import { useDrag, useDropTarget } from './hooks' 6 | import { Point } from './types' 7 | 8 | const DEFAULT_CONTAINER_TAG = 'div' 9 | 10 | type Props = HTMLAttributes & { 11 | children: React.ReactNode 12 | /** Determines whether drag functionality is enabled, defaults to true */ 13 | allowDrag?: boolean 14 | /** Called when the user finishes a sorting gesture. */ 15 | onSortEnd: (oldIndex: number, newIndex: number) => void 16 | /** Class applied to the item being dragged */ 17 | draggedItemClassName?: string 18 | /** Determines which type of html tag will be used for a container element */ 19 | as?: TTag 20 | /** Determines if an axis should be locked */ 21 | lockAxis?: 'x' | 'y' 22 | /** Reference to the Custom Holder element */ 23 | customHolderRef?: React.RefObject 24 | /** Drop target to be used when dragging */ 25 | dropTarget?: React.ReactNode 26 | } 27 | 28 | // this context is only used so that SortableItems can register/remove themselves 29 | // from the items list 30 | type Context = { 31 | registerItem: (item: HTMLElement) => void 32 | removeItem: (item: HTMLElement) => void 33 | registerKnob: (item: HTMLElement) => void 34 | removeKnob: (item: HTMLElement) => void 35 | } 36 | 37 | const SortableListContext = React.createContext(undefined) 38 | const SortableList = ({ 39 | children, 40 | allowDrag = true, 41 | onSortEnd, 42 | draggedItemClassName, 43 | as, 44 | lockAxis, 45 | customHolderRef, 46 | dropTarget, 47 | ...rest 48 | }: Props) => { 49 | // this array contains the elements than can be sorted (wrapped inside SortableItem) 50 | const itemsRef = React.useRef([]) 51 | // this array contains the coordinates of each sortable element (only computed on dragStart and used in dragMove for perf reason) 52 | const itemsRect = React.useRef([]) 53 | // Hold all registered knobs 54 | const knobs = React.useRef([]) 55 | // contains the container element 56 | const containerRef = React.useRef(null) 57 | // contains the target element (copy of the source element) 58 | const targetRef = React.useRef(null) 59 | // contains the index in the itemsRef array of the element being dragged 60 | const sourceIndexRef = React.useRef(undefined) 61 | // contains the index in the itemsRef of the element to be exchanged with the source item 62 | const lastTargetIndexRef = React.useRef(undefined) 63 | // contains the offset point where the initial drag occurred to be used when dragging the item 64 | const offsetPointRef = React.useRef({ x: 0, y: 0 }) 65 | // contains the dropTarget logic 66 | const dropTargetLogic = useDropTarget(dropTarget) 67 | 68 | React.useEffect(() => { 69 | const holder = customHolderRef?.current || document.body 70 | return () => { 71 | // cleanup the target element from the DOM when SortableList in unmounted 72 | if (targetRef.current) { 73 | holder.removeChild(targetRef.current) 74 | } 75 | } 76 | }, [customHolderRef]) 77 | 78 | const updateTargetPosition = (position: Point) => { 79 | if (targetRef.current && sourceIndexRef.current !== undefined) { 80 | const offset = offsetPointRef.current 81 | const sourceRect = itemsRect.current[sourceIndexRef.current] 82 | const newX = lockAxis === 'y' ? sourceRect.left : position.x - offset.x 83 | const newY = lockAxis === 'x' ? sourceRect.top : position.y - offset.y 84 | 85 | // we use `translate3d` to force using the GPU if available 86 | targetRef.current.style.transform = `translate3d(${newX}px, ${newY}px, 0px)` 87 | } 88 | } 89 | 90 | const copyItem = React.useCallback( 91 | (sourceIndex: number) => { 92 | if (!containerRef.current) { 93 | return 94 | } 95 | 96 | const source = itemsRef.current[sourceIndex] 97 | const sourceRect = itemsRect.current[sourceIndex] 98 | 99 | const copy = source.cloneNode(true) as HTMLElement 100 | 101 | // added the "dragged" class name 102 | if (draggedItemClassName) { 103 | draggedItemClassName.split(' ').forEach((c) => copy.classList.add(c)) 104 | } 105 | 106 | // we ensure the copy has the same size than the source element 107 | copy.style.width = `${sourceRect.width}px` 108 | copy.style.height = `${sourceRect.height}px` 109 | // we place the target starting position to the top left of the window 110 | // it will then be moved relatively using `transform: translate3d()` 111 | copy.style.position = 'fixed' 112 | copy.style.margin = '0' 113 | copy.style.top = '0' 114 | copy.style.left = '0' 115 | 116 | const sourceCanvases = source.querySelectorAll('canvas') 117 | copy.querySelectorAll('canvas').forEach((canvas, index) => { 118 | canvas.getContext('2d')?.drawImage(sourceCanvases[index], 0, 0) 119 | }) 120 | 121 | const holder = customHolderRef?.current || document.body 122 | holder.appendChild(copy) 123 | 124 | targetRef.current = copy 125 | }, 126 | [customHolderRef, draggedItemClassName] 127 | ) 128 | 129 | const listeners = useDrag({ 130 | allowDrag, 131 | containerRef, 132 | knobs: knobs.current, 133 | onStart: ({ pointInWindow }) => { 134 | if (!containerRef.current) { 135 | return 136 | } 137 | 138 | itemsRect.current = itemsRef.current.map((item) => item.getBoundingClientRect()) 139 | 140 | const sourceIndex = findItemIndexAtPosition(pointInWindow, itemsRect.current) 141 | // if we are not starting the drag gesture on a SortableItem, we exit early 142 | if (sourceIndex === -1) { 143 | return 144 | } 145 | 146 | // saving the index of the item being dragged 147 | sourceIndexRef.current = sourceIndex 148 | 149 | // the item being dragged is copied to the document body and will be used as the target 150 | copyItem(sourceIndex) 151 | 152 | // hide source during the drag gesture 153 | const source = itemsRef.current[sourceIndex] 154 | source.style.opacity = '0' 155 | source.style.visibility = 'hidden' 156 | 157 | // get the offset between the source item's window position relative to the point in window 158 | const sourceRect = source.getBoundingClientRect() 159 | offsetPointRef.current = { 160 | x: pointInWindow.x - sourceRect.left, 161 | y: pointInWindow.y - sourceRect.top, 162 | } 163 | 164 | updateTargetPosition(pointInWindow) 165 | dropTargetLogic.show?.(sourceRect) 166 | 167 | // Adds a nice little physical feedback 168 | if (window.navigator.vibrate) { 169 | window.navigator.vibrate(100) 170 | } 171 | }, 172 | onMove: ({ pointInWindow }) => { 173 | updateTargetPosition(pointInWindow) 174 | 175 | const sourceIndex = sourceIndexRef.current 176 | // if there is no source, we exit early (happened when drag gesture was started outside a SortableItem) 177 | if (sourceIndex === undefined || sourceIndexRef.current === undefined) { 178 | return 179 | } 180 | 181 | const sourceRect = itemsRect.current[sourceIndexRef.current] 182 | const targetPoint: Point = { 183 | x: lockAxis === 'y' ? sourceRect.left : pointInWindow.x, 184 | y: lockAxis === 'x' ? sourceRect.top : pointInWindow.y, 185 | } 186 | 187 | const targetIndex = findItemIndexAtPosition(targetPoint, itemsRect.current, { 188 | fallbackToClosest: true, 189 | }) 190 | // if not target detected, we don't need to update other items' position 191 | if (targetIndex === -1) { 192 | return 193 | } 194 | // we keep track of the last target index (to be passed to the onSortEnd callback) 195 | lastTargetIndexRef.current = targetIndex 196 | 197 | const isMovingRight = sourceIndex < targetIndex 198 | 199 | // in this loop, we go over each sortable item and see if we need to update their position 200 | for (let index = 0; index < itemsRef.current.length; index += 1) { 201 | const currentItem = itemsRef.current[index] 202 | const currentItemRect = itemsRect.current[index] 203 | // if current index is between sourceIndex and targetIndex, we need to translate them 204 | if ( 205 | (isMovingRight && index >= sourceIndex && index <= targetIndex) || 206 | (!isMovingRight && index >= targetIndex && index <= sourceIndex) 207 | ) { 208 | // we need to move the item to the previous or next item position 209 | const nextItemRects = itemsRect.current[isMovingRight ? index - 1 : index + 1] 210 | if (nextItemRects) { 211 | const translateX = nextItemRects.left - currentItemRect.left 212 | const translateY = nextItemRects.top - currentItemRect.top 213 | // we use `translate3d` to force using the GPU if available 214 | currentItem.style.transform = `translate3d(${translateX}px, ${translateY}px, 0px)` 215 | } 216 | } 217 | // otherwise, the item should be at its original position 218 | else { 219 | currentItem.style.transform = 'translate3d(0,0,0)' 220 | } 221 | // we want the translation to be animated 222 | currentItem.style.transitionDuration = '300ms' 223 | } 224 | 225 | dropTargetLogic.setPosition?.(lastTargetIndexRef.current, itemsRect.current, lockAxis) 226 | }, 227 | onEnd: () => { 228 | // we reset all items translations (the parent is expected to sort the items in the onSortEnd callback) 229 | for (let index = 0; index < itemsRef.current.length; index += 1) { 230 | const currentItem = itemsRef.current[index] 231 | currentItem.style.transform = '' 232 | currentItem.style.transitionDuration = '' 233 | } 234 | 235 | const sourceIndex = sourceIndexRef.current 236 | if (sourceIndex !== undefined) { 237 | // show the source item again 238 | const source = itemsRef.current[sourceIndex] 239 | if (source) { 240 | source.style.opacity = '1' 241 | source.style.visibility = '' 242 | } 243 | 244 | const targetIndex = lastTargetIndexRef.current 245 | if (targetIndex !== undefined) { 246 | if (sourceIndex !== targetIndex) { 247 | // sort our internal items array 248 | itemsRef.current = arrayMove(itemsRef.current, sourceIndex, targetIndex) 249 | // let the parent know 250 | onSortEnd(sourceIndex, targetIndex) 251 | } 252 | } 253 | } 254 | sourceIndexRef.current = undefined 255 | lastTargetIndexRef.current = undefined 256 | dropTargetLogic.hide?.() 257 | 258 | // cleanup the target element from the DOM 259 | if (targetRef.current) { 260 | const holder = customHolderRef?.current || document.body 261 | holder.removeChild(targetRef.current) 262 | targetRef.current = null 263 | } 264 | }, 265 | }) 266 | 267 | const registerItem = React.useCallback((item: HTMLElement) => { 268 | itemsRef.current.push(item) 269 | }, []) 270 | 271 | const removeItem = React.useCallback((item: HTMLElement) => { 272 | const index = itemsRef.current.indexOf(item) 273 | if (index !== -1) { 274 | itemsRef.current.splice(index, 1) 275 | } 276 | }, []) 277 | 278 | const registerKnob = React.useCallback((item: HTMLElement) => { 279 | knobs.current.push(item) 280 | }, []) 281 | 282 | const removeKnob = React.useCallback((item: HTMLElement) => { 283 | const index = knobs.current.indexOf(item) 284 | 285 | if (index !== -1) { 286 | knobs.current.splice(index, 1) 287 | } 288 | }, []) 289 | 290 | // we need to memoize the context to avoid re-rendering every children of the context provider 291 | // when not needed 292 | const context = React.useMemo(() => ({ registerItem, removeItem, registerKnob, removeKnob }), [ 293 | registerItem, 294 | removeItem, 295 | registerKnob, 296 | removeKnob, 297 | ]) 298 | 299 | return React.createElement( 300 | as || DEFAULT_CONTAINER_TAG, 301 | { 302 | ...(allowDrag ? listeners : {}), 303 | ...rest, 304 | ref: containerRef, 305 | }, 306 | 307 | {children} 308 | {dropTargetLogic.render?.()} 309 | 310 | ) 311 | } 312 | 313 | export default SortableList 314 | 315 | type ItemProps = { 316 | children: React.ReactElement 317 | } 318 | 319 | /** 320 | * SortableItem only adds a ref to its children so that we can register it to the main Sortable 321 | */ 322 | export const SortableItem = ({ children }: ItemProps) => { 323 | const context = React.useContext(SortableListContext) 324 | if (!context) { 325 | throw new Error('SortableItem must be a child of SortableList') 326 | } 327 | const { registerItem, removeItem } = context 328 | const elementRef = React.useRef(null) 329 | 330 | React.useEffect(() => { 331 | const currentItem = elementRef.current 332 | if (currentItem) { 333 | registerItem(currentItem) 334 | } 335 | 336 | return () => { 337 | if (currentItem) { 338 | removeItem(currentItem) 339 | } 340 | } 341 | // if the children changes, we want to re-register the DOM node 342 | }, [registerItem, removeItem, children]) 343 | 344 | return React.cloneElement(children, { ref: elementRef }) 345 | } 346 | 347 | export const SortableKnob = ({ children }: ItemProps) => { 348 | const context = React.useContext(SortableListContext) 349 | 350 | if (!context) { 351 | throw new Error('SortableKnob must be a child of SortableList') 352 | } 353 | 354 | const { registerKnob, removeKnob } = context 355 | 356 | const elementRef = React.useRef(null) 357 | 358 | React.useEffect(() => { 359 | const currentItem = elementRef.current 360 | 361 | if (currentItem) { 362 | registerKnob(currentItem) 363 | } 364 | 365 | return () => { 366 | if (currentItem) { 367 | removeKnob(currentItem) 368 | } 369 | } 370 | // if the children changes, we want to re-register the DOM node 371 | }, [registerKnob, removeKnob, children]) 372 | 373 | return React.cloneElement(children, { ref: elementRef }) 374 | } 375 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Point = { 2 | x: number 3 | y: number 4 | } 5 | -------------------------------------------------------------------------------- /stories/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export const generateItems = (count: number) => { 2 | const items = [] 3 | for (let i = 0; i < count; i++) { 4 | items.push(String.fromCharCode(65 + i)) 5 | } 6 | return items 7 | } 8 | -------------------------------------------------------------------------------- /stories/interactive-avatars/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import arrayMove from 'array-move' 3 | 4 | import { Story } from '@storybook/react' 5 | 6 | import SortableList, { SortableItem } from '../../src/index' 7 | import { Avatar, Fab, makeStyles } from '@material-ui/core' 8 | import FavoriteIcon from '@material-ui/icons/Favorite' 9 | 10 | export default { 11 | component: SortableList, 12 | title: 'react-easy-sort/Interactive avatars', 13 | parameters: { 14 | componentSubtitle: 'SortableList', 15 | }, 16 | argTypes: { 17 | count: { 18 | name: 'Number of elements', 19 | control: { 20 | type: 'range', 21 | min: 3, 22 | max: 12, 23 | step: 1, 24 | }, 25 | defaultValue: 8, 26 | }, 27 | }, 28 | } 29 | 30 | const useStyles = makeStyles({ 31 | root: { 32 | display: 'flex', 33 | flexWrap: 'wrap', 34 | userSelect: 'none', 35 | }, 36 | item: { 37 | position: 'relative', 38 | flexShrink: 0, 39 | display: 'flex', 40 | margin: 8, 41 | cursor: 'grab', 42 | userSelect: 'none', 43 | boxShadow: '0px 6px 6px -3px rgba(0, 0, 0, 0.2)', 44 | borderRadius: '100%', 45 | }, 46 | image: { 47 | width: 150, 48 | height: 150, 49 | pointerEvents: 'none', 50 | }, 51 | button: { 52 | position: 'absolute', 53 | bottom: 0, 54 | right: 0, 55 | }, 56 | dragged: { 57 | boxShadow: 58 | '0px 6px 6px -3px rgba(0, 0, 0, 0.2), 0px 10px 14px 1px rgba(0, 0, 0, 0.14), 0px 4px 18px 3px rgba(0, 0, 0, 0.12)', 59 | '& button': { 60 | opacity: 0, 61 | }, 62 | }, 63 | }) 64 | 65 | type StoryProps = { 66 | count: number 67 | } 68 | 69 | export const Demo: Story = ({ count }: StoryProps) => { 70 | const classes = useStyles() 71 | const [items, setItems] = React.useState([ 72 | { 73 | name: 'Alpha', 74 | image: 'https://i.pinimg.com/736x/ae/c4/53/aec453161b2f33ffc6219d8a758307a9.jpg', 75 | }, 76 | { 77 | name: 'Bravo', 78 | image: 'https://assets.slice.ca/wp-content/uploads/2020/01/cutest-dog-names-2020.jpg', 79 | }, 80 | { 81 | name: 'Charlie', 82 | image: 83 | 'https://topdogtips.com/wp-content/uploads/2014/12/Top-10-Cute-Dog-Breeds-Who-Wins-1.jpg', 84 | }, 85 | { 86 | name: 'Delta', 87 | image: 88 | 'https://encrypted-tbn0.gstatic.com/images?q=tbn%3AANd9GcQFNTv7ogK6omzBeZSWZOVJ7ZDqYi51MdJq6g&usqp=CAU', 89 | }, 90 | { 91 | name: 'Echo', 92 | image: 93 | 'https://hips.hearstapps.com/hmg-prod.s3.amazonaws.com/images/dog-puppy-on-garden-royalty-free-image-1586966191.jpg?crop=0.752xw:1.00xh;0.175xw,0&resize=640:*', 94 | }, 95 | { 96 | name: 'Foxtrot', 97 | image: 98 | 'https://hips.hearstapps.com/hmg-prod.s3.amazonaws.com/images/beau-enjoying-his-freedom-in-ohio-us-on-july-28-2015-a-cute-news-photo-484455470-1551896268.jpg?crop=0.419xw:1.00xh;0.236xw,0&resize=480:*', 99 | }, 100 | { 101 | name: 'Golf', 102 | image: 103 | 'data:image/jpeg;base64,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', 104 | }, 105 | { 106 | name: 'Hotel', 107 | image: 108 | 'https://img.huffingtonpost.com/asset/5ab4d4ac2000007d06eb2c56.jpeg?cache=sih0jwle4e&ops=282_200', 109 | }, 110 | { 111 | name: 'India', 112 | image: 113 | 'https://www.petmoo.com/wp-content/uploads/2018/08/Happy-Puppies-Questions-990x556.jpg', 114 | }, 115 | { 116 | name: 'Kilo', 117 | image: 118 | 'https://s36700.pcdn.co/wp-content/uploads/2017/08/A-happy-puppy-lying-in-the-grass-outside-600x400.jpg.optimal.jpg', 119 | }, 120 | { 121 | name: 'Lima', 122 | image: 'https://static.parade.com/wp-content/uploads/2021/03/Top-10-Puppy-Names-of-2021.jpg', 123 | }, 124 | ]) 125 | 126 | const onSortEnd = (oldIndex: number, newIndex: number) => { 127 | setItems((array) => arrayMove(array, oldIndex, newIndex)) 128 | } 129 | 130 | return ( 131 | 136 | {items.slice(0, count).map(({ name, image }) => ( 137 | 138 |
139 | 145 | alert('Woof!')} 151 | > 152 | 153 | 154 |
155 |
156 | ))} 157 |
158 | ) 159 | } 160 | -------------------------------------------------------------------------------- /stories/simple-grid/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import arrayMove from 'array-move' 3 | 4 | import { action } from '@storybook/addon-actions' 5 | import { Story } from '@storybook/react' 6 | 7 | import SortableList, { SortableItem } from '../../src/index' 8 | import { generateItems } from '../helpers' 9 | import { makeStyles } from '@material-ui/core' 10 | 11 | export default { 12 | component: SortableList, 13 | title: 'react-easy-sort/Simple grid', 14 | parameters: { 15 | componentSubtitle: 'SortableList', 16 | }, 17 | argTypes: { 18 | count: { 19 | name: 'Number of elements', 20 | control: { 21 | type: 'range', 22 | min: 3, 23 | max: 12, 24 | step: 1, 25 | }, 26 | defaultValue: 3, 27 | }, 28 | }, 29 | } 30 | 31 | const useStyles = makeStyles({ 32 | list: { 33 | fontFamily: 'Helvetica, Arial, sans-serif', 34 | userSelect: 'none', 35 | display: 'grid', 36 | gridTemplateColumns: 'auto auto auto', 37 | gridGap: 16, 38 | '@media (min-width: 600px)': { 39 | gridGap: 24, 40 | }, 41 | }, 42 | item: { 43 | display: 'flex', 44 | justifyContent: 'center', 45 | alignItems: 'center', 46 | backgroundColor: 'rgb(84, 84, 241)', 47 | color: 'white', 48 | height: 150, 49 | cursor: 'grab', 50 | fontSize: 20, 51 | userSelect: 'none', 52 | }, 53 | dragged: { 54 | backgroundColor: 'rgb(37, 37, 197)', 55 | }, 56 | }) 57 | 58 | type StoryProps = { 59 | count: number 60 | } 61 | 62 | export const Demo: Story = ({ count }: StoryProps) => { 63 | const classes = useStyles() 64 | 65 | const [items, setItems] = React.useState([]) 66 | React.useEffect(() => { 67 | setItems(generateItems(count)) 68 | }, [count]) 69 | 70 | const onSortEnd = (oldIndex: number, newIndex: number) => { 71 | action('onSortEnd')(`oldIndex=${oldIndex}, newIndex=${newIndex}`) 72 | setItems((array) => arrayMove(array, oldIndex, newIndex)) 73 | } 74 | 75 | return ( 76 | 81 | {items.map((item) => ( 82 | 83 |
{item}
84 |
85 | ))} 86 |
87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /stories/simple-horizontal-list/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import arrayMove from 'array-move' 3 | 4 | import { action } from '@storybook/addon-actions' 5 | import { Story } from '@storybook/react' 6 | 7 | import SortableList, { SortableItem } from '../../src/index' 8 | import { generateItems } from '../helpers' 9 | import { makeStyles } from '@material-ui/core' 10 | 11 | export default { 12 | component: SortableList, 13 | title: 'react-easy-sort/Simple horizontal list', 14 | parameters: { 15 | componentSubtitle: 'SortableList', 16 | }, 17 | argTypes: { 18 | count: { 19 | name: 'Number of elements', 20 | control: { 21 | type: 'range', 22 | min: 3, 23 | max: 12, 24 | step: 1, 25 | }, 26 | defaultValue: 3, 27 | }, 28 | }, 29 | } 30 | 31 | const useStyles = makeStyles({ 32 | list: { 33 | fontFamily: 'Helvetica, Arial, sans-serif', 34 | userSelect: 'none', 35 | display: 'flex', 36 | justifyContent: 'flex-start', 37 | }, 38 | item: { 39 | flexShrink: 0, 40 | display: 'flex', 41 | justifyContent: 'center', 42 | alignItems: 'center', 43 | backgroundColor: 'rgb(84, 84, 241)', 44 | color: 'white', 45 | margin: 8, 46 | width: 60, 47 | height: 60, 48 | cursor: 'grab', 49 | }, 50 | dragged: { 51 | backgroundColor: 'rgb(37, 37, 197)', 52 | }, 53 | }) 54 | 55 | type StoryProps = { 56 | count: number 57 | } 58 | 59 | export const Demo: Story = ({ count }: StoryProps) => { 60 | const classes = useStyles() 61 | 62 | const [items, setItems] = React.useState([]) 63 | React.useEffect(() => { 64 | setItems(generateItems(count)) 65 | }, [count]) 66 | 67 | const onSortEnd = (oldIndex: number, newIndex: number) => { 68 | action('onSortEnd')(`oldIndex=${oldIndex}, newIndex=${newIndex}`) 69 | setItems((array) => arrayMove(array, oldIndex, newIndex)) 70 | } 71 | 72 | return ( 73 | 78 | {items.map((item) => ( 79 | 80 |
{item}
81 |
82 | ))} 83 |
84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /stories/simple-vertical-list/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import arrayMove from 'array-move' 3 | 4 | import { action } from '@storybook/addon-actions' 5 | import { Story } from '@storybook/react' 6 | 7 | import SortableList, { SortableItem } from '../../src/index' 8 | import { generateItems } from '../helpers' 9 | import { makeStyles } from '@material-ui/core' 10 | 11 | export default { 12 | component: SortableList, 13 | title: 'react-easy-sort/Simple vertical list', 14 | parameters: { 15 | componentSubtitle: 'SortableList', 16 | }, 17 | argTypes: { 18 | count: { 19 | name: 'Number of elements', 20 | control: { 21 | type: 'range', 22 | min: 3, 23 | max: 12, 24 | step: 1, 25 | }, 26 | defaultValue: 3, 27 | }, 28 | }, 29 | } 30 | 31 | const useStyles = makeStyles({ 32 | list: { 33 | fontFamily: 'Helvetica, Arial, sans-serif', 34 | userSelect: 'none', 35 | }, 36 | item: { 37 | flexShrink: 0, 38 | display: 'flex', 39 | justifyContent: 'center', 40 | alignItems: 'center', 41 | backgroundColor: 'rgb(84, 84, 241)', 42 | color: 'white', 43 | margin: 8, 44 | width: 150, 45 | height: 34, 46 | cursor: 'grab', 47 | }, 48 | dragged: { 49 | backgroundColor: 'rgb(37, 37, 197)', 50 | }, 51 | }) 52 | 53 | type StoryProps = { 54 | count: number 55 | } 56 | 57 | export const Demo: Story = ({ count }: StoryProps) => { 58 | const classes = useStyles() 59 | 60 | const [items, setItems] = React.useState([]) 61 | React.useEffect(() => { 62 | setItems(generateItems(count)) 63 | }, [count]) 64 | 65 | const onSortEnd = (oldIndex: number, newIndex: number) => { 66 | action('onSortEnd')(`oldIndex=${oldIndex}, newIndex=${newIndex}`) 67 | setItems((array) => arrayMove(array, oldIndex, newIndex)) 68 | } 69 | 70 | return ( 71 | 76 | {items.map((item) => ( 77 | 78 |
{item}
79 |
80 | ))} 81 |
82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /stories/with-drop-target/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import arrayMove from 'array-move' 3 | 4 | import { action } from '@storybook/addon-actions' 5 | import { Story } from '@storybook/react' 6 | 7 | import SortableList, { SortableItem } from '../../src/index' 8 | import { generateItems } from '../helpers' 9 | import { makeStyles } from '@material-ui/core' 10 | 11 | export default { 12 | component: SortableList, 13 | title: 'react-easy-sort/With drop target', 14 | parameters: { 15 | componentSubtitle: 'SortableList', 16 | }, 17 | argTypes: { 18 | count: { 19 | name: 'Number of elements', 20 | control: { 21 | type: 'range', 22 | min: 3, 23 | max: 12, 24 | step: 1, 25 | }, 26 | defaultValue: 3, 27 | }, 28 | }, 29 | } 30 | 31 | const useStyles = makeStyles({ 32 | list: { 33 | fontFamily: 'Helvetica, Arial, sans-serif', 34 | userSelect: 'none', 35 | display: 'grid', 36 | gridTemplateColumns: 'auto auto auto', 37 | gridGap: 16, 38 | '@media (min-width: 600px)': { 39 | gridGap: 24, 40 | }, 41 | }, 42 | item: { 43 | display: 'flex', 44 | justifyContent: 'center', 45 | alignItems: 'center', 46 | backgroundColor: 'rgb(84, 84, 241)', 47 | color: 'white', 48 | height: 150, 49 | cursor: 'grab', 50 | fontSize: 20, 51 | userSelect: 'none', 52 | }, 53 | dragged: { 54 | backgroundColor: 'rgb(37, 37, 197)', 55 | }, 56 | dropTarget: { 57 | border: '2px dashed rgb(84, 84, 241)', 58 | height: 150, 59 | boxSizing: 'border-box', 60 | fontSize: 20, 61 | display: 'flex', 62 | justifyContent: 'center', 63 | alignItems: 'center', 64 | color: 'rgb(84, 84, 241)', 65 | }, 66 | }) 67 | 68 | type StoryProps = { 69 | count: number 70 | } 71 | 72 | export const Demo: Story = ({ count }: StoryProps) => { 73 | const classes = useStyles() 74 | 75 | const [items, setItems] = React.useState([]) 76 | React.useEffect(() => { 77 | setItems(generateItems(count)) 78 | }, [count]) 79 | 80 | const onSortEnd = (oldIndex: number, newIndex: number) => { 81 | action('onSortEnd')(`oldIndex=${oldIndex}, newIndex=${newIndex}`) 82 | setItems((array) => arrayMove(array, oldIndex, newIndex)) 83 | } 84 | 85 | return ( 86 | } 91 | > 92 | {items.map((item) => ( 93 | 94 |
{item}
95 |
96 | ))} 97 |
98 | ) 99 | } 100 | 101 | const DropTarget = () => { 102 | const classes = useStyles() 103 | return
Drop Target
104 | } 105 | -------------------------------------------------------------------------------- /stories/with-knobs/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import arrayMove from 'array-move' 3 | 4 | import { action } from '@storybook/addon-actions' 5 | import { Story } from '@storybook/react' 6 | 7 | import SortableList, { SortableItem, SortableKnob } from '../../src/index' 8 | import { generateItems } from '../helpers' 9 | import { makeStyles } from '@material-ui/core' 10 | 11 | export default { 12 | component: SortableList, 13 | title: 'react-easy-sort/With knobs', 14 | parameters: { 15 | componentSubtitle: 'SortableList', 16 | }, 17 | argTypes: { 18 | count: { 19 | name: 'Number of elements', 20 | control: { 21 | type: 'range', 22 | min: 3, 23 | max: 12, 24 | step: 1, 25 | }, 26 | defaultValue: 3, 27 | }, 28 | }, 29 | } 30 | 31 | const useStyles = makeStyles({ 32 | list: { 33 | fontFamily: 'Helvetica, Arial, sans-serif', 34 | userSelect: 'none', 35 | }, 36 | item: { 37 | flexShrink: 0, 38 | display: 'flex', 39 | justifyContent: 'space-between', 40 | alignItems: 'center', 41 | backgroundColor: 'rgb(84, 84, 241)', 42 | color: 'white', 43 | margin: 8, 44 | width: 150, 45 | height: 34, 46 | padding: '0 8px', 47 | }, 48 | dragged: { 49 | backgroundColor: 'rgb(37, 37, 197)', 50 | }, 51 | knob: { 52 | padding: '0.15rem 0.5rem', 53 | color: 'rgb(84, 84, 241)', 54 | fontSize: '0.8em', 55 | backgroundColor: 'white', 56 | marginRight: '0.5rem', 57 | borderRadius: '2px', 58 | cursor: 'grab', 59 | }, 60 | }) 61 | 62 | type StoryProps = { 63 | count: number 64 | } 65 | 66 | export const Demo: Story = ({ count }) => { 67 | const classes = useStyles() 68 | 69 | const [items, setItems] = React.useState([]) 70 | React.useEffect(() => { 71 | setItems(generateItems(count)) 72 | }, [count]) 73 | const onSortEnd = (oldIndex: number, newIndex: number) => { 74 | action('onSortEnd')(`oldIndex=${oldIndex}, newIndex=${newIndex}`) 75 | setItems((array) => arrayMove(array, oldIndex, newIndex)) 76 | } 77 | return ( 78 | 83 | {items.map((item) => ( 84 | 85 |
86 | 87 |
DRAG
88 |
89 | {item} 90 |
91 |
92 | ))} 93 |
94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /stories/with-locked-axis/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import arrayMove from 'array-move' 3 | 4 | import { action } from '@storybook/addon-actions' 5 | import { Story } from '@storybook/react' 6 | 7 | import SortableList, { SortableItem } from '../../src/index' 8 | import { generateItems } from '../helpers' 9 | import { makeStyles } from '@material-ui/core' 10 | 11 | export default { 12 | component: SortableList, 13 | title: 'react-easy-sort/Axis lock', 14 | parameters: { 15 | componentSubtitle: 'SortableList', 16 | }, 17 | argTypes: { 18 | count: { 19 | name: 'Number of elements', 20 | control: { 21 | type: 'range', 22 | min: 3, 23 | max: 12, 24 | step: 1, 25 | }, 26 | defaultValue: 6, 27 | }, 28 | lockAxis: { 29 | options: ['x', 'y'], 30 | control: { type: 'inline-radio' }, 31 | defaultValue: 'x', 32 | }, 33 | }, 34 | } 35 | 36 | const useStyles = makeStyles({ 37 | list: { 38 | fontFamily: 'Helvetica, Arial, sans-serif', 39 | userSelect: 'none', 40 | display: 'grid', 41 | gridTemplateColumns: 'auto auto auto', 42 | gridGap: 16, 43 | '@media (min-width: 600px)': { 44 | gridGap: 24, 45 | }, 46 | }, 47 | item: { 48 | display: 'flex', 49 | justifyContent: 'center', 50 | alignItems: 'center', 51 | backgroundColor: 'rgb(84, 84, 241)', 52 | color: 'white', 53 | height: 150, 54 | cursor: 'grab', 55 | fontSize: 20, 56 | userSelect: 'none', 57 | }, 58 | dragged: { 59 | backgroundColor: 'rgb(37, 37, 197)', 60 | }, 61 | }) 62 | 63 | type StoryProps = { 64 | count: number 65 | lockAxis: 'x' | 'y' 66 | } 67 | 68 | export const Demo: Story = ({ count, lockAxis }: StoryProps) => { 69 | const classes = useStyles() 70 | 71 | const [items, setItems] = React.useState([]) 72 | React.useEffect(() => { 73 | setItems(generateItems(count)) 74 | }, [count]) 75 | 76 | const onSortEnd = (oldIndex: number, newIndex: number) => { 77 | action('onSortEnd')(`oldIndex=${oldIndex}, newIndex=${newIndex}`) 78 | setItems((array) => arrayMove(array, oldIndex, newIndex)) 79 | } 80 | 81 | return ( 82 | 88 | {items.map((item) => ( 89 | 90 |
{item}
91 |
92 | ))} 93 |
94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "lib": ["es2015", "dom"], 5 | "jsx": "react", 6 | "strict": true, 7 | "declaration": true, 8 | "esModuleInterop": true, 9 | "sourceMap": true, 10 | "declarationMap": true, 11 | "importHelpers": true 12 | } 13 | } 14 | --------------------------------------------------------------------------------