├── .github └── workflows │ └── pr.yml ├── .gitignore ├── HISTORY.md ├── README.md ├── docs ├── 01-GettingStartedGuide.md ├── 02-SaferIdentifiersAndMultipleContainersGuide.md └── 03-Api.md ├── examples ├── App.res ├── App.styles.css ├── Route.res ├── components │ ├── Container.res │ ├── Container.styles.css │ ├── Control.res │ ├── Control.styles.css │ ├── Example.res │ ├── Example.styles.css │ ├── Input.res │ ├── Input.styles.css │ ├── Label.res │ ├── Label.styles.css │ ├── Link.res │ ├── Link.styles.css │ ├── Main.res │ ├── Main.styles.css │ ├── Nav.res │ └── Nav.styles.css ├── examples │ ├── CardBoard.res │ ├── NestedVerticalLists.res │ └── SimpleList.res ├── guides │ ├── GettingStartedGuide.res │ └── SaferIdentifiersAndMultipleContainersGuide.res ├── icons │ ├── CloseIcon.res │ ├── DragHandleIcon.res │ ├── GithubIcon.res │ └── MenuIcon.res ├── index.css ├── index.html ├── index.res └── libs │ ├── ArrayExt.res │ └── Identity.res ├── package.json ├── rescript.json ├── src ├── Dnd.res ├── Dnd__Config.res ├── Dnd__DndContext.res ├── Dnd__DndManager.res ├── Dnd__DraggableItem.res ├── Dnd__DroppableContainer.res ├── Dnd__Events.res ├── Dnd__Geometry.res ├── Dnd__ReactHooks.res ├── Dnd__Scrollable.res ├── Dnd__Scroller.res ├── Dnd__SelectionManager.res ├── Dnd__Style.res ├── Dnd__Types.res └── Dnd__Web.res └── yarn.lock /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | name: Build on ${{ matrix.os }} 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | node-version: [20.x] 15 | os: [ubuntu-latest, macOS-latest, windows-latest] 16 | 17 | steps: 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | 23 | - name: Checkout 24 | uses: actions/checkout@v2 25 | 26 | - name: Get yarn cache directory path 27 | id: yarn-cache-dir-path 28 | run: echo "::set-output name=dir::$(yarn cache dir)" 29 | 30 | - name: Restore yarn cache 31 | id: yarn-cache 32 | uses: actions/cache@v1 33 | with: 34 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 35 | key: ${{ matrix.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 36 | restore-keys: | 37 | ${{ matrix.os }}-yarn- 38 | 39 | - name: Install deps 40 | run: yarn install 41 | 42 | - name: Build 43 | run: yarn rescript build -with-deps 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | dist/ 4 | .parcel-cache/ 5 | yarn-error.log 6 | *.res.js 7 | .vercel 8 | .merlin 9 | .bsb.lock 10 | .cache 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | 3 | ## UNRELEASED 4 | 5 | --- 6 | 7 | ## 6.0.0 8 | * **[ BREAKING ]** Update to `rescript@11`. 9 | 10 | ## 5.0.0 11 | * **[ BREAKING ]** Update to `rescript@10`. 12 | * **[ BREAKING ]** Replace `bs-webapi` with `rescript-webapi`. 13 | 14 | ## 4.0.0 15 | * **[ BREAKING ]** Switch to `@rescript/react`. 16 | * **[ BREAKING ]** Update `bs-platform`. 17 | 18 | ## 3.0.0 19 | * **[ BREAKING ]** Replace `bs-log` with `res-logger`. 20 | * **[ BREAKING ]** Move `bs-webapi` to peer dependensies. 21 | 22 | ## 2.0.0 23 | * **[ BREAKING ]** Modules of `DndEntry` type must provide `cmp` function to avoid polymorphic comparison. See [this diff](https://github.com/MinimaHQ/rescript-dnd/commit/238a0abab8ad88a7c643c0b022c49887c025a89b) for details. 24 | * **[ BREAKING ]** Minimum required version of `bs-platform` is `7.1.1`. 25 | * **[ BREAKING ]** Minimum required version of `reason-react` is `0.8.0`. 26 | 27 | ## 1.2.0 28 | * `bs-platform` updated to `v7`. 29 | * `bs-log` updated to `v1`. 30 | 31 | ## 1.1.0 32 | * Added `onDragStart`, `onDropStart` & `onDropEnd` handlers. 33 | 34 | ## 1.0.0 35 | * Full rewrite using hooks api. 36 | 37 | ## 0.6.0 38 | **Features** 39 | * Horizontal lists support. 40 | 41 | ## 0.5.0 42 | **Features** 43 | * Scrollable containers support. 44 | 45 | ## 0.4.0 46 | **Features** 47 | * Auto-scroll at the vertical edges of the window. 48 | 49 | ## 0.3.1 50 | **Fixes** 51 | * Fix sorting for case when draggable that being dragged is way bigger or smaller than siblings. 52 | * Fix determination of a landing point when dropping on empty droppable with header. 53 | 54 | ## 0.3.0 55 | **Features** 56 | * Conditional drag & drop. Now each `Droppable` takes optional `accept` prop: 57 | 58 | ```reason 59 | ~accept: option(Draggable.t => bool)=? 60 | 61 | true 65 | | TodoList => false 66 | ) 67 | /> 68 | ``` 69 | 70 | * Custom drag handles. 71 | 72 | ```reason 73 | /* Without custom drag handle */ 74 | 75 | ...(Children("Drag me" |> ReasonReact.string)) 76 | 77 | 78 | /* With custom drag handle */ 79 | 80 | ...( 81 | ChildrenWithHandle( 82 | handle => 83 | 90 | ) 91 | ) 92 | 93 | ``` 94 | 95 | ## 0.2.0 96 | **API** 97 | * **[ BREAKING ]** `Config` updated: structure is changed and `eq` function is required for both types `Draggable.t` and `Droppable.t`. It should improve overall perf. 98 | 99 | ```diff 100 | - module type Config = { 101 | - type draggableId; 102 | - type droppableId; 103 | - }; 104 | 105 | + module type Config = { 106 | + module Draggable: { 107 | + type t; 108 | + let eq: (t, t) => bool; 109 | + }; 110 | + 111 | + module Droppable: { 112 | + type t; 113 | + let eq: (t, t) => bool; 114 | + }; 115 | + }; 116 | ``` 117 | 118 | * **[ BREAKING ]** `Droppable`'s '`className` function receives `~draggingOver: bool` instead of `~draggingOver: option('droppableId)` (by virtue of the first change). 119 | 120 | ```diff 121 | - type className = (~draggingOver: option('droppableId)) => string; 122 | + type className = (~draggingOver: bool) => string; 123 | ``` 124 | 125 | ## 0.1.1 126 | **Improvements** 127 | * Don't interrupt text selection on desktops. 128 | * Disable text selection on drag in Safari. 129 | 130 | ## 0.1.0 131 | **Features** 132 | * Add touch interactions support. 133 | * Make `className` prop a function (`Draggable` & `Droppable`). 134 | 135 | ## 0.0.1 136 | * Core architecture. 137 | 138 | **Features** 139 | * Vertical lists. 140 | * Multiple drop targets. 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rescript-dnd 2 | 3 | [![npm version](https://img.shields.io/npm/v/rescript-dnd.svg?style=flat-square)](https://www.npmjs.com/package/rescript-dnd) 4 | [![license](https://img.shields.io/npm/l/rescript-dnd.svg?style=flat-square)](https://www.npmjs.com/package/rescript-dnd) 5 | [![build](https://github.com/shakacode/re-dnd/actions/workflows/pr.yml/badge.svg)](https://github.com/shakacode/re-dnd/actions/workflows/pr.yml) 6 | 7 | Drag & drop for [`@rescript/react`](https://reasonml.github.io/reason-react/). 8 | 9 | ## Features 10 | * Vertical lists 11 | * Horizontal lists 12 | * Multiple drop targets 13 | * Mouse & Touch interactions 14 | * Conditional drag & drop 15 | * Custom drag handles 16 | * Scrollable containers 17 | * Auto-scroll when dragging at container's edge 18 | 19 | > ### ShakaCode 20 | > If you are looking for help with the development and optimization of your project, [ShakaCode](https://www.shakacode.com) can help you to take the reliability and performance of your app to the next level. 21 | > 22 | > If you are a developer interested in working on ReScript / TypeScript / Rust / Ruby on Rails projects, [we're hiring](https://www.shakacode.com/career/)! 23 | 24 | ## Installation 25 | 26 | ```shell 27 | # yarn 28 | yarn add rescript-dnd rescript-webapi 29 | # or npm 30 | npm install --save rescript-dnd rescript-webapi 31 | ``` 32 | 33 | Then add it to `bsconfig.json`: 34 | 35 | ```json 36 | "bs-dependencies": [ 37 | "rescript-dnd", 38 | "rescript-webapi" 39 | ] 40 | ``` 41 | 42 | ## Docs 43 | * [Getting Started](./docs/01-GettingStartedGuide.md) 44 | * [Advanced guide: Safer identifiers and multiple containers](./docs/02-SaferIdentifiersAndMultipleContainersGuide.md) 45 | * [Api](./docs/03-Api.md) 46 | 47 | ## Examples 48 | * Demos: [`live`](https://rescript-dnd.vercel.app) | [`sources`](./examples) 49 | * Production app: [`Minima`](https://minima.app) 50 | 51 | ## State 52 | 🚧 The library is not feature-complete and some features/apis might be missing.
53 | 🎙 Let us know if you miss anything via [creating an issue](issues/new).
54 | 🌎 We're using it in production BTW. 55 | 56 | 57 | ### Features we'd like to add at some point 58 | - [ ] Keyboard interactions 59 | - [ ] Ignore form elements (opt-out) 60 | - [ ] Drop-zones 61 | - [ ] Multi-select 62 | 63 | ## Thanks 64 | * To [`react-beautiful-dnd`](https://github.com/atlassian/react-beautiful-dnd) for inspiration 65 | 66 | ## License 67 | MIT. 68 | 69 | ## Supporters 70 | 71 | 72 | JetBrains 73 | 74 | 75 | 76 | 77 | 78 | ScoutAPM 79 | 80 | 81 |
82 | 83 | 84 | 85 | 86 | BrowserStack 87 | 88 | 89 | 90 | Rails Autoscale 91 | 92 | 93 | Honeybadger 94 | 95 | 96 |
97 |
98 | 99 | The following companies support our open source projects, and ShakaCode uses their products! 100 | -------------------------------------------------------------------------------- /docs/01-GettingStartedGuide.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | In this guide, we will build simple flat list with draggable items. 4 | 5 | ``` 6 | +----------------- CONTAINER ----------------+ 7 | | +------------------------------------+ | 8 | | | Item #1 | | 9 | | +------------------------------------+ | 10 | | +------------------------------------+ | 11 | | | Item #2 | | 12 | | +------------------------------------+ | 13 | | ... | 14 | | +------------------------------------+ | 15 | | | Item #N | | 16 | | +------------------------------------+ | 17 | +--------------------------------------------+ 18 | ``` 19 | 20 | > In all guides, we use `Belt` as a standard library and it's always `open`ed. So keep in mind that in all code snippets `Belt` module is implicitly opened. I.e. `Array.map` is the same as `Belt.Array.map`. 21 | 22 | First, let's shape up a state. This is going to be an array of ints: 23 | 24 | ```rescript 25 | type item = int 26 | type state = array 27 | ``` 28 | 29 | To create components that handle all drag & drop business, we need to call `Dnd.Make()` functor: 30 | 31 | ```rescript 32 | module Items = Dnd.Make(Item, Container) 33 | ``` 34 | 35 | This code wouldn't compile yet because we need to provide two modules to the `Dnd.Make` functor: 36 | 37 | - `Item`: configuration for draggable item. 38 | - `Container`: configuration for droppable container, which contains draggable items. 39 | 40 | Both of these modules has the same signature: 41 | 42 | ```rescript 43 | type t 44 | let eq: (t, t) => bool 45 | let cmp: (t, t) => int 46 | ``` 47 | 48 | Basically, functor asks you to answer the following questions: 49 | 1. What the thing is? _Answer: type `t`._ 50 | 1. When two things given, do those equal or not? _Answer: `eq` function._ 51 | 1. When two things given, how to compare those? _Answer: `cmp` function._ 52 | 53 | Let's start with very simple (and in general not 100% safe) implementation of `Item` container: 54 | 55 | ```rescript 56 | module Item = { 57 | type t = item // `item` is a type alias we defined above which is resolved to `int` 58 | let eq = (x1, x2) => x1 == x2 // or more concise: let eq = (==) 59 | let cmp = compare // default comparator from Pervasives module 60 | } 61 | ``` 62 | 63 | Regarding `Container` type, there is no specific entity in the app domain which can be associated with this single abstract box that holds our flat list of items in UI. So we need to keep its configuration abstract, e.g.: 64 | 65 | ```rescript 66 | module Container = { 67 | type t // abstract type 68 | external id: unit => t = "%identity" // `Container.id()` would produce value of abstract type `t` 69 | let eq = (_, _) => true // since `Container` is singleton, it's always equal to self 70 | let cmp = (_, _) => 0 // same logic applies 71 | } 72 | ``` 73 | 74 | For convenience, `rescript-dnd` exposes functor which would create such singleton for you, so you don't have to type this boilerplate yourself: 75 | 76 | ```rescript 77 | module Container = Dnd.MakeSingletonContainer() 78 | ``` 79 | 80 | Now, when we have complete configuration defined, we can create module which holds React components: 81 | 82 | ```rescript 83 | module Items = Dnd.Make(Item, Container) 84 | ``` 85 | 86 | Module `Items` holds 3 components (each link below leads to component's api): 87 | - [`Items.DndManager`](./03-Api.md#dndmanager-component): component that manages drag & drop state. 88 | - [`Items.DraggableItem`](./03-Api.md#draggableitem-component): component that is used to render draggable item. 89 | - [`Items.DroppableContainer`](./03-Api.md#droppablecontainer-component): component that is used to render droppable container. 90 | 91 | Let's render those: 92 | 93 | ```rescript 94 | let (state, dispatch) = reducer->React.useReducer(initialState) 95 | 96 | ReorderItems(result)->dispatch}> 97 | 98 | {state 99 | ->Array.mapWithIndex((index, item) => 100 | Int.toString} containerId={Container.id()} index> 102 | #Children(item->Int.toString->React.string) 103 | 104 | ) 105 | ->React.array} 106 | 107 | 108 | ``` 109 | 110 | Even though render tree looks good, to finish this component we still need to implement handler that would persist result of reordering when item gets dropped. 111 | 112 | This is how corresponding `action` constructor type looks like: 113 | 114 | ```rescript 115 | type action = ReorderItems(Dnd.result) 116 | ``` 117 | 118 | What `Dnd.result` type is? 119 | 120 | ```rescript 121 | type rec result<'item, 'container> = option> 122 | 123 | and reorderResult<'item, 'container> = 124 | | SameContainer('item, placement<'item>) 125 | | NewContainer('item, 'container, placement<'item>) 126 | 127 | and placement<'item> = 128 | | Before('item) 129 | | Last 130 | ``` 131 | 132 | Let's break down possible cases: 133 | 134 | ```rescript 135 | | ReorderItems(None) => 136 | // `None` means nothing has changed: 137 | // either user dropped the item on the same position 138 | // or pressed Esc key etc. 139 | 140 | | ReorderItems(Some(SameContainer(item, Before(beforeItem)))) => 141 | // `SameContainer` means that the `item` was dropped 142 | // onto the same container in which it was before the dragging. 143 | // `Before(beforeItem)` means it has landed in the position 144 | // before `beforeItem` in the list. 145 | // How new placement should be persisted is totally application concern. 146 | // `Dnd` only tells where the new placement is 147 | // relative to other elements in the list. 148 | 149 | | ReorderItems(Some(SameContainer(item, Last))) => 150 | // Similar to the previous branch, 151 | // but this time item has landed at the end of the list 152 | 153 | | ReorderItems(Some(NewContainer(item, newContainer, placement))) => 154 | // Same as `SameContainer`, but in this case 155 | // item was dropped onto the different container. 156 | ``` 157 | 158 | So with this in mind, let's implement reducer for our case: 159 | 160 | ```rescript 161 | let reducer = (state, action) => 162 | switch action { 163 | | ReorderItems(Some(SameContainer(item, placement))) => 164 | // Item has landed in the new position of the same container, 165 | // so it should be reinserted from the old position 166 | // in the array into the new one. 167 | // `ArrayExt.reinsert` is a helper which does just this. 168 | state->ArrayExt.reinsert( 169 | ~value=item, 170 | ~place=switch placement { 171 | | Before(id) => #Before(id) 172 | | Last => #Last 173 | }, 174 | ) 175 | 176 | // not possible since we have only one container 177 | | ReorderItems(Some(NewContainer(_))) 178 | | ReorderItems(None) => state 179 | } 180 | ``` 181 | 182 | > `ArrayExt.reinsert` is not a part of the public API since usually in a real-world app reordering is handled differently. If you want to inspect it or use it in your own code, you can find its definition in the [examples](../examples/libs/ArrayExt.res). 183 | 184 | Looks like we have everything in place. This is how the final module looks like: 185 | 186 | ```rescript 187 | type item = int 188 | 189 | module Item = { 190 | type t = item 191 | let eq = (x1, x2) => x1 == x2 192 | let cmp = compare 193 | } 194 | 195 | module Container = Dnd.MakeSingletonContainer() 196 | 197 | module Items = Dnd.Make(Item, Container) 198 | 199 | type state = array 200 | 201 | type action = ReorderItems(Dnd.result) 202 | 203 | let reducer = (state, action) => 204 | switch action { 205 | | ReorderItems(Some(SameContainer(item, placement))) => 206 | state->ArrayExt.reinsert( 207 | ~value=item, 208 | ~place=switch placement { 209 | | Before(id) => #Before(id) 210 | | Last => #Last 211 | }, 212 | ) 213 | | ReorderItems(Some(NewContainer(_))) 214 | | ReorderItems(None) => state 215 | } 216 | 217 | let initialState = [1, 2, 3, 4, 5, 6, 7] 218 | 219 | @react.component 220 | let make = () => { 221 | let (state, dispatch) = reducer->React.useReducer(initialState) 222 | 223 | ReorderItems(result)->dispatch}> 224 | 225 | {state 226 | ->Array.mapWithIndex((index, item) => 227 | Int.toString} containerId={Container.id()} index> 229 | #Children(item->Int.toString->React.string) 230 | 231 | ) 232 | ->React.array} 233 | 234 | 235 | } 236 | ``` 237 | 238 | _Source code of the final module for this guide: [`GettingStartedGuide.res`](../examples/guides/GettingStartedGuide.res)_ 239 | 240 | --- 241 | This guide gives base overview of how `rescript-dnd` works. To find out more about how to make it safer and how to deal with multiple containers—proceed to the [next guide](./02-SaferIdentifiersAndMultipleContainersGuide.md). 242 | -------------------------------------------------------------------------------- /docs/02-SaferIdentifiersAndMultipleContainersGuide.md: -------------------------------------------------------------------------------- 1 | # Safer identifiers and multiple containers 2 | In this guide, we will build a UI with multiple todo lists, each contains todos that can be reordered within a list, as well as dragged between the lists. 3 | 4 | Most of the topics here are more advanced (relative to [Getting Started](./01-GettingStartedGuide.md) guide), though these are mostly not specific to the `rescript-dnd`. 5 | 6 | ## Safe identifiers 7 | Pretty much each entity in our apps has special field that uniquely identifies it. Usually, it's called `id`. Type of such identifier can be `int` or `string` (or any other serializable data type). But when we deal with identifiers of these loose types, there are no guarantees that entity of identifier is not confused with identifier of another entity or even some arbitrary `int` or `string`. In some cases, like handling of nested lists, it can cause nasty bugs that won't be caught by compiler. But this is fixable. 8 | 9 | Consider `todo` entity of the following type: 10 | 11 | ```rescript 12 | type todo = { 13 | id: int, 14 | title: string, 15 | } 16 | ``` 17 | 18 | As a first step to make its identifier safer, let's create `TodoId` module and move type of `todo.id` to this module: 19 | 20 | ```rescript 21 | module TodoId = { 22 | type t = int 23 | } 24 | 25 | type todo = { 26 | id: TodoId.t, 27 | title: string, 28 | } 29 | ``` 30 | 31 | With this change, we didn't gain any additional safety yet since compiler still resolves the type of identifier to `int`. Basically, we just aliased it in our code but nothing has changed for compiler. To gain extra safety, we must hide underlying type of `TodoId.t` from the rest of the app and make this type _opaque_. 32 | 33 | ```rescript 34 | module TodoId: { type t } = { 35 | type t = int 36 | } 37 | ``` 38 | 39 | Spot the difference: in this version we annotated `TodoId` module. And within this annotation, type `t` doesn't have any special type assigned, it is opaque to the rest of the app. Though inside the module it is still aliased to `int`, but only internals of `TodoId` module are aware of it. 40 | 41 | How it affects a program flow: 42 | 43 | ```rescript 44 | // Before 45 | let x = todo.id + 1 // Compiles 46 | 47 | // After 48 | let x = todo.id + 1 // Error: This expression has type TodoId.t but an expression was expected of type int 49 | ``` 50 | 51 | Now we have compile time guarantees that todo identifier can never be confused with any other identifier or random `int`. 52 | 53 | As we will have to be dealing with conversion of raw value of identifier (from int or json or whatever) to the opaque type and back, `TodoId` module is going to contain such functions as `make`, `toInt`, `toString`, `fromJson`, `toJson`. Let's implement `make` function and restructure `TodoId` module a bit so we don't have to annotate all of its content. 54 | 55 | ```rescript 56 | module TodoId = { 57 | module Id: { 58 | type t 59 | } = { 60 | type t = int 61 | } 62 | 63 | type t = Id.t 64 | external make: int => t = "%identity" 65 | } 66 | ``` 67 | 68 | What we did here is hidden implementation of todo identifier in `Id` submodule and implemented `make` function which casts `int` to `t` using `"%identity"` external. Now, `TodoId.make(1)` would produce entity of `TodoId.t` type. 69 | 70 | As a side note, `make` function doesn't have any runtime footprint and gets erased during compilation. It exists exclusively for compiler and you get compile-time safety with no runtime cost. 71 | 72 | ## Shaping up the state 73 | Now, when we have safe identifiers, let's get back to UI and shape up the state of it. So, what we have here is `todoLists` and `todos` that belong to `todoLists`. The data that comes from a server (or any other source) are usually denormalized and looks similar to this: 74 | 75 | ``` 76 | { 77 | todoLists: [ 78 | { 79 | id: 1, 80 | title: "Todo list #1", 81 | todos: [ 82 | { 83 | id: 1, 84 | title: "Todo #1", 85 | todoListId: 1, 86 | }, 87 | { 88 | id: 2, 89 | title: "Todo #2", 90 | todoListId: 1, 91 | }, 92 | ], 93 | }, 94 | { 95 | id: 2, 96 | title: "Todo list #2", 97 | todos: [ 98 | { 99 | id: 3, 100 | title: "Todo #3", 101 | todoListId: 2, 102 | }, 103 | { 104 | id: 4, 105 | title: "Todo #4", 106 | todoListId: 2, 107 | }, 108 | ], 109 | }, 110 | ], 111 | } 112 | ``` 113 | 114 | Before storing data in a client state, we can _normalize_ data to the following shape. 115 | 116 | ``` 117 | { 118 | todoListIndex: [1, 2], 119 | todoListMap: { 120 | 1: { 121 | id: 1, 122 | title: "Todo list #1", 123 | todos: [1, 2], 124 | }, 125 | 2: { 126 | id: 2, 127 | title: "Todo list #2", 128 | todos: [3, 4], 129 | }, 130 | }, 131 | todoMap: { 132 | 1: { 133 | id: 1, 134 | title: "Todo #1", 135 | todoListId: 1, 136 | }, 137 | 2: { 138 | id: 2, 139 | title: "Todo #2", 140 | todoListId: 1, 141 | }, 142 | 3: { 143 | id: 3, 144 | title: "Todo #3", 145 | todoListId: 2, 146 | }, 147 | 4: { 148 | id: 4, 149 | title: "Todo #4", 150 | todoListId: 2, 151 | }, 152 | } 153 | } 154 | ``` 155 | 156 | The main point of this transformation is to have indices of entities as an ordered collections of identifiers and dictionaries of entities with keys as identifiers + values as entities themself. This way it is easier to update nested entities and manage reordering of things in UI. 157 | 158 | > How to perform normalization is out of the scope of this guide. 159 | > At Minima, we write normalizers by hand. It's usually 10-20 LOC each. 160 | 161 | Let's describe normalized data in Reason types. 162 | 163 | Indices are going to be arrays, i.e.: 164 | 165 | ```rescript 166 | type todoListIndex = array 167 | type todoIndex = array 168 | ``` 169 | 170 | And for the dictionaries we're going to be using `Map` provided by the `Belt`. Here, things get a little bit tricky. `Belt` has built-in `Map.Int.t` but since our identifiers are opaque types now, it's not useful for our case. We need to prepare our own `Map.t` for each kind of identifier by extending `TodoId` and `TodoListId` modules. 171 | 172 | ```rescript 173 | module Id: { 174 | type t 175 | } = { 176 | type t = int 177 | } 178 | 179 | type t = Id.t 180 | external make: int => t = "%identity" 181 | 182 | module Comparable = Belt.Id.MakeComparable({ 183 | type t = Id.t 184 | let cmp = Pervasives.compare 185 | }) 186 | 187 | module Map = { 188 | type t<'t> = Map.t 189 | let make = () => Map.make(~id=module(Comparable)) 190 | } 191 | ``` 192 | 193 | With all types in place, we can finally shape the state: 194 | 195 | ```rescript 196 | type todo = { 197 | id: TodoId.t, 198 | title: string, 199 | todoListId: TodoListId.t, 200 | } 201 | 202 | type todoList = { 203 | id: TodoListId.t, 204 | title: string, 205 | todos: array, 206 | } 207 | 208 | type state = { 209 | todoListIndex: array, 210 | todoMap: TodoId.Map.t, 211 | todoListMap: TodoListId.Map.t, 212 | } 213 | ``` 214 | 215 | ## Reducing boilerplate 216 | Since we need to create own module for each kind of identifier, there will be 2 identical modules: `TodoId` and `TodoListId`. It's pretty boilerplate'y, but we can abstract creation of module for each identifier into a functor: 217 | 218 | ```rescript 219 | module MakeId = () => { 220 | module Id: { 221 | type t 222 | } = { 223 | type t = int 224 | } 225 | 226 | type t = Id.t 227 | external make: int => t = "%identity" 228 | external toInt: t => int = "%identity" 229 | 230 | let eq = (x1, x2) => x1->toInt == x2->toInt 231 | let cmp = (x1, x2) => compare(x1->toInt, x2->toInt) 232 | 233 | module Comparable = Belt.Id.MakeComparable({ 234 | type t = Id.t 235 | let cmp = cmp 236 | }) 237 | 238 | module Map = { 239 | type t<'t> = Map.t 240 | let make = () => Map.make(~id=module(Comparable)) 241 | } 242 | } 243 | ``` 244 | 245 | And then to create a module for each identifier call it without arguments: 246 | 247 | ```rescript 248 | module TodoId = MakeId() 249 | module TodoListId = MakeId() 250 | ``` 251 | 252 | ## Drag & drop 253 | With all the hard prerequisite work done, we can finally proceed to implementation of drag & drop functionality. The same way as we did in the [Getting Started](./01-GettingStartedGuide.md) guide, we need to define configuration modules to create DnD components. 254 | 255 | ```rescript 256 | module DraggableItem = { 257 | type t = TodoId.t 258 | let eq = TodoId.eq 259 | let cmp = TodoId.cmp 260 | } 261 | 262 | module DroppableContainer = { 263 | type t = TodoListId.t 264 | let eq = TodoListId.eq 265 | let cmp = TodoListId.cmp 266 | } 267 | 268 | module Todos = Dnd.Make(DraggableItem, DroppableContainer) 269 | ``` 270 | 271 | Using created components, we can shape a render tree: 272 | 273 | ```rescript 274 | ReorderTodos(result)->dispatch}> 275 | {state.todoListIndex 276 | ->Array.map(todoListId => { 277 | let todoList = state.todoListMap->Map.getExn(todoListId) 278 | 279 | TodoListId.toString}> 281 |

{todoList.title->React.string}

282 | {todoList.todos 283 | ->Array.mapWithIndex((index, todoId) => { 284 | let todo = state.todoMap->Map.getExn(todoId) 285 | 286 | TodoId.toString} containerId=todoListId index> 288 | #Children(todo.title->React.string) 289 | 290 | }) 291 | ->React.array} 292 |
293 | }) 294 | ->React.array} 295 |
296 | ``` 297 | 298 | The last touch is to implement persistence logic as following: 299 | 1. If todo was dropped to the new position within the same todo list, reinsert its id in `todoList.todos` array. 300 | 2. If todo was dropped onto another todo list, then 301 | - update `todo.todoListId` value 302 | - remove its id from previous `todoList.todos` array 303 | - and insert it into the target `todoList.todos` array 304 | 305 | Let's implement it in a reducer: 306 | 307 | ```rescript 308 | let reducer = (state, action) => 309 | switch action { 310 | | ReorderTodos(Some(SameContainer(todoId, placement))) => 311 | let todo = state.todoMap->Map.getExn(todoId) 312 | { 313 | ...state, 314 | todoListMap: 315 | // Updating todos index of todo list 316 | state.todoListMap->Map.update(todo.todoListId, todoList => 317 | todoList->Option.map(todoList => { 318 | ...todoList, 319 | todos: todoList.todos->ArrayExt.reinsert( 320 | ~value=todoId, 321 | ~place=switch placement { 322 | | Before(id) => #Before(id) 323 | | Last => #Last 324 | }, 325 | ), 326 | }) 327 | ), 328 | } 329 | 330 | | ReorderTodos(Some(NewContainer(todoId, targetTodoListId, placement))) => 331 | let todo = state.todoMap->Map.getExn(todoId) 332 | { 333 | ...state, 334 | todoMap: 335 | // Updating todoListId of dropped todo 336 | state.todoMap->Map.update(todoId, todo => 337 | todo->Option.map(todo => {...todo, todoListId: targetTodoListId}) 338 | ), 339 | todoListMap: 340 | state.todoListMap 341 | // Removing todoId from old todo list index 342 | // since todo was dropped onto another container 343 | ->Map.update(todo.todoListId, todoList => 344 | todoList->Option.map(todoList => { 345 | ...todoList, 346 | todos: todoList.todos->Array.keep(todoId' => 347 | todoId'->TodoId.toInt != todoId->TodoId.toInt 348 | ), 349 | }) 350 | ) 351 | // Inserting todoId into the todos index of the target todo list 352 | ->Map.update(targetTodoListId, todoList => 353 | todoList->Option.map(todoList => { 354 | ...todoList, 355 | todos: todoList.todos->ArrayExt.insert( 356 | ~value=todoId, 357 | ~place=switch placement { 358 | | Before(id) => #Before(id) 359 | | Last => #Last 360 | }, 361 | ), 362 | }) 363 | ), 364 | } 365 | 366 | | ReorderTodos(None) => state 367 | } 368 | ``` 369 | 370 | _Source code of the final module for this guide: [`SaferIdentifiersAndMultipleContainersGuide.res`](../examples/guides/SaferIdentifiersAndMultipleContainersGuide.res)_ 371 | 372 | --- 373 | Phew, this was a long read! If you're interested in more examples, checkout [live demo](https://rescript-dnd.vercel.app/) and its [sources](../examples). 374 | 375 | Or you can explore [API doc](./03-Api.md). 376 | -------------------------------------------------------------------------------- /docs/03-Api.md: -------------------------------------------------------------------------------- 1 | # Api 2 | 3 | ## `Dnd.Make` functor 4 | Functor that takes configurations for draggable item and droppable container and produces a module with React components. 5 | 6 | ```rescript 7 | module T: DndComponents = Dnd.Make(Item: DndEntry, Container: DndEntry) 8 | 9 | module type DndEntry = { 10 | type t 11 | let eq: (t, t) => bool 12 | let cmp: (t, t) => int 13 | } 14 | 15 | module type DndComponents = { 16 | module type DndManager: DndManagerComponent 17 | module type DraggableItem: DraggableItemComponent 18 | module type DroppableContainer: DroppableContainerComponent 19 | } 20 | ``` 21 | 22 | ## `DndManager` component 23 | React component that manages drag & drop state. 24 | 25 | ```rescript 26 | let make: ( 27 | ~onDragStart: option=?, 28 | ~onDropStart: option=?, 29 | ~onDropEnd: option=?, 30 | ~onReorder: option> => unit, 31 | ~children: React.element, 32 | ) => React.element 33 | 34 | type hook = (~itemId: Item.t) => unit 35 | 36 | type result<'item, 'container> = option> 37 | 38 | and reorderResult<'item, 'container> = 39 | | SameContainer('item, placement<'item>) 40 | | NewContainer('item, 'container, placement<'item>) 41 | 42 | and placement<'item> = 43 | | Before('item) 44 | | Last 45 | ``` 46 | 47 | ## `DraggableItem` component 48 | React component that is used to render draggable item. 49 | 50 | ```rescript 51 | let make: ( 52 | ~id: Item.t, 53 | ~containerId: Container.t, 54 | ~index: int, 55 | ~className: option<(~dragging: bool) => string>=?, 56 | ~children: React.element, 57 | ) => React.element 58 | ``` 59 | 60 | ## `DroppableContainer` component 61 | React component that is used to render droppable container. 62 | 63 | ```rescript 64 | type axis = 65 | | X 66 | | Y 67 | 68 | let make: ( 69 | ~id: Container.t, 70 | ~axis: axis, 71 | ~lockAxis: bool=false, 72 | ~accept: option bool>=?, 73 | ~className: option<(~draggingOver: bool) => string>=?, 74 | ~children: React.element, 75 | ) => React.element 76 | ``` 77 | 78 | ## `Dnd.MakeSingletonContainer` functor 79 | For convenience, `rescript-dnd` exposes this functor which would create `Container` module when there is no specific entity in the app domain which can be associated with container that holds some single list of items in UI. 80 | 81 | ```rescript 82 | module Container: DndEntry = Dnd.MakeSingletonContainer() 83 | ``` 84 | -------------------------------------------------------------------------------- /examples/App.res: -------------------------------------------------------------------------------- 1 | type state = {mobileNavShown: bool} 2 | 3 | type action = 4 | | ShowMobileNav 5 | | HideMobileNav 6 | 7 | let reducer = (_state, action) => 8 | switch action { 9 | | ShowMobileNav => {mobileNavShown: true} 10 | | HideMobileNav => {mobileNavShown: false} 11 | } 12 | 13 | @react.component 14 | let make = () => { 15 | let url = RescriptReactRouter.useUrl() 16 | let route = React.useMemo1(() => url->Route.fromUrl, [url]) 17 | 18 | let (state, dispatch) = reducer->React.useReducer({mobileNavShown: false}) 19 | 20 | React.useEffect1(() => { 21 | HideMobileNav->dispatch 22 | None 23 | }, [route]) 24 | 25 | let showMobileNav = React.useCallback0(() => ShowMobileNav->dispatch) 26 | let hideMobileNav = React.useCallback0(() => HideMobileNav->dispatch) 27 | 28 | 29 |