├── .gitignore ├── .prettierrc.js ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── recording.mov ├── screenshot.png ├── src ├── App.tsx ├── Drag.tsx ├── DragContext.ts ├── Drop.tsx ├── Stack.tsx ├── index.css ├── index.tsx ├── react-app-env.d.ts ├── styles.module.css ├── types.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'es5', 3 | tabWidth: 2, 4 | semi: false, 5 | singleQuote: true, 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nested-dnd 2 | 3 | Proof of concept for doing a nested drag and drop in React. Features smooth, 4 | animated drop and cancel. 5 | 6 | --- 7 | 8 | ### :construction: **WORK IN PROGRESS** :construction: 9 | 10 | At the moment this serves more as a proof of concept (or example) rather than 11 | a library-like thing. 12 | 13 | --- 14 | 15 | Check out a [video](https://twitter.com/tczajecki/status/1122261807249412097) on 16 | my Twitter. 17 | 18 |

19 | 20 | ## Features 21 | 22 | - Allows you to drag a part of the stack with the items lying on top of the 23 | dragged one. 24 | - Drop it on top of any other stack so the elements will smoothly migrate there. 25 | - Drop it anywhere so the elements smoothly go back to their place. 26 | 27 | ## How it works? 28 | 29 | - The overlaying of the cards relies on CSS transforms. 30 | - Animations are triggered 31 | in JS via `element.animate(...)` API. 32 | - The element currently being dragged always stays on top with use of `:focus`. 33 | - Stack is a recursive component. 34 | - Drop zones are registered and passed using context API. 35 | - Changing parent stack uses waaaay too much logic bound to this example. 36 | 37 | ## TODO 38 | 39 | Putting it here so I won't forget. 40 | 41 | - [x] Get rid of example-specific magic numbers from ``. 42 | - [ ] Fix bug with dropping outside of `` sometimes being possible. 43 | - [ ] Extract some top level API components from ``. 44 | - [ ] Maybe use indexes instead of made up IDs that are mostly indexes anyway. 45 | - [ ] Rethink naming of things in a way that keeps the cards analogy but isn't 46 | specific to it when not necessary. 47 | 48 | ## Installation 49 | 50 | **tl;dr**: not yet. 51 | 52 | The only way to have fun with it is cloning the repository. 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "another-dnd", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/jest": "^24.0.11", 7 | "@types/node": "^11.13.7", 8 | "@types/react": "^16.8.14", 9 | "@types/react-dom": "^16.8.4", 10 | "react": "^16.8.6", 11 | "react-dom": "^16.8.6", 12 | "react-scripts": "3.0.0", 13 | "typescript": "^3.4.5" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "eslintConfig": { 22 | "extends": "react-app" 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/nested-dnd/cf52375dfd1f0697309d88bc59be6817ca45d232/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /recording.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/nested-dnd/cf52375dfd1f0697309d88bc59be6817ca45d232/recording.mov -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/nested-dnd/cf52375dfd1f0697309d88bc59be6817ca45d232/screenshot.png -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useReducer } from 'react' 2 | import { ID, Card, Dimensions } from './types' 3 | import styles from './styles.module.css' 4 | import Stack from './Stack' 5 | import { split, oneOf, friends } from './utils' 6 | import DragContext, { defaultContext } from './DragContext' 7 | 8 | type CardStack = { 9 | id: ID 10 | cards: Array 11 | } 12 | 13 | type State = { 14 | stacks: Array 15 | } 16 | 17 | type Action = { type: 'drop'; stackId: ID; cardId: ID } 18 | 19 | type BoxProps = { 20 | color: string 21 | } 22 | 23 | const Box = ({ color }: BoxProps) => ( 24 |
25 | ) 26 | 27 | const length = 7 28 | const maxRgb = 255 29 | 30 | const indexToPink = (i: number) => { 31 | const value = (maxRgb / length) * (i + 3) * 2 32 | return `rgb(${value / 2}, 0, ${value})` 33 | } 34 | 35 | const indexToOrange = (i: number) => { 36 | const value = (maxRgb / length) * (i + 3) * 2 37 | return `rgb(${value}, ${value / 2}, 0)` 38 | } 39 | 40 | const indexToBlue = (i: number) => { 41 | const value = (maxRgb / length) * (i + 3) * 2 42 | return `rgb(0, ${value / 2}, ${value})` 43 | } 44 | 45 | const Slot = () =>
46 | 47 | const reducer = (state: State, action: Action) => { 48 | switch (action.type) { 49 | case 'drop': 50 | let replaceId: any = -1 51 | let replace: any = undefined 52 | let moveId = state.stacks.findIndex(stack => stack.id === action.stackId) 53 | let move: any = undefined 54 | 55 | state.stacks.forEach((stack, id) => { 56 | if (stack.cards.findIndex(card => card.id === action.cardId) !== -1) { 57 | const [stays, moves] = split( 58 | card => card.id === action.cardId, 59 | stack.cards 60 | ) 61 | 62 | move = moves 63 | replace = stays 64 | replaceId = id 65 | } 66 | }) 67 | 68 | state.stacks[replaceId].cards = replace 69 | state.stacks[moveId].cards = [...state.stacks[moveId].cards, ...move] 70 | 71 | return { ...state } 72 | default: 73 | throw new Error(`Unrecognized action type, ${oneOf(friends)}`) 74 | } 75 | } 76 | 77 | const colors = [...Array(length)].map((_, index) => index) 78 | 79 | let id = 0 80 | const makeCard = (color: string) => { 81 | id = id + 1 82 | return { id: `${id}`, color } 83 | } 84 | 85 | const initial: State = { 86 | stacks: [ 87 | { 88 | id: '1', 89 | cards: [...colors.map(indexToPink).slice(0, 5)].map(makeCard), 90 | }, 91 | { 92 | id: '2', 93 | cards: [...colors.map(indexToOrange).slice(0, 4)].map(makeCard), 94 | }, 95 | { 96 | id: '3', 97 | cards: [...colors.map(indexToBlue).slice(0, 3)].map(makeCard), 98 | }, 99 | ], 100 | } 101 | 102 | export default () => { 103 | // It's important to use spread here so the context will be recreated on each 104 | // render. That way there is no need to change droppables, since every change 105 | // to them will happen only during rerender reaching *this* code. 106 | const droppables: Array<{ stackId: string } & Dimensions> = [ 107 | ...defaultContext, 108 | ] 109 | 110 | const [state, dispatch] = useReducer(reducer, { ...initial }) 111 | 112 | const handleDrop = (stackId: ID, cardId: ID) => 113 | dispatch({ type: 'drop', stackId, cardId }) 114 | 115 | return ( 116 |
117 | 118 | {state.stacks.map((stack, index) => ( 119 |
120 | 121 | } 126 | elementHeight={178} 127 | verticalOffset={33} 128 | /> 129 |
130 | ))} 131 |
132 |
133 | ) 134 | } 135 | -------------------------------------------------------------------------------- /src/Drag.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | ReactNode, 3 | useContext, 4 | useRef, 5 | useEffect, 6 | MouseEvent, 7 | } from 'react' 8 | import DragContext from './DragContext' 9 | import { ID, DropCallback } from './types' 10 | import { ANIMATION_TIME, insideOneOf, translate, animate } from './utils' 11 | 12 | type Props = { 13 | cardId: ID 14 | children: ReactNode 15 | onDrop: DropCallback 16 | elementHeight: number 17 | verticalOffset: number 18 | } 19 | 20 | const Drag = ({ 21 | children, 22 | cardId, 23 | onDrop, 24 | elementHeight, 25 | verticalOffset, 26 | }: Props) => { 27 | const draggable = useContext(DragContext) 28 | const ref = useRef(null) 29 | const offsetFromBottom = elementHeight - verticalOffset 30 | 31 | useEffect(() => { 32 | let pressed = false 33 | let pressPoint = { x: 0, y: -offsetFromBottom } 34 | let position = { x: 0, y: -offsetFromBottom } 35 | 36 | const callbacks = [ 37 | { 38 | key: 'mousedown', 39 | fn: (event: MouseEvent) => { 40 | if ((event.target as HTMLElement).parentNode !== ref.current) { 41 | return 42 | } 43 | pressed = true 44 | ref.current!.style.cursor = 'grabbing' 45 | pressPoint = { x: event.pageX, y: event.pageY } 46 | }, 47 | }, 48 | { 49 | key: 'mouseup', 50 | fn: (event: MouseEvent) => { 51 | if (!pressed) return 52 | pressed = false 53 | const dropPoint = { x: event.pageX, y: event.pageY } 54 | const { left, top, height } = ref.current!.getBoundingClientRect() 55 | const isInside = insideOneOf(dropPoint, draggable) 56 | 57 | const animateBack = () => { 58 | ref.current!.animate( 59 | // @ts-ignore 60 | ...animate(position, { x: 0, y: -offsetFromBottom }) 61 | ) 62 | ref.current!.style.cursor = 'grab' 63 | ref.current!.style.transform = translate({ 64 | x: 0, 65 | y: -offsetFromBottom, 66 | }) 67 | pressPoint = { x: 0, y: -offsetFromBottom } 68 | position = { x: 0, y: -offsetFromBottom } 69 | } 70 | 71 | if (isInside) { 72 | const end = { 73 | x: position.x + isInside.left - left, 74 | y: position.y + isInside.top - top + verticalOffset, 75 | } 76 | 77 | // Hacky way to check if the drop target is the same stack it was 78 | // in or not. 79 | if (!(end.x === 0 && end.y < 0 && end.y > -elementHeight)) { 80 | ref.current!.animate( 81 | // @ts-ignore 82 | ...animate(position, end, { fill: 'forwards' }) 83 | ) 84 | setTimeout(() => onDrop(isInside.stackId, cardId), ANIMATION_TIME) 85 | } else { 86 | animateBack() 87 | } 88 | } else { 89 | animateBack() 90 | } 91 | }, 92 | }, 93 | { 94 | key: 'mousemove', 95 | fn: (event: MouseEvent) => { 96 | if (!pressed) return 97 | position = { 98 | x: event.pageX - pressPoint.x, 99 | y: event.pageY - pressPoint.y - offsetFromBottom, 100 | } 101 | ref.current!.style.transform = translate(position) 102 | }, 103 | }, 104 | ] 105 | 106 | callbacks.forEach(callback => { 107 | // @ts-ignore 108 | window.addEventListener(callback.key, callback.fn) 109 | }) 110 | return () => { 111 | callbacks.forEach(callback => { 112 | // @ts-ignore 113 | window.removeEventListener(callback.key, callback.fn) 114 | }) 115 | } 116 | }, [draggable, cardId, onDrop]) 117 | 118 | return ( 119 |
126 | {children} 127 |
128 | ) 129 | } 130 | 131 | export default Drag 132 | -------------------------------------------------------------------------------- /src/DragContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | import { ID, Dimensions } from './types' 3 | 4 | export const defaultContext: any = [] 5 | 6 | const DragContext = createContext>( 7 | defaultContext 8 | ) 9 | 10 | export default DragContext 11 | -------------------------------------------------------------------------------- /src/Drop.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useRef, useEffect } from 'react' 2 | import DragContext from './DragContext' 3 | import { ID } from './types' 4 | import styles from './styles.module.css' 5 | 6 | type Props = { 7 | stackId: ID 8 | } 9 | 10 | const Drop = ({ stackId }: Props) => { 11 | const draggable = useContext(DragContext) 12 | const ref = useRef(null) 13 | 14 | useEffect(() => { 15 | const { left, top, width, height } = ref.current!.getBoundingClientRect() 16 | draggable.push({ stackId, left, top, width, height }) 17 | }) 18 | 19 | return
20 | } 21 | 22 | export default Drop 23 | -------------------------------------------------------------------------------- /src/Stack.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | import { ID, Card, DropCallback } from './types' 3 | import Drag from './Drag' 4 | import Drop from './Drop' 5 | 6 | type Props = { 7 | id: ID 8 | cards: Array 9 | onDrop: DropCallback 10 | render: (card: Card) => ReactNode 11 | verticalOffset: number 12 | elementHeight: number 13 | } 14 | 15 | const Stack = ({ 16 | id, 17 | cards, 18 | onDrop, 19 | render, 20 | elementHeight, 21 | verticalOffset, 22 | }: Props) => { 23 | if (cards.length === 0) return 24 | const [card, ...rest] = cards 25 | return ( 26 | 32 | {render(card)} 33 | 41 | 42 | ) 43 | } 44 | 45 | export default Stack 46 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://rsms.me/inter/inter.css'); 2 | 3 | body { 4 | margin: 0; 5 | padding: 0; 6 | font-family: 'Inter', sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | font-size: 16px; 10 | letter-spacing: -0.011em; 11 | line-height: 22px; 12 | } 13 | 14 | @supports (font-variation-settings: normal) { 15 | body { 16 | font-family: 'Inter var', sans-serif; 17 | } 18 | } 19 | 20 | h2 { 21 | font-size: 30px; 22 | font-weight: 400; 23 | letter-spacing: -0.021em; 24 | line-height: 42px; 25 | margin: 0; 26 | } 27 | 28 | p { 29 | margin: 0; 30 | color: #777; 31 | } 32 | 33 | * { 34 | box-sizing: border-box; 35 | } 36 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './index.css' 4 | import App from './App' 5 | 6 | ReactDOM.render(, document.getElementById('root')) 7 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/styles.module.css: -------------------------------------------------------------------------------- 1 | .app { 2 | position: relative; 3 | display: flex; 4 | justify-content: center; 5 | padding-top: 100px; 6 | } 7 | 8 | .box { 9 | width: 114px; 10 | height: 178px; 11 | border-radius: 8px; 12 | /* box-shadow: 0 0 0 1px #000 inset; */ 13 | } 14 | 15 | .drop { 16 | width: 114px; 17 | height: 178px; 18 | margin-top: -178px; 19 | border-radius: 8px; 20 | } 21 | 22 | div:focus { 23 | z-index: 10; 24 | outline: 0; 25 | } 26 | 27 | .stack { 28 | margin-right: 50px; 29 | } 30 | 31 | .stack:last-child { 32 | margin-right: 0; 33 | } 34 | 35 | .slot { 36 | /* background-color: rgba(0, 0, 0, 0.3); */ 37 | left: 0px; 38 | top: 0px; 39 | width: 114px; 40 | height: 178px; 41 | cursor: default; 42 | border-radius: 8px; 43 | } 44 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type ID = string 2 | 3 | export type DropCallback = (stackId: ID, cardId: ID) => void 4 | 5 | export type Dimensions = { 6 | left: number 7 | top: number 8 | width: number 9 | height: number 10 | } 11 | 12 | export type Point = { 13 | x: number 14 | y: number 15 | } 16 | 17 | export type Card = { 18 | id: string 19 | color: string 20 | } 21 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { ID, Point, Dimensions } from './types' 2 | 3 | export const ANIMATION_TIME = 250 4 | 5 | export const translate = (p: Point) => `translate(${p.x}px, ${p.y}px)` 6 | 7 | export const inside = (p: Point, d: Dimensions) => 8 | p.x >= d.left && 9 | p.x <= d.left + d.width && 10 | p.y >= d.top && 11 | p.y <= d.top + d.height 12 | 13 | export const insideOneOf = ( 14 | p: Point, 15 | array: Array<{ stackId: ID } & Dimensions> 16 | ) => { 17 | for (let i = 0; i < array.length; i++) { 18 | if (inside(p, array[i])) return array[i] 19 | } 20 | } 21 | 22 | export const animate = (from: Point, to: Point, options: any) => [ 23 | [{ transform: translate(from) }, { transform: translate(to) }], 24 | { 25 | duration: ANIMATION_TIME, 26 | easing: 'cubic-bezier(0.2, 1, 0.1, 1)', 27 | ...options, 28 | }, 29 | ] 30 | 31 | export const oneOf = (array: Array) => 32 | array[Math.round(Math.random() * (array.length - 1))] 33 | 34 | export const friends = ['bro', 'pal', 'mate', 'fella', 'buddy', 'dude'] 35 | 36 | export const split = function( 37 | predicate: (element: T) => boolean, 38 | array: Array 39 | ) { 40 | const index = array.findIndex(predicate, array) 41 | if (index === -1) { 42 | throw new Error(`Not present in ${array}`) 43 | } 44 | return [array.slice(0, index), array.slice(index)] 45 | } 46 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "preserve" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------