├── .changeset ├── README.md └── config.json ├── .eslintrc.json ├── .github └── workflows │ ├── checks.yml │ ├── playwright.yml │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── .ladle └── config.mjs ├── .lintstagedrc ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── assets └── react-movable.gif ├── commitlint.config.js ├── e2e ├── basic.a11y.test.ts ├── basic.a11y.test.ts-snapshots │ ├── cancel-the-move-of-first-item-to-second-position-1-chrome-linux.png │ ├── cancel-the-move-of-first-item-to-second-with-mouse-click-1-chrome-linux.png │ ├── move-1--5-6--2-and-3--5-1-chrome-linux.png │ ├── move-the-first-item-to-second-position-1-chrome-linux.png │ └── move-the-sixth-item-to-fifth-position-1-chrome-linux.png ├── basic.test.ts ├── basic.test.ts-snapshots │ ├── dnd-1--5-6--2-and-3--5-1-chrome-linux.png │ ├── dnd-the-first-item-to-second-position-1-chrome-linux.png │ └── dnd-the-sixth-item-to-fifth-position-1-chrome-linux.png ├── disabled-items.test.ts ├── disabled-items.test.ts-snapshots │ ├── attempt-to-dnd-the-fourth-item-should-have-no-effect-1-chrome-linux.png │ └── dnd-the-first-item-to-second-position-1-chrome-linux.png ├── disabled-list.test.ts ├── disabled-list.test.ts-snapshots │ └── attempt-to-dnd-the-first-item-to-second-position-should-have-no-effect-1-chrome-linux.png ├── heights.a11y.test.ts ├── heights.a11y.test.ts-snapshots │ ├── cancel-the-move-of-first-item-to-second-position-1-chrome-linux.png │ ├── move-1--5-4--2-and-3--5-1-chrome-linux.png │ ├── move-the-fifth-item-to-fourth-position-1-chrome-linux.png │ └── move-the-first-item-to-second-position-1-chrome-linux.png ├── heights.test.ts ├── heights.test.ts-snapshots │ ├── dnd-1--5-4--2-and-3--5-1-chrome-linux.png │ ├── dnd-the-fifth-item-to-fourth-position-1-chrome-linux.png │ └── dnd-the-first-item-to-second-position-1-chrome-linux.png ├── removable.test.ts ├── scrolling.container.test.ts ├── scrolling.window.test.ts └── utils.ts ├── examples ├── Basic.tsx ├── CustomComponent.tsx ├── CustomContainer.tsx ├── DisabledItems.tsx ├── DisabledList.tsx ├── Handle.tsx ├── InteractiveItems.tsx ├── LockVertically.tsx ├── NoAnimations.tsx ├── Removable.tsx ├── RemovableByMove.tsx ├── ScrollingContainer.tsx ├── ScrollingContainerHandle.tsx ├── ScrollingContainerShort.tsx ├── ScrollingWindow.tsx ├── SuperSimple.tsx ├── Table.tsx ├── TableAuto.tsx └── VaryingHeights.tsx ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── src ├── List.tsx ├── index.ts ├── list.stories.tsx ├── types.ts ├── utils.test.ts └── utils.ts └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { "repo": "tajo/react-movable" } 6 | ], 7 | "commit": false, 8 | "fixed": [], 9 | "linked": [], 10 | "access": "public", 11 | "baseBranch": "main", 12 | "updateInternalDependencies": "patch", 13 | "ignore": [] 14 | } 15 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "plugin:react/recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:prettier/recommended" 7 | ], 8 | "rules": { 9 | "@typescript-eslint/no-explicit-any": "off", 10 | "@typescript-eslint/ban-types": "off", 11 | "@typescript-eslint/ban-ts-comment": "off", 12 | "@typescript-eslint/explicit-module-boundary-types": "off", 13 | "react/prop-types": "off", 14 | "react/react-in-jsx-scope": "off" 15 | }, 16 | "settings": { 17 | "react": { 18 | "version": "detect" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | jobs: 8 | test: 9 | timeout-minutes: 15 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: lts/* 16 | - uses: pnpm/action-setup@v4 17 | - name: Install dependencies 18 | run: pnpm install 19 | - name: Run Unit tests 20 | run: pnpm test:unit 21 | - name: Run Typecheck 22 | run: pnpm typecheck 23 | - name: Run Lint 24 | run: pnpm lint 25 | - name: Run Build 26 | run: pnpm build 27 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | jobs: 8 | test: 9 | timeout-minutes: 15 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: lts/* 16 | - uses: pnpm/action-setup@v4 17 | - name: Install dependencies 18 | run: pnpm install 19 | - name: Install Playwright Browsers 20 | run: pnpm exec playwright install --with-deps 21 | - name: Run Playwright tests 22 | run: pnpm exec playwright test 23 | - uses: actions/upload-artifact@v4 24 | if: always() 25 | with: 26 | name: playwright-report 27 | path: playwright-report/ 28 | retention-days: 30 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | pull-requests: write 17 | id-token: write 18 | steps: 19 | - name: Checkout Repo 20 | uses: actions/checkout@v4 21 | with: 22 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 23 | fetch-depth: 0 24 | 25 | - uses: pnpm/action-setup@v4 26 | with: 27 | version: 9.1.4 28 | 29 | - name: Setup Node.js environment 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: 20.14 33 | cache: "pnpm" 34 | 35 | - name: Install dependencies 36 | run: pnpm install --frozen-lockfile 37 | 38 | - name: Creating .npmrc 39 | run: | 40 | cat << EOF > "$HOME/.npmrc" 41 | email=vojtech@miksu.cz 42 | //registry.npmjs.org/:_authToken=$NPM_TOKEN 43 | EOF 44 | env: 45 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 46 | 47 | - name: Create Release Pull Request or Publish to npm 48 | id: changesets 49 | uses: changesets/action@v1 50 | with: 51 | publish: pnpm release 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GIT_DEPLOY_KEY }} 54 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | npm-debug.log 4 | lib 5 | __diff_output__ 6 | /test-results/ 7 | /playwright-report/ 8 | /blob-report/ 9 | /playwright/.cache/ 10 | /e2e/**/*.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm exec lint-staged 2 | -------------------------------------------------------------------------------- /.ladle/config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | mount: ["examples"], 3 | addons: { 4 | theme: { 5 | enabled: false, 6 | }, 7 | mode: { 8 | defaultState: "single-scroll", 9 | }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,ts,tsx,css,scss,postcss,md,json}": [ 3 | "prettier --write --plugin-search-dir=.", 4 | "prettier --check --plugin-search-dir=." 5 | ], 6 | "*.{js,ts,tsx}": "eslint" 7 | } 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | es 3 | lib 4 | node_modules 5 | package.json 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": false, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # react-movable 2 | 3 | ## 3.4.1 4 | 5 | ### Patch Changes 6 | 7 | - [#117](https://github.com/tajo/react-movable/pull/117) [`e6c3702`](https://github.com/tajo/react-movable/commit/e6c3702575e981b1a98287075854464a46e9cf63) Thanks [@darshan-uber](https://github.com/darshan-uber)! - add check for element presence in "checkIfInteractive" utility 8 | 9 | ## 3.4.0 10 | 11 | ### Minor Changes 12 | 13 | - [#114](https://github.com/tajo/react-movable/pull/114) [`15d8290`](https://github.com/tajo/react-movable/commit/15d8290ad79b45ce53bda4fb49c729a9ed7d88e4) Thanks [@leomelzer](https://github.com/leomelzer)! - feat: implement afterDrag 14 | 15 | ## 3.3.1 16 | 17 | ### Patch Changes 18 | 19 | - [#110](https://github.com/tajo/react-movable/pull/110) [`09c7e76`](https://github.com/tajo/react-movable/commit/09c7e76cda3e7f563859e722d2a2fa324ddcb25f) Thanks [@tajo](https://github.com/tajo)! - Accept any version of React 20 | 21 | ## 3.3.0 22 | 23 | ### Minor Changes 24 | 25 | - [#107](https://github.com/tajo/react-movable/pull/107) [`15d02a7`](https://github.com/tajo/react-movable/commit/15d02a7485d0f1c42b5067b99c603ca0327b43ab) Thanks [@tajo](https://github.com/tajo)! - Adding disabled prop to the List 26 | -------------------------------------------------------------------------------- /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 vojtech@miksu.cz. 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 [https://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019-present, Vojtech Miksu 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-movable 2 | 3 | [![npm version](https://img.shields.io/npm/v/react-movable.svg?style=flat-square)](https://www.npmjs.com/package/react-movable) 4 | [![npm downloads](https://img.shields.io/npm/dm/react-movable.svg?style=flat-square)](https://www.npmjs.com/package/react-movable) 5 | [![size](https://img.shields.io/bundlephobia/minzip/react-movable.svg?style=flat)](https://bundlephobia.com/result?p=react-movable) 6 | stackblitz 7 | 8 | ![Basic list](https://raw.githubusercontent.com/tajo/react-movable/main/assets/react-movable.gif?raw=true) 9 | 10 | [See all the other examples](https://react-movable.pages.dev) and [their source code](https://github.com/tajo/react-movable/tree/main/examples)! Try it out in the [Stackblitz sandbox](https://stackblitz.com/edit/react-movable?file=src%2FApp.tsx)! 11 | 12 | ## Installation 13 | 14 | ``` 15 | pnpm add react-movable 16 | ``` 17 | 18 | ## Usage 19 | 20 | ```tsx 21 | import * as React from "react"; 22 | import { List, arrayMove } from "react-movable"; 23 | 24 | const SuperSimple: React.FC = () => { 25 | const [items, setItems] = React.useState(["Item 1", "Item 2", "Item 3"]); 26 | return ( 27 | 30 | setItems(arrayMove(items, oldIndex, newIndex)) 31 | } 32 | renderList={({ children, props }) => } 33 | renderItem={({ value, props }) =>
  • {value}
  • } 34 | /> 35 | ); 36 | }; 37 | ``` 38 | 39 | ## Features 40 | 41 | - **Vertical drag and drop for your lists and tables** 42 | - No wrapping divs or additional markup 43 | - Simple single component, no providers or HoCs 44 | - Unopinionated styling, great for **CSS in JS** too 45 | - **Accessible**, made for keyboards and screen readers 46 | - **Touchable**, works on mobile devices 47 | - Full control over the dragged item, it's a portaled React component 48 | - **Autoscrolling** when dragging (both for containers and the window) 49 | - Scrolling with the mousewheel / trackpad when dragging 50 | - Works with semantic table rows too 51 | - **Smooth animations**, can be disabled 52 | - Varying heights of items supported 53 | - Optional lock of the horizontal axis when dragging 54 | - **No dependencies, less than 4kB (gzipped)** 55 | - Coverage by [e2e playwright tests](#end-to-end-testing) 56 | 57 | ## Keyboard support 58 | 59 | - `tab` and `shift+tab` to focus items 60 | - `space` to lift or drop the item 61 | - `j` or `arrow down` to move the lifted item down 62 | - `k` or `arrow up` to move the lifted item up 63 | - `escape` to cancel the lift and return the item to its initial position 64 | 65 | ## `` props 66 | 67 | ### renderList 68 | 69 | ```ts 70 | renderList: (props: { 71 | children: React.ReactNode; 72 | isDragged: boolean; 73 | props: { 74 | ref: React.RefObject; 75 | }; 76 | }) => React.ReactNode; 77 | ``` 78 | 79 | `renderList` prop to define your list (root) element. **Your function gets three parameters and should return a React component**: 80 | 81 | - `props` containing `ref` - this needs to be spread over the root list element, note that items need to be direct children of the DOM element that's being set with this `ref` 82 | - `children` - the content of the list 83 | - `isDragged` - `true` if any item is being dragged 84 | 85 | ### renderItem 86 | 87 | ```ts 88 | renderItem: (params: { 89 | value: Value; 90 | index?: number; 91 | isDragged: boolean; 92 | isSelected: boolean; 93 | isDisabled: boolean; 94 | isOutOfBounds: boolean; 95 | props: { 96 | key?: number; 97 | tabIndex?: number; 98 | "aria-roledescription"?: string; 99 | onKeyDown?: (e: React.KeyboardEvent) => void; 100 | onWheel?: (e: React.WheelEvent) => void; 101 | style?: React.CSSProperties; 102 | ref?: React.RefObject; 103 | }; 104 | }) => React.ReactNode; 105 | ``` 106 | 107 | `renderItem` prop to define your item element. **Your function gets these parameters and should return a React component**: 108 | 109 | - `value` - an item of the array you passed into the `values` prop 110 | - `index` - the item index (order) 111 | - `isDragged` - `true` if the item is dragged, great for styling purposes 112 | - `isDisabled` - `true` if the list is disabled or `value.disabled` is `true`, great for styling purposes 113 | - `isSelected` - `true` if the item is lifted with the `space` 114 | - `isOutOfBounds` - `true` if the item is dragged far left or right 115 | - `props` - it has multiple props that you need to spread over your item element. Since one of these is `ref`, if you're spreading over a custom component, it must be wrapped in `React.forwardRef` like in the "Custom component" example. 116 | 117 | ### values 118 | 119 | ```ts 120 | values: Value[] 121 | ``` 122 | 123 | An array of values. The value can be a string or any more complex object. The length of the `values` array equals the number of rendered items. 124 | 125 | ### onChange 126 | 127 | ```ts 128 | onChange: (meta: { oldIndex: number; newIndex: number, targetRect: DOMRect }) => void 129 | ``` 130 | 131 | Called when the item is dropped to a new location: 132 | 133 | - `oldIndex` - the initial position of the element (0 indexed) 134 | - `newIndex` - the new position of the element (0 indexed), -1 when `removableByMove` is set and item dropped out of bounds 135 | - `targetRect` - [getBoundingClientRect](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) of dropped item 136 | 137 | The List component is `stateless` and `controlled` so you need to implement this function to change the order of input `values`. Check the initial example. 138 | 139 | ### beforeDrag 140 | 141 | ```ts 142 | beforeDrag?: (params: { elements: Element[]; index: number }) => void; 143 | ``` 144 | 145 | Called when a valid drag is initiated. It provides a direct access to all list DOM elements and the index of dragged item. This can be useful when you need to do some upfront measurements like when building a [table with variable column widths](https://react-movable.netlify.app/?story=list--table-auto-cell-widths). 146 | 147 | ### afterDrag 148 | 149 | ```ts 150 | afterDrag?: (params: { elements: Element[]; oldIndex: number; newIndex: number }) => void; 151 | ``` 152 | 153 | Called when a drag is completed. It provides a direct access to all list DOM elements, the old as well as the new index of the dragged item. This can be useful when you need to perform some cleanup, e.g. in conjunction with `beforeDrag`. Please note this is different to `onChange`, which will _only_ fire when the drag results in a changed order. 154 | 155 | ### removableByMove 156 | 157 | ```ts 158 | removableByMove: boolean; 159 | ``` 160 | 161 | Default is `false`. When set to `true` and an item is dragged far left or far right (out of bounds), the original gap disappears (animated) and following item drop will cause `onChange` being called with `newIndex = -1`. You can use that to remove the item from your `values` state. [Example](https://react-movable.pages.dev/?story=list--removable-by-move). 162 | 163 | ### transitionDuration 164 | 165 | ```ts 166 | transitionDuration: number; 167 | ``` 168 | 169 | The duration of CSS transitions. By default it's **300ms**. You can set it to 0 to disable all animations. 170 | 171 | ### lockVertically 172 | 173 | ```ts 174 | lockVertically: boolean; 175 | ``` 176 | 177 | If `true`, the dragged element can move only vertically when being dragged. 178 | 179 | ### disabled 180 | 181 | ```ts 182 | disabled: boolean; 183 | ``` 184 | 185 | If `true`, none of the items in the list will be draggable. 186 | 187 | ### voiceover 188 | 189 | ```ts 190 | voiceover: { 191 | item: (position: number) => string; 192 | lifted: (position: number) => string; 193 | dropped: (from: number, to: number) => string; 194 | moved: (position: number, up: boolean) => string; 195 | canceled: (position: number) => string; 196 | } 197 | ``` 198 | 199 | In order to support screen reader users, `react-movable` is triggering different messages when user is interacting with the list. There is already a set of [English messages](https://github.com/tajo/react-movable/blob/main/src/List.tsx#L77-L89) included but you can override it with this prop. 200 | 201 | ## container 202 | 203 | ```ts 204 | container?: Element; 205 | ``` 206 | 207 | Provide custom DOM element where moved item will be rendered. 208 | 209 | ## `arrayMove` and `arrayRemove` 210 | 211 | There are also additional two helper functions being exported: 212 | 213 | ```ts 214 | arrayMove: (array: T[], from: number, to: number) => T[]; 215 | arrayRemove: (array: T[], index: number) => T[]; 216 | ``` 217 | 218 | They are useful when you need to manipulate the state of `values` when `onChange` is triggered. 219 | 220 | ## Motivation 221 | 222 | There are two main ways how you can implement drag and drop today: 223 | 224 | - **[HTML5 drag and drop API](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API)**. However, it has some [severe limitations](https://www.quirksmode.org/blog/archives/2009/09/the_html5_drag.html). 225 | - Mouse and touch events. It's very low level. You have the full control but it has no concept of DnD. 226 | 227 | There are multiple great libraries in React's ecosystem already. DnD can get pretty complicated so each one of them covers different use-cases and has different goals: 228 | 229 | [react-dnd](https://github.com/react-dnd/react-dnd) is a general purpose DnD library that makes amazing job abstracting quirky HTML5 API. It provides well thought out lower-level DnD primitives and let you build anything you want. 230 | 231 | [react-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd) is a really beautiful DnD library for lists. It comes with a great support for accessibility and it's packed with awesome features. It doesn't use HTML5 API so it doesn't impose any of its limitations. 232 | 233 | [react-sortable-hoc](https://github.com/clauderic/react-sortable-hoc) provides a set of higher order components to make your lists dnd-able. It has many features and approaches similar to `react-beautiful-dnd` but it's more minimalistic and lacks some features as accessibility or unopinionated styling. 234 | 235 | So why `react-movable` was created? There are two main goals: 236 | 237 | - **Small footprint**. It's about 10 times smaller than `react-dnd` or `react-beautiful-dnd` (~3kB vs ~30kB) and half of the size of `react-sortable-hoc` (~7kB). That's especially important when you intend to use `react-movable` as a dependency in your own library. However, that also means that some features are left out - for example, the horizontal DnD is not supported. 238 | - **Simple but not compromised**. - Every byte counts but not if it comes down to the support for accessibility, screen readers, keyboards and touch devices. The goal is to support a limited set of use cases but without compromises. 239 | 240 | ### Features that are not supported (and never will be) 241 | 242 | - Horizontal sorting. 243 | - DnD between multiple list. 244 | - Combining items / multi drag support. 245 | - Supporting older versions of React. The minimum required version is `16.3` since the new `createRef` and `createPortal` APIs are used. 246 | 247 | If you need the features above, please give a try to `react-beautiful-dnd`. It's a really well-designed library with all those features and gives you a lot of power to customize! If you are building an application heavy on DnD interactions, it might be your best bet! `react-movable`'s goal is not to be feature complete with `react-beautiful-dnd`. 248 | 249 | ### Planned features 250 | 251 | - Built-in virtualization / windowing. 252 | 253 | Other feature requests will be thoroughly vetted. Again, the primary goal is to keep the size down while supporting main use-cases! 254 | 255 | ## End to end testing 256 | 257 | **This library is tightly coupled to many DOM APIs**. It would be very hard to write unit tests that would not involve a lot of mocking. Or we could re-architect the library to better abstract all DOM interfaces but that would mean more code and bigger footprint. 258 | 259 | Instead of that, `react-movable` is thoroughly tested by end to end tests powered by [puppeteer](https://github.com/GoogleChrome/puppeteer). It tests all user interactions: 260 | 261 | - [drag and drop](https://github.com/tajo/react-movable/blob/main/e2e/basic.test.ts) of items by mouse (same and different heights) 262 | - [keyboard controls](https://github.com/tajo/react-movable/blob/main/e2e/basic.a11y.test.ts) (moving items around) 263 | - [auto scrolling for containers](https://github.com/tajo/react-movable/blob/main/e2e/scrolling.container.test.ts) 264 | - [auto scrolling for the window](https://github.com/tajo/react-movable/blob/main/e2e/scrolling.window.test.ts) 265 | - [visual regression testing](https://github.com/americanexpress/jest-image-snapshot) 266 | 267 | All tests are automatically ran in Github Actions with headless chromium. This way, the public API is well tested, including pixel-perfect positioning. Also, the tests are pretty fast, reliable and very descriptive. Running them locally is easy: 268 | 269 | ```bash 270 | pnpm test:e2e 271 | ``` 272 | 273 | ## Browser support 274 | 275 | - **Chrome** (latest, mac, windows, iOS, Android) 276 | - **Firefox** (latest, mac, windows) 277 | - **Safari** (latest, mac, iOS) 278 | - **Edge** (latest, windows) 279 | 280 | ## Users 281 | 282 | - [Uber Base UI](https://baseui.design/components/dnd-list/) 283 | 284 | > If you are using react-movable, please open a PR and add yourself to this list! 285 | 286 | ## Contributing 287 | 288 | This is how you can spin up the dev environment: 289 | 290 | ``` 291 | git clone https://github.com/tajo/react-movable 292 | cd react-movable 293 | pnpm install 294 | pnpm ladle serve 295 | pnpm test 296 | pnpm typecheck 297 | ``` 298 | 299 | ## Learning more 300 | 301 | I wrote an article about [Building a Drag and Drop List](https://baseweb.design/blog/drag-and-drop-list/). 302 | 303 | Also, gave a talk at React Advanced London: What a Drag (2019): 304 | 305 | [![React Advanced London: What a Drag](https://img.youtube.com/vi/y_XkQ2qMTSA/0.jpg)](https://www.youtube.com/watch?v=y_XkQ2qMTSA) 306 | 307 | ## Shoutouts 🙏 308 | 309 | The popular React DnD libraries were already mentioned in the motivation part. Big shoutout to `react-beautiful-dnd` ❤️ ️ for supporting multiple great features and adding first-class support for accessibility! It was strongly used as an inspiration for `react-movable`! 310 | 311 | ## Author 312 | 313 | Vojtech Miksu 2024, [miksu.cz](https://miksu.cz), [@vmiksu](https://twitter.com/vmiksu) 314 | -------------------------------------------------------------------------------- /assets/react-movable.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/assets/react-movable.gif -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ["@commitlint/config-conventional"] }; 2 | -------------------------------------------------------------------------------- /e2e/basic.a11y.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { 3 | Examples, 4 | getTestUrl, 5 | getListItems, 6 | addFontStyles, 7 | waitForList, 8 | } from "./utils"; 9 | 10 | test.beforeEach(async ({ page }) => { 11 | await page.goto(getTestUrl(Examples.BASIC)); 12 | await page.waitForSelector("[data-storyloaded]"); 13 | await addFontStyles(page as any); 14 | await waitForList(page); 15 | }); 16 | 17 | test("move the first item to second position", async ({ page }) => { 18 | await page.keyboard.press("Tab"); 19 | await page.keyboard.press("Space"); 20 | await page.keyboard.press("ArrowDown"); 21 | await page.keyboard.press("Space"); 22 | expect(await getListItems(page as any)).toEqual([ 23 | "Item 2", 24 | "Item 1", 25 | "Item 3", 26 | "Item 4", 27 | "Item 5", 28 | "Item 6", 29 | ]); 30 | await page.mouse.click(1, 1); 31 | await expect(page).toHaveScreenshot(); 32 | }); 33 | 34 | test("move the sixth item to fifth position", async ({ page }) => { 35 | await page.keyboard.down("Shift"); 36 | await page.keyboard.press("Tab"); 37 | await page.keyboard.up("Shift"); 38 | await page.keyboard.press("Space"); 39 | await page.keyboard.press("ArrowUp"); 40 | await page.keyboard.press("Space"); 41 | expect(await getListItems(page as any)).toEqual([ 42 | "Item 1", 43 | "Item 2", 44 | "Item 3", 45 | "Item 4", 46 | "Item 6", 47 | "Item 5", 48 | ]); 49 | await page.mouse.click(1, 1); 50 | await expect(page).toHaveScreenshot(); 51 | }); 52 | 53 | test("move 1->5, 6->2 and 3->5", async ({ page }) => { 54 | // 1->5 55 | await page.keyboard.press("Tab"); 56 | await page.keyboard.press("Space"); 57 | await page.keyboard.press("ArrowDown"); 58 | await page.keyboard.press("ArrowDown"); 59 | await page.keyboard.press("ArrowDown"); 60 | await page.keyboard.press("ArrowDown"); 61 | await page.keyboard.press("Space"); 62 | 63 | // 6->2 64 | await page.keyboard.press("Tab"); 65 | await page.keyboard.press("Space"); 66 | await page.keyboard.press("ArrowUp"); 67 | await page.keyboard.press("ArrowUp"); 68 | await page.keyboard.press("ArrowUp"); 69 | await page.keyboard.press("ArrowUp"); 70 | await page.keyboard.press("Space"); 71 | 72 | // 3->5 73 | await page.keyboard.press("Tab"); 74 | await page.keyboard.press("Space"); 75 | await page.keyboard.press("ArrowDown"); 76 | await page.keyboard.press("ArrowDown"); 77 | await page.keyboard.press("Space"); 78 | 79 | expect(await getListItems(page as any)).toEqual([ 80 | "Item 2", 81 | "Item 6", 82 | "Item 4", 83 | "Item 5", 84 | "Item 3", 85 | "Item 1", 86 | ]); 87 | await page.mouse.click(1, 1); 88 | await expect(page).toHaveScreenshot(); 89 | }); 90 | 91 | test("cancel the move of first item to second position", async ({ page }) => { 92 | await page.keyboard.press("Tab"); 93 | await page.keyboard.press("Space"); 94 | await page.keyboard.press("ArrowDown"); 95 | await page.keyboard.press("Escape"); 96 | expect(await getListItems(page as any)).toEqual([ 97 | "Item 1", 98 | "Item 2", 99 | "Item 3", 100 | "Item 4", 101 | "Item 5", 102 | "Item 6", 103 | ]); 104 | await page.mouse.click(1, 1); 105 | await expect(page).toHaveScreenshot(); 106 | }); 107 | 108 | test("cancel the move of first item to second with mouse click", async ({ 109 | page, 110 | }) => { 111 | await page.keyboard.press("Tab"); 112 | await page.keyboard.press("Space"); 113 | await page.keyboard.press("ArrowDown"); 114 | await page.mouse.click(1, 1); 115 | expect(await getListItems(page as any)).toEqual([ 116 | "Item 1", 117 | "Item 2", 118 | "Item 3", 119 | "Item 4", 120 | "Item 5", 121 | "Item 6", 122 | ]); 123 | await page.mouse.click(1, 1); 124 | await expect(page).toHaveScreenshot(); 125 | }); 126 | -------------------------------------------------------------------------------- /e2e/basic.a11y.test.ts-snapshots/cancel-the-move-of-first-item-to-second-position-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/basic.a11y.test.ts-snapshots/cancel-the-move-of-first-item-to-second-position-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/basic.a11y.test.ts-snapshots/cancel-the-move-of-first-item-to-second-with-mouse-click-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/basic.a11y.test.ts-snapshots/cancel-the-move-of-first-item-to-second-with-mouse-click-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/basic.a11y.test.ts-snapshots/move-1--5-6--2-and-3--5-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/basic.a11y.test.ts-snapshots/move-1--5-6--2-and-3--5-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/basic.a11y.test.ts-snapshots/move-the-first-item-to-second-position-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/basic.a11y.test.ts-snapshots/move-the-first-item-to-second-position-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/basic.a11y.test.ts-snapshots/move-the-sixth-item-to-fifth-position-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/basic.a11y.test.ts-snapshots/move-the-sixth-item-to-fifth-position-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/basic.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { 3 | Examples, 4 | getTestUrl, 5 | trackMouse, 6 | untrackMouse, 7 | addFontStyles, 8 | getListItems, 9 | makeDnd, 10 | waitForList, 11 | } from "./utils"; 12 | 13 | const POSITIONS = [ 14 | [190, 111], 15 | [190, 190], 16 | [190, 263], 17 | [190, 346], 18 | [190, 418], 19 | [190, 501], 20 | ]; 21 | 22 | test.beforeEach(async ({ page }) => { 23 | await page.goto(getTestUrl(Examples.BASIC)); 24 | await page.waitForSelector("[data-storyloaded]"); 25 | await addFontStyles(page as any); 26 | await waitForList(page); 27 | }); 28 | 29 | test("dnd the first item to second position", async ({ page }) => { 30 | await trackMouse(page as any); 31 | await makeDnd(page, page.mouse, 1, 2, POSITIONS); 32 | expect(await getListItems(page as any)).toEqual([ 33 | "Item 2", 34 | "Item 1", 35 | "Item 3", 36 | "Item 4", 37 | "Item 5", 38 | "Item 6", 39 | ]); 40 | await untrackMouse(page as any); 41 | await expect(page).toHaveScreenshot(); 42 | }); 43 | 44 | test("dnd the sixth item to fifth position", async ({ page }) => { 45 | await trackMouse(page as any); 46 | await makeDnd(page, page.mouse, 6, 5, POSITIONS); 47 | expect(await getListItems(page as any)).toEqual([ 48 | "Item 1", 49 | "Item 2", 50 | "Item 3", 51 | "Item 4", 52 | "Item 6", 53 | "Item 5", 54 | ]); 55 | await untrackMouse(page as any); 56 | await expect(page).toHaveScreenshot(); 57 | }); 58 | 59 | test("dnd 1->5, 6->2 and 3->5", async ({ page }) => { 60 | await trackMouse(page as any); 61 | await makeDnd(page, page.mouse, 1, 5, POSITIONS); 62 | await makeDnd(page, page.mouse, 6, 2, POSITIONS); 63 | await makeDnd(page, page.mouse, 3, 5, POSITIONS); 64 | expect(await getListItems(page as any)).toEqual([ 65 | "Item 2", 66 | "Item 6", 67 | "Item 4", 68 | "Item 5", 69 | "Item 3", 70 | "Item 1", 71 | ]); 72 | await untrackMouse(page as any); 73 | await expect(page).toHaveScreenshot(); 74 | }); 75 | -------------------------------------------------------------------------------- /e2e/basic.test.ts-snapshots/dnd-1--5-6--2-and-3--5-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/basic.test.ts-snapshots/dnd-1--5-6--2-and-3--5-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/basic.test.ts-snapshots/dnd-the-first-item-to-second-position-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/basic.test.ts-snapshots/dnd-the-first-item-to-second-position-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/basic.test.ts-snapshots/dnd-the-sixth-item-to-fifth-position-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/basic.test.ts-snapshots/dnd-the-sixth-item-to-fifth-position-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/disabled-items.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { 3 | Examples, 4 | getTestUrl, 5 | trackMouse, 6 | untrackMouse, 7 | addFontStyles, 8 | getListItems, 9 | makeDnd, 10 | waitForList, 11 | } from "./utils"; 12 | 13 | const POSITIONS = [ 14 | [190, 111], 15 | [190, 190], 16 | [190, 263], 17 | [190, 346], 18 | [190, 418], 19 | [190, 501], 20 | ]; 21 | 22 | test.beforeEach(async ({ page }) => { 23 | await page.goto(getTestUrl(Examples.DISABLED_ITEMS)); 24 | await page.waitForSelector("[data-storyloaded]"); 25 | await addFontStyles(page as any); 26 | await waitForList(page); 27 | }); 28 | 29 | test("dnd the first item to second position", async ({ page }) => { 30 | await trackMouse(page as any); 31 | await makeDnd(page, page.mouse, 1, 2, POSITIONS); 32 | expect(await getListItems(page as any)).toEqual([ 33 | "Item 2", 34 | "Item 1", 35 | "Item 3", 36 | "Item 4", 37 | "Item 5", 38 | "Item 6", 39 | ]); 40 | await untrackMouse(page as any); 41 | await expect(page).toHaveScreenshot(); 42 | }); 43 | 44 | test("attempt to dnd the fourth item, should have no effect", async ({ 45 | page, 46 | }) => { 47 | await trackMouse(page as any); 48 | await makeDnd(page, page.mouse, 4, 1, POSITIONS); 49 | expect(await getListItems(page as any)).toEqual([ 50 | "Item 1", 51 | "Item 2", 52 | "Item 3", 53 | "Item 4", 54 | "Item 5", 55 | "Item 6", 56 | ]); 57 | await untrackMouse(page as any); 58 | await expect(page).toHaveScreenshot(); 59 | }); 60 | -------------------------------------------------------------------------------- /e2e/disabled-items.test.ts-snapshots/attempt-to-dnd-the-fourth-item-should-have-no-effect-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/disabled-items.test.ts-snapshots/attempt-to-dnd-the-fourth-item-should-have-no-effect-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/disabled-items.test.ts-snapshots/dnd-the-first-item-to-second-position-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/disabled-items.test.ts-snapshots/dnd-the-first-item-to-second-position-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/disabled-list.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { 3 | Examples, 4 | getTestUrl, 5 | trackMouse, 6 | untrackMouse, 7 | addFontStyles, 8 | getListItems, 9 | makeDnd, 10 | waitForList, 11 | } from "./utils"; 12 | 13 | const POSITIONS = [ 14 | [190, 111], 15 | [190, 190], 16 | [190, 263], 17 | [190, 346], 18 | [190, 418], 19 | [190, 501], 20 | ]; 21 | 22 | test.beforeEach(async ({ page }) => { 23 | await page.goto(getTestUrl(Examples.DISABLED_LIST)); 24 | await page.waitForSelector("[data-storyloaded]"); 25 | await addFontStyles(page as any); 26 | await waitForList(page); 27 | }); 28 | 29 | test("attempt to dnd the first item to second position, should have no effect", async ({ 30 | page, 31 | }) => { 32 | await trackMouse(page as any); 33 | await makeDnd(page, page.mouse, 1, 2, POSITIONS); 34 | expect(await getListItems(page as any)).toEqual([ 35 | "Item 1", 36 | "Item 2", 37 | "Item 3", 38 | "Item 4", 39 | "Item 5", 40 | "Item 6", 41 | ]); 42 | await untrackMouse(page as any); 43 | await expect(page).toHaveScreenshot(); 44 | }); 45 | -------------------------------------------------------------------------------- /e2e/disabled-list.test.ts-snapshots/attempt-to-dnd-the-first-item-to-second-position-should-have-no-effect-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/disabled-list.test.ts-snapshots/attempt-to-dnd-the-first-item-to-second-position-should-have-no-effect-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/heights.a11y.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { 3 | Examples, 4 | getTestUrl, 5 | getListItems, 6 | addFontStyles, 7 | waitForList, 8 | } from "./utils"; 9 | 10 | test.beforeEach(async ({ page }) => { 11 | await page.goto(getTestUrl(Examples.HEIGHTS)); 12 | await page.waitForSelector("[data-storyloaded]"); 13 | await addFontStyles(page as any); 14 | await waitForList(page); 15 | }); 16 | 17 | test("move the first item to second position", async ({ page }) => { 18 | await page.keyboard.press("Tab"); 19 | await page.keyboard.press("Space"); 20 | await page.keyboard.press("ArrowDown"); 21 | await page.keyboard.press("Space"); 22 | expect(await getListItems(page as any)).toEqual([ 23 | "100px Item 2", 24 | "70px Item 1", 25 | "70px Item 3", 26 | "70px Item 4", 27 | "150px Item 5", 28 | ]); 29 | await page.mouse.click(1, 1); 30 | await expect(page).toHaveScreenshot(); 31 | }); 32 | 33 | test("move the fifth item to fourth position", async ({ page }) => { 34 | await page.keyboard.down("Shift"); 35 | await page.keyboard.press("Tab"); 36 | await page.keyboard.up("Shift"); 37 | await page.keyboard.press("Space"); 38 | await page.keyboard.press("ArrowUp"); 39 | await page.keyboard.press("Space"); 40 | expect(await getListItems(page as any)).toEqual([ 41 | "70px Item 1", 42 | "100px Item 2", 43 | "70px Item 3", 44 | "150px Item 5", 45 | "70px Item 4", 46 | ]); 47 | await page.mouse.click(1, 1); 48 | await expect(page).toHaveScreenshot(); 49 | }); 50 | 51 | test("move 1->5, 4->2 and 3->5", async ({ page }) => { 52 | // 1->5 53 | await page.keyboard.press("Tab"); 54 | await page.keyboard.press("Space"); 55 | await page.keyboard.press("ArrowDown"); 56 | await page.keyboard.press("ArrowDown"); 57 | await page.keyboard.press("ArrowDown"); 58 | await page.keyboard.press("ArrowDown"); 59 | await page.keyboard.press("Space"); 60 | 61 | // 4->2 62 | await page.keyboard.down("Shift"); 63 | await page.keyboard.press("Tab"); 64 | await page.keyboard.up("Shift"); 65 | await page.keyboard.press("Space"); 66 | await page.keyboard.press("ArrowUp"); 67 | await page.keyboard.press("ArrowUp"); 68 | await page.keyboard.press("Space"); 69 | 70 | // 3->5 71 | await page.keyboard.press("Tab"); 72 | await page.keyboard.press("Space"); 73 | await page.keyboard.press("ArrowDown"); 74 | await page.keyboard.press("ArrowDown"); 75 | await page.keyboard.press("Space"); 76 | 77 | expect(await getListItems(page as any)).toEqual([ 78 | "100px Item 2", 79 | "150px Item 5", 80 | "70px Item 4", 81 | "70px Item 1", 82 | "70px Item 3", 83 | ]); 84 | await page.mouse.click(1, 1); 85 | await expect(page).toHaveScreenshot(); 86 | }); 87 | 88 | test("cancel the move of first item to second position", async ({ page }) => { 89 | await page.keyboard.press("Tab"); 90 | await page.keyboard.press("Space"); 91 | await page.keyboard.press("ArrowDown"); 92 | await page.keyboard.press("Escape"); 93 | expect(await getListItems(page as any)).toEqual([ 94 | "70px Item 1", 95 | "100px Item 2", 96 | "70px Item 3", 97 | "70px Item 4", 98 | "150px Item 5", 99 | ]); 100 | await page.mouse.click(1, 1); 101 | await expect(page).toHaveScreenshot(); 102 | }); 103 | -------------------------------------------------------------------------------- /e2e/heights.a11y.test.ts-snapshots/cancel-the-move-of-first-item-to-second-position-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/heights.a11y.test.ts-snapshots/cancel-the-move-of-first-item-to-second-position-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/heights.a11y.test.ts-snapshots/move-1--5-4--2-and-3--5-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/heights.a11y.test.ts-snapshots/move-1--5-4--2-and-3--5-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/heights.a11y.test.ts-snapshots/move-the-fifth-item-to-fourth-position-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/heights.a11y.test.ts-snapshots/move-the-fifth-item-to-fourth-position-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/heights.a11y.test.ts-snapshots/move-the-first-item-to-second-position-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/heights.a11y.test.ts-snapshots/move-the-first-item-to-second-position-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/heights.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { 3 | Examples, 4 | getTestUrl, 5 | trackMouse, 6 | untrackMouse, 7 | addFontStyles, 8 | getListItems, 9 | makeDnd, 10 | waitForList, 11 | } from "./utils"; 12 | 13 | const getPositions = (yPositions: number[]) => { 14 | return [ 15 | [190, yPositions[0] || 1], 16 | [190, yPositions[1] || 1], 17 | [190, yPositions[2] || 1], 18 | [190, yPositions[3] || 1], 19 | [190, yPositions[4] || 1], 20 | ]; 21 | }; 22 | 23 | test.beforeEach(async ({ page }) => { 24 | await page.goto(getTestUrl(Examples.HEIGHTS)); 25 | await page.waitForSelector("[data-storyloaded]"); 26 | await addFontStyles(page as any); 27 | await addFontStyles(page as any); 28 | await waitForList(page); 29 | }); 30 | 31 | test("dnd the first item to second position", async ({ page }) => { 32 | await trackMouse(page as any); 33 | await makeDnd(page, page.mouse, 1, 2, getPositions([111, 190])); 34 | expect(await getListItems(page as any)).toEqual([ 35 | "100px Item 2", 36 | "70px Item 1", 37 | "70px Item 3", 38 | "70px Item 4", 39 | "150px Item 5", 40 | ]); 41 | await untrackMouse(page as any); 42 | await expect(page).toHaveScreenshot(); 43 | }); 44 | 45 | test("dnd the fifth item to fourth position", async ({ page }) => { 46 | await trackMouse(page as any); 47 | await makeDnd(page, page.mouse, 5, 4, getPositions([0, 0, 0, 395, 493])); 48 | expect(await getListItems(page as any)).toEqual([ 49 | "70px Item 1", 50 | "100px Item 2", 51 | "70px Item 3", 52 | "150px Item 5", 53 | "70px Item 4", 54 | ]); 55 | await untrackMouse(page as any); 56 | await expect(page).toHaveScreenshot(); 57 | }); 58 | 59 | test("dnd 1->5, 4->2 and 3->5", async ({ page }) => { 60 | await trackMouse(page as any); 61 | await makeDnd(page, page.mouse, 1, 5, getPositions([108, 0, 0, 0, 491])); 62 | await makeDnd(page, page.mouse, 4, 2, getPositions([0, 228, 0, 419, 0])); 63 | await makeDnd(page, page.mouse, 3, 5, getPositions([0, 0, 373, 0, 536])); 64 | expect(await getListItems(page as any)).toEqual([ 65 | "100px Item 2", 66 | "150px Item 5", 67 | "70px Item 4", 68 | "70px Item 1", 69 | "70px Item 3", 70 | ]); 71 | await untrackMouse(page as any); 72 | await expect(page).toHaveScreenshot(); 73 | }); 74 | -------------------------------------------------------------------------------- /e2e/heights.test.ts-snapshots/dnd-1--5-4--2-and-3--5-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/heights.test.ts-snapshots/dnd-1--5-4--2-and-3--5-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/heights.test.ts-snapshots/dnd-the-fifth-item-to-fourth-position-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/heights.test.ts-snapshots/dnd-the-fifth-item-to-fourth-position-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/heights.test.ts-snapshots/dnd-the-first-item-to-second-position-1-chrome-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tajo/react-movable/3b38363fa8609b5fe2e756fb68e75b4ec83b00fb/e2e/heights.test.ts-snapshots/dnd-the-first-item-to-second-position-1-chrome-linux.png -------------------------------------------------------------------------------- /e2e/removable.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { 3 | Examples, 4 | getTestUrl, 5 | trackMouse, 6 | untrackMouse, 7 | addFontStyles, 8 | getListItems, 9 | waitForList, 10 | } from "./utils"; 11 | 12 | test.use({ 13 | viewport: { width: 1030, height: 800 }, 14 | }); 15 | 16 | test.beforeEach(async ({ page }) => { 17 | await page.goto(getTestUrl(Examples.REMOVABLE)); 18 | await page.waitForSelector("[data-storyloaded]"); 19 | await addFontStyles(page as any); 20 | await waitForList(page); 21 | }); 22 | 23 | test("dnd the second item out the bounds to be removed", async ({ page }) => { 24 | await trackMouse(page as any); 25 | await page.mouse.move(517, 275); 26 | await page.mouse.down(); 27 | await page.mouse.move(828, 222); 28 | await page.mouse.up(); 29 | await new Promise((r) => setTimeout(r, 300)); 30 | expect(await getListItems(page as any)).toEqual([ 31 | "You can remove items by moving them far left or right. Also, onChange always gives you the getBoundingClientRect of the dropped item.", 32 | "Item 3", 33 | "Item 4", 34 | "Item 5", 35 | "Item 6", 36 | ]); 37 | await untrackMouse(page as any); 38 | }); 39 | -------------------------------------------------------------------------------- /e2e/scrolling.container.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { Examples, getTestUrl, trackMouse, waitForList } from "./utils"; 3 | 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto(getTestUrl(Examples.SCROLLING_CONTAINER)); 6 | await page.waitForSelector("[data-storyloaded]"); 7 | await waitForList(page); 8 | }); 9 | 10 | test("scroll down", async ({ page }) => { 11 | await trackMouse(page as any); 12 | await page.mouse.move(190, 140); 13 | await page.mouse.down(); 14 | await page.mouse.move(190, 690); 15 | await new Promise((r) => setTimeout(r, 200)); 16 | await page.mouse.up(); 17 | const list = await page.$("#ladle-root ul"); 18 | const scrollTop = await page.evaluate((el) => { 19 | if (el) { 20 | return el.scrollTop; 21 | } 22 | }, list); 23 | expect(scrollTop).toBeGreaterThan(0); 24 | }); 25 | 26 | test("scroll up", async ({ page }) => { 27 | await trackMouse(page as any); 28 | const list = await page.$("#ladle-root ul"); 29 | await page.evaluate((el) => { 30 | if (el) { 31 | el.scrollTop = 300; 32 | } 33 | }, list); 34 | await page.mouse.move(190, 641); 35 | await page.mouse.down(); 36 | await page.mouse.move(190, 100); 37 | await new Promise((r) => setTimeout(r, 200)); 38 | await page.mouse.up(); 39 | const scrollTop = await page.evaluate((el) => { 40 | if (el) { 41 | return el.scrollTop; 42 | } 43 | }, list); 44 | expect(scrollTop).toBeLessThan(300); 45 | }); 46 | -------------------------------------------------------------------------------- /e2e/scrolling.window.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { Examples, getTestUrl, trackMouse, waitForList } from "./utils"; 3 | 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto(getTestUrl(Examples.SCROLLING_WINDOW)); 6 | await page.waitForSelector("[data-storyloaded]"); 7 | await waitForList(page); 8 | }); 9 | 10 | test("scroll down", async ({ page }) => { 11 | await trackMouse(page as any); 12 | await page.mouse.move(190, 140); 13 | await page.mouse.down(); 14 | await page.mouse.move(190, 690); 15 | await new Promise((r) => setTimeout(r, 200)); 16 | await page.mouse.up(); 17 | const pageYOffset = await page.evaluate(() => window.pageYOffset); 18 | expect(pageYOffset).toBeGreaterThan(0); 19 | }); 20 | 21 | test("scroll up", async ({ page }) => { 22 | await trackMouse(page as any); 23 | await page.evaluate(() => window.scrollTo(0, 300)); 24 | await page.mouse.move(190, 641); 25 | await page.mouse.down(); 26 | await page.mouse.move(190, 100); 27 | await new Promise((r) => setTimeout(r, 200)); 28 | await page.mouse.up(); 29 | const pageYOffset = await page.evaluate(() => window.pageYOffset); 30 | expect(pageYOffset).toBeLessThan(301); 31 | }); 32 | -------------------------------------------------------------------------------- /e2e/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Page, Mouse } from "@playwright/test"; 2 | 3 | export enum Examples { 4 | BASIC, 5 | HEIGHTS, 6 | SCROLLING_CONTAINER, 7 | SCROLLING_WINDOW, 8 | REMOVABLE, 9 | DISABLED_ITEMS, 10 | DISABLED_LIST, 11 | } 12 | 13 | export const getTestUrl = (example: Examples): string => { 14 | switch (example) { 15 | case Examples.BASIC: 16 | return `/?story=list--basic&mode=preview`; 17 | case Examples.HEIGHTS: 18 | return `/?story=list--varying-heights&mode=preview`; 19 | case Examples.SCROLLING_CONTAINER: 20 | return `/?story=list--scrolling-container&mode=preview`; 21 | case Examples.SCROLLING_WINDOW: 22 | return `/?story=list--scrolling-window&mode=preview`; 23 | case Examples.REMOVABLE: 24 | return `/?story=list--removable-by-move&mode=preview`; 25 | case Examples.DISABLED_ITEMS: 26 | return `/?story=list--disabled-items&mode=preview`; 27 | case Examples.DISABLED_LIST: 28 | return `/?story=list--disabled-list&mode=preview`; 29 | } 30 | }; 31 | 32 | export const getListItems = async (page: Page) => { 33 | const items = await page.$$("#ladle-root li"); 34 | await new Promise((r) => setTimeout(r, 200)); 35 | return await Promise.all( 36 | items.map((item) => page.evaluate((el) => (el as any).innerText, item)), 37 | ); 38 | }; 39 | 40 | export const waitForList = async (page: Page) => { 41 | await page.waitForSelector(`#ladle-root li`); 42 | }; 43 | 44 | export const makeDnd = async ( 45 | page: Page, 46 | mouse: Mouse, 47 | from: number, 48 | to: number, 49 | positions: number[][], 50 | ) => { 51 | await mouse.move(positions[from - 1][0], positions[from - 1][1]); 52 | await mouse.down(); 53 | await mouse.move(positions[to - 1][0], positions[to - 1][1]); 54 | await mouse.up(); 55 | // make sure that originally dragged item is visible (rendered) 56 | // in a new place 57 | await page.waitForSelector(`#ladle-root li:nth-child(${from})`, { 58 | state: "visible", 59 | }); 60 | }; 61 | 62 | export const trackMouse = async (page: Page) => { 63 | await page.evaluate(showCursor); 64 | }; 65 | 66 | export const untrackMouse = async (page: Page) => { 67 | await page.evaluate(hideCursor); 68 | await page.waitForSelector(".mouse-helper", { state: "hidden" }); 69 | }; 70 | 71 | export const addFontStyles = async (page: Page) => { 72 | await page.evaluate(fontStyles); 73 | }; 74 | 75 | // This injects a box into the page that moves with the mouse; 76 | // Useful for debugging 77 | const showCursor = () => { 78 | const box = document.createElement("div"); 79 | box.classList.add("mouse-helper"); 80 | const styleElement = document.createElement("style"); 81 | styleElement.innerHTML = ` 82 | .mouse-helper { 83 | pointer-events: none; 84 | position: absolute; 85 | top: 0; 86 | left: 0; 87 | width: 20px; 88 | height: 20px; 89 | background: rgba(0,0,0,.4); 90 | border: 1px solid white; 91 | border-radius: 10px; 92 | margin-left: -10px; 93 | margin-top: -10px; 94 | transition: background .2s, border-radius .2s, border-color .2s; 95 | z-index: 5000; 96 | } 97 | .mouse-helper.button-1 { 98 | transition: none; 99 | background: rgba(0,0,0,0.9); 100 | } 101 | .mouse-helper.button-2 { 102 | transition: none; 103 | border-color: rgba(0,0,255,0.9); 104 | } 105 | .mouse-helper.button-3 { 106 | transition: none; 107 | border-radius: 4px; 108 | } 109 | .mouse-helper.button-4 { 110 | transition: none; 111 | border-color: rgba(255,0,0,0.9); 112 | } 113 | .mouse-helper.button-5 { 114 | transition: none; 115 | border-color: rgba(0,255,0,0.9); 116 | } 117 | `; 118 | 119 | const onMouseMove = (event: MouseEvent) => { 120 | box.style.left = event.pageX + "px"; 121 | box.style.top = event.pageY + "px"; 122 | updateButtons(event.buttons); 123 | }; 124 | 125 | const onMouseDown = (event: MouseEvent) => { 126 | updateButtons(event.buttons); 127 | box.classList.add("button-" + event.which); 128 | }; 129 | 130 | const onMouseUp = (event: MouseEvent) => { 131 | updateButtons(event.buttons); 132 | box.classList.remove("button-" + event.which); 133 | }; 134 | 135 | document.head.appendChild(styleElement); 136 | document.body.appendChild(box); 137 | document.addEventListener("mousemove", onMouseMove, true); 138 | document.addEventListener("mousedown", onMouseDown, true); 139 | document.addEventListener("mouseup", onMouseUp, true); 140 | function updateButtons(buttons: any) { 141 | for (let i = 0; i < 5; i++) 142 | // @ts-ignore 143 | box.classList.toggle("button-" + i, buttons & (1 << i)); 144 | } 145 | }; 146 | 147 | // make the cursor invisble, good for visual snaps 148 | const hideCursor = () => { 149 | const styleElement = document.createElement("style"); 150 | styleElement.innerHTML = ` 151 | .mouse-helper { 152 | display: none; 153 | } 154 | `; 155 | document.head.appendChild(styleElement); 156 | }; 157 | 158 | // This injects a box into the page that moves with the mouse; 159 | // Useful for debugging 160 | const fontStyles = () => { 161 | const styleElement = document.createElement("style"); 162 | styleElement.innerHTML = ` 163 | body { 164 | margin: 8px; 165 | } 166 | li { 167 | font-weight: normal; 168 | font-style: normal; 169 | text-rendering: optimizeLegibility; 170 | -webkit-font-smoothing: antialiased; 171 | } 172 | `; 173 | document.head.appendChild(styleElement); 174 | }; 175 | -------------------------------------------------------------------------------- /examples/Basic.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { List, arrayMove } from "../src/index"; 3 | 4 | const Basic: React.FC = () => { 5 | const [items, setItems] = React.useState([ 6 | "Item 1", 7 | "Item 2", 8 | "Item 3", 9 | "Item 4", 10 | "Item 5", 11 | "Item 6", 12 | ]); 13 | 14 | return ( 15 |
    23 | 26 | setItems(arrayMove(items, oldIndex, newIndex)) 27 | } 28 | renderList={({ children, props, isDragged }) => ( 29 |
      33 | {children} 34 |
    35 | )} 36 | renderItem={({ value, props, isDragged, isSelected }) => ( 37 |
  • 54 | {value} 55 |
  • 56 | )} 57 | /> 58 |
    59 | ); 60 | }; 61 | 62 | export default Basic; 63 | -------------------------------------------------------------------------------- /examples/CustomComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { List, arrayMove } from "../src/index"; 3 | import type { IItemProps } from "../src/types"; 4 | 5 | const CustomItem = React.forwardRef( 6 | ( 7 | { 8 | children, 9 | ...props 10 | }: IItemProps & { 11 | children: string; 12 | }, 13 | ref: React.Ref, 14 | ) => ( 15 |
  • 16 | {children} 17 |
  • 18 | ), 19 | ); 20 | CustomItem.displayName = "CustomItem"; 21 | 22 | const CustomComponent: React.FC = () => { 23 | const [items, setItems] = React.useState([ 24 | "Item 1", 25 | "Item 2", 26 | "Item 3", 27 | "Item 4", 28 | "Item 5", 29 | "Item 6", 30 | ]); 31 | return ( 32 | 35 | setItems(arrayMove(items, oldIndex, newIndex)) 36 | } 37 | renderList={({ children, props }) =>
      {children}
    } 38 | renderItem={({ value, props }) => ( 39 | 40 | {value} 41 | 42 | )} 43 | /> 44 | ); 45 | }; 46 | 47 | export default CustomComponent; 48 | -------------------------------------------------------------------------------- /examples/CustomContainer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { List, arrayMove } from "../src/index"; 3 | 4 | const CustomContainer: React.FC = () => { 5 | const wrapper = React.useRef(null); 6 | const [items, setItems] = React.useState([ 7 | "Item 1", 8 | "Item 2", 9 | "Item 3", 10 | "Item 4", 11 | "Item 5", 12 | "Item 6", 13 | ]); 14 | const [container, setContainer] = React.useState(null); 15 | React.useEffect(() => { 16 | setContainer(wrapper.current); 17 | }, [wrapper.current]); 18 | 19 | return ( 20 |
    29 | 33 | setItems(arrayMove(items, oldIndex, newIndex)) 34 | } 35 | renderList={({ children, props, isDragged }) => ( 36 |
      43 | {children} 44 |
    45 | )} 46 | renderItem={({ value, props, isDragged, isSelected }) => ( 47 |
  • 64 | {value} 65 |
  • 66 | )} 67 | /> 68 |
    69 | ); 70 | }; 71 | 72 | export default CustomContainer; 73 | -------------------------------------------------------------------------------- /examples/DisabledItems.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { List, arrayMove } from "../src/index"; 3 | 4 | const DisabledItems: React.FC = () => { 5 | const [items, setItems] = React.useState([ 6 | { label: "Item 1" }, 7 | { label: "Item 2" }, 8 | { label: "Item 3" }, 9 | { label: "Item 4", disabled: true }, 10 | { label: "Item 5", disabled: true }, 11 | { label: "Item 6" }, 12 | ]); 13 | 14 | return ( 15 |
    23 | 26 | setItems(arrayMove(items, oldIndex, newIndex)) 27 | } 28 | renderList={({ children, props, isDragged }) => ( 29 |
      33 | {children} 34 |
    35 | )} 36 | renderItem={({ value, props, isDragged, isSelected, isDisabled }) => ( 37 |
  • 56 | {value.label} 57 |
  • 58 | )} 59 | /> 60 |
    61 | ); 62 | }; 63 | 64 | export default DisabledItems; 65 | -------------------------------------------------------------------------------- /examples/DisabledList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { List, arrayMove } from "../src/index"; 3 | 4 | const DisabledList: React.FC = () => { 5 | const [items, setItems] = React.useState([ 6 | "Item 1", 7 | "Item 2", 8 | "Item 3", 9 | "Item 4", 10 | "Item 5", 11 | "Item 6", 12 | ]); 13 | 14 | return ( 15 |
    23 | 27 | setItems(arrayMove(items, oldIndex, newIndex)) 28 | } 29 | renderList={({ children, props, isDragged }) => ( 30 |
      34 | {children} 35 |
    36 | )} 37 | renderItem={({ value, props, isDragged, isSelected, isDisabled }) => ( 38 |
  • 57 | {value} 58 |
  • 59 | )} 60 | /> 61 |
    62 | ); 63 | }; 64 | 65 | export default DisabledList; 66 | -------------------------------------------------------------------------------- /examples/Handle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { List, arrayMove } from "../src/index"; 3 | 4 | export const HandleIcon = () => ( 5 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | 26 | export const buttonStyles = { 27 | border: "none", 28 | margin: 0, 29 | padding: 0, 30 | width: "auto", 31 | overflow: "visible", 32 | cursor: "pointer", 33 | background: "transparent", 34 | }; 35 | 36 | const Handle: React.FC = () => { 37 | const [items, setItems] = React.useState([ 38 | "Item 1", 39 | "Item 2", 40 | "Item 3", 41 | "Item 4", 42 | "Item 5", 43 | "Item 6", 44 | ]); 45 | 46 | return ( 47 |
    56 | 59 | setItems(arrayMove(items, oldIndex, newIndex)) 60 | } 61 | renderList={({ children, props, isDragged }) => ( 62 |
      69 | {children} 70 |
    71 | )} 72 | renderItem={({ value, props, isDragged, isSelected }) => ( 73 |
  • 90 |
    96 | {/* 97 | Mark any node with the data-movable-handle attribute if you wish 98 | to use is it as a DnD handle. The rest of renderItem will be then 99 | ignored and not start the drag and drop. 100 | */} 101 | 112 |
    {value}
    113 |
    114 |
  • 115 | )} 116 | /> 117 |
    118 | ); 119 | }; 120 | 121 | export default Handle; 122 | -------------------------------------------------------------------------------- /examples/InteractiveItems.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { List, arrayMove } from "../src/index"; 3 | 4 | const InteractiveItems: React.FC = () => { 5 | const [inputValue, setInputValue] = React.useState("Input"); 6 | const [taValue, setTaValue] = React.useState("Textarea"); 7 | const [selectValue, setSelectValue] = React.useState("Parrot"); 8 | const [checkboxValue, setCheckboxValue] = React.useState(false); 9 | const elements = [ 10 | { 14 | setInputValue(e.target.value); 15 | }} 16 | />, 17 |