├── .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 |
--------------------------------------------------------------------------------