├── .yarnrc.yml ├── packages ├── examples │ ├── .prettierrc │ ├── src │ │ ├── vite-env.d.ts │ │ ├── styles.css │ │ ├── main.tsx │ │ ├── simple-squares │ │ │ ├── styles.css │ │ │ ├── App.tsx │ │ │ ├── DroppableSquare.tsx │ │ │ └── DraggableSquare.tsx │ │ ├── sticky-squares-scroller │ │ │ ├── utils.ts │ │ │ ├── styles.css │ │ │ ├── Square.tsx │ │ │ ├── models.ts │ │ │ └── squares-scroller.tsx │ │ ├── tldr-example │ │ │ ├── styles.css │ │ │ └── App.tsx │ │ ├── simple-draggable-list │ │ │ ├── styles.css │ │ │ └── draggable-list.tsx │ │ ├── complex-draggable-list │ │ │ ├── styles.css │ │ │ └── draggable-list.tsx │ │ └── kanban-dashboard │ │ │ ├── store.tsx │ │ │ ├── data.ts │ │ │ ├── styled.tsx │ │ │ └── dashboard.tsx │ ├── vite.config.ts │ ├── tsconfig.node.json │ ├── .gitignore │ ├── index.html │ ├── .eslintrc.cjs │ ├── tsconfig.json │ ├── package.json │ └── README.md ├── snapdrag │ ├── src │ │ ├── core.ts │ │ ├── index.ts │ │ └── plugins.ts │ ├── tsup.config.ts │ ├── tsconfig.json │ └── package.json ├── plugins │ ├── src │ │ ├── index.ts │ │ └── scroller.ts │ ├── tsconfig.json │ ├── tsup.config.ts │ └── package.json ├── core │ ├── src │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── droppable.ts │ │ ├── utils │ │ │ └── defaultPointerHandlers.ts │ │ ├── types.ts │ │ └── draggable.ts │ ├── tsconfig.json │ ├── tsup.config.ts │ └── package.json └── react │ ├── src │ ├── index.ts │ ├── utils │ │ └── getDropTargets.tsx │ ├── Overlay.tsx │ ├── types.ts │ ├── useDroppable.tsx │ └── useDraggable.tsx │ ├── tsup.config.ts │ ├── tsconfig.json │ └── package.json ├── assets ├── Snapdrag.webp ├── tldr-drop.avif ├── simple-squares.avif ├── snapdrag-black.webp ├── drag-and-drop-kanban.avif ├── drag-and-drop-squares.avif ├── drag-and-drop-simple-list.avif ├── drag-and-drop-advanced-list.avif ├── drag-and-drop-dynamic-border.avif └── drag-and-drop-draggable-color.avif ├── .prettierrc ├── .vscode └── settings.json ├── .gitattributes ├── .editorconfig ├── LICENSE ├── package.json ├── .gitignore └── README.md /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | # .yarnrc.yml 2 | nodeLinker: node-modules 3 | -------------------------------------------------------------------------------- /packages/examples/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "line_width": 100 3 | } 4 | -------------------------------------------------------------------------------- /packages/snapdrag/src/core.ts: -------------------------------------------------------------------------------- 1 | export * from "@snapdrag/core"; 2 | -------------------------------------------------------------------------------- /packages/snapdrag/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "@snapdrag/react"; 2 | -------------------------------------------------------------------------------- /packages/snapdrag/src/plugins.ts: -------------------------------------------------------------------------------- 1 | export * from "@snapdrag/plugins"; 2 | -------------------------------------------------------------------------------- /packages/examples/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/plugins/src/index.ts: -------------------------------------------------------------------------------- 1 | export { createScroller } from "./scroller"; 2 | -------------------------------------------------------------------------------- /assets/Snapdrag.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zheksoon/snapdrag/HEAD/assets/Snapdrag.webp -------------------------------------------------------------------------------- /assets/tldr-drop.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zheksoon/snapdrag/HEAD/assets/tldr-drop.avif -------------------------------------------------------------------------------- /assets/simple-squares.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zheksoon/snapdrag/HEAD/assets/simple-squares.avif -------------------------------------------------------------------------------- /assets/snapdrag-black.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zheksoon/snapdrag/HEAD/assets/snapdrag-black.webp -------------------------------------------------------------------------------- /assets/drag-and-drop-kanban.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zheksoon/snapdrag/HEAD/assets/drag-and-drop-kanban.avif -------------------------------------------------------------------------------- /assets/drag-and-drop-squares.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zheksoon/snapdrag/HEAD/assets/drag-and-drop-squares.avif -------------------------------------------------------------------------------- /assets/drag-and-drop-simple-list.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zheksoon/snapdrag/HEAD/assets/drag-and-drop-simple-list.avif -------------------------------------------------------------------------------- /assets/drag-and-drop-advanced-list.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zheksoon/snapdrag/HEAD/assets/drag-and-drop-advanced-list.avif -------------------------------------------------------------------------------- /assets/drag-and-drop-dynamic-border.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zheksoon/snapdrag/HEAD/assets/drag-and-drop-dynamic-border.avif -------------------------------------------------------------------------------- /packages/examples/src/styles.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | font-family: Arial, sans-serif; 4 | margin: 0; 5 | padding: 0; 6 | } 7 | -------------------------------------------------------------------------------- /assets/drag-and-drop-draggable-color.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zheksoon/snapdrag/HEAD/assets/drag-and-drop-draggable-color.avif -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "semi": true, 6 | "singleQuote": false 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "pointermove", 4 | "pointerup", 5 | "scroller", 6 | "Snapdrag" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/** linguist-vendored 2 | /.yarn/releases/* binary 3 | /.yarn/plugins/**/* binary 4 | /.pnp.* binary linguist-generated 5 | -------------------------------------------------------------------------------- /packages/core/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DRAGGABLE_ATTRIBUTE = "data-draggable"; 2 | export const DROPPABLE_ATTRIBUTE = "data-droppable"; 3 | export const DROPPABLE_FORCE_ATTRIBUTE = "data-droppable-force"; 4 | -------------------------------------------------------------------------------- /packages/examples/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /packages/react/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Overlay } from "./Overlay"; 2 | export { useDraggable } from "./useDraggable"; 3 | export { useDroppable } from "./useDroppable"; 4 | export type { DraggableConfig, DroppableConfig } from "./types"; 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,yml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Draggable, createDraggable } from "./draggable"; 2 | export { Droppable, createDroppable } from "./droppable"; 3 | export { DRAGGABLE_ATTRIBUTE, DROPPABLE_ATTRIBUTE, DROPPABLE_FORCE_ATTRIBUTE } from "./constants"; 4 | export * from "./types"; 5 | -------------------------------------------------------------------------------- /packages/examples/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/examples/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /packages/snapdrag/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts', 'src/core.ts', 'src/plugins.ts'], 5 | format: ['esm', 'cjs'], 6 | dts: true, 7 | splitting: false, 8 | sourcemap: true, 9 | clean: true, 10 | target: 'es2020', 11 | esbuildOptions(options) { 12 | options.mangleProps = /^_/; 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /packages/react/src/utils/getDropTargets.tsx: -------------------------------------------------------------------------------- 1 | import { DropTargetsMap } from "@snapdrag/core"; 2 | import { DropTargetData } from "../types"; 3 | 4 | export function getDropTargets(dropTargets: DropTargetsMap) { 5 | const result = [] as Array; 6 | 7 | dropTargets.forEach((target, element) => { 8 | result.push({ 9 | data: target.data, 10 | element, 11 | }); 12 | }); 13 | 14 | return result; 15 | } 16 | -------------------------------------------------------------------------------- /packages/examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/snapdrag/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "rootDir": "src", 5 | "composite": true, // Important for project references/monorepos 6 | "declaration": true, // Generate .d.ts files 7 | "declarationMap": true, // Generate sourcemaps for .d.ts files 8 | "moduleResolution": "bundler", // Modern module resolution 9 | "module": "es2020", // Use ESNext module system 10 | }, 11 | "include": ["src/**/*"], 12 | "exclude": ["node_modules", "dist"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/examples/src/main.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client"; 2 | // import App from "./kanban-dashboard/dashboard"; 3 | // import App from "./simple-squares/App"; 4 | // import App from "./complex-draggable-list/draggable-list"; 5 | // import App from "./simple-draggable-list/draggable-list"; 6 | // import App from "./sticky-squares-scroller/squares-scroller"; 7 | import App from "./tldr-example/App"; 8 | 9 | import "./styles.css"; 10 | 11 | ReactDOM.createRoot(document.getElementById("root")!).render(); 12 | -------------------------------------------------------------------------------- /packages/examples/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "moduleResolution": "bundler", 5 | "target": "es2020", 6 | "module": "esnext", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "outDir": "./dist", 12 | "declaration": true, 13 | "declarationMap": true, 14 | "sourceMap": true, 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules", "dist"] 18 | } 19 | -------------------------------------------------------------------------------- /packages/plugins/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "moduleResolution": "bundler", 5 | "target": "es2020", 6 | "module": "esnext", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "outDir": "./dist", 12 | "declaration": true, 13 | "declarationMap": true, 14 | "sourceMap": true 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules", "dist"] 18 | } 19 | -------------------------------------------------------------------------------- /packages/examples/src/simple-squares/styles.css: -------------------------------------------------------------------------------- 1 | .square { 2 | width: 150px; 3 | height: 150px; 4 | user-select: none; 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | border-radius: 8px; 9 | box-shadow: 0 0 20px rgba(0, 0, 0, 0.8); 10 | text-align: center; 11 | font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; 12 | font-size: 20px; 13 | color: black; 14 | } 15 | 16 | .square.draggable { 17 | cursor: grab; 18 | } 19 | 20 | .square.droppable { 21 | border: 3px dashed white; 22 | } 23 | -------------------------------------------------------------------------------- /packages/examples/src/sticky-squares-scroller/utils.ts: -------------------------------------------------------------------------------- 1 | export function getMouseQuadrant( 2 | targetEl: HTMLElement, 3 | event: MouseEvent, 4 | size: number 5 | ) { 6 | const { left, top } = targetEl.getBoundingClientRect(); 7 | const x = event.x - left; 8 | const y = event.y - top; 9 | const h = x > y; 10 | const v = size - x > y; 11 | 12 | if (h && v) { 13 | return "top"; 14 | } else if (h && !v) { 15 | return "right"; 16 | } else if (!h && v) { 17 | return "left"; 18 | } else { 19 | return "bottom"; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/examples/src/sticky-squares-scroller/styles.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | .App { 8 | width: 100dvw; 9 | height: 100dvh; 10 | } 11 | 12 | .canvas { 13 | width: 100%; 14 | height: 100%; 15 | overflow: scroll; 16 | overscroll-behavior-y: contain; 17 | touch-action: pan-x, pan-y; 18 | } 19 | 20 | .canvasInner { 21 | position: relative; 22 | } 23 | 24 | .square { 25 | position: absolute; 26 | box-sizing: border-box; 27 | border-width: 0px; 28 | border-style: solid; 29 | border-color: gray; 30 | touch-action: none; 31 | } 32 | -------------------------------------------------------------------------------- /packages/core/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { type Options, defineConfig } from "tsup"; 2 | 3 | const config: Options = { 4 | entry: { 5 | index: "./src/index.ts", 6 | }, 7 | outDir: "./dist", 8 | format: ["esm", "cjs"], 9 | target: "es2020", 10 | ignoreWatch: ["**/dist/**", "**/node_modules/**", "*.test.ts"], 11 | clean: true, 12 | dts: true, // Use tsconfig for dts options 13 | sourcemap: true, 14 | splitting: true, 15 | treeshake: true, 16 | minify: process.env.NODE_ENV === "production", 17 | skipNodeModulesBundle: true, 18 | external: ["node_modules"], 19 | esbuildOptions(options) { 20 | options.mangleProps = /^_/; 21 | }, 22 | }; 23 | 24 | export default defineConfig([config]); 25 | -------------------------------------------------------------------------------- /packages/examples/src/tldr-example/styles.css: -------------------------------------------------------------------------------- 1 | .app { 2 | position: relative; 3 | margin-top: 100px; 4 | } 5 | 6 | .absolute { 7 | position: absolute; 8 | } 9 | 10 | .left-100 { 11 | left: 100px; 12 | } 13 | 14 | .left-300 { 15 | left: 300px; 16 | } 17 | 18 | .square { 19 | width: 100px; 20 | height: 100px; 21 | border: 3px solid rgba(255, 255, 255, 0.7); 22 | border-radius: 5px; 23 | box-shadow: 0 0 20px rgba(0, 0, 0, 0.8); 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | text-align: center; 28 | touch-action: none; 29 | user-select: none; 30 | } 31 | 32 | .red { 33 | background-color: red; 34 | } 35 | 36 | .green { 37 | background-color: green; 38 | } 39 | -------------------------------------------------------------------------------- /packages/react/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { type Options, defineConfig } from "tsup"; 2 | 3 | const config: Options = { 4 | entry: { 5 | index: "./src/index.ts", 6 | }, 7 | outDir: "./dist", 8 | format: ["esm", "cjs"], 9 | target: "es2020", 10 | ignoreWatch: ["**/dist/**", "**/node_modules/**", "*.test.ts"], 11 | clean: true, 12 | dts: true, // Use tsconfig for dts options 13 | sourcemap: true, 14 | splitting: true, 15 | treeshake: true, 16 | minify: process.env.NODE_ENV === "production", 17 | skipNodeModulesBundle: true, 18 | external: ["node_modules", "@snapdrag/core"], 19 | esbuildOptions(options) { 20 | options.mangleProps = /^_/; 21 | }, 22 | }; 23 | 24 | export default defineConfig([config]); 25 | -------------------------------------------------------------------------------- /packages/plugins/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { type Options, defineConfig } from "tsup"; 2 | 3 | const config: Options = { 4 | entry: { 5 | index: "./src/index.ts", 6 | }, 7 | outDir: "./dist", 8 | format: ["esm", "cjs"], 9 | target: "es2020", 10 | ignoreWatch: ["**/dist/**", "**/node_modules/**", "*.test.ts"], 11 | clean: true, 12 | dts: true, // Use tsconfig for dts options 13 | sourcemap: true, 14 | splitting: true, 15 | treeshake: true, 16 | minify: process.env.NODE_ENV === "production", 17 | skipNodeModulesBundle: true, 18 | external: ["node_modules", "@snapdrag/core"], 19 | esbuildOptions(options) { 20 | options.mangleProps = /^_/; 21 | }, 22 | }; 23 | 24 | export default defineConfig([config]); 25 | -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "lib": ["ESNext", "DOM"], 5 | "target": "es2020", 6 | "strict": true, 7 | "moduleResolution": "bundler", 8 | "sourceMap": true, 9 | "jsx": "react-jsx", 10 | "esModuleInterop": true, 11 | "declaration": true, 12 | "declarationMap": true, 13 | "outDir": "./dist", 14 | "paths": { 15 | "@snapdrag/core/*": ["../core/src/*"] 16 | }, 17 | "rootDir": "src", 18 | "allowSyntheticDefaultImports": true, 19 | "skipLibCheck": true, 20 | "forceConsistentCasingInFileNames": true 21 | }, 22 | "include": ["src/**/*"], 23 | "exclude": ["node_modules", "dist", "test", "src/core", "src/plugins"] 24 | } 25 | -------------------------------------------------------------------------------- /packages/examples/src/simple-draggable-list/styles.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 0; 5 | text-align: center; 6 | } 7 | 8 | .item-wrapper { 9 | position: relative; 10 | user-select: none; 11 | width: 300px; 12 | cursor: pointer; 13 | } 14 | 15 | .dropline { 16 | position: absolute; 17 | top: -5px; 18 | left: 0; 19 | right: 0; 20 | height: 2px; 21 | background-color: blue; 22 | } 23 | 24 | .item-wrapper.dragging { 25 | opacity: 0.8; 26 | } 27 | 28 | .item-wrapper.dragging .item { 29 | margin: 0; 30 | } 31 | 32 | .item { 33 | background-color: coral; 34 | margin: 10px; 35 | padding: 10px; 36 | font-size: 20px; 37 | line-height: 20px; 38 | color: black; 39 | position: relative; 40 | } 41 | -------------------------------------------------------------------------------- /packages/examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src", "src/kanban-dashboard/data.ts"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /packages/examples/src/sticky-squares-scroller/Square.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useObserver } from "onek/react"; 3 | import { SquareModel } from "./models"; 4 | 5 | type SquareProps = { 6 | model: SquareModel; 7 | style?: React.CSSProperties; 8 | }; 9 | 10 | export const Square = React.forwardRef( 11 | ({ model, style = {} }, ref) => { 12 | const observer = useObserver(); 13 | 14 | return observer(() => { 15 | const squareStyle = { 16 | width: 100, 17 | height: 100, 18 | backgroundColor: model.color, 19 | userSelect: "none", 20 | ...style, 21 | } as const; 22 | 23 | return
; 24 | }); 25 | } 26 | ); 27 | -------------------------------------------------------------------------------- /packages/examples/src/sticky-squares-scroller/models.ts: -------------------------------------------------------------------------------- 1 | import { observable } from "onek"; 2 | import { makeObservable } from "onek/mobx"; 3 | 4 | let idx = 0; 5 | 6 | export class SquareModel { 7 | index: number; 8 | 9 | constructor(public x: number, public y: number, public color: string) { 10 | this.x = observable.prop(x); 11 | this.y = observable.prop(y); 12 | this.color = observable.prop(color); 13 | 14 | this.index = ++idx; 15 | 16 | makeObservable(this); 17 | } 18 | } 19 | 20 | export const squares = Array.from( 21 | { length: 100 }, 22 | () => 23 | new SquareModel( 24 | Math.random() * 1000, 25 | Math.random() * 4000, 26 | // @ts-expect-error strange way to generate a color 27 | "#" + (Math.random() * 256 ** 3).toFixed(10).toString(16).slice(0, 6) 28 | ) 29 | ); 30 | -------------------------------------------------------------------------------- /packages/examples/src/tldr-example/App.tsx: -------------------------------------------------------------------------------- 1 | import { useDraggable, useDroppable, Overlay } from "snapdrag"; 2 | import "./styles.css"; 3 | 4 | const App = () => { 5 | const { draggable } = useDraggable({ 6 | kind: "SQUARE", 7 | data: { color: "red" }, 8 | move: true, 9 | }); 10 | 11 | const { droppable } = useDroppable({ 12 | accepts: "SQUARE", 13 | onDrop({ data }) { 14 | alert(`Dropped ${data.color} square`); 15 | }, 16 | }); 17 | 18 | return ( 19 |
20 |
21 | {draggable(
Drag me
)} 22 |
23 |
24 | {droppable(
Drop on me
)} 25 |
26 | 27 |
28 | ); 29 | }; 30 | 31 | export default App; 32 | -------------------------------------------------------------------------------- /packages/examples/src/simple-squares/App.tsx: -------------------------------------------------------------------------------- 1 | import { Overlay } from "snapdrag"; 2 | import { DraggableSquare } from "./DraggableSquare"; 3 | import { DroppableSquare } from "./DroppableSquare"; 4 | 5 | import "./styles.css"; 6 | 7 | export default function App() { 8 | return ( 9 | <> 10 |
11 |
12 | 13 |
14 |
15 | 16 |
17 |
18 | 19 |
20 |
21 | 22 |
23 |
24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /packages/examples/src/simple-squares/DroppableSquare.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useDroppable } from "snapdrag"; 3 | 4 | export const DroppableSquare = ({ color }: { color: string }) => { 5 | const [text, setText] = useState("Drop here"); 6 | 7 | const { droppable } = useDroppable({ 8 | accepts: "SQUARE", 9 | data: { color }, 10 | // Optional callbacks 11 | onDragIn({ data }) { 12 | // Some draggable is hovering over this droppable 13 | // data is the data of the draggable 14 | setText(`Hovered over ${data.color}`); 15 | }, 16 | onDragOut() { 17 | // The draggable is no longer hovering over this droppable 18 | setText("Drop here"); 19 | }, 20 | onDrop({ data }) { 21 | // Finally, the draggable is dropped on this droppable 22 | setText(`Dropped ${data.color}`); 23 | }, 24 | }); 25 | 26 | return droppable( 27 |
28 | {text} 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /packages/core/src/droppable.ts: -------------------------------------------------------------------------------- 1 | import { DROPPABLE_ATTRIBUTE } from "./constants"; 2 | import { Destructor, DroppableConfig, IDroppable } from "./types"; 3 | 4 | export const registeredDropTargets = new WeakMap(); 5 | 6 | export class Droppable implements IDroppable { 7 | constructor(public config: DroppableConfig) {} 8 | 9 | public setConfig = (config: DroppableConfig) => { 10 | this.config = config; 11 | }; 12 | 13 | public listen = (element: HTMLElement): Destructor => { 14 | element.setAttribute(DROPPABLE_ATTRIBUTE, "true"); 15 | 16 | registeredDropTargets.set(element, this); 17 | 18 | return () => { 19 | element.removeAttribute(DROPPABLE_ATTRIBUTE); 20 | 21 | registeredDropTargets.delete(element); 22 | }; 23 | }; 24 | 25 | get data() { 26 | return this.config.data; 27 | } 28 | 29 | get disabled(): boolean { 30 | return !!this.config.disabled; 31 | } 32 | } 33 | 34 | export function createDroppable(config: DroppableConfig): IDroppable { 35 | return new Droppable(config); 36 | } 37 | -------------------------------------------------------------------------------- /packages/core/src/utils/defaultPointerHandlers.ts: -------------------------------------------------------------------------------- 1 | import { PointerEventHandler } from "../types"; 2 | 3 | export function defaultPointerDownHandler(element: HTMLElement, handler: PointerEventHandler) { 4 | element.addEventListener("pointerdown", handler); 5 | 6 | return () => { 7 | element.removeEventListener("pointerdown", handler); 8 | }; 9 | } 10 | 11 | export function defaultPointerMoveHandler(handler: PointerEventHandler) { 12 | document.addEventListener("pointermove", handler); 13 | 14 | return () => { 15 | document.removeEventListener("pointermove", handler); 16 | }; 17 | } 18 | 19 | export function defaultPointerUpHandler(handler: PointerEventHandler) { 20 | document.addEventListener("pointerup", handler); 21 | 22 | return () => { 23 | document.removeEventListener("pointerup", handler); 24 | }; 25 | } 26 | 27 | export function defaultPointerCancelHandler(handler: PointerEventHandler) { 28 | document.addEventListener("pointercancel", handler); 29 | 30 | return () => { 31 | document.removeEventListener("pointercancel", handler); 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Eugene Daragan 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 | -------------------------------------------------------------------------------- /packages/examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snapdrag-examples", 3 | "version": "0.8.1", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "vite build", 8 | "dev": "vite --open --host 0.0.0.0", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0 && yarn typecheck", 10 | "preview": "vite preview", 11 | "typecheck": "tsc --noEmit" 12 | }, 13 | "dependencies": { 14 | "classnames": "^2.5.1", 15 | "lodash": "^4.17.21", 16 | "onek": "^0.2.0", 17 | "react": ">=18.3.0", 18 | "react-dom": ">=18.3.0", 19 | "snapdrag": "0.8.9", 20 | "styled-components": "^6.1.8" 21 | }, 22 | "devDependencies": { 23 | "@types/lodash": "^4.17.1", 24 | "@types/react": ">=18.3.0", 25 | "@types/react-dom": ">=18.3.0", 26 | "@typescript-eslint/eslint-plugin": "^7.2.0", 27 | "@typescript-eslint/parser": "^7.2.0", 28 | "@vitejs/plugin-react": "^4.2.1", 29 | "eslint": "^8.57.0", 30 | "eslint-plugin-react-hooks": "^4.6.0", 31 | "eslint-plugin-react-refresh": "^0.4.6", 32 | "typescript": "^5.2.2", 33 | "vite": "^5.2.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snapdrag-monorepo", 3 | "private": true, 4 | "repository": { 5 | "type": "git", 6 | "url": "git+https://github.com/zheksoon/snapdrag.git" 7 | }, 8 | "license": "MIT", 9 | "author": { 10 | "name": "Eugene Daragan" 11 | }, 12 | "type": "module", 13 | "workspaces": [ 14 | "packages/*" 15 | ], 16 | "scripts": { 17 | "build": "NODE_ENV=production yarn ws-exec build", 18 | "dev": "NODE_ENV=development yarn ws-exec-parallel dev", 19 | "lint": "yarn ws-exec-parallel lint", 20 | "test": "yarn ws-exec-parallel test", 21 | "typecheck": "yarn ws-exec-parallel typecheck", 22 | "publish": "yarn workspaces foreach --all --topological --exclude snapdrag-monorepo --exclude snapdrag-examples --verbose version \"$@\" && yarn workspaces foreach --all --topological --exclude snapdrag-monorepo --exclude snapdrag-examples --verbose npm publish --access public", 23 | "ws-exec": "yarn workspaces foreach --all --topological --exclude snapdrag-monorepo --verbose run", 24 | "ws-exec-parallel": "yarn workspaces foreach --all --parallel --exclude snapdrag-monorepo --verbose run" 25 | }, 26 | "packageManager": "yarn@4.9.1", 27 | "engines": { 28 | "node": ">=20.18.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/examples/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | sourceType: 'module', 22 | project: ['./tsconfig.json', './tsconfig.node.json'], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | } 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /packages/examples/src/simple-squares/DraggableSquare.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useDraggable } from "snapdrag"; 3 | 4 | export const DraggableSquare = ({ color }: { color: string }) => { 5 | const [text, setText] = useState("Drag me"); 6 | const { draggable, isDragging } = useDraggable({ 7 | kind: "SQUARE", 8 | data: { color }, 9 | move: true, 10 | // Callbacks are totally optional 11 | onDragStart({ data }) { 12 | // data is the own data of the draggable 13 | setText(`Dragging ${data.color}`); 14 | }, 15 | onDragMove({ dropTargets }) { 16 | // Check if there are any drop targets under the pointer 17 | if (dropTargets.length > 0) { 18 | // Update the text based on the first drop target color 19 | setText(`Over ${dropTargets[0].data.color}`); 20 | } else { 21 | setText("Dragging..."); 22 | } 23 | }, 24 | onDragEnd({ dropTargets }) { 25 | // Check if the draggable was dropped on a valid target 26 | if (dropTargets.length > 0) { 27 | setText(`Dropped on ${dropTargets[0].data.color}`); 28 | } else { 29 | setText("Drag me"); 30 | } 31 | }, 32 | }); 33 | 34 | const opacity = isDragging ? 0.5 : 1; 35 | 36 | return draggable( 37 |
41 | {text} 42 |
43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /packages/examples/src/complex-draggable-list/styles.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | font-family: 'Roboto', sans-serif; 7 | } 8 | 9 | .item-wrapper { 10 | user-select: none; 11 | width: 300px; 12 | padding: 5px; 13 | cursor: grab; 14 | } 15 | 16 | .item-wrapper.dragging { 17 | opacity: 0.8; 18 | cursor: grabbing; 19 | } 20 | 21 | .with-animation { 22 | transition: height 0.7s cubic-bezier(.23, 1, .32, 1); 23 | } 24 | 25 | .item-placeholder { 26 | height: 0px; 27 | will-change: height; 28 | } 29 | 30 | .item-placeholder.hovered { 31 | height: 68px; 32 | } 33 | 34 | .placeholder { 35 | height: 68px; 36 | } 37 | 38 | .placeholder.collapsed { 39 | height: 0px; 40 | } 41 | 42 | .moved-placeholder { 43 | height: 40px; 44 | } 45 | 46 | .moved-placeholder.collapsed { 47 | height: 0px; 48 | } 49 | 50 | .item { 51 | background-color: #fffbf3; /* Soft beige background */ 52 | padding: 10px; 53 | font-size: 18px; 54 | line-height: 24px; 55 | color: #333333; 56 | border: 2px solid #ff6f61; /* Coral border */ 57 | border-radius: 8px; 58 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 59 | margin-bottom: 10px; 60 | transition: transform 0.2s ease-in-out, border-color 0.2s ease-in-out; 61 | } 62 | 63 | .item:hover { 64 | transform: scale(1.03); 65 | border-color: #ff4d3a; /* Darker coral on hover */ 66 | } 67 | 68 | .item:active { 69 | transform: scale(0.98); 70 | } 71 | 72 | .last-placeholder { 73 | height: 100px; 74 | } 75 | 76 | @media (max-width: 768px) { 77 | .item-wrapper { 78 | width: 100%; 79 | } 80 | } -------------------------------------------------------------------------------- /packages/plugins/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@snapdrag/plugins", 3 | "version": "0.8.9", 4 | "description": "Plugins for Snapdrag drag and drop library", 5 | "keywords": [ 6 | "snapdrag", 7 | "drag", 8 | "drop", 9 | "drag-and-drop", 10 | "draggable", 11 | "vanilla", 12 | "typescript", 13 | "plugins", 14 | "scroller" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/zheksoon/snapdrag.git", 19 | "directory": "packages/plugins" 20 | }, 21 | "license": "MIT", 22 | "author": { 23 | "name": "Eugene Daragan" 24 | }, 25 | "type": "module", 26 | "exports": { 27 | ".": { 28 | "import": { 29 | "types": "./dist/index.d.ts", 30 | "default": "./dist/index.js" 31 | }, 32 | "require": { 33 | "types": "./dist/index.d.cts", 34 | "default": "./dist/index.cjs" 35 | } 36 | } 37 | }, 38 | "main": "./dist/index.cjs", 39 | "module": "./dist/index.js", 40 | "source": "./src/index.ts", 41 | "types": "./dist/index.d.ts", 42 | "files": [ 43 | "dist/**/*", 44 | "src/**/*", 45 | "package.json", 46 | "README.md", 47 | "LICENSE" 48 | ], 49 | "sideEffects": false, 50 | "scripts": { 51 | "build": "tsup --clean && yarn copy-files && yarn publint", 52 | "copy-files": "cp ../../README.md ../../LICENSE dist", 53 | "dev": "tsup --watch", 54 | "lint": "yarn typecheck", 55 | "typecheck": "tsc --noEmit" 56 | }, 57 | "dependencies": { 58 | "@snapdrag/core": "workspace:*" 59 | }, 60 | "devDependencies": { 61 | "@types/node": ">=20.18.0", 62 | "publint": "^0.3.9", 63 | "tsup": "^8.4.0", 64 | "typescript": "^5.2.2" 65 | }, 66 | "packageManager": "yarn@4.9.1", 67 | "engines": { 68 | "node": ">=20.18.0" 69 | }, 70 | "publishConfig": { 71 | "access": "public" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@snapdrag/core", 3 | "version": "0.8.9", 4 | "description": "Core logic for Snapdrag drag and drop library", 5 | "keywords": [ 6 | "snapdrag", 7 | "drag", 8 | "drop", 9 | "draggable", 10 | "droppable", 11 | "sortable", 12 | "kanban", 13 | "vanilla", 14 | "typescript", 15 | "performance", 16 | "customizable", 17 | "touch", 18 | "dnd", 19 | "react-dnd", 20 | "dnd-kit", 21 | "beautiful-dnd", 22 | "sortablejs", 23 | "dragula", 24 | "interactjs", 25 | "draggablejs", 26 | "typescript" 27 | ], 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/zheksoon/snapdrag.git", 31 | "directory": "packages/core" 32 | }, 33 | "license": "MIT", 34 | "author": { 35 | "name": "Eugene Daragan" 36 | }, 37 | "type": "module", 38 | "exports": { 39 | ".": { 40 | "import": { 41 | "types": "./dist/index.d.ts", 42 | "default": "./dist/index.js" 43 | }, 44 | "require": { 45 | "types": "./dist/index.d.cts", 46 | "default": "./dist/index.cjs" 47 | } 48 | } 49 | }, 50 | "main": "./dist/index.cjs", 51 | "module": "./dist/index.js", 52 | "source": "./src/index.ts", 53 | "types": "./dist/index.d.ts", 54 | "files": [ 55 | "dist/**/*", 56 | "src/**/*", 57 | "package.json", 58 | "README.md", 59 | "LICENSE" 60 | ], 61 | "sideEffects": false, 62 | "scripts": { 63 | "build": "tsup --clean && yarn copy-files && yarn publint", 64 | "copy-files": "cp ../../LICENSE dist", 65 | "dev": "tsup --watch", 66 | "lint": "yarn typecheck", 67 | "typecheck": "tsc --noEmit" 68 | }, 69 | "devDependencies": { 70 | "@types/node": ">=20.18.0", 71 | "publint": "^0.3.9", 72 | "tsup": "^8.4.0", 73 | "typescript": "^5.2.2" 74 | }, 75 | "packageManager": "yarn@4.9.1", 76 | "engines": { 77 | "node": ">=20.18.0" 78 | }, 79 | "publishConfig": { 80 | "access": "public" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@snapdrag/react", 3 | "version": "0.8.9", 4 | "description": "React hooks for Snapdrag drag and drop library", 5 | "keywords": [ 6 | "snapdrag", 7 | "drag", 8 | "drop", 9 | "draggable", 10 | "droppable", 11 | "sortable", 12 | "kanban", 13 | "react", 14 | "vanilla", 15 | "typescript", 16 | "performance", 17 | "customizable", 18 | "touch", 19 | "dnd", 20 | "react-dnd", 21 | "dnd-kit", 22 | "beautiful-dnd", 23 | "sortablejs", 24 | "dragula", 25 | "interactjs", 26 | "draggablejs" 27 | ], 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/zheksoon/snapdrag.git", 31 | "directory": "packages/react" 32 | }, 33 | "license": "MIT", 34 | "author": { 35 | "name": "Eugene Daragan" 36 | }, 37 | "type": "module", 38 | "exports": { 39 | ".": { 40 | "import": { 41 | "types": "./dist/index.d.ts", 42 | "default": "./dist/index.js" 43 | }, 44 | "require": { 45 | "types": "./dist/index.d.cts", 46 | "default": "./dist/index.cjs" 47 | } 48 | } 49 | }, 50 | "main": "./dist/index.cjs", 51 | "module": "./dist/index.js", 52 | "source": "./src/index.ts", 53 | "types": "./dist/index.d.ts", 54 | "files": [ 55 | "dist/**/*", 56 | "src/**/*", 57 | "package.json", 58 | "README.md", 59 | "LICENSE" 60 | ], 61 | "sideEffects": false, 62 | "scripts": { 63 | "build": "tsup --clean && yarn copy-files && yarn publint", 64 | "copy-files": "cp ../../README.md ../../LICENSE dist", 65 | "dev": "tsup --watch", 66 | "lint": "yarn typecheck", 67 | "typecheck": "tsc --noEmit" 68 | }, 69 | "dependencies": { 70 | "@snapdrag/core": "workspace:*" 71 | }, 72 | "devDependencies": { 73 | "@types/node": ">=20.18.0", 74 | "@types/react": ">=18.3.0", 75 | "publint": "^0.3.9", 76 | "react": ">=18.3.0", 77 | "tsup": "^8.4.0", 78 | "typescript": "^5.2.2" 79 | }, 80 | "peerDependencies": { 81 | "react": ">=18.3.0" 82 | }, 83 | "packageManager": "yarn@4.9.1", 84 | "engines": { 85 | "node": ">=20.18.0" 86 | }, 87 | "publishConfig": { 88 | "access": "public" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /packages/react/src/Overlay.tsx: -------------------------------------------------------------------------------- 1 | import { DROPPABLE_ATTRIBUTE, DROPPABLE_FORCE_ATTRIBUTE } from "@snapdrag/core"; 2 | import React, { ReactElement, useEffect, useRef, useState } from "react"; 3 | 4 | const NOOP = () => {}; 5 | 6 | export let setOverlayVisible: (visible: boolean) => void = NOOP; 7 | export let setDragElementPosition: (position: { top: number; left: number }) => void = NOOP; 8 | 9 | export const OVERLAY_ID = "$snapdrag-overlay"; 10 | 11 | export function Overlay({ style = {}, className = "" }) { 12 | const dragOverlayRef = useRef(null); 13 | const dragWrapperRef = useRef(null); 14 | 15 | useEffect(() => { 16 | setOverlayVisible = (visible: boolean) => { 17 | const dragOverlay = dragOverlayRef.current; 18 | 19 | if (!dragOverlay) return; 20 | 21 | dragOverlay.style.display = visible ? "block" : "none"; 22 | 23 | const dragWrapper = dragWrapperRef.current; 24 | 25 | if (!dragWrapper) return; 26 | 27 | // force the drag wrapper to be not droppable, so it will not interfere with real drop event 28 | // self-drop doesn't make sense 29 | if (visible) { 30 | dragWrapper.setAttribute(DROPPABLE_FORCE_ATTRIBUTE, "false"); 31 | } else { 32 | dragWrapper.removeAttribute(DROPPABLE_FORCE_ATTRIBUTE); 33 | } 34 | }; 35 | 36 | setDragElementPosition = (position: { top: number; left: number }) => { 37 | const { current } = dragWrapperRef; 38 | 39 | if (!current) return; 40 | 41 | current.style.transform = `translateX(${position.left}px) translateY(${position.top}px)`; 42 | }; 43 | 44 | return () => { 45 | setDragElementPosition = NOOP; 46 | setOverlayVisible = NOOP; 47 | }; 48 | }, []); 49 | 50 | const dragWrapperStyle = { 51 | position: "relative" as const, 52 | transform: `translateX(0px) translateY(0px)`, 53 | willChange: "transform", 54 | }; 55 | 56 | const dragOverlayStyle = { 57 | position: "fixed" as const, 58 | top: 0, 59 | bottom: 0, 60 | left: 0, 61 | right: 0, 62 | display: "none", 63 | ...style, 64 | }; 65 | 66 | return ( 67 |
68 |
69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /packages/examples/src/kanban-dashboard/store.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useEffect } from "react"; 2 | import { partition } from "lodash"; 3 | import { IColumn, IProject, ITask, initialTasks } from "./data"; 4 | 5 | type AddTask = (task: Partial) => void; 6 | 7 | type RemoveTask = (task: ITask) => void; 8 | 9 | type UpdateTask = (task: ITask, data: Partial) => void; 10 | 11 | export const TasksContext = React.createContext({ 12 | tasks: [] as ITask[], 13 | addTask: (() => {}) as AddTask, 14 | removeTask: (() => {}) as RemoveTask, 15 | updateTask: (() => {}) as UpdateTask, 16 | }); 17 | 18 | const updateOrder = (tasks: ITask[], project: IProject["id"], status: IColumn["status"]) => { 19 | const [projectTasks, otherTasks] = partition( 20 | tasks, 21 | (t) => t.project === project && t.status === status 22 | ); 23 | 24 | const orderedTasks = projectTasks 25 | .sort((a, b) => a.order - b.order) 26 | .map((t, i) => ({ ...t, order: i + 1 })); 27 | 28 | return [...otherTasks, ...orderedTasks]; 29 | }; 30 | 31 | export const TasksProvider = ({ children }: { children: React.ReactElement | React.ReactElement[] }) => { 32 | const [tasks, setTasks] = useState(() => { 33 | const storageTasks = localStorage.getItem("tasks"); 34 | 35 | return storageTasks ? JSON.parse(storageTasks) : initialTasks; 36 | }); 37 | 38 | const addTask = useCallback((task) => { 39 | setTasks((tasks) => { 40 | const newTask = { ...task, id: (Math.random() * 1e15) | 0 } as ITask; 41 | 42 | return updateOrder([...tasks, newTask], newTask.project, newTask.status); 43 | }); 44 | }, []); 45 | 46 | const removeTask = useCallback((task) => { 47 | setTasks((tasks) => tasks.filter((_task) => _task.id !== task.id)); 48 | }, []); 49 | 50 | const updateTask = useCallback((task, data) => { 51 | setTasks((tasks) => { 52 | const newTasks = tasks.map((_task) => 53 | task.id === _task.id ? { ..._task, ...data } : _task 54 | ); 55 | 56 | return updateOrder(newTasks, task.project, task.status); 57 | }); 58 | }, []); 59 | 60 | useEffect(() => { 61 | localStorage.setItem("tasks", JSON.stringify(tasks)); 62 | }, [tasks]); 63 | 64 | return ( 65 | 66 | {children} 67 | 68 | ); 69 | }; 70 | 71 | export const useTasks = () => React.useContext(TasksContext); 72 | -------------------------------------------------------------------------------- /packages/examples/src/simple-draggable-list/draggable-list.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Overlay, useDraggable, useDroppable } from "snapdrag"; 3 | import cx from "classnames"; 4 | import "./styles.css"; 5 | 6 | type Item = { text: string; index: number }; 7 | 8 | const defaultItems: Item[] = [ 9 | { text: "Learn React", index: 0 }, 10 | { text: "Build a To-Do App", index: 1 }, 11 | { text: "Read about Hooks", index: 2 }, 12 | { text: "Write Unit Tests", index: 3 }, 13 | { text: "Explore Redux", index: 4 }, 14 | { text: "Style Components", index: 5 }, 15 | { text: "Deploy to Netlify", index: 6 }, 16 | { text: "Optimize Performance", index: 7 }, 17 | { text: "Implement DnD", index: 8 }, 18 | { text: "Review Code", index: 9 }, 19 | ]; 20 | 21 | function moveItem(items: Item[], from: number, to: number) { 22 | return items 23 | .map((item) => (item.index === from ? { ...item, index: to - 0.5 } : item)) 24 | .sort((a, b) => a.index - b.index) 25 | .map((item, index) => ({ ...item, index })); 26 | } 27 | 28 | interface ItemProps { 29 | text: string; 30 | index: number; 31 | moveItem(fromIndex: number, toIndex: number): void; 32 | } 33 | 34 | function Item({ text, index, moveItem }: ItemProps) { 35 | const { draggable, isDragging } = useDraggable({ 36 | kind: "ITEM", 37 | data: { index }, 38 | move: true, 39 | }); 40 | 41 | const { droppable, hovered } = useDroppable({ 42 | accepts: "ITEM", 43 | onDrop({ data }) { 44 | if (!isDragging && data.index !== index) { 45 | moveItem(data.index, index); 46 | } 47 | }, 48 | }); 49 | 50 | return droppable( 51 | draggable( 52 |
53 | {hovered &&
} 54 |
{text}
55 |
56 | ) 57 | ); 58 | } 59 | 60 | function App() { 61 | const [items, setItems] = useState(defaultItems); 62 | 63 | const _moveItem = (fromIndex: number, toIndex: number) => { 64 | setItems((_items) => moveItem(_items, fromIndex, toIndex)); 65 | }; 66 | 67 | return ( 68 | <> 69 |
70 | {items.map((item) => ( 71 | 77 | ))} 78 |
79 | 80 | 81 | ); 82 | } 83 | 84 | export default App; 85 | -------------------------------------------------------------------------------- /packages/examples/src/kanban-dashboard/data.ts: -------------------------------------------------------------------------------- 1 | export interface ITask { 2 | id: number; 3 | title: string; 4 | project: number; 5 | status: string; 6 | order: number; 7 | } 8 | 9 | export interface IProject { 10 | id: number; 11 | title: string; 12 | } 13 | 14 | export interface IColumn { 15 | name: string; 16 | status: string; 17 | } 18 | 19 | export const columns: IColumn[] = [ 20 | { name: "Todo", status: "todo" }, 21 | { name: "In Progress", status: "in-progress" }, 22 | { name: "Done", status: "done" }, 23 | ]; 24 | 25 | export const projects: IProject[] = [ 26 | { id: 1, title: "Website Redesign" }, 27 | { id: 2, title: "Mobile App Development" }, 28 | ]; 29 | 30 | export const initialTasks: ITask[] = [ 31 | { 32 | id: 1, 33 | title: "Implement login functionality", 34 | project: 1, 35 | status: "todo", 36 | order: 0, 37 | }, 38 | { 39 | id: 2, 40 | title: "Refactor API integration", 41 | project: 2, 42 | status: "todo", 43 | order: 2, 44 | }, 45 | { 46 | id: 3, 47 | title: "Fix bug in data validation", 48 | project: 1, 49 | status: "in-progress", 50 | order: 2, 51 | }, 52 | { 53 | id: 4, 54 | title: "Add new feature", 55 | project: 2, 56 | status: "in-progress", 57 | order: 1, 58 | }, 59 | { 60 | id: 5, 61 | title: "Write unit tests", 62 | project: 1, 63 | status: "done", 64 | order: 0, 65 | }, 66 | { 67 | id: 6, 68 | title: "Optimize performance", 69 | project: 2, 70 | status: "done", 71 | order: 1, 72 | }, 73 | { 74 | id: 7, 75 | title: "Design user interface", 76 | project: 1, 77 | status: "todo", 78 | order: 1, 79 | }, 80 | { 81 | id: 8, 82 | title: "Implement data caching", 83 | project: 2, 84 | status: "in-progress", 85 | order: 0, 86 | }, 87 | { 88 | id: 9, 89 | title: "Deploy application to production", 90 | project: 1, 91 | status: "done", 92 | order: 2, 93 | }, 94 | { 95 | id: 10, 96 | title: "Write documentation", 97 | project: 2, 98 | status: "todo", 99 | order: 0, 100 | }, 101 | { 102 | id: 11, 103 | title: "Fix styling issues", 104 | project: 1, 105 | status: "in-progress", 106 | order: 1, 107 | }, 108 | { 109 | id: 12, 110 | title: "Add internationalization support", 111 | project: 2, 112 | status: "done", 113 | order: 2, 114 | }, 115 | ]; 116 | -------------------------------------------------------------------------------- /packages/snapdrag/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snapdrag", 3 | "version": "0.8.9", 4 | "description": "A simple, lightweight, and performant drag and drop library for React and vanilla JS", 5 | "keywords": [ 6 | "drag", 7 | "drop", 8 | "draggable", 9 | "droppable", 10 | "sortable", 11 | "kanban", 12 | "react", 13 | "vanilla", 14 | "typescript", 15 | "performance", 16 | "customizable", 17 | "touch", 18 | "dnd", 19 | "react-dnd", 20 | "dnd-kit", 21 | "beautiful-dnd", 22 | "sortablejs", 23 | "dragula", 24 | "interactjs", 25 | "draggablejs", 26 | "typescript" 27 | ], 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/zheksoon/snapdrag.git" 31 | }, 32 | "license": "MIT", 33 | "author": { 34 | "name": "Eugene Daragan" 35 | }, 36 | "type": "module", 37 | "exports": { 38 | ".": { 39 | "import": { 40 | "types": "./dist/index.d.ts", 41 | "default": "./dist/index.js" 42 | }, 43 | "require": { 44 | "types": "./dist/index.d.cts", 45 | "default": "./dist/index.cjs" 46 | } 47 | }, 48 | "./core": { 49 | "import": { 50 | "types": "./dist/core.d.ts", 51 | "default": "./dist/core.js" 52 | }, 53 | "require": { 54 | "types": "./dist/core.d.cts", 55 | "default": "./dist/core.cjs" 56 | } 57 | }, 58 | "./plugins": { 59 | "import": { 60 | "types": "./dist/plugins.d.ts", 61 | "default": "./dist/plugins.js" 62 | }, 63 | "require": { 64 | "types": "./dist/plugins.d.cts", 65 | "default": "./dist/plugins.cjs" 66 | } 67 | } 68 | }, 69 | "main": "./dist/index.cjs", 70 | "module": "./dist/index.js", 71 | "types": "./dist/index.d.ts", 72 | "files": [ 73 | "dist/**/*", 74 | "package.json", 75 | "README.md", 76 | "LICENSE" 77 | ], 78 | "scripts": { 79 | "build": "tsup --clean && cp ../../README.md ../../LICENSE ./", 80 | "dev": "tsup --watch", 81 | "lint": "yarn typecheck", 82 | "typecheck": "tsc --noEmit" 83 | }, 84 | "dependencies": { 85 | "@snapdrag/core": "workspace:*", 86 | "@snapdrag/plugins": "workspace:*", 87 | "@snapdrag/react": "workspace:*" 88 | }, 89 | "devDependencies": { 90 | "@types/node": ">=20.18.0", 91 | "@types/react": ">=18.3.0", 92 | "publint": "^0.3.9", 93 | "react": ">=18.3.0", 94 | "tsup": "^8.4.0", 95 | "typescript": "^5.2.2" 96 | }, 97 | "peerDependencies": { 98 | "react": ">=18.3.0" 99 | }, 100 | "packageManager": "yarn@4.9.1", 101 | "engines": { 102 | "node": ">=20.18.0" 103 | }, 104 | "publishConfig": { 105 | "access": "public" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /packages/react/src/types.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Kind } from "@snapdrag/core"; 3 | 4 | export type DropTargetData = { 5 | data: any; 6 | element: HTMLElement; 7 | }; 8 | 9 | export type DraggableConfig = { 10 | kind: Kind; 11 | data?: any; 12 | component?: (args: { data: any; props: Record }) => React.ReactElement; 13 | placeholder?: (args: { data: any; props: Record }) => React.ReactElement; 14 | offset?: 15 | | { top: number; left: number } 16 | | ((args: { element: HTMLElement; dragStartEvent: MouseEvent; data: any }) => { 17 | top: number; 18 | left: number; 19 | }); 20 | mapCoords?: (args: { 21 | top: number; 22 | left: number; 23 | event: MouseEvent; 24 | dragStartEvent: MouseEvent; 25 | element: HTMLElement; 26 | data: any; 27 | }) => { top: number; left: number }; 28 | shouldDrag?: (args: { 29 | event: MouseEvent; 30 | dragStartEvent: MouseEvent; 31 | element: HTMLElement; 32 | data: any; 33 | }) => boolean; 34 | onDragStart?: (args: { 35 | event: MouseEvent; 36 | dragStartEvent: MouseEvent; 37 | element: HTMLElement; 38 | data: any; 39 | }) => void; 40 | onDragMove?: (args: { 41 | top: number; 42 | left: number; 43 | event: MouseEvent; 44 | dragStartEvent: MouseEvent; 45 | dropTargets: DropTargetData[]; 46 | element: HTMLElement; 47 | data: any; 48 | }) => void; 49 | onDragEnd?: (args: { 50 | top: number; 51 | left: number; 52 | event: MouseEvent; 53 | dragStartEvent: MouseEvent; 54 | dropTargets: DropTargetData[]; 55 | element: HTMLElement; 56 | data: any; 57 | }) => void; 58 | move?: boolean; 59 | disabled?: boolean; 60 | pointerConfig?: any; 61 | plugins?: any; 62 | }; 63 | 64 | export type DroppableConfig = { 65 | disabled?: boolean; 66 | accepts: Kind | Kind[] | ((props: { kind: Kind; data: any }) => boolean); 67 | data?: any; 68 | onDragIn?: (props: { 69 | kind: Kind; 70 | data: any; 71 | event: MouseEvent; 72 | element: HTMLElement; 73 | dropElement: HTMLElement; 74 | dropTargets: DropTargetData[]; 75 | }) => void; 76 | onDragOut?: (props: { 77 | kind: Kind; 78 | data: any; 79 | event: MouseEvent; 80 | element: HTMLElement; 81 | dropElement: HTMLElement; 82 | dropTargets: DropTargetData[]; 83 | }) => void; 84 | onDragMove?: (props: { 85 | kind: Kind; 86 | data: any; 87 | event: MouseEvent; 88 | element: HTMLElement; 89 | dropElement: HTMLElement; 90 | dropTargets: DropTargetData[]; 91 | }) => void; 92 | onDrop?: (props: { 93 | kind: Kind; 94 | data: any; 95 | event: MouseEvent; 96 | element: HTMLElement; 97 | dropElement: HTMLElement; 98 | dropTargets: DropTargetData[]; 99 | }) => void; 100 | }; 101 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | export type PointerEventHandler = (event: PointerEvent) => void; 2 | 3 | export type Destructor = () => void; 4 | 5 | export type DropTargetsMap = Map; 6 | 7 | export type DragStarHandlerArgs = { 8 | event: PointerEvent; 9 | dragElement: HTMLElement; 10 | dragStartEvent: PointerEvent; 11 | data?: any; 12 | }; 13 | 14 | type DragHandlerArgs = { 15 | event: PointerEvent; 16 | dragElement: HTMLElement; 17 | dragStartEvent: PointerEvent; 18 | dropTargets: DropTargetsMap; 19 | data?: any; 20 | }; 21 | 22 | export type PluginType = { 23 | onDragStart?: (args: DragStarHandlerArgs) => boolean | undefined | void; 24 | onDragMove?: (args: DragHandlerArgs) => void; 25 | onDragEnd?: (args: DragHandlerArgs) => void; 26 | cleanup?: () => void; 27 | }; 28 | 29 | export type PointerConfig = { 30 | pointerDown?: (element: HTMLElement, handler: PointerEventHandler) => Destructor; 31 | pointerMove?: (handler: PointerEventHandler) => Destructor; 32 | pointerUp?: (handler: PointerEventHandler) => Destructor; 33 | pointerCancel?: (handler: PointerEventHandler) => Destructor; 34 | }; 35 | 36 | export type DraggableDataFactory = (args: { 37 | dragElement: HTMLElement; 38 | dragStartEvent: PointerEvent; 39 | }) => any; 40 | 41 | export type Kind = string | symbol; 42 | 43 | export type DraggableConfig = { 44 | disabled?: boolean; 45 | kind: Kind; 46 | data: any | DraggableDataFactory; 47 | shouldDrag?: (args: DragStarHandlerArgs) => boolean; 48 | onDragStart?: (args: DragStarHandlerArgs) => void; 49 | onDragEnd?: (args: DragHandlerArgs) => void; 50 | onDragMove?: (args: DragHandlerArgs) => void; 51 | pointerConfig?: PointerConfig; 52 | plugins?: Array; 53 | }; 54 | 55 | export type DropHandlerArgs = { 56 | event: PointerEvent; 57 | sourceType: string | symbol; 58 | sourceData: any; 59 | dragStartEvent: PointerEvent; 60 | dragElement: HTMLElement; 61 | dropTarget: IDroppable; 62 | dropTargets: DropTargetsMap; 63 | dropElement: HTMLElement; 64 | }; 65 | 66 | export type DropHandler = (args: DropHandlerArgs) => void; 67 | 68 | export type DroppableAccepts = (args: { 69 | kind: string | symbol; 70 | data: any; 71 | element: HTMLElement; 72 | event: PointerEvent; 73 | }) => boolean; 74 | 75 | export type DroppableConfig = { 76 | disabled?: boolean; 77 | accepts: Kind | Kind[] | DroppableAccepts; 78 | data?: any; 79 | onDragIn?: DropHandler; 80 | onDragOut?: DropHandler; 81 | onDragMove?: DropHandler; 82 | onDrop?: DropHandler; 83 | }; 84 | 85 | export declare class IDraggable { 86 | constructor(config: DraggableConfig); 87 | setConfig: (config: DraggableConfig) => void; 88 | listen: (element: HTMLElement, setAttribute?: boolean) => Destructor; 89 | } 90 | 91 | export declare class IDroppable { 92 | constructor(config: DroppableConfig); 93 | setConfig: (config: DroppableConfig) => void; 94 | listen: (element: HTMLElement) => Destructor; 95 | readonly config: DroppableConfig; 96 | readonly data: any; 97 | readonly disabled: boolean; 98 | } 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | 3 | packages/snapdrag/LICENSE 4 | packages/snapdrag/README.md 5 | 6 | .yarn/ 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/sdks 12 | !.yarn/versions 13 | 14 | # Swap the comments on the following lines if you don't wish to use zero-installs 15 | # Documentation here: https://yarnpkg.com/features/zero-installs 16 | !.yarn/cache 17 | #.pnp.* 18 | 19 | # Logs 20 | logs 21 | *.log 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | lerna-debug.log* 26 | .pnpm-debug.log* 27 | 28 | # Diagnostic reports (https://nodejs.org/api/report.html) 29 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 30 | 31 | # Runtime data 32 | pids 33 | *.pid 34 | *.seed 35 | *.pid.lock 36 | 37 | # Directory for instrumented libs generated by jscoverage/JSCover 38 | lib-cov 39 | 40 | # Coverage directory used by tools like istanbul 41 | coverage 42 | *.lcov 43 | 44 | # nyc test coverage 45 | .nyc_output 46 | 47 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 48 | .grunt 49 | 50 | # Bower dependency directory (https://bower.io/) 51 | bower_components 52 | 53 | # node-waf configuration 54 | .lock-wscript 55 | 56 | # Compiled binary addons (https://nodejs.org/api/addons.html) 57 | build/Release 58 | 59 | # Dependency directories 60 | node_modules/ 61 | jspm_packages/ 62 | 63 | # Snowpack dependency directory (https://snowpack.dev/) 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | *.tsbuildinfo 68 | 69 | # Optional npm cache directory 70 | .npm 71 | 72 | # Optional eslint cache 73 | .eslintcache 74 | 75 | # Optional stylelint cache 76 | .stylelintcache 77 | 78 | # Microbundle cache 79 | .rpt2_cache/ 80 | .rts2_cache_cjs/ 81 | .rts2_cache_es/ 82 | .rts2_cache_umd/ 83 | 84 | # Optional REPL history 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | *.tgz 89 | 90 | # Yarn Integrity file 91 | .yarn-integrity 92 | 93 | # dotenv environment variable files 94 | .env 95 | .env.development.local 96 | .env.test.local 97 | .env.production.local 98 | .env.local 99 | 100 | # parcel-bundler cache (https://parceljs.org/) 101 | .cache 102 | .parcel-cache 103 | 104 | # Next.js build output 105 | .next 106 | out 107 | 108 | # Nuxt.js build / generate output 109 | .nuxt 110 | dist 111 | 112 | # Gatsby files 113 | .cache/ 114 | # Comment in the public line in if your project uses Gatsby and not Next.js 115 | # https://nextjs.org/blog/next-9-1#public-directory-support 116 | # public 117 | 118 | # vuepress build output 119 | .vuepress/dist 120 | 121 | # vuepress v2.x temp and cache directory 122 | .temp 123 | .cache 124 | 125 | # Docusaurus cache and generated files 126 | .docusaurus 127 | 128 | # Serverless directories 129 | .serverless/ 130 | 131 | # FuseBox cache 132 | .fusebox/ 133 | 134 | # DynamoDB Local files 135 | .dynamodb/ 136 | 137 | # TernJS port file 138 | .tern-port 139 | 140 | # Stores VSCode versions used for testing VSCode extensions 141 | .vscode-test 142 | 143 | # yarn v2 144 | .yarn/cache 145 | .yarn/unplugged 146 | .yarn/build-state.yml 147 | .yarn/install-state.gz 148 | .pnp.* 149 | -------------------------------------------------------------------------------- /packages/react/src/useDroppable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo, useRef, useState } from "react"; 2 | import { DroppableConfig as DroppableCoreConfig, createDroppable, Kind } from "@snapdrag/core"; 3 | import { DroppableConfig } from "./types"; 4 | import { getDropTargets } from "./utils/getDropTargets"; 5 | 6 | type HoveredData = { 7 | kind: Kind; 8 | data: any; 9 | element: HTMLElement; 10 | dropElement: HTMLElement; 11 | }; 12 | 13 | export function useDroppable(config: DroppableConfig) { 14 | const [hovered, setHovered] = useState(null); 15 | 16 | let { accepts } = config; 17 | 18 | const droppableCoreConfig: DroppableCoreConfig = { 19 | disabled: config.disabled, 20 | accepts: accepts, 21 | data: config.data, 22 | onDragIn(props) { 23 | setHovered({ 24 | kind: props.sourceType, 25 | data: props.sourceData, 26 | element: props.dragElement, 27 | dropElement: props.dropElement, 28 | }); 29 | 30 | config.onDragIn?.({ 31 | kind: props.sourceType, 32 | data: props.sourceData, 33 | event: props.event, 34 | element: props.dragElement, 35 | dropElement: props.dropElement, 36 | dropTargets: getDropTargets(props.dropTargets), 37 | }); 38 | }, 39 | onDragOut(props) { 40 | setHovered(null); 41 | 42 | config.onDragOut?.({ 43 | kind: props.sourceType, 44 | data: props.sourceData, 45 | event: props.event, 46 | element: props.dragElement, 47 | dropElement: props.dropElement, 48 | dropTargets: getDropTargets(props.dropTargets), 49 | }); 50 | }, 51 | onDragMove(props) { 52 | config.onDragMove?.({ 53 | kind: props.sourceType, 54 | data: props.sourceData, 55 | event: props.event, 56 | element: props.dragElement, 57 | dropElement: props.dropElement, 58 | dropTargets: getDropTargets(props.dropTargets), 59 | }); 60 | }, 61 | onDrop(props) { 62 | setHovered(null); 63 | 64 | config.onDrop?.({ 65 | kind: props.sourceType, 66 | data: props.sourceData, 67 | event: props.event, 68 | element: props.dragElement, 69 | dropElement: props.dropElement, 70 | dropTargets: getDropTargets(props.dropTargets), 71 | }); 72 | }, 73 | }; 74 | 75 | const dropTarget = useMemo(() => createDroppable(droppableCoreConfig), []); 76 | 77 | dropTarget.setConfig(droppableCoreConfig); 78 | 79 | const originalRef = useRef(null as any); 80 | 81 | const dropComponentRef = useCallback((element: HTMLElement | null) => { 82 | if (element) { 83 | dropTarget.listen(element); 84 | } 85 | 86 | const ref = originalRef.current; 87 | 88 | if (typeof ref === "function") { 89 | ref(element); 90 | } else if (ref && ref.hasOwnProperty("current")) { 91 | ref.current = element; 92 | } 93 | }, []); 94 | 95 | const droppable = useCallback( 96 | (child: React.ReactElement> | null) => { 97 | if (!child) { 98 | return null; 99 | } 100 | 101 | // @ts-ignore React 16-19+ refs compatibility. 102 | originalRef.current = child.props?.ref ?? child.ref; 103 | 104 | return React.cloneElement(child, { ref: dropComponentRef }); 105 | }, 106 | [] 107 | ); 108 | 109 | return { 110 | droppable, 111 | hovered, 112 | }; 113 | } 114 | -------------------------------------------------------------------------------- /packages/examples/src/complex-draggable-list/draggable-list.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Overlay, useDraggable, useDroppable } from "snapdrag"; 3 | import cx from "classnames"; 4 | import "./styles.css"; 5 | 6 | type Item = { text: string; index: number }; 7 | 8 | const defaultItems = [ 9 | { text: "Learn React", index: 0 }, 10 | { text: "Build a To-Do App", index: 1 }, 11 | { text: "Read about Hooks", index: 2 }, 12 | { text: "Write Unit Tests", index: 3 }, 13 | { text: "Explore Redux", index: 4 }, 14 | { text: "Style Components", index: 5 }, 15 | { text: "Deploy to Netlify", index: 6 }, 16 | { text: "Optimize Performance", index: 7 }, 17 | { text: "Implement DnD", index: 8 }, 18 | { text: "Review Code", index: 9 }, 19 | ]; 20 | 21 | function moveItem(items: Item[], from: number, to: number) { 22 | return items 23 | .map((item) => (item.index === from ? { ...item, index: to - 0.5 } : item)) 24 | .sort((a, b) => a.index - b.index) 25 | .map((item, index) => ({ ...item, index })); 26 | } 27 | 28 | function Placeholder() { 29 | const [placeholderVisible, setPlaceholderVisible] = useState(true); 30 | 31 | const { droppable } = useDroppable({ 32 | accepts: "ITEM", 33 | onDragOut() { 34 | setPlaceholderVisible(false); 35 | }, 36 | }); 37 | 38 | return droppable( 39 |
46 | ); 47 | } 48 | 49 | interface ItemProps { 50 | text: string; 51 | index: number; 52 | moveItem: (fromIndex: number, toIndex: number) => void; 53 | } 54 | 55 | function Item({ text, index, moveItem }: ItemProps) { 56 | const [collapsed, setCollapsed] = useState(false); 57 | 58 | const { draggable, isDragging } = useDraggable({ 59 | kind: "ITEM", 60 | data: { index }, 61 | move: true, 62 | placeholder: () => , 63 | }); 64 | 65 | const { droppable, hovered } = useDroppable({ 66 | accepts: "ITEM", 67 | onDrop({ data }) { 68 | if (isDragging) { 69 | return; 70 | } 71 | 72 | if (data.index !== index) { 73 | moveItem(data.index, index); 74 | } 75 | 76 | setCollapsed(true); 77 | 78 | requestAnimationFrame(() => { 79 | setCollapsed(false); 80 | }); 81 | }, 82 | }); 83 | 84 | return droppable( 85 | draggable( 86 |
87 |
94 |
{text}
95 |
96 | ) 97 | ); 98 | } 99 | 100 | function App() { 101 | const [items, setItems] = useState(defaultItems); 102 | 103 | const { droppable } = useDroppable({ 104 | accepts: "ITEM", 105 | onDrop({ data }) { 106 | _moveItem(data.index, items.length); 107 | }, 108 | }); 109 | 110 | const _moveItem = (fromIndex: number, toIndex: number) => { 111 | setItems((_items) => moveItem(_items, fromIndex, toIndex)); 112 | }; 113 | 114 | return ( 115 | <> 116 |
117 |
118 | {items.map((item) => ( 119 | 125 | ))} 126 |
127 | {droppable(
)} 128 |
129 | 130 | 131 | ); 132 | } 133 | 134 | export default App; 135 | -------------------------------------------------------------------------------- /packages/examples/src/kanban-dashboard/styled.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const theme = { 4 | colors: { 5 | primary: "#42a5f5", 6 | background: { 7 | gradient: "linear-gradient(135deg, #f5f7fa, #c3cfe2)", 8 | light: "rgba(255, 255, 255, 0.8)", 9 | placeholder: "#f0f0f0", 10 | }, 11 | text: { 12 | default: "#333", 13 | muted: "#888", 14 | }, 15 | border: "#eee", 16 | shadow: "rgba(0, 0, 0, 0.1)", 17 | hover: "#e3f2fd", 18 | success: "green", 19 | danger: "#f44336", 20 | }, 21 | }; 22 | 23 | export const DashboardColumns = styled.div` 24 | display: flex; 25 | background: ${theme.colors.background.gradient}; 26 | font-family: "Roboto", sans-serif; 27 | color: ${theme.colors.text.default}; 28 | height: 100vh; 29 | `; 30 | 31 | export const Column = styled.div` 32 | flex: 1; 33 | padding: 16px; 34 | background-color: ${theme.colors.background.light}; 35 | margin: 10px; 36 | border-radius: 8px; 37 | box-shadow: 0 4px 8px ${theme.colors.shadow}; 38 | overflow: scroll; 39 | `; 40 | 41 | export const ColumnHeader = styled.h1``; 42 | 43 | export const TaskGroup = styled.div<{ $hovered: boolean }>` 44 | position: relative; 45 | margin: 16px 0; 46 | padding: 10px; 47 | background: ${theme.colors.background.light}; 48 | border: 1px solid ${theme.colors.border}; 49 | border-radius: 6px; 50 | box-shadow: 0 2px 4px ${theme.colors.shadow}; 51 | 52 | &:hover { 53 | background-color: ${theme.colors.hover}; 54 | } 55 | 56 | ${(props) => props.$hovered && `background-color: ${theme.colors.hover}`} 57 | `; 58 | 59 | export const TaskGroupHeader = styled.div` 60 | display: flex; 61 | justify-content: space-between; 62 | align-items: start; 63 | `; 64 | 65 | export const TaskGroupTitle = styled.h2` 66 | margin: 0; 67 | padding-bottom: 10px; 68 | border-bottom: 1px solid ${theme.colors.border}; 69 | font-size: 1.2em; 70 | `; 71 | 72 | export const NoTasksPlaceholder = styled.div` 73 | color: ${theme.colors.text.muted}; 74 | font-style: italic; 75 | margin-top: 10px; 76 | `; 77 | 78 | export const Button = styled.button` 79 | font-size: 14px; 80 | line-height: 14px; 81 | background-color: ${theme.colors.background.light}; 82 | padding-left: 4px; 83 | padding-right: 4px; 84 | padding-top: 6px; 85 | padding-bottom: 3px; 86 | border: 1px solid ${theme.colors.border}; 87 | border-radius: 4px; 88 | box-shadow: 0 2px 4px ${theme.colors.shadow}; 89 | `; 90 | 91 | export const NewTask = styled.div` 92 | position: relative; 93 | margin: 10px 0; 94 | padding: 8px; 95 | background-color: ${theme.colors.background.placeholder}; 96 | display: flex; 97 | align-items: center; 98 | border-radius: 4px; 99 | box-shadow: 0 1px 3px ${theme.colors.shadow}; 100 | `; 101 | 102 | export const NewTaskInput = styled.input.attrs({ 103 | type: "text", 104 | })` 105 | flex: 1; 106 | border: none; 107 | padding: 4px; 108 | border-radius: 4px; 109 | min-width: 100px; 110 | `; 111 | 112 | export const AddTaskButton = styled(Button)` 113 | color: ${theme.colors.success}; 114 | border: none; 115 | padding: 5px; 116 | border-radius: 4px; 117 | cursor: pointer; 118 | width: 40px; 119 | flex: 0; 120 | margin-left: 8px; 121 | `; 122 | 123 | export const RemoveTaskButton = styled(Button)` 124 | padding: 4px; 125 | border-radius: 4px; 126 | cursor: pointer; 127 | width: 40px; 128 | flex: 0; 129 | margin-left: 8px; 130 | border: 1px solid transparent; 131 | &:hover { 132 | border: 1px solid ${theme.colors.danger}; 133 | } 134 | `; 135 | 136 | export const Task = styled.div` 137 | padding: 8px; 138 | background-color: ${theme.colors.background.placeholder}; 139 | display: flex; 140 | align-items: center; 141 | justify-content: space-between; 142 | border-radius: 4px; 143 | box-shadow: 0 1px 3px ${theme.colors.shadow}; 144 | `; 145 | 146 | export const TaskTitle = styled.div` 147 | flex: 1; 148 | `; 149 | 150 | export const TaskWrapper = styled.div<{ $isDragging?: boolean }>` 151 | position: relative; 152 | margin: 10px 0; 153 | 154 | ${(props) => props.$isDragging && `opacity: 0.8; width: 300px;`} 155 | `; 156 | 157 | export const DragHandle = styled.div` 158 | cursor: move; 159 | padding: 4px 8px; 160 | user-select: none; 161 | margin-right: 10px; 162 | color: ${theme.colors.primary}; 163 | touch-action: none; 164 | `; 165 | 166 | export const TaskDropLine = styled.div<{ 167 | $active: boolean; 168 | $stopAnimation?: boolean; 169 | }>` 170 | height: 0px; 171 | transition: height 0.25s ease-out; 172 | 173 | ${(props) => props.$active && `height: 36px;`} 174 | 175 | ${(props) => props.$stopAnimation && `transition: none;`} 176 | `; 177 | -------------------------------------------------------------------------------- /packages/examples/src/sticky-squares-scroller/squares-scroller.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import "./styles.css"; 3 | 4 | import { SquareModel, squares } from "./models"; 5 | import { Square } from "./Square"; 6 | import { getMouseQuadrant } from "./utils"; 7 | 8 | import { Overlay, useDraggable, useDroppable } from "snapdrag"; 9 | import { createScroller } from "snapdrag/plugins"; 10 | import { tx } from "onek"; 11 | 12 | const borderWidth = "5px"; 13 | 14 | function getBorderStyle(position: string) { 15 | const style = { 16 | borderColor: "red", 17 | borderStyle: "solid", 18 | } as React.CSSProperties; 19 | 20 | switch (position) { 21 | case "top": 22 | style.borderTopWidth = borderWidth; 23 | break; 24 | case "right": 25 | style.borderRightWidth = borderWidth; 26 | break; 27 | case "left": 28 | style.borderLeftWidth = borderWidth; 29 | break; 30 | case "bottom": 31 | style.borderBottomWidth = borderWidth; 32 | break; 33 | default: 34 | } 35 | return style; 36 | } 37 | 38 | // Create a scroller plugin 39 | // It returns a function that takes a container element 40 | // This function can be called with new container each time 41 | // in the render loop 42 | const scroller = createScroller({ 43 | x: { threshold: 300 }, 44 | y: { threshold: 300 }, 45 | }); 46 | 47 | function TargetSquare({ model }: { model: SquareModel }) { 48 | const [style, setStyle] = useState(null); 49 | const [opacity, setOpacity] = useState(1.0); 50 | 51 | // Do a trick - at the moment of render the canvas element 52 | // might not be available. 53 | // Set it only after the render, so it will update 54 | // the draggable scroller with corrent element. 55 | const [canvasEl, setCanvasEl] = useState(null); 56 | 57 | useEffect(() => { 58 | setCanvasEl(document.getElementById("canvas")); 59 | }, []); 60 | 61 | const { draggable } = useDraggable({ 62 | kind: "SQUARE", 63 | data: { model }, 64 | move: true, 65 | onDragStart() { 66 | setOpacity(0.8); 67 | }, 68 | onDragEnd({ top, left, dropTargets }) { 69 | setOpacity(1.0); 70 | 71 | if (!canvasEl || dropTargets.length > 0) { 72 | return; 73 | } 74 | 75 | tx(() => { 76 | model.x = canvasEl.offsetLeft + canvasEl.scrollLeft + left; 77 | model.y = canvasEl.offsetTop + canvasEl.scrollTop + top; 78 | }); 79 | }, 80 | // Attach plugin to the draggable, so it will react 81 | // to its drag events and update the scroll position 82 | plugins: [scroller(canvasEl!)], 83 | }); 84 | 85 | const { droppable } = useDroppable({ 86 | accepts: "SQUARE", 87 | data: { model }, 88 | onDragOut: () => { 89 | setStyle(null); 90 | }, 91 | onDragMove: ({ event, dropElement }) => { 92 | const position = getMouseQuadrant(dropElement, event, 100); 93 | const borderStyle = getBorderStyle(position); 94 | setStyle(borderStyle); 95 | }, 96 | onDrop: ({ data, event, dropElement }) => { 97 | const targetModel = data.model; 98 | const position = getMouseQuadrant(dropElement, event, 100); 99 | 100 | // update the draggable model position 101 | // based on the position of the drop element 102 | switch (position) { 103 | case "top": 104 | targetModel.x = model.x; 105 | targetModel.y = model.y - 100; 106 | break; 107 | case "bottom": 108 | targetModel.x = model.x; 109 | targetModel.y = model.y + 100; 110 | break; 111 | case "left": 112 | targetModel.x = model.x - 100; 113 | targetModel.y = model.y; 114 | break; 115 | case "right": 116 | targetModel.x = model.x + 100; 117 | targetModel.y = model.y; 118 | break; 119 | } 120 | 121 | setStyle(null); 122 | }, 123 | }); 124 | 125 | return ( 126 |
127 | {draggable( 128 | droppable()! 129 | )} 130 |
131 | ); 132 | } 133 | 134 | function Canvas() { 135 | const height = Math.max(...squares.map((sq) => sq.y)); 136 | const width = Math.max(...squares.map((sq) => sq.x)); 137 | 138 | return ( 139 | // canvas is scrollable 140 |
141 | {/* canvasInner is bigger size than the canvas viewport */} 142 |
143 | {squares.map((model) => ( 144 | 145 | ))} 146 |
147 |
148 | ); 149 | } 150 | 151 | export default function App() { 152 | return ( 153 |
154 | 155 | 156 |
157 | ); 158 | } 159 | -------------------------------------------------------------------------------- /packages/plugins/src/scroller.ts: -------------------------------------------------------------------------------- 1 | import { PluginType } from "@snapdrag/core"; 2 | 3 | type AxisConfig = { 4 | threshold?: number; 5 | speed?: number; 6 | distancePower?: number; 7 | }; 8 | 9 | type ScrollerConfig = { 10 | x?: AxisConfig | boolean; 11 | y?: AxisConfig | boolean; 12 | }; 13 | 14 | const defaultAxisConfig: AxisConfig = { 15 | threshold: 100, 16 | speed: 2000, 17 | distancePower: 1.5, 18 | }; 19 | 20 | function getAxisConfig(axisConfig: AxisConfig | boolean) { 21 | if (typeof axisConfig === "boolean") { 22 | return { ...defaultAxisConfig } as Required; 23 | } 24 | 25 | return { ...defaultAxisConfig, ...axisConfig } as Required; 26 | } 27 | 28 | function getContainerBoundingRect(container: HTMLElement | Window, scale: number) { 29 | if (container instanceof Window) { 30 | return { 31 | top: 0, 32 | left: 0, 33 | bottom: container.innerHeight, 34 | right: container.innerWidth, 35 | }; 36 | } else { 37 | let { top, bottom, left, right } = container.getBoundingClientRect(); 38 | 39 | return { 40 | top: top * scale, 41 | bottom: bottom * scale, 42 | left: left * scale, 43 | right: right * scale, 44 | }; 45 | } 46 | } 47 | 48 | export function createScroller(config: ScrollerConfig) { 49 | return function Scroller(container: HTMLElement | Window | null): PluginType { 50 | if (!container) { 51 | return {}; 52 | } 53 | 54 | const configX = config.x ? getAxisConfig(config.x) : null; 55 | const configY = config.y ? getAxisConfig(config.y) : null; 56 | 57 | let isMouseDown = false; 58 | let lastAnimationFrame: number | null = null; 59 | let lastTimestamp: number = 0; 60 | let lastMouseX: number = 0; 61 | let lastMouseY: number = 0; 62 | let scale = 1.0; 63 | 64 | function animationLoop(timestamp: number) { 65 | if (!isMouseDown) { 66 | return; 67 | } 68 | 69 | const deltaT = timestamp - lastTimestamp; 70 | lastTimestamp = timestamp; 71 | 72 | let scrollDeltaX = 0; 73 | let scrollDeltaY = 0; 74 | 75 | const { top, bottom, left, right } = getContainerBoundingRect(container!, scale); 76 | 77 | if (configX) { 78 | const { threshold, speed, distancePower } = configX; 79 | 80 | const borderDistanceX = Math.max( 81 | threshold + left - lastMouseX, 82 | threshold - right + lastMouseX 83 | ); 84 | 85 | const scrollSpeed = 86 | Math.pow(Math.min(borderDistanceX / threshold, 1.0), distancePower) * speed; 87 | 88 | const scrollDelta = (scrollSpeed * deltaT) / 1000; 89 | 90 | if (lastMouseX < threshold - left) { 91 | scrollDeltaX = -scrollDelta; 92 | } else if (lastMouseX > right - threshold) { 93 | scrollDeltaX = scrollDelta; 94 | } 95 | } 96 | 97 | if (configY) { 98 | const { threshold, speed, distancePower } = configY; 99 | 100 | const borderDistanceX = Math.max( 101 | threshold + top - lastMouseY, 102 | threshold - bottom + lastMouseY 103 | ); 104 | 105 | const scrollSpeed = 106 | Math.pow(Math.min(borderDistanceX / threshold, 1.0), distancePower) * speed; 107 | 108 | const scrollDelta = (scrollSpeed * deltaT) / 1000; 109 | 110 | if (lastMouseY < threshold - top) { 111 | scrollDeltaY = -scrollDelta; 112 | } else if (lastMouseY > bottom - threshold) { 113 | scrollDeltaY = scrollDelta; 114 | } 115 | } 116 | 117 | // prevent scroll from firing every animation frame 118 | // if there is nothing to scroll 119 | if (scrollDeltaX !== 0 || scrollDeltaY !== 0) { 120 | container?.scrollBy(scrollDeltaX, scrollDeltaY); 121 | } 122 | 123 | lastAnimationFrame = requestAnimationFrame(animationLoop); 124 | } 125 | 126 | function onDragStart() { 127 | isMouseDown = true; 128 | lastTimestamp = performance.now(); 129 | } 130 | 131 | function onDragEnd() { 132 | isMouseDown = false; 133 | lastTimestamp = 0; 134 | 135 | if (lastAnimationFrame) { 136 | cancelAnimationFrame(lastAnimationFrame); 137 | } 138 | } 139 | 140 | function onDragMove({ event }: { event: UIEvent }) { 141 | const ratio = window.devicePixelRatio; 142 | const viewportScale = window.visualViewport ? window.visualViewport.scale : 1; 143 | 144 | scale = ratio / viewportScale; 145 | 146 | lastMouseX = (event as MouseEvent).x * scale; 147 | lastMouseY = (event as MouseEvent).y * scale; 148 | 149 | const { top, bottom, left, right } = getContainerBoundingRect(container!, scale); 150 | 151 | let shouldRun = false; 152 | 153 | if (configX) { 154 | shouldRun ||= 155 | lastMouseX < configX.threshold + left || lastMouseX > right - configX.threshold; 156 | } 157 | 158 | if (configY) { 159 | shouldRun ||= 160 | lastMouseY < configY.threshold + top || lastMouseY > bottom - configY.threshold; 161 | } 162 | 163 | if (lastAnimationFrame) { 164 | cancelAnimationFrame(lastAnimationFrame); 165 | } 166 | 167 | if (shouldRun) { 168 | lastAnimationFrame = requestAnimationFrame(animationLoop); 169 | } 170 | } 171 | 172 | function cleanup() { 173 | onDragEnd(); 174 | } 175 | 176 | return { 177 | onDragStart, 178 | onDragEnd, 179 | onDragMove, 180 | cleanup, 181 | }; 182 | }; 183 | } 184 | -------------------------------------------------------------------------------- /packages/examples/src/kanban-dashboard/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Overlay, useDraggable, useDroppable } from "snapdrag"; 3 | import { TasksProvider, useTasks } from "./store"; 4 | import { columns, projects } from "./data"; 5 | import type { ITask, IColumn, IProject } from "./data"; 6 | import * as Styled from "./styled"; 7 | 8 | const Task = ({ task }: { task: ITask }) => { 9 | const { updateTask, removeTask } = useTasks(); 10 | 11 | const [stopAnimation, setStopAnimation] = useState(false); 12 | 13 | const { draggable, dragHandle, isDragging } = useDraggable({ 14 | kind: "TASK", 15 | data: { task }, 16 | move: true, 17 | shouldDrag(props) { 18 | const { dragStartEvent, event } = props; 19 | 20 | return ( 21 | Math.abs(event.pageX - dragStartEvent.pageX) > 15 || 22 | Math.abs(event.pageY - dragStartEvent.pageY) > 15 23 | ); 24 | }, 25 | }); 26 | 27 | const { droppable, hovered } = useDroppable({ 28 | accepts: ({ kind, data }) => 29 | kind === "TASK" && data.task.project === task.project, 30 | onDrop({ data }) { 31 | setStopAnimation(true); 32 | updateTask(data.task, { status: task.status, order: task.order - 0.5 }); 33 | 34 | requestAnimationFrame(() => { 35 | setStopAnimation(false); 36 | }); 37 | }, 38 | }); 39 | 40 | return droppable( 41 | draggable( 42 | 43 | 47 | 48 | {dragHandle()} 49 | {task.title} 50 | {!isDragging && ( 51 | removeTask(task)}> 52 | ❌ 53 | 54 | )} 55 | 56 | 57 | ) 58 | ); 59 | }; 60 | 61 | type NewTaskProps = { 62 | status: IColumn["status"]; 63 | project: IProject["id"]; 64 | onSubmit: (task: Partial) => void; 65 | onCancel: () => void; 66 | }; 67 | 68 | // new task with input and add button that reacts to Enter and escape 69 | const NewTask = ({ status, project, onSubmit, onCancel }: NewTaskProps) => { 70 | const [title, setTitle] = useState(""); 71 | 72 | const handleSubmit = () => { 73 | if (title) { 74 | onSubmit({ title, status, project }); 75 | setTitle(""); 76 | } 77 | }; 78 | 79 | return ( 80 | 81 | setTitle(e.target.value)} 84 | onKeyDown={(e) => { 85 | if (e.key === "Enter") { 86 | handleSubmit(); 87 | } else if (e.key === "Escape") { 88 | onCancel(); 89 | } 90 | }} 91 | /> 92 | 93 | 94 | ); 95 | }; 96 | 97 | type TaskGroupProps = { 98 | status: IColumn["status"]; 99 | project: IProject; 100 | tasks: ITask[]; 101 | }; 102 | 103 | const TaskGroup = ({ status, project, tasks }: TaskGroupProps) => { 104 | const { addTask, updateTask } = useTasks(); 105 | 106 | const { droppable, hovered } = useDroppable({ 107 | accepts: ({ kind, data }) => 108 | kind === "TASK" && data.task.project === project.id, 109 | onDrop({ data, dropTargets }) { 110 | // we are the only drop target 111 | if (dropTargets.length === 1) { 112 | updateTask(data.task, { status, order: 1e10 }); 113 | } 114 | }, 115 | }); 116 | 117 | const [newTaskVisible, setNewTaskVisible] = useState(false); 118 | 119 | const showNewTask = () => { 120 | setNewTaskVisible(true); 121 | }; 122 | 123 | const submitNewTask = (task: Partial) => { 124 | addTask({ ...task, order: 1e10 }); 125 | setNewTaskVisible(false); 126 | }; 127 | 128 | const cancelNewTask = () => { 129 | setNewTaskVisible(false); 130 | }; 131 | 132 | return droppable( 133 | 134 | 135 | {project.title} 136 | 137 | 138 | {tasks.length > 0 && 139 | tasks.map((task) => )} 140 | {tasks.length === 0 && !newTaskVisible && ( 141 | No tasks 142 | )} 143 | {newTaskVisible && ( 144 | 150 | )} 151 | 152 | ); 153 | }; 154 | 155 | type ColumnProps = { 156 | name: IColumn["name"]; 157 | status: IColumn["status"]; 158 | tasks: ITask[]; 159 | }; 160 | 161 | const Column = ({ name, status, tasks }: ColumnProps) => { 162 | return ( 163 | 164 | {name} 165 | {projects.map((project) => ( 166 | task.project === project.id) 172 | .sort((a, b) => a.order - b.order)} 173 | /> 174 | ))} 175 | 176 | ); 177 | }; 178 | 179 | const Dashboard = () => { 180 | const { tasks } = useTasks(); 181 | 182 | return ( 183 | 184 | {columns.map((column) => ( 185 | task.status === column.status)} 190 | /> 191 | ))} 192 | 193 | ); 194 | }; 195 | 196 | export default function App() { 197 | return ( 198 | 199 | 200 | 201 | 202 | ); 203 | } 204 | -------------------------------------------------------------------------------- /packages/react/src/useDraggable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useMemo, useCallback, ReactElement } from "react"; 2 | import { createPortal } from "react-dom"; 3 | import { 4 | DraggableConfig as DraggableCoreConfig, 5 | DragStarHandlerArgs, 6 | createDraggable, 7 | PluginType, 8 | DRAGGABLE_ATTRIBUTE, 9 | } from "@snapdrag/core"; 10 | import { DraggableConfig } from "./types"; 11 | import { setDragElementPosition, OVERLAY_ID, setOverlayVisible } from "./Overlay"; 12 | import { getDropTargets } from "./utils/getDropTargets"; 13 | 14 | const DragComponent = React.forwardRef>( 15 | ({ dragComponent, placeholderComponent }, ref) => { 16 | const dragComponentWithRef = React.cloneElement(dragComponent, { ref }); 17 | 18 | return ( 19 | <> 20 | {placeholderComponent} 21 | {createPortal(dragComponentWithRef, document.getElementById(OVERLAY_ID)!)} 22 | 23 | ); 24 | } 25 | ); 26 | 27 | export function useDraggable(config: DraggableConfig) { 28 | const [isDragging, setIsDragging] = useState(false); 29 | const [data, setData] = useState(null); 30 | 31 | const refs = useRef({ 32 | dragElementSnapshot: null as React.ReactElement | null, 33 | dragElement: null as HTMLElement | null, 34 | element: null as HTMLElement | null, 35 | elementOffset: { top: 0, left: 0 }, 36 | originalRef: null as any, 37 | isDragging: false, 38 | config, 39 | data: null as any, 40 | draggableByDefault: true, 41 | }); 42 | 43 | refs.current.config = config; 44 | 45 | const shouldDrag = (props: DragStarHandlerArgs) => { 46 | const shouldDrag = config.shouldDrag?.({ 47 | event: props.event, 48 | dragStartEvent: props.dragStartEvent, 49 | element: props.dragElement, 50 | data: props.data, 51 | }); 52 | 53 | return !!shouldDrag; 54 | }; 55 | 56 | const draggableCoreConfig: DraggableCoreConfig = { 57 | disabled: config.disabled, 58 | kind: config.kind, 59 | data: config.data, 60 | shouldDrag: config.shouldDrag && shouldDrag, 61 | onDragStart(props) { 62 | const current = refs.current; 63 | 64 | const { top, left } = current.element!.getBoundingClientRect(); 65 | 66 | let offset; 67 | 68 | if (current.config.offset) { 69 | if (typeof current.config.offset === "function") { 70 | offset = current.config.offset({ 71 | element: current.element!, 72 | dragStartEvent: props.dragStartEvent, 73 | data: props.data, 74 | }); 75 | } else { 76 | offset = current.config.offset; 77 | } 78 | } else { 79 | offset = { 80 | top: top - props.dragStartEvent.clientY, 81 | left: left - props.dragStartEvent.clientX, 82 | }; 83 | } 84 | 85 | current.elementOffset = offset; 86 | current.isDragging = true; 87 | current.data = props.data; 88 | 89 | setOverlayVisible(true); 90 | setDragElementPosition({ top, left }); 91 | setIsDragging(true); 92 | setData(props.data); 93 | 94 | config.onDragStart?.({ 95 | element: props.dragElement, 96 | event: props.dragStartEvent, 97 | dragStartEvent: props.dragStartEvent, 98 | data: props.data, 99 | }); 100 | }, 101 | onDragMove(props) { 102 | const { elementOffset } = refs.current; 103 | 104 | let top = elementOffset.top + props.event.clientY; 105 | let left = elementOffset.left + props.event.clientX; 106 | 107 | if (config.mapCoords) { 108 | const coords = config.mapCoords({ 109 | top, 110 | left, 111 | event: props.event, 112 | dragStartEvent: props.dragStartEvent, 113 | element: refs.current.dragElement!, 114 | data: props.data, 115 | }); 116 | 117 | top = coords.top; 118 | left = coords.left; 119 | } 120 | 121 | setDragElementPosition({ top, left }); 122 | 123 | const dropTargets = getDropTargets(props.dropTargets); 124 | 125 | config.onDragMove?.({ 126 | event: props.event, 127 | dragStartEvent: props.dragStartEvent, 128 | element: refs.current.dragElement!, 129 | top, 130 | left, 131 | dropTargets, 132 | data: props.data, 133 | }); 134 | }, 135 | onDragEnd(props) { 136 | const current = refs.current; 137 | 138 | const top = current.elementOffset.top + props.event.clientY; 139 | const left = current.elementOffset.left + props.event.clientX; 140 | 141 | current.dragElementSnapshot = null; 142 | current.isDragging = false; 143 | current.elementOffset = { top: 0, left: 0 }; 144 | 145 | setOverlayVisible(false); 146 | setIsDragging(false); 147 | setData(null); 148 | setDragElementPosition({ top: 0, left: 0 }); 149 | 150 | const dropTargets = getDropTargets(props.dropTargets); 151 | 152 | config.onDragEnd?.({ 153 | top, 154 | left, 155 | event: props.event, 156 | dragStartEvent: props.dragStartEvent, 157 | element: refs.current.dragElement!, 158 | data: props.data, 159 | dropTargets, 160 | }); 161 | }, 162 | pointerConfig: config.pointerConfig, 163 | plugins: config.plugins as PluginType[], 164 | }; 165 | 166 | const dragSource = useMemo(() => createDraggable(draggableCoreConfig), []); 167 | 168 | dragSource.setConfig(draggableCoreConfig); 169 | 170 | const componentRef = useCallback( 171 | (element: HTMLElement | null) => { 172 | const current = refs.current; 173 | 174 | if (element) { 175 | current.element = element; 176 | 177 | dragSource.listen(element); 178 | 179 | if (!refs.current.draggableByDefault) { 180 | element.setAttribute(DRAGGABLE_ATTRIBUTE, "false"); 181 | } 182 | } 183 | 184 | const ref = current.originalRef; 185 | 186 | if (typeof ref === "function") { 187 | ref(element); 188 | } else if (ref && ref.hasOwnProperty("current")) { 189 | ref.current = element; 190 | } 191 | }, 192 | [dragSource] 193 | ); 194 | 195 | const dragComponentRef = useCallback( 196 | (element: HTMLElement | null) => { 197 | if (element) { 198 | dragSource.listen(element); 199 | 200 | if (!refs.current.draggableByDefault) { 201 | element.setAttribute(DRAGGABLE_ATTRIBUTE, "false"); 202 | } 203 | } 204 | 205 | refs.current.dragElement = element; 206 | }, 207 | [dragSource] 208 | ); 209 | 210 | const draggable = useCallback( 211 | (child: React.ReactElement>) => { 212 | if (!child) { 213 | return null; 214 | } 215 | 216 | const current = refs.current; 217 | 218 | // @ts-ignore React 16-19+ refs compatibility. 219 | current.originalRef = child.props?.ref ?? child.ref; 220 | 221 | const clone = React.cloneElement(child, { ref: componentRef }); 222 | 223 | if (!current.dragElementSnapshot) { 224 | current.dragElementSnapshot = clone; 225 | } 226 | 227 | if (current.isDragging) { 228 | let dragComponent = 229 | current.config.component?.({ data: current.data, props: child.props }) ?? child; 230 | 231 | dragComponent = React.cloneElement(dragComponent, { ref: dragComponentRef }); 232 | 233 | let placeholderComponent: ReactElement | null = current.dragElementSnapshot; 234 | 235 | if (current.config.placeholder) { 236 | placeholderComponent = 237 | current.config.placeholder?.({ data: current.data, props: child.props }) ?? null; 238 | } 239 | 240 | if (current.config.move) { 241 | placeholderComponent = null; 242 | } 243 | 244 | return ( 245 | 249 | ); 250 | } 251 | 252 | return clone; 253 | }, 254 | [componentRef, dragComponentRef] 255 | ); 256 | 257 | const handleRef = useCallback((element: HTMLElement | null) => { 258 | if (element) { 259 | element.setAttribute(DRAGGABLE_ATTRIBUTE, "true"); 260 | } 261 | }, []); 262 | 263 | const dragHandle = useCallback( 264 | (component: React.ReactElement>) => { 265 | if (!component) { 266 | return null; 267 | } 268 | 269 | refs.current.draggableByDefault = false; 270 | 271 | return React.cloneElement(component, { 272 | ref: handleRef, 273 | }); 274 | }, 275 | [] 276 | ); 277 | 278 | return { 279 | draggable, 280 | dragHandle, 281 | isDragging, 282 | data, 283 | }; 284 | } 285 | -------------------------------------------------------------------------------- /packages/core/src/draggable.ts: -------------------------------------------------------------------------------- 1 | import { DRAGGABLE_ATTRIBUTE, DROPPABLE_ATTRIBUTE, DROPPABLE_FORCE_ATTRIBUTE } from "./constants"; 2 | import { registeredDropTargets } from "./droppable"; 3 | import { 4 | Destructor, 5 | DraggableConfig, 6 | DraggableDataFactory, 7 | DragStarHandlerArgs, 8 | DropHandlerArgs, 9 | DropTargetsMap, 10 | IDraggable, 11 | IDroppable, 12 | PluginType, 13 | } from "./types"; 14 | import { 15 | defaultPointerDownHandler, 16 | defaultPointerMoveHandler, 17 | defaultPointerUpHandler, 18 | defaultPointerCancelHandler, 19 | } from "./utils/defaultPointerHandlers"; 20 | 21 | type PartialDropArgs = Omit; 22 | 23 | export class Draggable implements IDraggable { 24 | private _dragElement: HTMLElement | null = null; 25 | 26 | private _dragStartTriggered = false; 27 | 28 | private _dragStartEvent: PointerEvent | null = null; 29 | 30 | private _newDropTargets: DropTargetsMap = new Map(); 31 | 32 | private _currentDropTargets: DropTargetsMap = new Map(); 33 | 34 | private _pointerEventsDestructor: Destructor | null = null; 35 | 36 | private _pluginsSnapshot: PluginType[] = []; 37 | 38 | private _currentData: any | null = null; 39 | 40 | private _disabledDropTargets = new Set(); 41 | 42 | private _acceptedDropTargets = new Map(); 43 | 44 | constructor(public config: DraggableConfig) {} 45 | 46 | private _getDropTargets(event: PointerEvent): DropTargetsMap { 47 | const targetElementsList = document.elementsFromPoint(event.x, event.y); 48 | 49 | const targetElements = (targetElementsList ?? []) as HTMLElement[]; 50 | 51 | const dropTargets = new Map() as DropTargetsMap; 52 | 53 | targetElements.forEach((element) => { 54 | const dropTargetAttribute = element.getAttribute(DROPPABLE_ATTRIBUTE); 55 | 56 | if (!dropTargetAttribute || dropTargetAttribute === "false") { 57 | return; 58 | } 59 | 60 | const dropTarget = registeredDropTargets.get(element); 61 | 62 | if (dropTarget) { 63 | dropTargets.set(element, dropTarget); 64 | } 65 | }); 66 | 67 | return dropTargets; 68 | } 69 | 70 | private _handleDragStart(event: PointerEvent) { 71 | const dragStartArgs: DragStarHandlerArgs = { 72 | event, 73 | dragElement: this._dragElement!, 74 | dragStartEvent: this._dragStartEvent!, 75 | data: this._currentData!, 76 | }; 77 | 78 | if (this.config.shouldDrag && !this.config.shouldDrag(dragStartArgs)) { 79 | return false; 80 | } 81 | 82 | this.config.onDragStart?.(dragStartArgs); 83 | 84 | this._pluginsSnapshot.forEach((plugin) => { 85 | plugin.onDragStart?.(dragStartArgs); 86 | }); 87 | 88 | return true; 89 | } 90 | 91 | private _handleDragMove(event: PointerEvent) { 92 | const dragMoveArgs = { 93 | event, 94 | dragElement: this._dragElement!, 95 | dragStartEvent: this._dragStartEvent!, 96 | dropTargets: this._acceptedDropTargets, 97 | data: this._currentData!, 98 | }; 99 | 100 | this.config.onDragMove?.(dragMoveArgs); 101 | 102 | this._pluginsSnapshot.forEach((plugin) => { 103 | plugin.onDragMove?.(dragMoveArgs); 104 | }); 105 | } 106 | 107 | private _handleDragSourceEnd(event: PointerEvent) { 108 | const dropArgs = this._getDropHandlerArgs(event); 109 | 110 | this._acceptedDropTargets.forEach((dropTarget, dropElement) => { 111 | dropTarget.config.onDrop?.({ 112 | ...dropArgs, 113 | dropTarget, 114 | dropElement, 115 | }); 116 | }); 117 | 118 | const dragEndArgs = { 119 | event, 120 | dragElement: dropArgs.dragElement, 121 | dragStartEvent: dropArgs.dragStartEvent, 122 | dropTargets: dropArgs.dropTargets, 123 | data: this._currentData!, 124 | }; 125 | 126 | this.config.onDragEnd?.(dragEndArgs); 127 | 128 | this._pluginsSnapshot.forEach((plugin) => { 129 | plugin.onDragEnd?.(dragEndArgs); 130 | }); 131 | } 132 | 133 | private _getDropHandlerArgs(event: PointerEvent) { 134 | return { 135 | event, 136 | dragElement: this._dragElement!, 137 | dragStartEvent: this._dragStartEvent!, 138 | dropTargets: this._acceptedDropTargets, 139 | sourceType: this.config.kind, 140 | sourceData: this._currentData!, 141 | } as PartialDropArgs; 142 | } 143 | 144 | private _handleTargetDragInOrMove(dropHandlerArgs: PartialDropArgs) { 145 | this._acceptedDropTargets.forEach((dropTarget, dropElement) => { 146 | const args = { 147 | ...dropHandlerArgs, 148 | dropTarget, 149 | dropElement, 150 | }; 151 | 152 | if (this._currentDropTargets.has(dropElement)) { 153 | dropTarget.config.onDragMove?.(args); 154 | } else { 155 | dropTarget.config.onDragIn?.(args); 156 | } 157 | }); 158 | } 159 | 160 | private _handleTargetDragOut(dropHandlerArgs: PartialDropArgs) { 161 | this._currentDropTargets.forEach((dropTarget, dropElement) => { 162 | if (this._newDropTargets.has(dropElement)) { 163 | return; 164 | } 165 | 166 | this._acceptedDropTargets.delete(dropElement); 167 | 168 | if (this._disabledDropTargets.delete(dropElement)) { 169 | return; 170 | } 171 | 172 | dropTarget.config.onDragOut?.({ 173 | ...dropHandlerArgs, 174 | dropTarget, 175 | dropElement, 176 | }); 177 | }); 178 | } 179 | 180 | private _pointerDownHandler(event: PointerEvent) { 181 | if (!event.isPrimary) { 182 | return; 183 | } 184 | 185 | const { disabled, data, pointerConfig } = this.config; 186 | 187 | if (disabled) { 188 | return; 189 | } 190 | 191 | const target = event.target as HTMLElement; 192 | 193 | const dragElement = target.closest(`[${DRAGGABLE_ATTRIBUTE}]`) as HTMLElement; 194 | 195 | if (!dragElement || dragElement.getAttribute(DRAGGABLE_ATTRIBUTE) === "false") { 196 | return; 197 | } 198 | 199 | this._dragElement = dragElement; 200 | this._dragStartEvent = event; 201 | 202 | this._currentData = 203 | typeof data === "function" 204 | ? (data as DraggableDataFactory)({ dragElement, dragStartEvent: event }) 205 | : data; 206 | 207 | const pointerMoveHandler = pointerConfig?.pointerMove ?? defaultPointerMoveHandler; 208 | const pointerUpHandler = pointerConfig?.pointerUp ?? defaultPointerUpHandler; 209 | const pointerCancelHandler = pointerConfig?.pointerCancel ?? defaultPointerCancelHandler; 210 | 211 | const pointerMoveDestructor = pointerMoveHandler(this._safePointerMoveHandler); 212 | const pointerUpDestructor = pointerUpHandler(this._safePointerUpHandler); 213 | const pointerCancelDestructor = pointerCancelHandler(this._safePointerCancelHandler); 214 | 215 | this._pointerEventsDestructor = () => { 216 | pointerMoveDestructor(); 217 | pointerUpDestructor(); 218 | pointerCancelDestructor(); 219 | }; 220 | 221 | this._pluginsSnapshot = this.config.plugins?.slice() ?? []; 222 | } 223 | 224 | private _populateDisabledDropTargets(event: PointerEvent) { 225 | this._newDropTargets.forEach((dropTarget, element) => { 226 | if (this._disabledDropTargets.has(element)) { 227 | return; 228 | } 229 | 230 | if (dropTarget.disabled || element.closest(`[${DROPPABLE_FORCE_ATTRIBUTE}="false"]`)) { 231 | this._disabledDropTargets.add(element); 232 | return; 233 | } 234 | 235 | const { accepts } = dropTarget.config; 236 | 237 | let shouldAccept = true; 238 | 239 | if (Array.isArray(accepts)) { 240 | shouldAccept = accepts.includes(this.config.kind); 241 | } else if (typeof accepts === "function") { 242 | if (!this._acceptedDropTargets.has(element)) { 243 | shouldAccept = accepts({ 244 | kind: this.config.kind, 245 | data: this._currentData!, 246 | element: this._dragElement!, 247 | event, 248 | }); 249 | } 250 | } else { 251 | shouldAccept = accepts === this.config.kind; 252 | } 253 | 254 | if (!shouldAccept) { 255 | this._disabledDropTargets.add(element); 256 | return; 257 | } 258 | 259 | this._acceptedDropTargets.set(element, dropTarget); 260 | }); 261 | } 262 | 263 | private _pointerMoveHandler(event: PointerEvent) { 264 | if (!event.isPrimary) { 265 | return; 266 | } 267 | 268 | if (!this._dragStartTriggered) { 269 | if (!this._handleDragStart(event)) { 270 | return; 271 | } 272 | 273 | this._dragStartTriggered = true; 274 | } 275 | 276 | this._newDropTargets = this._getDropTargets(event); 277 | 278 | this._populateDisabledDropTargets(event); 279 | 280 | const dropHandlerArgs = this._getDropHandlerArgs(event); 281 | 282 | this._handleTargetDragInOrMove(dropHandlerArgs); 283 | 284 | this._handleTargetDragOut(dropHandlerArgs); 285 | 286 | this._handleDragMove(event); 287 | 288 | this._currentDropTargets = this._newDropTargets; 289 | } 290 | 291 | private _pointerUpHandler(event: PointerEvent) { 292 | if (!event.isPrimary) { 293 | return; 294 | } 295 | 296 | if (this._dragStartTriggered) { 297 | this._handleDragSourceEnd(event); 298 | } 299 | 300 | this._cleanup(); 301 | } 302 | 303 | private _pointerCancelHandler(event: PointerEvent) { 304 | if (!event.isPrimary) { 305 | return; 306 | } 307 | 308 | if (this._dragStartTriggered) { 309 | // Simulate drag out for current targets before ending 310 | const dropHandlerArgs = this._getDropHandlerArgs(event); 311 | this._handleTargetDragOut(dropHandlerArgs); 312 | // Trigger drag end for the source 313 | this._handleDragSourceEnd(event); 314 | } 315 | this._cleanup(); 316 | } 317 | 318 | private _cleanup() { 319 | this._pointerEventsDestructor?.(); 320 | this._pointerEventsDestructor = null; 321 | 322 | this._dragElement = null; 323 | this._dragStartTriggered = false; 324 | this._newDropTargets.clear(); 325 | this._currentDropTargets.clear(); 326 | this._currentData = null; 327 | 328 | this._disabledDropTargets.clear(); 329 | this._acceptedDropTargets.clear(); 330 | 331 | this._pluginsSnapshot.forEach((plugin) => { 332 | plugin.cleanup?.(); 333 | }); 334 | 335 | this._pluginsSnapshot = []; 336 | } 337 | 338 | private _safePointerMoveHandler = (event: PointerEvent) => { 339 | try { 340 | this._pointerMoveHandler(event); 341 | } catch (err) { 342 | this._cleanup(); 343 | 344 | throw err; 345 | } 346 | }; 347 | 348 | private _safePointerUpHandler = (event: PointerEvent) => { 349 | try { 350 | this._pointerUpHandler(event); 351 | } catch (err) { 352 | this._cleanup(); 353 | 354 | throw err; 355 | } 356 | }; 357 | 358 | private _safePointerCancelHandler = (event: PointerEvent) => { 359 | try { 360 | this._pointerCancelHandler(event); 361 | } catch (err) { 362 | this._cleanup(); 363 | 364 | throw err; 365 | } 366 | }; 367 | 368 | private _safePointerDownHandler = (event: PointerEvent) => { 369 | try { 370 | this._pointerDownHandler(event); 371 | } catch (err) { 372 | this._cleanup(); 373 | 374 | throw err; 375 | } 376 | }; 377 | 378 | public setConfig = (config: DraggableConfig) => { 379 | this.config = config; 380 | }; 381 | 382 | public listen = (element: HTMLElement, setAttribute = true): Destructor => { 383 | const pointerDownHandler = this.config.pointerConfig?.pointerDown ?? defaultPointerDownHandler; 384 | 385 | const pointerDownDestructor = pointerDownHandler(element, this._safePointerDownHandler); 386 | 387 | if (setAttribute && !element.hasAttribute(DRAGGABLE_ATTRIBUTE)) { 388 | element.setAttribute(DRAGGABLE_ATTRIBUTE, "true"); 389 | } 390 | 391 | return () => { 392 | this._cleanup(); 393 | 394 | pointerDownDestructor(); 395 | 396 | if (setAttribute) { 397 | element.removeAttribute(DRAGGABLE_ATTRIBUTE); 398 | } 399 | }; 400 | }; 401 | } 402 | 403 | export function createDraggable(config: T) { 404 | return new Draggable(config); 405 | } 406 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Snapdrag 3 |

4 | 5 |

6 | ⚡️ Simple yet powerful drag-and-drop for React and Vanilla JS ⚡️ 7 |

8 | 9 |

10 | Snapdrag in action 11 |

12 | 13 | ## What is Snapdrag? 14 | 15 | **Snapdrag** is an alternative vision of how drag-and-drop should be done in React - simple, intuitive, and performant. With just two hooks and an overlay component you can build rich drag-and-drop interactions - starting from simple squares, ending with scrollable and sortable multi-lists. 16 | 17 | Snapdrag is built on top of `snapdrag/core`, a universal building block that works with any framework or vanilla JavaScript. 18 | 19 | ## Key Features 20 | 21 | - 🚀 **Minimal, modern API:** just two hooks and one overlay component 22 | - 🎛️ **Full control:** granular event callbacks for every drag stage 23 | - 🔄 **Two-way data flow:** draggables and droppables exchange data seamlessly 24 | - 🗂️ **Multiple drop targets:** supports overlapping and nested zones 25 | - 🔌 **Plugins system:** easily extend functionality 26 | - 🛑 **No HTML5 DnD:** consistent, reliable behavior across browsers 27 | - ⚡️ **Built for performance and extensibility** 28 | 29 | ## TL;DR 30 | 31 | ```tsx 32 | import { useDraggable, useDroppable, Overlay } from "snapdrag"; 33 | import "./styles.css"; 34 | 35 | const App = () => { 36 | const { draggable } = useDraggable({ 37 | kind: "SQUARE", 38 | data: { color: "red" }, 39 | move: true, 40 | }); 41 | 42 | const { droppable } = useDroppable({ 43 | accepts: "SQUARE", 44 | onDrop({ data }) { 45 | alert(`Dropped ${data.color} square`); 46 | }, 47 | }); 48 | 49 | return ( 50 |
51 |
52 | {draggable(
Drag me
)} 53 |
54 |
55 | {droppable(
Drop on me
)} 56 |
57 | 58 |
59 | ); 60 | }; 61 | ``` 62 | 63 | Result: 64 | 65 |

66 | TL;DR example 67 |

68 | 69 | ## Table of Contents 70 | 71 | - [Installation](#installation) 72 | - [Basic Concepts](#basic-concepts) 73 | - [Quick Start Example](#quick-start-example) 74 | - [How Snapdrag Works](#how-snapdrag-works) 75 | - [Core Components](#core-components) 76 | - [useDraggable](#usedraggable) 77 | - [useDroppable](#usedroppable) 78 | - [Overlay](#overlay) 79 | - [Draggable Lifecycle](#draggable-lifecycle) 80 | - [Droppable Lifecycle](#droppable-lifecycle) 81 | - [Common Patterns](#common-patterns) 82 | - [Examples](#examples) 83 | - [Basic: Colored Squares](#basic-colored-squares) 84 | - [Intermediate: Simple List](#intermediate-simple-list) 85 | - [Advanced: List with Animations](#advanced-list-with-animations) 86 | - [Expert: Kanban Board](#expert-kanban-board) 87 | - [API Reference](#api-reference) 88 | - [useDraggable Configuration](#usedraggable-configuration) 89 | - [useDroppable Configuration](#usedroppable-configuration) 90 | - [Plugins](#plugins) 91 | - [Browser Compatibility](#browser-compatibility) 92 | - [License](#license) 93 | - [Author](#author) 94 | 95 | ## Installation 96 | 97 | ```bash 98 | # npm 99 | npm install --save snapdrag 100 | 101 | # yarn 102 | yarn add snapdrag 103 | ``` 104 | 105 | ## Basic Concepts 106 | 107 | Snapdrag is built around three core components: 108 | 109 | - **`useDraggable`** - A hook that makes any React element draggable 110 | - **`useDroppable`** - A hook that makes any React element a potential drop target 111 | - **`Overlay`** - A component that renders the dragged element during drag operations 112 | 113 | The fundamental relationship works like this: 114 | 115 | 1. Each draggable has a **`kind`** (like "CARD" or "ITEM") that identifies what type of element it is 116 | 2. Each droppable specifies what **`kind`** it **`accepts`** through its configuration 117 | 3. They exchange **`data`** during interactions, allowing for rich behaviors and communication 118 | 119 | When a draggable is over a compatible droppable, they can exchange information. This unlocks dynamic behaviors such as highlighting, sorting, or visually transforming elements based on the ongoing interaction. 120 | 121 | ## Quick Start Example 122 | 123 | Here is more comprehensive example that demonstrate the lifecycle of draggable and droppable items. Usually you need to use only subset of that, but we will show almost every callback for clarity. 124 | 125 |

126 | Simple drag-and-drop squares 127 |

128 | 129 | **DraggableSquare.tsx** 130 | 131 | ```tsx 132 | import { useState } from "react"; 133 | import { useDraggable } from "snapdrag"; 134 | 135 | export const DraggableSquare = ({ color }: { color: string }) => { 136 | const [text, setText] = useState("Drag me"); 137 | const { draggable, isDragging } = useDraggable({ 138 | kind: "SQUARE", 139 | data: { color }, 140 | move: true, 141 | // Callbacks are totally optional 142 | onDragStart({ data }) { 143 | // data is the own data of the draggable 144 | setText(`Dragging ${data.color}`); 145 | }, 146 | onDragMove({ dropTargets }) { 147 | // Check if there are any drop targets under the pointer 148 | if (dropTargets.length > 0) { 149 | // Update the text based on the first drop target color 150 | setText(`Over ${dropTargets[0].data.color}`); 151 | } else { 152 | setText("Dragging..."); 153 | } 154 | }, 155 | onDragEnd({ dropTargets }) { 156 | // Check if the draggable was dropped on a valid target 157 | if (dropTargets.length > 0) { 158 | setText(`Dropped on ${dropTargets[0].data.color}`); 159 | } else { 160 | setText("Drag me"); 161 | } 162 | }, 163 | }); 164 | 165 | const opacity = isDragging ? 0.5 : 1; 166 | 167 | return draggable( 168 |
169 | {text} 170 |
171 | ); 172 | }; 173 | ``` 174 | 175 | **DroppableSquare.tsx** 176 | 177 | ```tsx 178 | import { useState } from "react"; 179 | import { useDroppable } from "snapdrag"; 180 | 181 | export const DroppableSquare = ({ color }: { color: string }) => { 182 | const [text, setText] = useState("Drop here"); 183 | 184 | const { droppable } = useDroppable({ 185 | accepts: "SQUARE", 186 | data: { color }, 187 | // Optional callbacks 188 | onDragIn({ data }) { 189 | // Some draggable is hovering over this droppable 190 | // data is the data of the draggable 191 | setText(`Hovered over ${data.color}`); 192 | }, 193 | onDragOut() { 194 | // The draggable is no longer hovering over this droppable 195 | setText("Drop here"); 196 | }, 197 | onDrop({ data }) { 198 | // Finally, the draggable is dropped on this droppable 199 | setText(`Dropped ${data.color}`); 200 | }, 201 | }); 202 | 203 | return droppable( 204 |
205 | {text} 206 |
207 | ); 208 | }; 209 | ``` 210 | 211 | **App.tsx** 212 | 213 | ```tsx 214 | import { Overlay } from "snapdrag"; 215 | 216 | export default function App() { 217 | return ( 218 |
219 | {/* Just two squares for simplicity */} 220 |
221 | 222 |
223 |
224 | 225 |
226 | 227 | {/* Render overlay to show the dragged component */} 228 | 229 |
230 | ); 231 | } 232 | ``` 233 | 234 | This example on [CodeSandbox](https://codesandbox.io/p/sandbox/snapdrag-simple-squares-8rw96s) 235 | 236 | ## How Snapdrag Works 237 | 238 | Under the hood, Snapdrag takes a different approach than traditional drag-and-drop libraries: 239 | 240 | 1. **Event Listening**: Snapdrag attaches a `pointerdown` event listener to draggable elements 241 | 2. **Tracking Movement**: Once triggered, it tracks `pointermove` events on the document until `pointerup` occurs 242 | 3. **Finding Targets**: On every move, it uses `document.elementsFromPoint()` to check what elements are under the cursor 243 | 4. **Target Handling**: It then determines which droppable elements are valid targets and manages the interaction 244 | 5. **Event Firing**: Appropriate callbacks are fired based on the current state of the drag operation 245 | 246 | Unlike HTML5 drag-and-drop which has limited customization options, Snapdrag gives you control over every aspect of the drag experience. 247 | 248 | You can change settings of draggable and droppable at any time during the drag operation, making Snapdrag extremely flexible. Want to dynamically change what a draggable can do based on its current position? No problem! 249 | 250 | ## Core Components 251 | 252 | ### `useDraggable` 253 | 254 | The `useDraggable` hook makes any React element draggable. It returns an object with two properties: 255 | 256 | - `draggable`: A function that wraps your component, making it draggable 257 | - `isDragging`: A boolean indicating if the element is currently being dragged 258 | 259 | Basic usage: 260 | 261 | ```tsx 262 | const DraggableItem = () => { 263 | const { draggable, isDragging } = useDraggable({ 264 | kind: "ITEM", // Required: identifies this draggable type 265 | data: { id: "123" }, // Optional: data to share during drag operations 266 | move: true, // Optional: move vs clone during dragging 267 | }); 268 | 269 | return draggable(
Drag me!
); 270 | }; 271 | ``` 272 | 273 | **Important Note**: The wrapped component must accept a `ref` to the DOM node to be draggable. If you already have a ref, Snapdrag will handle it correctly: 274 | 275 | ```jsx 276 | const myRef = useRef(null); 277 | 278 | const { draggable } = useDraggable({ 279 | kind: "ITEM", 280 | }); 281 | 282 | // Both refs work correctly 283 | return draggable(
); 284 | ``` 285 | 286 | You can even make an element both draggable and droppable: 287 | 288 | ```jsx 289 | const { draggable } = useDraggable({ kind: "ITEM" }); 290 | const { droppable } = useDroppable({ accepts: "ITEM" }); 291 | 292 | // Combine the wrappers (order doesn't matter) 293 | return draggable(droppable(
I'm both!
)); 294 | ``` 295 | 296 | ### `useDroppable` 297 | 298 | The `useDroppable` hook makes any React element a potential drop target. It returns: 299 | 300 | - `droppable`: A function that wraps your component, making it a drop target 301 | - `hovered`: Data about the draggable currently hovering over this element (or `null` if none) 302 | 303 | Basic usage: 304 | 305 | ```jsx 306 | const DropZone = () => { 307 | const { droppable, hovered } = useDroppable({ 308 | accepts: "ITEM", // Required: which draggable kinds to accept 309 | data: { zone: "main" }, // Optional: data to share with draggables 310 | onDrop({ data }) { 311 | // Optional: handle successful drops 312 | console.log("Dropped item:", data.id); 313 | }, 314 | }); 315 | 316 | // Change appearance when being hovered 317 | const isHovered = Boolean(hovered); 318 | 319 | return droppable(
Drop here
); 320 | }; 321 | ``` 322 | 323 | ### `Overlay` 324 | 325 | The `Overlay` component renders the currently dragged element. It should be included once in your application: 326 | 327 | ```tsx 328 | import { Overlay } from "snapdrag"; 329 | 330 | function App() { 331 | return ( 332 |
333 | {/* Your app content */} 334 | 335 | 336 | {/* Required: Shows the dragged element */} 337 | 338 |
339 | ); 340 | } 341 | ``` 342 | 343 | You can add your own classes and styles to the overlay to make it fit your application. 344 | 345 | ## Draggable Lifecycle 346 | 347 | The draggable component goes through a lifecycle during drag interactions, with callbacks at each stage. 348 | 349 | ### `onDragStart` 350 | 351 | Called when the drag operation begins (after the user clicks and begins moving, and after `shouldDrag` if provided, returns `true`): 352 | 353 | ```jsx 354 | const { draggable } = useDraggable({ 355 | kind: "CARD", 356 | onDragStart({ data, event, dragStartEvent, element }) { 357 | console.log("Started dragging card:", data.id); 358 | // Setup any state needed during dragging 359 | }, 360 | }); 361 | ``` 362 | 363 | The callback receives an object with the following properties: 364 | 365 | - `data`: The draggable's data (from the `data` config option of `useDraggable`). 366 | - `event`: The `PointerEvent` that triggered the drag start (usually the first `pointermove` after `pointerdown` and `shouldDrag` validation). 367 | - `dragStartEvent`: The initial `PointerEvent` from `pointerdown` that initiated the drag attempt. 368 | - `element`: The DOM element that is being dragged (this is the element rendered in the `Overlay`). 369 | 370 | ### `onDragMove` 371 | 372 | Called on every pointer movement during dragging: 373 | 374 | ```jsx 375 | const { draggable } = useDraggable({ 376 | kind: "CARD", 377 | onDragMove({ dropTargets, top, left, data, event, dragStartEvent, element }) { 378 | // dropTargets contains info about all drop targets under the pointer 379 | if (dropTargets.length > 0) { 380 | console.log("Over drop zone:", dropTargets[0].data.zone); 381 | } 382 | 383 | // top and left are the screen coordinates of the draggable 384 | console.log(`Position: ${left}px, ${top}px`); 385 | }, 386 | }); 387 | ``` 388 | 389 | In addition to the properties from `onDragStart` (`data`, `dragStartEvent`, `element`), this callback receives: 390 | 391 | - `event`: The current `PointerEvent` from the `pointermove` handler. 392 | - `dropTargets`: An array of objects, each representing a droppable target currently under the pointer. Each object contains: 393 | - `data`: The `data` associated with the droppable (from its `useDroppable` configuration). 394 | - `element`: The DOM element of the droppable. 395 | - `top`: The calculated top screen coordinate of the draggable element in the overlay. 396 | - `left`: The calculated left screen coordinate of the draggable element in the overlay. 397 | 398 | **Note**: This callback is called frequently, so avoid expensive operations here. 399 | 400 | ### `onDragEnd` 401 | 402 | Called when the drag operation completes (on `pointerup`): 403 | 404 | ```jsx 405 | const { draggable } = useDraggable({ 406 | kind: "CARD", 407 | onDragEnd({ dropTargets, top, left, data, event, dragStartEvent, element }) { 408 | if (dropTargets.length > 0) { 409 | console.log("Dropped on:", dropTargets[0].data.zone); 410 | } else { 411 | console.log("Dropped outside of any drop zone"); 412 | // Handle "cancel" logic 413 | } 414 | }, 415 | }); 416 | ``` 417 | 418 | Receives the same properties as `onDragMove` (`data`, `event`, `dragStartEvent`, `element`, `dropTargets`, `top`, `left`). 419 | If the user dropped the element on valid drop targets, `dropTargets` will contain them; otherwise, it will be an empty array. 420 | The `top` and `left` coordinates represent the final position of the draggable in the overlay just before it's hidden. 421 | 422 | ## Droppable Lifecycle 423 | 424 | The droppable component also has lifecycle events during drag interactions. All droppable callbacks receive a `dropTargets` array, similar to the one in `useDraggable`'s `onDragMove` and `onDragEnd`, representing all droppables currently under the pointer. 425 | 426 | ### `onDragIn` 427 | 428 | Called when a draggable first enters this drop target: 429 | 430 | ```jsx 431 | const { droppable } = useDroppable({ 432 | accepts: "CARD", 433 | onDragIn({ kind, data, event, element, dropElement, dropTargets }) { 434 | console.log(`${kind} entered drop zone`); 435 | // Change appearance, update state, etc. 436 | }, 437 | }); 438 | ``` 439 | 440 | The callback receives an object with: 441 | 442 | - `kind`: The `kind` of the draggable that entered. 443 | - `data`: The `data` from the draggable. 444 | - `event`: The current `PointerEvent` from the `pointermove` handler. 445 | - `element`: The DOM element of the draggable. 446 | - `dropElement`: The DOM element of this droppable. 447 | - `dropTargets`: Array of all active drop targets under the pointer, including the current one. Each entry contains: 448 | - `data`: The `data` from the droppable (from its `useDroppable` configuration). 449 | - `element`: The DOM element of the droppable. 450 | 451 | This is called once when a draggable enters and can be used to trigger animations or state changes. 452 | 453 | ### `onDragMove` (Droppable) 454 | 455 | Called as a draggable moves _within_ the drop target: 456 | 457 | ```jsx 458 | const { droppable } = useDroppable({ 459 | accepts: "CARD", 460 | onDragMove({ kind, data, event, element, dropElement, dropTargets }) { 461 | // Calculate position within the drop zone 462 | const rect = dropElement.getBoundingClientRect(); 463 | const x = event.clientX - rect.left; 464 | const y = event.clientY - rect.top; 465 | 466 | console.log(`Position in drop zone: ${x}px, ${y}px`); 467 | }, 468 | }); 469 | ``` 470 | 471 | Receives the same properties as `onDragIn`. Like the draggable version, this is called frequently, so keep operations light. This is perfect for creating dynamic visual cues like highlighting different sections of your drop zone based on cursor position. 472 | 473 | ### `onDragOut` 474 | 475 | Called when a draggable leaves the drop target: 476 | 477 | ```jsx 478 | const { droppable } = useDroppable({ 479 | accepts: "CARD", 480 | onDragOut({ kind, data, event, element, dropElement, dropTargets }) { 481 | console.log(`${kind} left drop zone`); 482 | // Revert animations, update state, etc. 483 | }, 484 | }); 485 | ``` 486 | 487 | Receives the same properties as `onDragIn`. This is typically used to undo changes made in `onDragIn`. Use it to clean up and reset any visual changes you made when the draggable entered. 488 | 489 | ### `onDrop` 490 | 491 | Called when a draggable is successfully dropped on this target: 492 | 493 | ```jsx 494 | const { droppable } = useDroppable({ 495 | accepts: "CARD", 496 | onDrop({ kind, data, event, element, dropElement, dropTargets }) { 497 | console.log(`${kind} was dropped with data:`, data); 498 | // Handle the dropped item 499 | }, 500 | }); 501 | ``` 502 | 503 | Receives the same properties as `onDragIn`. This is where you implement the main logic for what happens when a drop succeeds. Update your application state, save the new position, or trigger any other business logic related to the completed drag operation. 504 | 505 | ## Common Patterns 506 | 507 | ### Two-way Data Exchange 508 | 509 | Snapdrag makes it simple for draggables and droppables to talk to each other by exchanging data in both directions: 510 | 511 | ```jsx 512 | // Draggable component accessing droppable data 513 | const { draggable } = useDraggable({ 514 | kind: "CARD", 515 | data: { id: "card-1", color: "red" }, 516 | onDragMove({ dropTargets }) { 517 | if (dropTargets.length > 0) { 518 | // Read data from the drop zone underneath 519 | const dropZoneType = dropTargets[0].data.type; 520 | console.log(`Over ${dropZoneType} zone`); 521 | } 522 | }, 523 | }); 524 | 525 | // Droppable component accessing draggable data 526 | const { droppable, hovered } = useDroppable({ 527 | accepts: "CARD", 528 | data: { type: "inbox" }, 529 | onDragIn({ data }) { 530 | console.log(`Card ${data.id} entered inbox`); 531 | }, 532 | }); 533 | ``` 534 | 535 | This pattern is especially useful for adapting the UI based on the interaction context. 536 | 537 | ### Dynamic Colors Example 538 | 539 | Here's how to create a draggable that changes color based on the droppable it's over: 540 | 541 | ```jsx 542 | // In DraggableSquare.tsx 543 | import { useState } from "react"; 544 | import { useDraggable } from "snapdrag"; 545 | 546 | export const DraggableSquare = ({ color: initialColor }) => { 547 | const [color, setColor] = useState(initialColor); 548 | 549 | const { draggable, isDragging } = useDraggable({ 550 | kind: "SQUARE", 551 | data: { color }, 552 | move: true, 553 | onDragMove({ dropTargets }) { 554 | if (dropTargets.length) { 555 | setColor(dropTargets[0].data.color); 556 | } else { 557 | setColor(initialColor); 558 | } 559 | }, 560 | onDragEnd() { 561 | setColor(initialColor); // Reset on drop 562 | }, 563 | }); 564 | 565 | return draggable( 566 |
573 | {isDragging ? "Dragging" : "Drag me"} 574 |
575 | ); 576 | }; 577 | 578 | // In DroppableSquare.tsx 579 | import { useDroppable } from "snapdrag"; 580 | 581 | export const DroppableSquare = ({ color }) => { 582 | const [text, setText] = useState("Drop here"); 583 | 584 | const { droppable } = useDroppable({ 585 | accepts: "SQUARE", 586 | data: { color }, // Share this color with draggables 587 | onDrop({ data }) { 588 | setText(`Dropped ${data.color}`); 589 | }, 590 | }); 591 | 592 | return droppable( 593 |
594 | {text} 595 |
596 | ); 597 | }; 598 | ``` 599 | 600 | ### Dynamic Border Example 601 | 602 | This example shows how to create a visual indication of where an item will be dropped: 603 | 604 | ```jsx 605 | import { useState } from "react"; 606 | import { useDroppable } from "snapdrag"; 607 | 608 | export const DroppableSquare = ({ color }) => { 609 | const [text, setText] = useState("Drop here"); 610 | const [borderPosition, setBorderPosition] = useState(""); 611 | 612 | const { droppable } = useDroppable({ 613 | accepts: "SQUARE", 614 | onDragMove({ event, dropElement }) { 615 | // Calculate which quadrant of the square the pointer is in 616 | const { top, left, height } = dropElement.getBoundingClientRect(); 617 | const x = event.clientX - left; 618 | const y = event.clientY - top; 619 | 620 | // Set border on the appropriate side 621 | if (x / y < 1.0) { 622 | if (x / (height - y) < 1.0) { 623 | setBorderPosition("borderLeft"); 624 | } else { 625 | setBorderPosition("borderBottom"); 626 | } 627 | } else { 628 | if (x / (height - y) < 1.0) { 629 | setBorderPosition("borderTop"); 630 | } else { 631 | setBorderPosition("borderRight"); 632 | } 633 | } 634 | }, 635 | onDragOut() { 636 | setBorderPosition(""); // Remove border when draggable leaves 637 | }, 638 | onDrop({ data }) { 639 | setText(`Dropped ${data.color}`); 640 | setBorderPosition(""); // Remove border after drop 641 | }, 642 | }); 643 | 644 | // Add border to appropriate side 645 | const style = { 646 | backgroundColor: color, 647 | [borderPosition]: "10px solid red", 648 | }; 649 | 650 | return droppable( 651 |
652 | {text} 653 |
654 | ); 655 | }; 656 | ``` 657 | 658 | ### Multiple Drop Targets 659 | 660 | Snapdrag handles the case where multiple drop targets overlap: 661 | 662 | ```jsx 663 | const { draggable } = useDraggable({ 664 | kind: "ITEM", 665 | onDragMove({ dropTargets }) { 666 | // Sort by order to find the topmost 667 | const sorted = [...dropTargets].sort((a, b) => b.data.order - a.data.order); 668 | 669 | if (sorted.length) { 670 | console.log(`Topmost target: ${sorted[0].data.name}`); 671 | } 672 | }, 673 | }); 674 | 675 | // ... somewere in your code 676 | 677 | const { droppable } = useDroppable({ 678 | accepts: "ITEM", 679 | data: { order: 10 }, 680 | }); 681 | ``` 682 | 683 | You also can you DOM elements to get the topmost drop target: 684 | 685 | ```tsx 686 | const { draggable } = useDraggable({ 687 | kind: "ITEM", 688 | onDragMove({ dropTargets }) { 689 | // Sort by order to find the topmost 690 | 691 | const sorted = [...dropTargets].sort((a, b) => { 692 | // access drop target element instead of data 693 | const aIndex = a.element.getComputedStyle().zIndex || 0; 694 | const bIndex = b.element.getComputedStyle().zIndex || 0; 695 | 696 | return bIndex - aIndex; 697 | }); 698 | 699 | if (sorted.length) { 700 | console.log(`Topmost target: ${sorted[0].data.name}`); 701 | } 702 | }, 703 | }); 704 | ``` 705 | 706 | ### Drag Threshold 707 | 708 | For finer control, you can start dragging only after the pointer has moved a certain distance: 709 | 710 | ```jsx 711 | const { draggable } = useDraggable({ 712 | kind: "ITEM", 713 | shouldDrag({ event, dragStartEvent }) { 714 | // Calculate distance from start position 715 | const dx = event.clientX - dragStartEvent.clientX; 716 | const dy = event.clientY - dragStartEvent.clientY; 717 | const distance = Math.sqrt(dx * dx + dy * dy); 718 | 719 | // Only start dragging after moving 5px 720 | return distance > 5; 721 | }, 722 | }); 723 | ``` 724 | 725 | ### Touch Support 726 | 727 | Snapdrag supports touch events out of the box. It uses `PointerEvent` to handle both mouse and touch interactions seamlessly. You can use the same API for both types of events. 728 | 729 | To make your draggable elements touch-friendly, ensure they are touchable (e.g., using `touch-action: none` in CSS). The container can have `touch-action: pan-x` or `touch-action: pan-y` to allow scrolling while dragging. 730 | 731 | ## Examples 732 | 733 | Snapdrag includes several examples that demonstrate its capabilities, from simple to complex use cases. 734 | 735 | ### Basic: Colored Squares 736 | 737 | The simplest example shows dragging a colored square onto a drop target: 738 | 739 |

740 | Simple squares 741 |

742 | 743 | This demonstrates the fundamentals of drag-and-drop with Snapdrag: 744 | 745 | - Defining a draggable with `kind` and `data` 746 | - Creating a drop target that `accepts` the draggable 747 | - Handling the `onDrop` event 748 | 749 | [Try it on CodeSandbox](https://codesandbox.io/p/sandbox/snapdrag-simple-squares-8rw96s) 750 | 751 | ### Intermediate: Simple List 752 | 753 | A sortable list where items can be reordered by dragging: 754 | 755 |

756 | Simple List 757 |

758 | 759 | This example demonstrates: 760 | 761 | - Using data to identify list items 762 | - Visual feedback during dragging (blue insertion line) 763 | - Reordering items in a state array on drop 764 | 765 | [Try it on CodeSandbox](https://codesandbox.io/p/sandbox/snapdrag-simple-list-w4njk5) 766 | 767 | ### Advanced: List with Animations 768 | 769 | A more sophisticated list with smooth animations: 770 | 771 |

772 | Advanced list 773 |

774 | 775 | This example showcases: 776 | 777 | - CSS transitions for smooth animations 778 | - A special drop area for appending items to the end 779 | - Animated placeholders that create space for dropped items 780 | 781 | [Try it on CodeSandbox](https://codesandbox.io/p/sandbox/snapdrag-advanced-list-5p44wd) 782 | 783 | ### Expert: Kanban Board 784 | 785 | A full kanban board with multiple columns and draggable cards: 786 | 787 |

788 | Kanban Board 789 |

790 | 791 | This complex example demonstrates advanced features: 792 | 793 | - Multiple drop targets with different behaviors 794 | - Conditional acceptance of draggables 795 | - Smooth animations during drag operations 796 | - Two-way data exchange between components 797 | - Touch support with drag threshold 798 | - Item addition and removal 799 | 800 | All this is achieved in just about 200 lines of code (excluding state management and styling). 801 | 802 | [Try it on CodeSandbox](https://codesandbox.io/p/sandbox/snapdrag-kanban-board-jlj4wc) 803 | 804 | ## API Reference 805 | 806 | ### `useDraggable` Configuration 807 | 808 | The `useDraggable` hook accepts a configuration object with these options: 809 | 810 | | Option | Type | Description | 811 | | ------------- | ---------------------- | ------------------------------------------------------------------------------------- | 812 | | `kind` | `string` or `symbol` | **Required.** Identifies this draggable type | 813 | | `data` | `object` or `function` | Data to share with droppables. Can be a static object or a function that returns data | 814 | | `disabled` | `boolean` | When `true`, disables dragging functionality | 815 | | `move` | `boolean` | When `true`, moves the component instead of cloning it to the overlay | 816 | | `component` | `function` | Provides a custom component to show while dragging | 817 | | `placeholder` | `function` | Custom component to show in place of the dragged item | 818 | | `offset` | `object` or `function` | Controls positioning relative to cursor | 819 | 820 | **Event Callbacks:** 821 | 822 | | Callback | Description | 823 | | ------------- | ---------------------------------------------------------------------------- | 824 | | `shouldDrag` | Function determining if dragging should start. Must return `true` or `false` | 825 | | `onDragStart` | Called when drag begins | 826 | | `onDragMove` | Called on every pointer move while dragging | 827 | | `onDragEnd` | Called when dragging ends | 828 | 829 | #### Detailed Configuration Description 830 | 831 | ##### `kind` (Required) 832 | 833 | Defines the type of the draggable. It must be a unique string or symbol. 834 | 835 | ```jsx 836 | const { draggable } = useDraggable({ 837 | kind: "SQUARE", // Identify this as a "SQUARE" type 838 | }); 839 | ``` 840 | 841 | ##### `data` 842 | 843 | Data associated with the draggable. It can be a static object or a function that returns an object: 844 | 845 | ```jsx 846 | // Static object 847 | const { draggable } = useDraggable({ 848 | kind: "SQUARE", 849 | data: { color: "red", id: "square-1" }, 850 | }); 851 | 852 | // Function (calculated at drag start) 853 | const { draggable } = useDraggable({ 854 | kind: "SQUARE", 855 | data: ({ dragElement, dragStartEvent }) => ({ 856 | id: dragElement.id, 857 | position: { x: dragStartEvent.clientX, y: dragStartEvent.clientY }, 858 | }), 859 | }); 860 | ``` 861 | 862 | ##### `disabled` 863 | 864 | When `true`, temporarily disables dragging: 865 | 866 | ```jsx 867 | const { draggable } = useDraggable({ 868 | kind: "SQUARE", 869 | disabled: !canDrag, // Disable based on some condition 870 | }); 871 | ``` 872 | 873 | ##### `move` 874 | 875 | When `true`, the original component is moved during dragging instead of creating a clone: 876 | 877 | ```jsx 878 | const { draggable } = useDraggable({ 879 | kind: "SQUARE", 880 | move: true, // Move the actual component 881 | }); 882 | ``` 883 | 884 | Note: If `move` is `false` (default), the component is cloned to the overlay layer while the original stays in place. The original component won't receive prop updates during dragging. 885 | 886 | ##### `component` 887 | 888 | A function that returns a custom component to be shown during dragging: 889 | 890 | ```jsx 891 | const { draggable } = useDraggable({ 892 | kind: "SQUARE", 893 | component: ({ data, props }) => , 894 | }); 895 | ``` 896 | 897 | ##### `placeholder` 898 | 899 | A function that returns a component to be shown in place of the dragged item: 900 | 901 | ```jsx 902 | const { draggable } = useDraggable({ 903 | kind: "SQUARE", 904 | placeholder: ({ data, props }) => , 905 | }); 906 | ``` 907 | 908 | When specified, the `move` option is ignored. 909 | 910 | ##### `offset` 911 | 912 | Controls the offset of the dragging component relative to the cursor: 913 | 914 | ```jsx 915 | // Static offset 916 | const { draggable } = useDraggable({ 917 | kind: "SQUARE", 918 | offset: { top: 10, left: 10 }, // 10px down and right from cursor 919 | }); 920 | 921 | // Dynamic offset 922 | const { draggable } = useDraggable({ 923 | kind: "SQUARE", 924 | offset: ({ element, event, data }) => { 925 | // Calculate based on event or element position 926 | return { top: 0, left: 0 }; 927 | }, 928 | }); 929 | ``` 930 | 931 | If not specified, the offset is calculated to maintain the element's initial position relative to the cursor. 932 | 933 | #### Callback Details 934 | 935 | ##### `shouldDrag` 936 | 937 | Function that determines if dragging should start. It's called on every pointer move until it returns `true` or the drag attempt ends: 938 | 939 | ```jsx 940 | const { draggable } = useDraggable({ 941 | kind: "SQUARE", 942 | shouldDrag: ({ event, dragStartEvent, element, data }) => { 943 | // Only drag if shifted 10px horizontally 944 | return Math.abs(event.clientX - dragStartEvent.clientX) > 10; 945 | }, 946 | }); 947 | ``` 948 | 949 | ##### `onDragStart` 950 | 951 | Called when dragging begins (after `shouldDrag` returns `true`): 952 | 953 | ```jsx 954 | const { draggable } = useDraggable({ 955 | kind: "SQUARE", 956 | onDragStart: ({ event, dragStartEvent, element, data }) => { 957 | console.log("Drag started at:", event.clientX, event.clientY); 958 | // Setup any initial state needed during drag 959 | }, 960 | }); 961 | ``` 962 | 963 | ##### `onDragMove` 964 | 965 | Called on every pointer move during dragging: 966 | 967 | ```jsx 968 | const { draggable } = useDraggable({ 969 | kind: "SQUARE", 970 | onDragMove: ({ event, dragStartEvent, element, data, dropTargets, top, left }) => { 971 | // Current drop targets under the pointer 972 | if (dropTargets.length) { 973 | console.log("Over drop zone:", dropTargets[0].data.name); 974 | } 975 | 976 | // Current position of the draggable 977 | console.log("Position:", top, left); 978 | }, 979 | }); 980 | ``` 981 | 982 | The `dropTargets` array contains information about all current drop targets under the cursor. Each entry has `data` (from the droppable's configuration) and `element` (the DOM element). 983 | 984 | ##### `onDragEnd` 985 | 986 | Called when dragging ends: 987 | 988 | ```jsx 989 | const { draggable } = useDraggable({ 990 | kind: "SQUARE", 991 | onDragEnd: ({ event, dragStartEvent, element, data, dropTargets }) => { 992 | if (dropTargets.length) { 993 | console.log("Dropped on:", dropTargets[0].data.name); 994 | } else { 995 | console.log("Dropped outside any drop target"); 996 | // Handle "cancel" case 997 | } 998 | }, 999 | }); 1000 | ``` 1001 | 1002 | ### `useDroppable` Configuration 1003 | 1004 | The `useDroppable` hook accepts a configuration object with these options: 1005 | 1006 | | Option | Type | Description | 1007 | | ---------- | ------------------------------------------ | -------------------------------------------- | 1008 | | `accepts` | `string`, `symbol`, `array`, or `function` | **Required.** What draggable kinds to accept | 1009 | | `data` | `object` | Data to share with draggables | 1010 | | `disabled` | `boolean` | When `true`, disables dropping | 1011 | 1012 | **Event Callbacks:** 1013 | 1014 | | Callback | Description | 1015 | | ------------ | ---------------------------------------------------- | 1016 | | `onDragIn` | Called when a draggable enters this droppable | 1017 | | `onDragOut` | Called when a draggable leaves this droppable | 1018 | | `onDragMove` | Called when a draggable moves within this droppable | 1019 | | `onDrop` | Called when a draggable is dropped on this droppable | 1020 | 1021 | #### Detailed Configuration Description 1022 | 1023 | ##### `accepts` (Required) 1024 | 1025 | Defines what kinds of draggables this drop target can accept: 1026 | 1027 | ```jsx 1028 | // Accept a single kind 1029 | const { droppable } = useDroppable({ 1030 | accepts: "SQUARE", 1031 | }); 1032 | 1033 | // Accept multiple kinds 1034 | const { droppable } = useDroppable({ 1035 | accepts: ["SQUARE", "CIRCLE"], 1036 | }); 1037 | 1038 | // Use a function for more complex logic 1039 | const { droppable } = useDroppable({ 1040 | accepts: ({ kind, data }) => { 1041 | // Check both kind and data to determine acceptance 1042 | return kind === "SQUARE" && data.color === "red"; 1043 | }, 1044 | }); 1045 | ``` 1046 | 1047 | ##### `data` 1048 | 1049 | Data associated with the droppable area: 1050 | 1051 | ```jsx 1052 | const { droppable } = useDroppable({ 1053 | accepts: "SQUARE", 1054 | data: { 1055 | zoneId: "dropzone-1", 1056 | capacity: 5, 1057 | color: "blue", 1058 | }, 1059 | }); 1060 | ``` 1061 | 1062 | This data is accessible to draggables through the `dropTargets` array in their callbacks. 1063 | 1064 | ##### `disabled` 1065 | 1066 | When `true`, temporarily disables dropping: 1067 | 1068 | ```jsx 1069 | const { droppable } = useDroppable({ 1070 | accepts: "SQUARE", 1071 | disabled: isFull, // Disable based on some condition 1072 | }); 1073 | ``` 1074 | 1075 | #### Callback Details 1076 | 1077 | ##### `onDragIn` 1078 | 1079 | Called when a draggable of an accepted kind first enters this drop target: 1080 | 1081 | ```jsx 1082 | const { droppable } = useDroppable({ 1083 | accepts: "SQUARE", 1084 | onDragIn: ({ kind, data, event, element, dropElement, dropTargets }) => { 1085 | console.log(`${kind} entered with data:`, data); 1086 | // Change appearance, play sound, etc. 1087 | }, 1088 | }); 1089 | ``` 1090 | 1091 | Arguments: 1092 | 1093 | - `kind` - The kind of the draggable 1094 | - `data` - The data from the draggable 1095 | - `event` - The current pointer event 1096 | - `element` - The draggable element 1097 | - `dropElement` - The droppable element 1098 | - `dropTargets` - Array of all current drop targets under the pointer 1099 | 1100 | ##### `onDragOut` 1101 | 1102 | Called when a draggable leaves this drop target: 1103 | 1104 | ```jsx 1105 | const { droppable } = useDroppable({ 1106 | accepts: "SQUARE", 1107 | onDragOut: ({ kind, data, event, element, dropElement, dropTargets }) => { 1108 | console.log(`${kind} left the drop zone`); 1109 | // Revert appearance changes, etc. 1110 | }, 1111 | }); 1112 | ``` 1113 | 1114 | Arguments are the same as `onDragIn`. 1115 | 1116 | ##### `onDragMove` 1117 | 1118 | Called when a draggable moves within this drop target: 1119 | 1120 | ```jsx 1121 | const { droppable } = useDroppable({ 1122 | accepts: "SQUARE", 1123 | onDragMove: ({ kind, data, event, element, dropElement, dropTargets }) => { 1124 | // Calculate position within drop zone 1125 | const rect = dropElement.getBoundingClientRect(); 1126 | const relativeX = event.clientX - rect.left; 1127 | const relativeY = event.clientY - rect.top; 1128 | 1129 | console.log(`Position in zone: ${relativeX}px, ${relativeY}px`); 1130 | }, 1131 | }); 1132 | ``` 1133 | 1134 | Arguments are the same as `onDragIn`. 1135 | 1136 | ##### `onDrop` 1137 | 1138 | Called when a draggable is dropped on this target: 1139 | 1140 | ```jsx 1141 | const { droppable } = useDroppable({ 1142 | accepts: "SQUARE", 1143 | onDrop: ({ kind, data, event, element, dropElement, dropTargets }) => { 1144 | console.log(`${kind} was dropped with data:`, data); 1145 | // Handle the dropped item (update state, etc.) 1146 | }, 1147 | }); 1148 | ``` 1149 | 1150 | Arguments are the same as the other callbacks. 1151 | 1152 | ## Plugins 1153 | 1154 | Snapdrag offers a plugin system to extend its core functionality. Plugins can hook into the draggable lifecycle events (`onDragStart`, `onDragMove`, `onDragEnd`) to add custom behaviors. 1155 | 1156 | ### Scroller Plugin 1157 | 1158 | The `scroller` plugin automatically scrolls a container element when a dragged item approaches its edges. This is useful for large scrollable areas where users might need to drag items beyond the visible viewport. 1159 | 1160 | **Initialization** 1161 | 1162 | To use the scroller plugin, first create an instance of it by calling `createScroller(config)`. 1163 | 1164 | ```typescript 1165 | import { createScroller } from "snapdrag/plugins"; 1166 | 1167 | const scroller = createScroller({ 1168 | x: true, // Enable horizontal scrolling with default settings 1169 | y: { threshold: 150, speed: 1000, distancePower: 2 }, // Enable vertical scrolling with custom settings 1170 | }); 1171 | ``` 1172 | 1173 | **Configuration Options (`ScrollerConfig`)** 1174 | 1175 | - `x`: (Optional) Enables or configures horizontal scrolling. 1176 | - `boolean`: If `true`, uses default settings. If `false` or omitted, horizontal scrolling is disabled. 1177 | - `object (AxisConfig)`: Allows fine-tuning of horizontal scrolling behavior: 1178 | - `threshold` (number, default: `100`): The distance in pixels from the container's edge at which scrolling should begin. 1179 | - `speed` (number, default: `2000`): The maximum scroll speed in pixels per second when the pointer is at the very edge of the container. 1180 | - `distancePower` (number, default: `1.5`): Controls the acceleration of scrolling as the pointer gets closer to the edge. A higher value means faster acceleration. 1181 | - `y`: (Optional) Enables or configures vertical scrolling. Accepts the same `boolean` or `object (AxisConfig)` values as `x`. 1182 | 1183 | **Usage with `useDraggable`** 1184 | 1185 | Once created, the scroller instance needs to be passed to the `plugins` array in the `useDraggable` hook's configuration. The scroller function itself takes the scrollable container element as an argument. 1186 | 1187 | ```jsx 1188 | import { useDraggable } from "snapdrag"; 1189 | import { createScroller } from "snapdrag/plugins"; 1190 | import { useRef, useEffect, useState } from "react"; 1191 | 1192 | // Initialize the scroller plugin 1193 | const scrollerPlugin = createScroller({ x: true, y: true }); 1194 | 1195 | const DraggableComponent = () => { 1196 | // State to hold the container element once it's mounted 1197 | const [scrollContainer, setScrollContainer] = useState(null); 1198 | 1199 | const { draggable } = useDraggable({ 1200 | kind: "ITEM", 1201 | data: { id: "my-item" }, 1202 | plugins: [scrollerPlugin(scrollContainer)], 1203 | }); 1204 | 1205 | return ( 1206 |
1210 |
1211 | {/* Inner content larger than container */} 1212 | {draggable( 1213 |
1214 | Drag me 1215 |
1216 | )} 1217 | {/* More draggable items or content here */} 1218 |
1219 |
1220 | ); 1221 | }; 1222 | ``` 1223 | 1224 | **How it Works** 1225 | 1226 | 1. **Initialization**: `createScroller` returns a new scroller function configured with your desired settings. 1227 | 2. **Plugin Attachment**: When you pass `scrollerPlugin(containerElement)` to `useDraggable`, Snapdrag calls the appropriate lifecycle methods of the plugin (`onDragStart`, `onDragMove`, `onDragEnd`). 1228 | 3. **Drag Monitoring**: During a drag operation, `onDragMove` is continuously called. The scroller plugin checks the pointer's position relative to the specified `containerElement`. 1229 | 4. **Edge Detection**: If the pointer moves within the `threshold` distance of an edge for an enabled axis (x or y), the plugin initiates scrolling. 1230 | 5. **Scrolling Speed**: The scrolling speed increases polynomially (based on `distancePower`) as the pointer gets closer to the edge, up to the maximum `speed`. 1231 | 6. **Animation Loop**: Scrolling is performed using `requestAnimationFrame` for smooth animation. 1232 | 7. **Cleanup**: When the drag ends (`onDragEnd`) or the component unmounts, the plugin cleans up any active animation frames. 1233 | 1234 | **Important Considerations:** 1235 | 1236 | - The `containerElement` passed to the scroller function must be the actual scrollable DOM element. 1237 | - Ensure the `containerElement` has `overflow: auto` or `overflow: scroll` CSS properties set for the respective axes you want to enable scrolling on. 1238 | - If the scrollable container is not immediately available on component mount (e.g., if its ref is populated later), you might need to conditionally apply the plugin or update it, as shown in the example using `useState` and `useEffect` to pass the container element once it's available. 1239 | - The plugin calculates distances based on the viewport. If your scroll container or draggable items are scaled using CSS transforms, you might need to adjust threshold and speed values accordingly or ensure pointer events are correctly mapped. 1240 | 1241 | The `scroller` plugin offers a straightforward way to add automatic scrolling to your drag-and-drop interfaces. It significantly enhances usability, especially when users need to drag items across large, scrollable containers or overflowing content areas. 1242 | 1243 | ## Browser Compatibility 1244 | 1245 | Snapdrag is compatible with all modern browsers that support Pointer Events. This includes: 1246 | 1247 | - Chrome 55+ 1248 | - Firefox 59+ 1249 | - Safari 13.1+ 1250 | - Edge 18+ 1251 | 1252 | Mobile devices are also supported as long as they support Pointer Events. 1253 | 1254 | ## License 1255 | 1256 | MIT 1257 | 1258 | ## Author 1259 | 1260 | Eugene Daragan 1261 | --------------------------------------------------------------------------------