diff --git a/.changeset/drag-new-package.md b/.changeset/drag-new-package.md new file mode 100644 index 000000000..8b7bf5043 --- /dev/null +++ b/.changeset/drag-new-package.md @@ -0,0 +1,29 @@ +--- +"@solid-primitives/drag-drop": minor +--- + +New package: `@solid-primitives/drag-drop` — composable drag-and-drop primitives for Solid 2.0. + +### Exports + +**Pointer-event DnD** (UI element dragging): +- `makeDraggable` — non-reactive base, no Solid owner required +- `makeDroppable` — non-reactive drop target base +- `createDraggable` — reactive draggable with `isDragging`, `transform`, auto style/class; keyboard-accessible (`Space`/`Enter` to pick up/drop, arrow keys to nudge, `Escape` to cancel) +- `createDroppable` — reactive drop target with `isOver`, `active`, auto style/class +- `createSortable` — combines draggable + droppable on the same element +- `createDragContext` — coordinates a tree of draggables and droppables, with optional `autoScroll` near viewport edges +- `arrayMove` — pure reorder helper for `onDragEnd` + `createSortable` + +**Native HTML5 DnD** (file drops, `draggable="true"` elements): +- `makeNativeDroppable` — non-reactive base with depth-counter fix for child elements +- `createNativeDroppable` — reactive native drop zone for OS file drops + +**Collision detection strategies** (pure functions, pass to `createDragContext`): +- `closestCenter`, `closestCorners`, `rectIntersection`, `pointerWithin` (default) + +### Notes + +- The reported `transform` is corrected for page scroll during a drag, so the dragged element doesn't visually drift from the pointer if the page scrolls mid-drag (needed for `autoScroll` to look right, and for any scrollable page in general). +- `createDroppable`/`createSortable` log a dev-mode warning when used without a `createDragContext` ancestor, since `isOver`/`active` silently stay `false`/`null` in that case. +- `isDragging`/`isOver` are backed by `createProjection` instead of a per-instance `active()?.id === id` memo, so a drag start/end or hover change in a large list only notifies the specific items involved. diff --git a/deno.lock b/deno.lock index 52e0d6499..36a3baa9d 100644 --- a/deno.lock +++ b/deno.lock @@ -12,7 +12,7 @@ "npm:@graphql-typed-document-node/core@^3.2.0": "3.2.0_graphql@16.14.2", "npm:@iconify-json/ph@^1.2.2": "1.2.2", "npm:@iconify-json/ri@^1.2.10": "1.2.10", - "npm:@kobalte/solidbase@~0.6.13": "0.6.13_@solidjs+start@2.0.0__@solidjs+router@0.15.4___solid-js@1.9.14__vite@8.1.3___@types+node@22.20.0___esbuild@0.28.1___yaml@2.9.0__@testing-library+jest-dom@6.9.1__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_solid-js@2.0.0-beta.34_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_@testing-library+jest-dom@6.9.1_@types+node@22.20.0_esbuild@0.28.1", + "npm:@kobalte/solidbase@~0.6.13": "0.6.13_@solidjs+start@2.0.0__@solidjs+router@0.15.4___solid-js@2.0.0-beta.34__vite@8.1.3___@types+node@22.20.0___esbuild@0.28.1___yaml@2.9.0_solid-js@2.0.0-beta.34_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0", "npm:@nothing-but/node-resolve-ts@^1.0.1": "1.0.1", "npm:@reduxjs/toolkit@^1.9.7": "1.9.7_react@19.2.7", "npm:@solidjs/meta@~0.29.4": "0.29.4_solid-js@2.0.0-beta.34", @@ -57,7 +57,7 @@ "npm:storybook@^10.4.6": "10.4.6_@types+react@19.2.17_prettier@3.9.4_@testing-library+dom@10.4.1_react@19.2.7", "npm:tsdown@~0.22.3": "0.22.3_typescript@6.0.3", "npm:typescript@^7.0.2": "7.0.2", - "npm:unplugin-solid@1": "1.0.0_solid-js@2.0.0-beta.34_@types+node@22.20.0_esbuild@0.28.1_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0", + "npm:unplugin-solid@1": "1.0.0_solid-js@2.0.0-beta.34", "npm:vite-plugin-solid@3.0.0-next.5": "3.0.0-next.5_@solidjs+web@2.0.0-beta.34__solid-js@2.0.0-beta.34_@testing-library+jest-dom@6.9.1_solid-js@2.0.0-beta.34_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_@types+node@22.20.0_esbuild@0.28.1", "npm:vite@8": "8.1.3_@types+node@22.20.0_esbuild@0.28.1_yaml@2.9.0", "npm:vite@^6.4.3": "6.4.3_@types+node@22.20.0_sass@1.101.0", @@ -1980,7 +1980,7 @@ "solid-prevent-scroll" ] }, - "@kobalte/solidbase@0.6.13_@solidjs+start@2.0.0__@solidjs+router@0.15.4___solid-js@1.9.14__vite@8.1.3___@types+node@22.20.0___esbuild@0.28.1___yaml@2.9.0__@testing-library+jest-dom@6.9.1__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_solid-js@2.0.0-beta.34_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_@testing-library+jest-dom@6.9.1_@types+node@22.20.0_esbuild@0.28.1": { + "@kobalte/solidbase@0.6.13_@solidjs+start@2.0.0__@solidjs+router@0.15.4___solid-js@2.0.0-beta.34__vite@8.1.3___@types+node@22.20.0___esbuild@0.28.1___yaml@2.9.0_solid-js@2.0.0-beta.34_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0": { "integrity": "sha512-rERXMPu+NiWqkB8EvHizUQareRqKVtUdCa0mjVIJtiJuUAmMR6M3VgHWH/C0llk/0ph/7VkYcwdbBXYLerDGtQ==", "dependencies": [ "@alloc/quick-lru", @@ -2006,7 +2006,7 @@ "@solid-primitives/storage", "@solidjs/meta@0.29.4_solid-js@2.0.0-beta.34", "@solidjs/router@0.15.4_solid-js@2.0.0-beta.34", - "@solidjs/start@2.0.0_@solidjs+router@0.15.4__solid-js@1.9.14_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_@testing-library+jest-dom@6.9.1_@types+node@22.20.0_esbuild@0.28.1_yaml@2.9.0", + "@solidjs/start@2.0.0_@solidjs+router@0.15.4__solid-js@2.0.0-beta.34_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0", "cross-spawn", "diff", "esast-util-from-js", @@ -2121,8 +2121,6 @@ "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1": { "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dependencies": [ - "@emnapi/core@1.11.1", - "@emnapi/runtime@1.11.1", "@tybys/wasm-util" ] }, @@ -2137,8 +2135,6 @@ "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.9.2_@emnapi+runtime@1.9.2": { "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dependencies": [ - "@emnapi/core@1.9.2", - "@emnapi/runtime@1.9.2", "@tybys/wasm-util" ] }, @@ -3184,12 +3180,6 @@ "solid-js@2.0.0-beta.34" ] }, - "@solidjs/router@0.15.4_solid-js@1.9.14": { - "integrity": "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ==", - "dependencies": [ - "solid-js@1.9.14" - ] - }, "@solidjs/router@0.15.4_solid-js@2.0.0-beta.34": { "integrity": "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ==", "dependencies": [ @@ -3211,14 +3201,14 @@ "@solidjs/signals@2.0.0-beta.34": { "integrity": "sha512-i/mvOH8XHOPKOUvXTdj/BxJMkqi+x5/dFGTHyYnLe//7CTo23eYXbQI0AH1EPFNkBrPyy/Ly5Ur74vA+8tEnDg==" }, - "@solidjs/start@2.0.0_@solidjs+router@0.15.4__solid-js@1.9.14_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_@testing-library+jest-dom@6.9.1_@types+node@22.20.0_esbuild@0.28.1_yaml@2.9.0": { + "@solidjs/start@2.0.0_@solidjs+router@0.15.4__solid-js@2.0.0-beta.34_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0": { "integrity": "sha512-OXO2BzEi5aqEgPqu9bbSXlKuAr9Pp4D33hJ0cAJh65fQFr6Zn+IxeMSpIyTeR8/Mzz2SAJwGAxwvMuvdZ1ylZQ==", "dependencies": [ "@babel/core", "@babel/traverse", "@babel/types@7.29.7", "@solidjs/meta@0.29.4_solid-js@1.9.14", - "@solidjs/router@0.15.4_solid-js@1.9.14", + "@solidjs/router@0.15.4_solid-js@2.0.0-beta.34", "@types/babel__traverse", "@types/micromatch", "cookie-es", @@ -3243,7 +3233,7 @@ "vite-plugin-solid@2.11.14_@testing-library+jest-dom@6.9.1_solid-js@1.9.14_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_@types+node@22.20.0_esbuild@0.28.1_yaml@2.9.0" ], "optionalPeers": [ - "@solidjs/router@0.15.4_solid-js@1.9.14" + "@solidjs/router@0.15.4_solid-js@2.0.0-beta.34" ] }, "@solidjs/start@2.0.0_@solidjs+router@1.0.0__solid-js@1.9.14_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_@testing-library+jest-dom@6.9.1_@types+node@22.20.0_esbuild@0.28.1": { @@ -4078,11 +4068,7 @@ "integrity": "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==", "dependencies": [ "@babel/core", - "babel-plugin-jsx-dom-expressions", - "solid-js@2.0.0-beta.34" - ], - "optionalPeers": [ - "solid-js@2.0.0-beta.34" + "babel-plugin-jsx-dom-expressions" ] }, "babel-preset-solid@2.0.0-beta.17_@babel+core@7.29.7_solid-js@2.0.0-beta.34": { @@ -4105,10 +4091,6 @@ "base64-js@1.5.1": { "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, - "baseline-browser-mapping@2.10.42": { - "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", - "bin": true - }, "baseline-browser-mapping@2.11.14": { "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", "bin": true @@ -4154,7 +4136,7 @@ "browserslist@4.28.8": { "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dependencies": [ - "baseline-browser-mapping@2.11.14", + "baseline-browser-mapping", "caniuse-lite", "electron-to-chromium", "node-releases", @@ -6639,10 +6621,6 @@ "mute-stream@0.0.8": { "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" }, - "nanoid@3.3.15": { - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", - "bin": true - }, "nanoid@3.3.18": { "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "bin": true @@ -6655,7 +6633,6 @@ "dependencies": [ "@next/env", "@swc/helpers", - "baseline-browser-mapping@2.10.42", "postcss@8.4.31", "react", "react-dom", @@ -7122,7 +7099,6 @@ "postcss@8.4.31": { "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dependencies": [ - "nanoid@3.3.15", "picocolors", "source-map-js" ] @@ -7130,7 +7106,7 @@ "postcss@8.5.26": { "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dependencies": [ - "nanoid@3.3.18", + "nanoid", "picocolors", "source-map-js" ] @@ -8362,7 +8338,7 @@ "unplugin" ] }, - "unplugin-solid@1.0.0_solid-js@2.0.0-beta.34_@types+node@22.20.0_esbuild@0.28.1_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0": { + "unplugin-solid@1.0.0_solid-js@2.0.0-beta.34": { "integrity": "sha512-pv1CS3XMtf3WwX8Dq9Bvo4qH6mfjN2xOgbaPcnqW1dLhyP/JQCvueGEsN0dYIZ4JvxaD/G/Ot1JnBzNQGHkfeA==", "dependencies": [ "@babel/core", @@ -8962,6 +8938,14 @@ ] } }, + "packages/drag-drop": { + "packageJson": { + "dependencies": [ + "npm:@solidjs/web@2.0.0-beta.34", + "npm:solid-js@2.0.0-beta.34" + ] + } + }, "packages/event-bus": { "packageJson": { "dependencies": [ diff --git a/packages/drag-drop/LICENSE b/packages/drag-drop/LICENSE new file mode 100644 index 000000000..ba8f69c3d --- /dev/null +++ b/packages/drag-drop/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Solid Primitives Working Group + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/drag-drop/README.md b/packages/drag-drop/README.md new file mode 100644 index 000000000..51284c0e1 --- /dev/null +++ b/packages/drag-drop/README.md @@ -0,0 +1,288 @@ +

+ Solid Primitives Drag & Drop +

+ +# @solid-primitives/drag-drop + +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/drag-drop?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/drag-drop) +[![version](https://img.shields.io/npm/v/@solid-primitives/drag-drop?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/drag-drop) +[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) + +Composable, tree-shakeable drag-and-drop primitives. + +`createDraggable` is keyboard-accessible out of the box — `Space`/`Enter` picks up the drag, arrow keys nudge it, `Space`/`Enter` drops it, and `Escape` cancels, mirroring the pointer sensor's lifecycle. + +Two separate drag systems are provided: + +- **[Pointer events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events)** (`makeDraggable`, `makeDroppable`, `createDraggable`, `createDroppable`, `createSortable`, `createDragContext`) — for UI elements moved by the user. The primitives track `pointerdown`/`pointermove`/`pointerup` on the dragged element and document, computing position/collision manually; nothing here uses the browser's native drag-and-drop. +- **[Native HTML5 Drag and Drop API](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API)** (`makeNativeDroppable`, `createNativeDroppable`) — for OS file drops and `draggable="true"` elements; used internally by `@solid-primitives/upload`'s `createDropzone`. + +## Installation + +```bash +npm install @solid-primitives/drag-drop +# or +pnpm add @solid-primitives/drag-drop +``` + +## Primitives + +### `makeDraggable` + +Non-reactive base. Attaches pointer listeners to an element. No Solid owner required. + +> **Note:** When pairing with `makeDroppable`, apply `pointer-events: none` to the dragged element during the drag so that `pointerenter`/`pointerleave` can reach the underlying drop targets. The context-based `createDroppable` uses rect collision and is not affected. + +```ts +const cleanup = makeDraggable(el, { + onStart: e => { + el.style.pointerEvents = "none"; // required for makeDroppable coordination + console.log("start", e.clientX); + }, + onMove: delta => (el.style.transform = `translate(${delta.x}px,${delta.y}px)`), + onEnd: () => { + el.style.pointerEvents = ""; + el.style.transform = ""; + }, +}); +// later +cleanup(); +``` + +### `makeDroppable` + +Non-reactive base. Marks an element as a drop target for pointer-event drags. + +> **Note:** Requires `pointer-events: none` on the dragged element during the drag — otherwise the dragged element intercepts `pointerenter`/`pointerleave` before they reach this element. + +```ts +const cleanup = makeDroppable(el, { + onEnter: () => el.classList.add("over"), + onLeave: () => el.classList.remove("over"), + onDrop: () => acceptDrop(), +}); +``` + +### `makeNativeDroppable` + +Non-reactive base for OS/browser native drag events (e.g. file drops). Handles the `dragenter`/`dragleave` depth problem with child elements automatically. + +```ts +const cleanup = makeNativeDroppable(el, { + accept: e => e.dataTransfer?.types.includes("Files") ?? false, + onEnter: () => el.classList.add("over"), + onDrop: e => handleFiles(e.dataTransfer!.files), +}); +``` + +### `createDraggable` + +Reactive draggable. Attach via `ref`. Works standalone or inside a `createDragContext` provider. + +Uses `setPointerCapture` internally so the drag is never stuck if the pointer leaves the viewport. If the page scrolls mid-drag, `transform` is corrected for the scroll delta so the element stays glued to the pointer instead of drifting. + +**Keyboard support:** `ref` sets `tabindex="0"`, `role="button"`, and `aria-roledescription="draggable"` on the element unless you've already set them yourself. With the element focused: `Space`/`Enter` picks up the drag, arrow keys nudge it by `keyboardStep` pixels (default 25), `Space`/`Enter` drops it, and `Escape` cancels. + +```tsx +const drag = createDraggable("card-1", myData, { + class: "cursor-grab", + draggingClass: "opacity-50 cursor-grabbing", + draggingStyle: { boxShadow: "0 8px 24px rgba(0,0,0,.2)" }, +}); + +
+ {drag.isDragging() ? "dragging…" : drag.transform()?.x} +
+``` + +| Option | Type | Description | +|---|---|---| +| `style` | `Partial` | Applied to the element on `ref` | +| `class` | `string` | Added to the element on `ref` | +| `draggingStyle` | `Partial` | Applied while dragging, removed on drop | +| `draggingClass` | `string` | Added while dragging, removed on drop | +| `disabled` | `boolean \| Accessor` | Prevents drag when true | +| `keyboardStep` | `number` | Pixels moved per arrow-key press while picked up via keyboard. Defaults to 25 | + +| Return | Description | +|---|---| +| `ref` | Attach to element's `ref` prop | +| `isDragging` | Accessor — true while a drag is active | +| `transform` | Accessor — `{ x, y }` delta from drag start, or `null` | +| `id` | The id passed to the primitive | + +### `createDroppable` + +Reactive drop target. Requires a `createDragContext` ancestor to coordinate collision detection — using it without one leaves `isOver`/`active` permanently `false`/`null` and logs a dev-mode warning, since it usually means a missing ``. + +```tsx +const drop = createDroppable("zone-1", zoneData, { + class: "border-2 border-dashed border-transparent", + overClass: "border-indigo-500 bg-indigo-50", +}); + +
+ {drop.isOver() ? `release to drop ${drop.active()?.id}` : "drop here"} +
+``` + +`accept` is called fresh on every collision check (not memoized), so reading a signal inside it gives you reactive accept logic for free: + +```tsx +const [locked, setLocked] = createSignal(false); +const drop = createDroppable("zone-1", zoneData, { + accept: draggable => !locked() && draggable.data.type === "file", +}); +``` + +### `createNativeDroppable` + +Reactive native drop zone. Tracks `isOver` state via HTML5 drag events. + +```tsx +const drop = createNativeDroppable({ + accept: e => e.dataTransfer?.types.includes("Files") ?? false, + onDrop: e => handleFiles(e.dataTransfer!.files), +}); + +
+ Drop files here +
+``` + +### `createDragContext` + +Coordinates draggables and droppables. Provide it as a context via `ctx.Provider`. + +Droppable rects are snapshotted once at drag start — `getBoundingClientRect` is never called during `pointermove`. Collision checks run at display rate (rAF-throttled) and only write reactive state when the hovered zone actually changes. `createDraggable`'s `isDragging` and `createDroppable`'s `isOver` are backed by `createProjection`, so in a list of many draggables/droppables, a drag start/end or hover change only notifies the specific items whose state actually flipped — not every item in the list. + +```tsx +const ctx = createDragContext({ + collisionDetection: closestCenter, + onDragStart: item => console.log("started", item.id), + onDragEnd: (item, over) => console.log("dropped", item.id, "on", over?.id), + onDragCancel: item => console.log("cancelled", item.id), + autoScroll: true, // or { threshold: 80, speed: 20 } + keyboardStep: 25, +}); + + + + + +``` + +| Option | Type | Description | +|---|---|---| +| `collisionDetection` | `CollisionDetector` | Defaults to `pointerWithin` | +| `onDragStart` / `onDragMove` / `onDragEnd` / `onDragCancel` | callbacks | Drag lifecycle hooks | +| `keyboardStep` | `number` | Pixels moved per arrow-key press while picked up via keyboard. Defaults to 25 | +| `autoScroll` | `boolean \| { threshold?, speed? }` | Scrolls the window when the pointer nears a viewport edge during a drag. Off by default; ignored for keyboard-driven drags | + +| Return | Description | +|---|---| +| `Provider` | Wrap your drag-and-drop tree in this component | +| `active` | Accessor — the currently dragged `DragItem`, or `null` | +| `over` | Accessor — the current `DroppableItem` under the draggable, or `null` | +| `transform` | Accessor — `{ x, y }` delta from drag start, or `null` | + +> **Note:** `autoScroll` only scrolls the window — it doesn't track scrolling of an arbitrary nested overflow container. + +### `createSortable` + +Combines `createDraggable` and `createDroppable` on the same element. When another item is dragged over it, `isActiveDropzone()` is true. The active draggable is never its own drop target. + +```tsx + + {item => { + const s = createSortable(item.id, item); + return ( +
+ {item.label} +
+ ); + }} +
+``` + +Pair it with `arrayMove` in `onDragEnd` to reorder the backing array — see [`arrayMove`](#arraymove) below. + +## Collision detection strategies + +All four are exported as pure functions — pass any of them as `collisionDetection` to `createDragContext`. + +| Strategy | Description | +|---|---| +| `closestCenter` | Nearest droppable by center-to-center distance | +| `closestCorners` | Nearest droppable by minimum corner-to-pointer distance | +| `rectIntersection` | Droppable with largest overlap area | +| `pointerWithin` | Topmost droppable containing the pointer (default) | + +```ts +import { createDragContext, closestCenter } from "@solid-primitives/drag-drop"; + +const ctx = createDragContext({ collisionDetection: closestCenter }); +``` + +You can also write a custom detector: + +```ts +import type { CollisionDetector } from "@solid-primitives/drag-drop"; + +const myDetector: CollisionDetector = (draggable, droppables, pointer) => { + // Return the id of the winning droppable, or null + return droppables[0]?.id ?? null; +}; +``` + +## `arrayMove` + +Reorders an array by moving the item at `from` to `to`, returning a new array. Pairs naturally with `createDragContext`'s `onDragEnd` for reordering a `createSortable` list — see the [`createSortable`](#createsortable) example above for the full picture. + +```ts +import { arrayMove } from "@solid-primitives/drag-drop"; + +const ctx = createDragContext({ + onDragEnd: (dragged, over) => { + if (!over) return; + setItems(items => arrayMove( + items, + items.findIndex(i => i.id === dragged.id), + items.findIndex(i => i.id === over.id), + )); + }, +}); +``` + +Out-of-range or equal indices return an unmodified copy of the array. + +## Integration with `@solid-primitives/upload` + +`@solid-primitives/upload`'s `createDropzone` uses `createNativeDroppable` from this package under the hood. No manual wiring is needed — just use `createDropzone` from the upload package directly: + +```tsx +import { createDropzone } from "@solid-primitives/upload"; + +const { ref, files, isDragging } = createDropzone({ + onDrop: async files => await upload(files), +}); + +
+ Drop files here +
+``` + +If you need lower-level control (custom accept filter, access to the raw `DragEvent`, etc.), use `createNativeDroppable` directly: + +```tsx +import { createNativeDroppable } from "@solid-primitives/drag-drop"; + +const drop = createNativeDroppable({ + accept: e => e.dataTransfer?.types.includes("Files") ?? false, + onDrop: e => processFiles(e.dataTransfer!.files), +}); +``` + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md). diff --git a/packages/drag-drop/deno.jsonc b/packages/drag-drop/deno.jsonc new file mode 100644 index 000000000..1bd78ed8e --- /dev/null +++ b/packages/drag-drop/deno.jsonc @@ -0,0 +1,24 @@ +{ + "name": "@solid-primitives/drag-drop", + "version": "0.0.100", + "description": "Composable drag-and-drop primitives.", + "license": "MIT", + "exports": "./src/index.ts", + "publish": { + "include": [ + "README.md", + "LICENSE", + "src/**/*.ts", + "src/**/*.tsx", + "package.json" + ], + "exclude": [ + "dist", + "dev", + "test", + "node_modules", + "vitest.config.ts", + "tsconfig.json" + ] + } +} diff --git a/packages/drag-drop/package.json b/packages/drag-drop/package.json new file mode 100644 index 000000000..4e9291ac2 --- /dev/null +++ b/packages/drag-drop/package.json @@ -0,0 +1,82 @@ +{ + "name": "@solid-primitives/drag-drop", + "version": "0.0.100", + "description": "Composable drag-and-drop primitives.", + "author": "David Di Biase ", + "contributors": [], + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/drag-drop", + "repository": { + "type": "git", + "url": "git+https://github.com/solidjs-community/solid-primitives.git" + }, + "bugs": { + "url": "https://github.com/solidjs-community/solid-primitives/issues" + }, + "primitive": { + "name": "drag-drop", + "stage": 0, + "list": [ + "makeDraggable", + "makeDroppable", + "makeNativeDroppable", + "createDraggable", + "createDroppable", + "createNativeDroppable", + "createSortable", + "createDragContext", + "closestCenter", + "closestCorners", + "rectIntersection", + "pointerWithin" + ], + "category": "UI Patterns" + }, + "keywords": [ + "solid", + "primitives", + "drag", + "drop", + "dnd", + "sortable" + ], + "private": false, + "sideEffects": false, + "files": [ + "dist" + ], + "type": "module", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "browser": {}, + "exports": { + "import": { + "@solid-primitives/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "tsdown": { + "entry": "src/**/*.{ts,tsx}", + "outDir": "dist" + }, + "typesVersions": {}, + "scripts": { + "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts", + "build": "pnpm -w build", + "vitest": "vitest -c ../../configs/vitest.config.ts", + "test": "pnpm run vitest", + "test:ssr": "pnpm run vitest --mode ssr" + }, + "dependencies": { + "@solid-primitives/utils": "workspace:^" + }, + "devDependencies": { + "@solidjs/web": "catalog:", + "solid-js": "catalog:" + }, + "peerDependencies": { + "@solidjs/web": "catalog:peer", + "solid-js": "catalog:peer" + } +} diff --git a/packages/drag-drop/src/collision.ts b/packages/drag-drop/src/collision.ts new file mode 100644 index 000000000..2a0821fcb --- /dev/null +++ b/packages/drag-drop/src/collision.ts @@ -0,0 +1,67 @@ +import type { CollisionDetector } from "./types.ts"; + +/** Returns the droppable whose center is nearest to the pointer. Uses squared distance — no sqrt needed. */ +export const closestCenter: CollisionDetector = (_draggable, droppables, pointer) => { + let best: string | number | null = null; + let bestDistSq = Infinity; + const px = pointer.x, py = pointer.y; + for (const d of droppables) { + // >> 1 instead of * 0.5: truncates width/height to a 32-bit int and floors the + // halving, so the computed center can be off by up to ~1px on fractional rects. + const dx = px - (d.rect.left + (d.rect.width >> 1)); + const dy = py - (d.rect.top + (d.rect.height >> 1)); + const distSq = dx * dx + dy * dy; + if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } + } + return best; +}; + +/** Returns the droppable whose nearest corner is closest to the pointer. Uses squared distance — no sqrt needed. */ +export const closestCorners: CollisionDetector = (_draggable, droppables, pointer) => { + let best: string | number | null = null; + let bestDistSq = Infinity; + const px = pointer.x, py = pointer.y; + for (const d of droppables) { + const { left, right, top, bottom } = d.rect; + // Precompute each delta and its square once — each is reused by two of the + // four corners below (dxl by top-left & bottom-left, dyt by top-left & top-right, etc.) + const dxl = px - left, dxr = px - right, dyt = py - top, dyb = py - bottom; + const dxls = dxl * dxl, dxrs = dxr * dxr, dyts = dyt * dyt, dybs = dyb * dyb; + let distSq: number; + // top-left + distSq = dxls + dyts; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } + // top-right + distSq = dxrs + dyts; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } + // bottom-left + distSq = dxls + dybs; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } + // bottom-right + distSq = dxrs + dybs; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } + } + return best; +}; + +/** Returns the droppable with the largest overlap area with the draggable. */ +export const rectIntersection: CollisionDetector = (draggable, droppables) => { + const dr = draggable.rect; + let best: string | number | null = null; + let bestArea = 0; + for (const d of droppables) { + const r = d.rect; + const xOverlap = Math.max(0, Math.min(dr.right, r.right) - Math.max(dr.left, r.left)); + const yOverlap = Math.max(0, Math.min(dr.bottom, r.bottom) - Math.max(dr.top, r.top)); + const area = xOverlap * yOverlap; + if (area > bestArea) { bestArea = area; best = d.id; } + } + return best; +}; + +/** Returns the topmost droppable whose rect contains the pointer. */ +export const pointerWithin: CollisionDetector = (_draggable, droppables, pointer) => { + const px = pointer.x, py = pointer.y; + for (let i = droppables.length - 1; i >= 0; i--) { + const d = droppables[i]!; + const r = d.rect; + if (px >= r.left && px <= r.right && py >= r.top && py <= r.bottom) return d.id; + } + return null; +}; diff --git a/packages/drag-drop/src/context.tsx b/packages/drag-drop/src/context.tsx new file mode 100644 index 000000000..17a05c11c --- /dev/null +++ b/packages/drag-drop/src/context.tsx @@ -0,0 +1,347 @@ +import { createContext, createProjection, createSignal, onCleanup, useContext, untrack, type Element } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { INTERNAL_OPTIONS } from "@solid-primitives/utils"; +import { pointerWithin } from "./collision.ts"; +import type { + AcceptPredicate, + DragContextOptions, + DragContextReturn, + DragItem, + DragRect, + DroppableItem, + DroppableRect, + Transform, +} from "./types.ts"; + +const DEFAULT_KEYBOARD_STEP = 25; +const DEFAULT_AUTO_SCROLL_THRESHOLD = 60; +const DEFAULT_AUTO_SCROLL_SPEED = 15; + +type DroppableEntry = { + element: HTMLElement; + data: unknown; + accept?: AcceptPredicate; +}; + +export type DragContextValue = { + active: () => DragItem | null; + over: () => DroppableItem | null; + transform: () => Transform | null; + _registerDroppable: ( + id: string | number, + element: HTMLElement, + data: unknown, + accept?: AcceptPredicate, + ) => void; + _unregisterDroppable: (id: string | number) => void; + _startDrag: (id: string | number, element: HTMLElement, data: unknown, event: PointerEvent) => void; + /** Starts a drag from a keyboard activation — anchors the synthetic pointer to the element's center. */ + _startKeyboardDrag: (id: string | number, element: HTMLElement, data: unknown) => void; + /** Nudges the active keyboard-driven drag by a delta in viewport pixels. No-op if nothing is dragging. */ + _moveBy: (dx: number, dy: number) => void; + /** Drops the active drag (equivalent to releasing the pointer). No-op if nothing is dragging. */ + _endDrag: () => void; + /** + * `true` iff `id` is the currently active draggable. Backed by `createProjection`, so checking + * this for every draggable in a large list only invalidates the (at most two) ids that actually + * changed, instead of every instance re-comparing against `active()?.id` on each drag start/end. + */ + _isActive: (id: string | number) => boolean; + /** Same fine-grained lookup as `_isActive`, for the currently hovered droppable. */ + _isOver: (id: string | number) => boolean; +}; + +const DragCtx = createContext(); + +export function useDragContext(): DragContextValue | undefined { + try { + return useContext(DragCtx); + } catch { + return undefined; + } +} + +export function createDragContext(options: DragContextOptions = {}): DragContextReturn { + if (isServer) { + const Provider = (props: { children: Element }): Element => props.children; + return { Provider, active: () => null, over: () => null, transform: () => null }; + } + + const droppables = new Map(); + + const [active, setActive] = createSignal(null, INTERNAL_OPTIONS); + const [over, setOver] = createSignal(null, INTERNAL_OPTIONS); + const [transform, setTransform] = createSignal(null, INTERNAL_OPTIONS); + + // Fine-grained "is this id the one" lookups. A plain `active()?.id === id` memo per draggable + // would mark every instance in a list as stale on each drag start/end; these projections only + // notify the (at most two) ids whose membership actually flipped. + let prevActiveId: string | number | undefined; + const isActiveId = createProjection>(s => { + const id = active()?.id; + if (id != null) s[id] = true; + if (prevActiveId != null && prevActiveId !== id) delete s[prevActiveId]; + prevActiveId = id; + }, {}); + + let prevOverId: string | number | undefined; + const isOverId = createProjection>(s => { + const id = over()?.id; + if (id != null) s[id] = true; + if (prevOverId != null && prevOverId !== id) delete s[prevOverId]; + prevOverId = id; + }, {}); + + let currentDrag: DragItem | null = null; + let startX = 0; + let startY = 0; + let dragStartScrollX = 0; + let dragStartScrollY = 0; + + // Droppable rects snapshotted at drag start — avoids getBoundingClientRect on every pointermove. + // Re-snapshotted on scroll or when droppables are added/removed during a drag. + const cachedRects: DroppableRect[] = []; + let dragStartLeft = 0, dragStartTop = 0, dragStartWidth = 0, dragStartHeight = 0; + + const snapshotRects = (activeId: string | number) => { + cachedRects.length = 0; + for (const [id, entry] of droppables) { + if (id !== activeId) { + cachedRects.push({ id, rect: entry.element.getBoundingClientRect() }); + } + } + }; + + const collide = options.collisionDetection ?? pointerWithin; + + // RAF throttle — buffer latest pointer position, flush to signals at display rate. + // `rafPending` guards deduplication so synchronous rAF shims (used in tests) work correctly. + let rafPending = false; + let rafId = -1; + let pendingX = 0; + let pendingY = 0; + + // Auto-scrolls the window when the pointer sits near a viewport edge. Only called from the + // pointer-driven path — keyboard nudges move a synthetic point that isn't meant to trigger it. + const maybeAutoScroll = () => { + const cfg = options.autoScroll; + if (!cfg) return; + const threshold = (typeof cfg === "object" ? cfg.threshold : undefined) ?? DEFAULT_AUTO_SCROLL_THRESHOLD; + const speed = (typeof cfg === "object" ? cfg.speed : undefined) ?? DEFAULT_AUTO_SCROLL_SPEED; + + let dx = 0; + let dy = 0; + if (pendingX < threshold) dx = -speed; + else if (pendingX > window.innerWidth - threshold) dx = speed; + if (pendingY < threshold) dy = -speed; + else if (pendingY > window.innerHeight - threshold) dy = speed; + + if (dx !== 0 || dy !== 0) window.scrollBy(dx, dy); + }; + + // Recomputes transform + collision state from the current pending pointer position. + // Shared by the rAF-throttled pointer path and the immediate keyboard-nudge path. + const applyMove = () => { + if (!currentDrag) return; + + const tx = pendingX - startX; + const ty = pendingY - startY; + + // The reported transform is what the consumer applies as a CSS translate. It needs the + // scroll delta added on top of the raw pointer delta — otherwise the element (which scrolls + // with the page like any other in-flow content) visually drifts away from the pointer if the + // page scrolls mid-drag. The collision rect below intentionally uses the raw (uncompensated) + // tx/ty: once the transform above is scroll-corrected, `dragStartLeft + tx` is exactly where + // the element will end up on screen. + const scrollDX = window.scrollX - dragStartScrollX; + const scrollDY = window.scrollY - dragStartScrollY; + setTransform({ x: tx + scrollDX, y: ty + scrollDY }); + + // Compute draggable rect from initial snapshot + current delta — zero layout reflows during move. + const draggableRect: DragRect = { + id: currentDrag.id, + rect: { + left: dragStartLeft + tx, + right: dragStartLeft + dragStartWidth + tx, + top: dragStartTop + ty, + bottom: dragStartTop + dragStartHeight + ty, + width: dragStartWidth, + height: dragStartHeight, + x: dragStartLeft + tx, + y: dragStartTop + ty, + }, + }; + + const winnerId = collide(draggableRect, cachedRects, { x: pendingX, y: pendingY }); + + let winner: DroppableItem | null = null; + if (winnerId !== null) { + const entry = droppables.get(winnerId); + if (entry && !(entry.accept && !entry.accept(currentDrag))) { + winner = { id: winnerId, data: entry.data, element: entry.element }; + } + } + + // Only write the signal when the hovered zone actually changes. + if (winner?.id !== untrack(over)?.id) setOver(winner); + + options.onDragMove?.(currentDrag, { x: tx, y: ty }); + }; + + const processMove = () => { + rafPending = false; + applyMove(); + maybeAutoScroll(); + }; + + const onPointerMove = (event: PointerEvent) => { + if (!currentDrag) return; + pendingX = event.clientX; + pendingY = event.clientY; + if (!rafPending) { + rafPending = true; + rafId = requestAnimationFrame(() => { + rafPending = false; + processMove(); + }); + } + }; + + const cancelPendingMove = () => { + if (rafPending) { cancelAnimationFrame(rafId); rafPending = false; } + }; + + const finishDrag = () => { + if (!currentDrag) return; + cancelPendingMove(); + + const overItem = untrack(over); + const item = currentDrag; + currentDrag = null; + cleanupDrag(); + options.onDragEnd?.(item, overItem); + + setActive(null); + setOver(null); + setTransform(null); + }; + + const onPointerUp = (_event: PointerEvent) => finishDrag(); + + // Shared by Escape (explicit user cancel) and pointercancel (OS/browser-interrupted + // drag, e.g. a touch gesture cancelled by the system) — both end the drag without a + // drop, so both go through onDragCancel rather than onDragEnd. + const cancelDrag = () => { + if (!currentDrag) return; + cancelPendingMove(); + + const item = currentDrag; + currentDrag = null; + cleanupDrag(); + options.onDragCancel?.(item); + + setActive(null); + setOver(null); + setTransform(null); + }; + + const onPointerCancel = (_event: PointerEvent) => cancelDrag(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") cancelDrag(); + }; + + const onScroll = () => { + if (currentDrag) snapshotRects(currentDrag.id); + }; + + function cleanupDrag() { + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + document.removeEventListener("pointercancel", onPointerCancel); + document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("scroll", onScroll, { capture: true }); + } + + const beginDrag = ( + id: string | number, + element: HTMLElement, + data: unknown, + clientX: number, + clientY: number, + ) => { + startX = clientX; + startY = clientY; + pendingX = clientX; + pendingY = clientY; + dragStartScrollX = window.scrollX; + dragStartScrollY = window.scrollY; + + // Snapshot layout once — all pointermove collision checks use this cache. + const r = element.getBoundingClientRect(); + dragStartLeft = r.left; + dragStartTop = r.top; + dragStartWidth = r.width; + dragStartHeight = r.height; + snapshotRects(id); + + const item: DragItem = { id, data, element }; + currentDrag = item; + setActive(item); + setTransform({ x: 0, y: 0 }); + + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", onPointerUp); + document.addEventListener("pointercancel", onPointerCancel); + document.addEventListener("keydown", onKeyDown); + document.addEventListener("scroll", onScroll, { passive: true, capture: true }); + + options.onDragStart?.(item); + }; + + const _startDrag = (id: string | number, element: HTMLElement, data: unknown, event: PointerEvent) => { + beginDrag(id, element, data, event.clientX, event.clientY); + }; + + const _startKeyboardDrag = (id: string | number, element: HTMLElement, data: unknown) => { + const r = element.getBoundingClientRect(); + beginDrag(id, element, data, r.left + r.width / 2, r.top + r.height / 2); + }; + + const _moveBy = (dx: number, dy: number) => { + if (!currentDrag) return; + pendingX += dx; + pendingY += dy; + applyMove(); + }; + + onCleanup(cleanupDrag); + + const contextValue: DragContextValue = { + active, + over, + transform, + _registerDroppable: (id, element, data, accept) => { + droppables.set(id, { element, data, accept }); + if (currentDrag) snapshotRects(currentDrag.id); + }, + _unregisterDroppable: id => { + droppables.delete(id); + if (currentDrag) snapshotRects(currentDrag.id); + }, + _startDrag, + _startKeyboardDrag, + _moveBy, + _endDrag: finishDrag, + _isActive: id => !!isActiveId[id], + _isOver: id => !!isOverId[id], + }; + + const Provider = (props: { children: Element }): Element => ( + {props.children} + ); + + return { Provider, active, over, transform }; +} + +export { DEFAULT_KEYBOARD_STEP }; diff --git a/packages/drag-drop/src/dom.ts b/packages/drag-drop/src/dom.ts new file mode 100644 index 000000000..042cc6a57 --- /dev/null +++ b/packages/drag-drop/src/dom.ts @@ -0,0 +1,57 @@ +/** + * Delta between two client points, compensated for any window scroll that happened + * in between. Without this, a pointer-anchored drag transform silently drifts by the + * scroll amount if the page scrolls mid-drag (the dragged element moves with the page + * like any other in-flow content, but the reported delta wouldn't account for that). + */ +export function scrollCompensatedDelta( + clientX: number, + clientY: number, + startX: number, + startY: number, + startScrollX: number, + startScrollY: number, +): { x: number; y: number } { + return { + x: clientX - startX + (window.scrollX - startScrollX), + y: clientY - startY + (window.scrollY - startScrollY), + }; +} + +export function applyStyle(el: HTMLElement, style: Partial | undefined): void { + if (!style) return; + for (const [k, v] of Object.entries(style)) { + (el.style as any)[k] = v as string; + } +} + +export function removeStyle(el: HTMLElement, style: Partial | undefined): void { + if (!style) return; + for (const k of Object.keys(style)) { + (el.style as any)[k] = ""; + } +} + +export function applyClass(el: HTMLElement, classes: string | undefined): void { + if (!classes) return; + for (const cls of classes.split(" ")) { + if (cls) el.classList.add(cls); + } +} + +export function removeClass(el: HTMLElement, classes: string | undefined): void { + if (!classes) return; + for (const cls of classes.split(" ")) { + if (cls) el.classList.remove(cls); + } +} + +/** + * Marks an element as a keyboard-reachable drag handle — makes it focusable and + * announces its role, without clobbering any of these the consumer already set explicitly. + */ +export function markAsDraggable(el: HTMLElement): void { + if (!el.hasAttribute("tabindex")) el.tabIndex = 0; + if (!el.hasAttribute("role")) el.setAttribute("role", "button"); + if (!el.hasAttribute("aria-roledescription")) el.setAttribute("aria-roledescription", "draggable"); +} diff --git a/packages/drag-drop/src/draggable.ts b/packages/drag-drop/src/draggable.ts new file mode 100644 index 000000000..1db4dd1e7 --- /dev/null +++ b/packages/drag-drop/src/draggable.ts @@ -0,0 +1,288 @@ +import { createEffect, createMemo, createSignal, flush } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { access, INTERNAL_OPTIONS, noop } from "@solid-primitives/utils"; +import { DEFAULT_KEYBOARD_STEP, useDragContext } from "./context.tsx"; +import { applyClass, applyStyle, markAsDraggable, removeClass, removeStyle, scrollCompensatedDelta } from "./dom.ts"; +import type { CreateDraggableOptions, DraggableReturn, MakeDraggableOptions, Transform } from "./types.ts"; + +/** + * Attaches pointer-based drag behaviour to an existing element. + * Non-reactive — no Solid owner required. Returns a cleanup function. + * + * Note: `pointerenter`/`pointerleave` on drop targets won't fire reliably + * while the dragged element is under the pointer unless you set + * `pointer-events: none` on it during the drag. The context-based + * `createDroppable` uses rect collision and is not affected. + * + * @example + * ```ts + * const cleanup = makeDraggable(el, { + * onStart: e => console.log("start", e.clientX), + * onMove: delta => el.style.transform = `translate(${delta.x}px,${delta.y}px)`, + * onEnd: () => el.style.transform = "", + * }); + * ``` + */ +export function makeDraggable( + el: HTMLElement, + options: MakeDraggableOptions = {}, +): VoidFunction { + if (isServer) return noop; + + let startX = 0; + let startY = 0; + let startScrollX = 0; + let startScrollY = 0; + + const onPointerMove = (event: PointerEvent) => { + const delta = scrollCompensatedDelta(event.clientX, event.clientY, startX, startY, startScrollX, startScrollY); + options.onMove?.(delta, event); + }; + + // Shared by pointerup (normal end) and pointercancel (OS/browser-interrupted drag, + // e.g. a touch gesture cancelled by the system) — both must stop tracking and + // report an end, or the document-level listeners leak and the caller never learns + // the drag stopped. + const stopTracking = () => { + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + document.removeEventListener("pointercancel", onPointerCancel); + }; + + const onPointerUp = (event: PointerEvent) => { + stopTracking(); + const delta = scrollCompensatedDelta(event.clientX, event.clientY, startX, startY, startScrollX, startScrollY); + options.onEnd?.(delta, event); + }; + + const onPointerCancel = (event: PointerEvent) => { + stopTracking(); + const delta = scrollCompensatedDelta(event.clientX, event.clientY, startX, startY, startScrollX, startScrollY); + options.onEnd?.(delta, event); + }; + + const onPointerDown = (event: PointerEvent) => { + if (options.disabled) return; + if (event.button !== 0) return; + event.preventDefault(); + startX = event.clientX; + startY = event.clientY; + startScrollX = window.scrollX; + startScrollY = window.scrollY; + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", onPointerUp); + document.addEventListener("pointercancel", onPointerCancel); + options.onStart?.(event); + }; + + el.addEventListener("pointerdown", onPointerDown); + + return () => { + el.removeEventListener("pointerdown", onPointerDown); + stopTracking(); + }; +} + +/** + * Reactive draggable primitive. Attach to a JSX element via `ref={drag.ref}`. + * + * When used inside a `createDragContext` Provider, drag state is coordinated + * with registered droppables. Without a Provider, `isDragging` and `transform` + * still work in standalone mode. + * + * Keyboard-accessible: the element receives `tabindex`/`role`/`aria-roledescription` + * automatically (unless already set). `Space`/`Enter` picks up the drag, arrow keys + * nudge it by `keyboardStep` pixels (default 25), `Space`/`Enter` drops it, and + * `Escape` cancels — mirroring the pointer sensor's lifecycle. + * + * @example + * ```tsx + * const drag = createDraggable("card-1", { title: "My card" }, { + * class: "cursor-grab", + * draggingClass: "opacity-50 cursor-grabbing", + * }); + *
{drag.isDragging() ? "dragging…" : "drag me"}
+ * ``` + */ +export function createDraggable( + id: string | number, + data?: T, + options: CreateDraggableOptions = {}, +): DraggableReturn { + if (isServer) { + return { ref: noop, isDragging: () => false, transform: () => null, id }; + } + + const ctx = useDragContext(); + const [elSignal, setElSignal] = createSignal(undefined, INTERNAL_OPTIONS); + const step = () => options.keyboardStep ?? DEFAULT_KEYBOARD_STEP; + + let isDragging: () => boolean; + let currentTransform: () => Transform | null; + + if (ctx) { + // `ctx._isActive` is a createProjection lookup — checking it doesn't mark every other + // draggable's `isDragging` stale on each drag start/end, unlike `ctx.active()?.id === id`. + isDragging = () => ctx._isActive(id); + currentTransform = createMemo(() => (isDragging() ? ctx.transform() : null)); + } else { + const [_isDragging, setIsDragging] = createSignal(false, INTERNAL_OPTIONS); + const [_transform, setTransform] = createSignal(null, INTERNAL_OPTIONS); + isDragging = _isDragging; + currentTransform = _transform; + + let startX = 0; + let startY = 0; + let startScrollX = 0; + let startScrollY = 0; + + const onPointerMove = (event: PointerEvent) => { + setTransform(scrollCompensatedDelta(event.clientX, event.clientY, startX, startY, startScrollX, startScrollY)); + }; + + // Shared by pointerup (normal end) and pointercancel (OS/browser-interrupted drag) — + // both must stop tracking and reset state, or isDragging/transform get stuck. + const stopTracking = () => { + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + document.removeEventListener("pointercancel", onPointerCancel); + setIsDragging(false); + setTransform(null); + }; + + const onPointerUp = () => stopTracking(); + const onPointerCancel = () => stopTracking(); + + const nudge = (dx: number, dy: number) => { + setTransform(t => ({ x: (t?.x ?? 0) + dx, y: (t?.y ?? 0) + dy })); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (access(options.disabled)) return; + + if (!_isDragging()) { + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + setIsDragging(true); + setTransform({ x: 0, y: 0 }); + } + return; + } + + switch (event.key) { + case "ArrowUp": event.preventDefault(); nudge(0, -step()); break; + case "ArrowDown": event.preventDefault(); nudge(0, step()); break; + case "ArrowLeft": event.preventDefault(); nudge(-step(), 0); break; + case "ArrowRight": event.preventDefault(); nudge(step(), 0); break; + case " ": + case "Enter": + case "Escape": + event.preventDefault(); + setIsDragging(false); + setTransform(null); + break; + } + }; + + createEffect( + () => elSignal(), + el => { + if (!el) return; + const onPointerDown = (event: PointerEvent) => { + if (access(options.disabled)) return; + if (event.button !== 0) return; + event.preventDefault(); + el.setPointerCapture(event.pointerId); + startX = event.clientX; + startY = event.clientY; + startScrollX = window.scrollX; + startScrollY = window.scrollY; + setIsDragging(true); + setTransform({ x: 0, y: 0 }); + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", onPointerUp); + document.addEventListener("pointercancel", onPointerCancel); + }; + el.addEventListener("pointerdown", onPointerDown); + el.addEventListener("keydown", onKeyDown); + return () => { + el.removeEventListener("pointerdown", onPointerDown); + el.removeEventListener("keydown", onKeyDown); + stopTracking(); + }; + }, + ); + } + + // Context mode: attach pointerdown/keydown listeners only (registration handled by droppables). + if (ctx) { + createEffect( + () => elSignal(), + el => { + if (!el) return; + const onPointerDown = (event: PointerEvent) => { + if (access(options.disabled)) return; + if (event.button !== 0) return; + event.preventDefault(); + el.setPointerCapture(event.pointerId); + ctx._startDrag(id, el, data, event); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (access(options.disabled)) return; + + if (!isDragging()) { + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + ctx._startKeyboardDrag(id, el, data); + } + return; + } + + switch (event.key) { + case "ArrowUp": event.preventDefault(); ctx._moveBy(0, -step()); break; + case "ArrowDown": event.preventDefault(); ctx._moveBy(0, step()); break; + case "ArrowLeft": event.preventDefault(); ctx._moveBy(-step(), 0); break; + case "ArrowRight": event.preventDefault(); ctx._moveBy(step(), 0); break; + case " ": + case "Enter": + event.preventDefault(); + ctx._endDrag(); + break; + // Escape is handled by the context's own document-level listener. + } + }; + el.addEventListener("pointerdown", onPointerDown); + el.addEventListener("keydown", onKeyDown); + return () => { + el.removeEventListener("pointerdown", onPointerDown); + el.removeEventListener("keydown", onKeyDown); + }; + }, + ); + } + + // Reactive draggingStyle / draggingClass + createEffect( + () => ({ dragging: isDragging(), el: elSignal() }), + ({ dragging, el }) => { + if (!el) return; + if (dragging) { + applyStyle(el, options.draggingStyle); + applyClass(el, options.draggingClass); + } else { + removeStyle(el, options.draggingStyle); + removeClass(el, options.draggingClass); + } + }, + ); + + const ref = (el: HTMLElement) => { + setElSignal(() => el); + applyStyle(el, options.style); + applyClass(el, options.class); + markAsDraggable(el); + flush(); + }; + + return { ref, isDragging, transform: currentTransform, id }; +} diff --git a/packages/drag-drop/src/droppable.ts b/packages/drag-drop/src/droppable.ts new file mode 100644 index 000000000..7175afbcf --- /dev/null +++ b/packages/drag-drop/src/droppable.ts @@ -0,0 +1,318 @@ +import { createEffect, createMemo, createSignal, DEV, flush } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { access, INTERNAL_OPTIONS, noop } from "@solid-primitives/utils"; +import { useDragContext } from "./context.tsx"; +import { applyClass, applyStyle, removeClass, removeStyle } from "./dom.ts"; +import type { + CreateDroppableOptions, + CreateNativeDroppableOptions, + DragItem, + DroppableReturn, + MakeDroppableOptions, + MakeNativeDroppableOptions, + NativeDroppableReturn, +} from "./types.ts"; + +/** + * Attaches pointer-based drop-zone behaviour to an existing element. + * Non-reactive — no Solid owner required. Returns a cleanup function. + * + * Note: coordination with `makeDraggable` requires that the dragging element + * has `pointer-events: none` applied during the drag, otherwise + * `pointerenter`/`pointerleave` will not reach this element. + * + * @example + * ```ts + * const cleanup = makeDroppable(el, { + * onEnter: () => el.classList.add("over"), + * onLeave: () => el.classList.remove("over"), + * onDrop: () => acceptDrop(), + * }); + * ``` + */ +export function makeDroppable(el: HTMLElement, options: MakeDroppableOptions = {}): VoidFunction { + if (isServer) return noop; + + const onPointerEnter = (event: PointerEvent) => { + if (options.disabled) return; + options.onEnter?.(event); + }; + + const onPointerLeave = (event: PointerEvent) => { + if (options.disabled) return; + options.onLeave?.(event); + }; + + const onPointerUp = (event: PointerEvent) => { + if (options.disabled) return; + options.onDrop?.(event); + }; + + el.addEventListener("pointerenter", onPointerEnter); + el.addEventListener("pointerleave", onPointerLeave); + el.addEventListener("pointerup", onPointerUp); + + return () => { + el.removeEventListener("pointerenter", onPointerEnter); + el.removeEventListener("pointerleave", onPointerLeave); + el.removeEventListener("pointerup", onPointerUp); + }; +} + +/** + * Reactive droppable primitive. Attach to a JSX element via `ref={drop.ref}`. + * + * Requires a `createDragContext` Provider ancestor to coordinate with draggables. + * Without a Provider, `isOver` and `active` remain at their initial values (a dev-mode + * warning is logged in that case, since it usually means a missing ``). + * + * @example + * ```tsx + * const drop = createDroppable("zone-1", myData, { + * class: "border-2 border-dashed border-transparent", + * overClass: "border-indigo-500 bg-indigo-50", + * }); + *
{drop.isOver() ? "release to drop" : "drop here"}
+ * ``` + */ +export function createDroppable( + id: string | number, + data?: T, + options: CreateDroppableOptions = {}, +): DroppableReturn { + if (isServer) { + return { ref: noop, isOver: () => false, active: () => null, id }; + } + + const ctx = useDragContext(); + + if (DEV && !ctx) { + // oxlint-disable-next-line no-console + console.warn( + "[@solid-primitives/drag-drop] createDroppable/createSortable was used without a " + + "createDragContext ancestor — isOver/active will stay false/null. Wrap it in the " + + " returned by createDragContext().", + ); + } + + const [elSignal, setElSignal] = createSignal(undefined, INTERNAL_OPTIONS); + + // `ctx._isOver` is a createProjection lookup — checking it doesn't mark every other + // droppable's `isOver` stale on each hover change, unlike `ctx.over()?.id === id`. + const isOver: () => boolean = ctx ? () => ctx._isOver(id) : () => false; + + const active: () => DragItem | null = ctx + ? createMemo((): DragItem | null => (isOver() ? ctx.active() : null)) + : () => null; + + // Single effect tracks both element and disabled — avoids stale element reads. + if (ctx) { + createEffect( + () => ({ el: elSignal(), disabled: access(options.disabled) }), + ({ el, disabled }) => { + if (!el || disabled) { + ctx._unregisterDroppable(id); + return; + } + ctx._registerDroppable(id, el, data, options.accept); + return () => ctx._unregisterDroppable(id); + }, + ); + } + + // Reactive overStyle / overClass + createEffect( + () => ({ over: isOver(), el: elSignal() }), + ({ over, el }) => { + if (!el) return; + if (over) { + applyStyle(el, options.overStyle); + applyClass(el, options.overClass); + } else { + removeStyle(el, options.overStyle); + removeClass(el, options.overClass); + } + }, + ); + + const ref = (el: HTMLElement) => { + setElSignal(() => el); + applyStyle(el, options.style); + applyClass(el, options.class); + flush(); + }; + + return { ref, isOver, active, id }; +} + +/** + * Attaches native HTML5 drag-event listeners to an existing element. + * Designed for OS file drops and `draggable="true"` element drops. + * Non-reactive — no Solid owner required. Returns a cleanup function. + * + * Handles the dragenter/dragleave child-element depth problem internally + * so `onEnter` / `onLeave` fire exactly once per zone entry/exit. + * + * @example + * ```ts + * const cleanup = makeNativeDroppable(el, { + * accept: e => e.dataTransfer?.types.includes("Files") ?? false, + * onEnter: () => el.classList.add("over"), + * onLeave: () => el.classList.remove("over"), + * onDrop: e => handleFiles(e.dataTransfer!.files), + * }); + * ``` + */ +export function makeNativeDroppable( + el: HTMLElement, + options: MakeNativeDroppableOptions = {}, +): VoidFunction { + if (isServer) return noop; + + // Depth counter handles dragenter/dragleave from child elements + let depth = 0; + + const onDragEnter = (event: DragEvent) => { + if (options.disabled) return; + if (options.accept && !options.accept(event)) { + event.dataTransfer && (event.dataTransfer.dropEffect = "none"); + return; + } + depth++; + if (depth === 1) options.onEnter?.(event); + }; + + const onDragLeave = (event: DragEvent) => { + if (options.disabled) return; + if (options.accept && !options.accept(event)) return; + if (depth > 0) depth--; + if (depth === 0) options.onLeave?.(event); + }; + + const onDragOver = (event: DragEvent) => { + event.preventDefault(); + if (options.disabled) return; + if (options.accept && !options.accept(event)) { + event.dataTransfer && (event.dataTransfer.dropEffect = "none"); + return; + } + options.onOver?.(event); + }; + + const onDrop = (event: DragEvent) => { + event.preventDefault(); + depth = 0; + if (options.disabled) return; + if (options.accept && !options.accept(event)) return; + options.onDrop?.(event); + }; + + el.addEventListener("dragenter", onDragEnter); + el.addEventListener("dragleave", onDragLeave); + el.addEventListener("dragover", onDragOver); + el.addEventListener("drop", onDrop); + + return () => { + el.removeEventListener("dragenter", onDragEnter); + el.removeEventListener("dragleave", onDragLeave); + el.removeEventListener("dragover", onDragOver); + el.removeEventListener("drop", onDrop); + }; +} + +/** + * Reactive native drop-zone primitive. Attach to a JSX element via `ref={drop.ref}`. + * Use this as the foundation for file-upload drop zones (see `createDropzone` in + * the upload package) or any element that accepts OS / browser-native drag payloads. + * + * @example + * ```tsx + * const drop = createNativeDroppable({ + * accept: e => e.dataTransfer?.types.includes("Files") ?? false, + * onDrop: e => handleFiles(e.dataTransfer!.files), + * }); + *
+ * Drop files here + *
+ * ``` + */ +export function createNativeDroppable( + options: CreateNativeDroppableOptions = {}, +): NativeDroppableReturn { + if (isServer) { + return { ref: noop, isOver: () => false }; + } + + const [elSignal, setElSignal] = createSignal(undefined, INTERNAL_OPTIONS); + const [isOver, setIsOver] = createSignal(false, INTERNAL_OPTIONS); + + createEffect( + () => elSignal(), + el => { + if (!el) return; + + let depth = 0; + + const onDragEnter = (event: DragEvent) => { + if (access(options.disabled)) return; + if (options.accept && !options.accept(event)) { + event.dataTransfer && (event.dataTransfer.dropEffect = "none"); + return; + } + depth++; + if (depth === 1) { + setIsOver(true); + options.onEnter?.(event); + } + }; + + const onDragLeave = (event: DragEvent) => { + if (access(options.disabled)) return; + if (options.accept && !options.accept(event)) return; + if (depth > 0) depth--; + if (depth === 0) { + setIsOver(false); + options.onLeave?.(event); + } + }; + + const onDragOver = (event: DragEvent) => { + event.preventDefault(); + if (access(options.disabled)) return; + if (options.accept && !options.accept(event)) { + event.dataTransfer && (event.dataTransfer.dropEffect = "none"); + return; + } + options.onOver?.(event); + }; + + const onDrop = (event: DragEvent) => { + event.preventDefault(); + depth = 0; + setIsOver(false); + if (access(options.disabled)) return; + if (options.accept && !options.accept(event)) return; + options.onDrop?.(event); + }; + + el.addEventListener("dragenter", onDragEnter); + el.addEventListener("dragleave", onDragLeave); + el.addEventListener("dragover", onDragOver); + el.addEventListener("drop", onDrop); + + return () => { + el.removeEventListener("dragenter", onDragEnter); + el.removeEventListener("dragleave", onDragLeave); + el.removeEventListener("dragover", onDragOver); + el.removeEventListener("drop", onDrop); + }; + }, + ); + + const ref = (el: HTMLElement) => { + setElSignal(() => el); + flush(); + }; + + return { ref, isOver }; +} diff --git a/packages/drag-drop/src/index.ts b/packages/drag-drop/src/index.ts new file mode 100644 index 000000000..5b35fc216 --- /dev/null +++ b/packages/drag-drop/src/index.ts @@ -0,0 +1,38 @@ +export type { + Transform, + Point, + Rect, + DragItem, + DroppableItem, + DragRect, + DroppableRect, + CollisionDetector, + AcceptPredicate, + MakeDraggableOptions, + MakeDroppableOptions, + MakeNativeDroppableOptions, + CreateDraggableOptions, + CreateDroppableOptions, + CreateNativeDroppableOptions, + DraggableReturn, + DroppableReturn, + NativeDroppableReturn, + SortableReturn, + DragContextOptions, + DragContextReturn, +} from "./types.ts"; + +export { closestCenter, closestCorners, rectIntersection, pointerWithin } from "./collision.ts"; + +export { createDragContext } from "./context.tsx"; + +export { makeDraggable, createDraggable } from "./draggable.ts"; + +export { + makeDroppable, + createDroppable, + makeNativeDroppable, + createNativeDroppable, +} from "./droppable.ts"; + +export { createSortable, arrayMove } from "./sortable.ts"; diff --git a/packages/drag-drop/src/sortable.ts b/packages/drag-drop/src/sortable.ts new file mode 100644 index 000000000..c6e8da587 --- /dev/null +++ b/packages/drag-drop/src/sortable.ts @@ -0,0 +1,102 @@ +import { isServer } from "@solidjs/web"; +import { noop } from "@solid-primitives/utils"; +import { createDraggable } from "./draggable.ts"; +import { createDroppable } from "./droppable.ts"; +import type { SortableReturn } from "./types.ts"; + +/** + * Reorders an array by moving the item at `from` to `to`, returning a new array — + * the array itself and the item order at both indices are left untouched if either + * index is out of range or they're equal. Pairs naturally with `createDragContext`'s + * `onDragEnd` for reordering a `createSortable` list. + * + * @example + * ```ts + * const ctx = createDragContext({ + * onDragEnd: (dragged, over) => { + * if (!over) return; + * setItems(items => arrayMove( + * items, + * items.findIndex(i => i.id === dragged.id), + * items.findIndex(i => i.id === over.id), + * )); + * }, + * }); + * ``` + */ +export function arrayMove(array: readonly T[], from: number, to: number): T[] { + const next = array.slice(); + if ( + !Number.isInteger(from) || + !Number.isInteger(to) || + from < 0 || + from >= array.length || + to < 0 || + to >= array.length || + from === to + ) { + return next; + } + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved as T); + return next; +} + +/** + * Combines `createDraggable` and `createDroppable` on the same element. + * The element can both be dragged and serve as a drop target for other items. + * + * When inside a `createDragContext`, a sortable never registers as its own + * drop target — the context filters out the active draggable from collision + * detection, so `isOver` is always false while this item is being dragged. + * + * @example + * ```tsx + * const [items, setItems] = createSignal(["A", "B", "C"]); + * + * + * {(item) => { + * const sortable = createSortable(item); + * return ( + *
+ * {item} + *
+ * ); + * }} + *
+ * ``` + */ +export function createSortable( + id: string | number, + data?: T, +): SortableReturn { + if (isServer) { + return { + ref: noop, + isDragging: () => false, + transform: () => null, + isOver: () => false, + active: () => null, + isActiveDropzone: () => false, + id, + }; + } + + const draggable = createDraggable(id, data); + const droppable = createDroppable(id, data); + + const ref = (el: HTMLElement) => { + draggable.ref(el); + droppable.ref(el); + }; + + return { + ref, + isDragging: draggable.isDragging, + transform: draggable.transform, + isOver: droppable.isOver, + active: droppable.active, + isActiveDropzone: droppable.isOver, + id, + }; +} diff --git a/packages/drag-drop/src/types.ts b/packages/drag-drop/src/types.ts new file mode 100644 index 000000000..8314b0d16 --- /dev/null +++ b/packages/drag-drop/src/types.ts @@ -0,0 +1,138 @@ +import type { Accessor, Element } from "solid-js"; + +export type Transform = { x: number; y: number }; +export type Point = { x: number; y: number }; + +export type DragItem = { + id: string | number; + data: T; + element: HTMLElement; +}; + +export type DroppableItem = { + id: string | number; + data: T; + element: HTMLElement; +}; + +export type Rect = { + left: number; + right: number; + top: number; + bottom: number; + width: number; + height: number; + x: number; + y: number; +}; +export type DragRect = { id: string | number; rect: Rect }; +export type DroppableRect = { id: string | number; rect: Rect }; + +export type CollisionDetector = ( + draggable: DragRect, + droppables: DroppableRect[], + pointer: Point, +) => string | number | null; + +export type MakeDraggableOptions = { + data?: T; + onStart?: (event: PointerEvent) => void; + onMove?: (delta: Transform, event: PointerEvent) => void; + onEnd?: (delta: Transform, event: PointerEvent) => void; + disabled?: boolean; +}; + +export type MakeDroppableOptions = { + onEnter?: (event: PointerEvent) => void; + onLeave?: (event: PointerEvent) => void; + onDrop?: (event: PointerEvent) => void; + disabled?: boolean; +}; + +export type MakeNativeDroppableOptions = { + onEnter?: (event: DragEvent) => void; + onLeave?: (event: DragEvent) => void; + onOver?: (event: DragEvent) => void; + onDrop?: (event: DragEvent) => void; + /** Return false to reject; checked on dragenter, dragover, and drop. */ + accept?: (event: DragEvent) => boolean; + disabled?: boolean; +}; + +export type CreateDraggableOptions = { + disabled?: boolean | Accessor; + style?: Partial; + draggingStyle?: Partial; + class?: string; + draggingClass?: string; + /** Pixels moved per arrow-key press while picked up via keyboard. Defaults to 25. */ + keyboardStep?: number; +}; + +export type AcceptPredicate = (draggable: DragItem) => boolean; + +export type CreateDroppableOptions = { + disabled?: boolean | Accessor; + /** Called fresh on every collision check — read a signal inside it for reactive accept logic. */ + accept?: AcceptPredicate; + style?: Partial; + overStyle?: Partial; + class?: string; + overClass?: string; +}; + +export type CreateNativeDroppableOptions = MakeNativeDroppableOptions & { + disabled?: boolean | Accessor; +}; + +export type DraggableReturn<_T = unknown> = { + ref: (el: HTMLElement) => void; + isDragging: Accessor; + transform: Accessor; + id: string | number; +}; + +export type DroppableReturn<_T = unknown> = { + ref: (el: HTMLElement) => void; + isOver: Accessor; + active: Accessor; + id: string | number; +}; + +export type NativeDroppableReturn = { + ref: (el: HTMLElement) => void; + isOver: Accessor; +}; + +export type SortableReturn<_T = unknown> = { + ref: (el: HTMLElement) => void; + isDragging: Accessor; + transform: Accessor; + isOver: Accessor; + active: Accessor; + isActiveDropzone: Accessor; + id: string | number; +}; + +export type DragContextOptions = { + collisionDetection?: CollisionDetector; + onDragStart?: (item: DragItem) => void; + onDragMove?: (item: DragItem, transform: Transform) => void; + onDragEnd?: (item: DragItem, over: DroppableItem | null) => void; + onDragCancel?: (item: DragItem) => void; + /** Pixels moved per arrow-key press while picked up via keyboard. Defaults to 25. */ + keyboardStep?: number; + /** + * Auto-scroll the window when the pointer nears the viewport edge during a drag. + * `true` uses the default threshold/speed; pass an object to tune them, or omit / + * `false` to disable. Ignored for keyboard-driven drags. + */ + autoScroll?: boolean | { threshold?: number; speed?: number }; +}; + +export type DragContextReturn = { + Provider: (props: { children: Element }) => Element; + active: Accessor; + over: Accessor; + transform: Accessor; +}; diff --git a/packages/drag-drop/stories/drag-drop.stories.tsx b/packages/drag-drop/stories/drag-drop.stories.tsx new file mode 100644 index 000000000..2144cc4b7 --- /dev/null +++ b/packages/drag-drop/stories/drag-drop.stories.tsx @@ -0,0 +1,490 @@ +import { createMemo, createSignal, For } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import readme from "../README.md?raw"; +import { + BoolRow, + Button, + ButtonRow, + Card, + Container, + StatRow, +} from "../../../.storybook/ui/index.js"; +import { + createDraggable, + createDroppable, + createDragContext, + createNativeDroppable, + createSortable, + closestCenter, + closestCorners, + rectIntersection, + pointerWithin, +} from "@solid-primitives/drag-drop"; +import type { CollisionDetector } from "@solid-primitives/drag-drop"; + +const meta = preview.meta({ + title: "UI Patterns/Drag & Drop", + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: readme, + }, + }, + }, +}); + +export default meta; + +// ── Story 1: createDraggable standalone ─────────────────────────────────────── + +export const StandaloneDrag = meta.story({ + name: "Floating card", + parameters: { + layout: "centered", + docs: { + description: { + story: + "`createDraggable` tracks pointer position and exposes `isDragging` and `transform`. Apply the transform in JSX to move the element. `draggingStyle` and `draggingClass` are applied automatically by the primitive while dragging — no manual effect needed.", + }, + }, + }, + render: () => { + const drag = createDraggable("card", undefined, { + draggingStyle: { + boxShadow: "0 20px 48px rgba(0,0,0,.18)", + opacity: "0.92", + }, + }); + + return ( +
+
+
+ {drag.isDragging() ? "Dragging…" : "Drag me"} +
+
+ + + + + + + +

+ The card springs back when released. draggingStyle adds the shadow + automatically — the primitive applies and removes it without any manual effect. +

+
+ ); + }, +}); + +// ── Story 2: createDragContext ──────────────────────────────────────────────── + +type Strategy = { label: string; fn: CollisionDetector }; +const STRATEGIES: Strategy[] = [ + { label: "pointerWithin", fn: pointerWithin }, + { label: "closestCenter", fn: closestCenter }, + { label: "closestCorners", fn: closestCorners }, + { label: "rectIntersection", fn: rectIntersection }, +]; + +const ZONES = [ + { id: "red", label: "Red zone", bg: "#fee2e2", active: "#ef4444" }, + { id: "green", label: "Green zone", bg: "#dcfce7", active: "#22c55e" }, + { id: "blue", label: "Blue zone", bg: "#dbeafe", active: "#3b82f6" }, +]; + +export const DragAndDrop = meta.story({ + name: "Drop zones with collision strategies", + parameters: { + layout: "centered", + docs: { + description: { + story: + "`createDragContext` coordinates a tree of draggables and droppables via a shared Provider. Collision detection resolves which droppable is active — swap strategies with the buttons below to see how each behaves.", + }, + }, + }, + render: () => { + const [strategyIdx, setStrategyIdx] = createSignal(0); + const [lastDrop, setLastDrop] = createSignal(null); + + const ctx = createDragContext({ + collisionDetection: (...args) => STRATEGIES[strategyIdx()]!.fn(...args), + onDragEnd: (item, over) => + setLastDrop(over ? `"${item.id}" → "${over.id}"` : `"${item.id}" → (none)`), + }); + + // createDraggable and createDroppable must be called inside ctx.Provider's + // children so useDragContext() finds the provider in the reactive owner chain. + function DemoContent() { + const drag = createDraggable("chip", undefined, { + draggingStyle: { boxShadow: "0 12px 32px rgba(0,0,0,.15)", opacity: "0.9" }, + }); + + return ( + <> + {/* Drop zones */} +
+ + {zone => { + const drop = createDroppable(zone.id); + return ( +
+ {zone.label} +
+ ); + }} +
+
+ + {/* Draggable chip */} +
+
+ chip +
+
+ + ); + } + + return ( + +
+ + + {/* Collision strategy selector */} + + + {(s, i) => ( + + )} + + + + + + + + + + +
+
+ ); + }, +}); + +// ── Story 3: createSortable ─────────────────────────────────────────────────── + +type SortItem = { id: string; label: string; color: string }; + +const INITIAL_ITEMS: SortItem[] = [ + { id: "a", label: "Apple", color: "#fee2e2" }, + { id: "b", label: "Banana", color: "#fef9c3" }, + { id: "c", label: "Cherry", color: "#fce7f3" }, + { id: "d", label: "Date", color: "#f3e8ff" }, + { id: "e", label: "Elderberry", color: "#dbeafe" }, +]; + +// Item height (padding × 2 + line-height) + gap — used to compute neighbour shifts. +// Must match the item's rendered size: 0.625rem×2 padding + ~1.4 line-height at 0.9rem ≈ 44px, gap 0.4rem ≈ 6px. +const SORTABLE_STRIDE = 50; + +export const SortableList = meta.story({ + name: "Reorderable list", + parameters: { + docs: { + description: { + story: + "`createSortable` composes `createDraggable` and `createDroppable` on the same element. While dragging, neighbouring items shift via `translateY` to show where the item will land. The actual reorder (and the snap-back) happens in `onDragEnd`.", + }, + }, + }, + render: () => { + const [items, setItems] = createSignal(INITIAL_ITEMS); + + const ctx = createDragContext({ + collisionDetection: closestCenter, + onDragEnd: (dragged, over) => { + if (!over) return; + setItems(prev => { + const from = prev.findIndex(i => i.id === dragged.id); + const to = prev.findIndex(i => i.id === over.id); + if (from < 0 || to < 0 || from === to) return prev; + const next = [...prev]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved!); + return next; + }); + }, + }); + + return ( + +
+

+ Drag items to reorder +

+
+ + {(item, index) => { + const s = createSortable(item.id, item); + + // Compute how much this item should shift while another item is dragged + // over a new position. Items between the drag source and the hover target + // shift by one stride to open a visual slot for the floating item. + const shift = createMemo(() => { + if (s.isDragging()) return 0; + const active = ctx.active(); + const over = ctx.over(); + if (!active || !over) return 0; + const fromIdx = items().findIndex(i => i.id === active.id); + const toIdx = items().findIndex(i => i.id === over.id); + const myIdx = index(); + if (fromIdx < toIdx && myIdx > fromIdx && myIdx <= toIdx) return -SORTABLE_STRIDE; + if (fromIdx > toIdx && myIdx >= toIdx && myIdx < fromIdx) return SORTABLE_STRIDE; + return 0; + }); + + return ( +
+ ⠿ {item.label} +
+ ); + }} +
+
+ + + + +
+
+ ); + }, +}); + +// ── Story 4: createNativeDroppable ──────────────────────────────────────────── + +export const NativeFileDrop = meta.story({ + name: "File drop zone", + parameters: { + docs: { + description: { + story: + "`createNativeDroppable` tracks OS-level drag events (files, links, text) via HTML5 `DragEvent`. It solves the `dragenter`/`dragleave` child-element depth problem internally, so `isOver` transitions cleanly regardless of nested elements. Use as the drop-zone backend for `@solid-primitives/upload`.", + }, + }, + }, + render: () => { + const [files, setFiles] = createSignal([]); + + const drop = createNativeDroppable({ + accept: e => (e.dataTransfer?.types ?? []).includes("Files"), + onDrop: e => { + const names = Array.from(e.dataTransfer?.files ?? []).map(f => f.name); + setFiles(prev => [...names, ...prev].slice(0, 8)); + }, + }); + + return ( + +
+ {drop.isOver() ? "⬇️" : "📁"} + + {drop.isOver() ? "Release to drop" : "Drag files here"} + +
+ + + + + +
+
+ Dropped files +
+
+ + None yet +

+ } + > + {name => ( +
+ {name} +
+ )} +
+
+
+ + + + +
+ ); + }, +}); diff --git a/packages/drag-drop/stories/tsconfig.json b/packages/drag-drop/stories/tsconfig.json new file mode 100644 index 000000000..0cd588e9e --- /dev/null +++ b/packages/drag-drop/stories/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../.storybook/tsconfig.json", + "include": ["./**/*"] +} diff --git a/packages/drag-drop/test/index.test.ts b/packages/drag-drop/test/index.test.ts new file mode 100644 index 000000000..b343a35ba --- /dev/null +++ b/packages/drag-drop/test/index.test.ts @@ -0,0 +1,1342 @@ +import "./setup.js"; +import { createRoot, flush } from "solid-js"; +import { render } from "@solidjs/web"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + makeDraggable, + makeDroppable, + makeNativeDroppable, + createDraggable, + createDroppable, + createNativeDroppable, + createDragContext, + createSortable, + arrayMove, + closestCenter, + closestCorners, + rectIntersection, + pointerWithin, +} from "../src/index.js"; +import type { DraggableReturn, DroppableReturn, Transform } from "../src/index.js"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function ptr(target: EventTarget, type: string, init: PointerEventInit = {}) { + target.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, ...init })); +} + +function drag(target: EventTarget, type: string, init: DragEventInit = {}) { + target.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, ...init })); +} + +function el() { + return document.createElement("div"); +} + +function mockRect(element: HTMLElement, rect: Partial) { + Object.defineProperty(element, "getBoundingClientRect", { + value: () => ({ + left: 0, top: 0, right: 100, bottom: 100, + width: 100, height: 100, x: 0, y: 0, + toJSON: () => ({}), + ...rect, + }), + configurable: true, + }); +} + +function mockScroll(x: number, y: number) { + Object.defineProperty(window, "scrollX", { value: x, configurable: true }); + Object.defineProperty(window, "scrollY", { value: y, configurable: true }); +} + +function key(target: EventTarget, keyName: string) { + target.dispatchEvent(new KeyboardEvent("keydown", { key: keyName, bubbles: true, cancelable: true })); +} + +afterEach(() => { + mockScroll(0, 0); +}); + +// ── makeDraggable ───────────────────────────────────────────────────────────── + +describe("makeDraggable", () => { + it("calls onStart on left pointerdown", () => { + const div = el(); + let started = false; + const cleanup = makeDraggable(div, { onStart: () => { started = true; } }); + ptr(div, "pointerdown", { button: 0 }); + expect(started).toBe(true); + cleanup(); + }); + + it("ignores non-left-button events", () => { + const div = el(); + let started = false; + const cleanup = makeDraggable(div, { onStart: () => { started = true; } }); + ptr(div, "pointerdown", { button: 2 }); + expect(started).toBe(false); + cleanup(); + }); + + it("calls onMove with accumulated delta", () => { + const div = el(); + let delta: Transform | null = null; + const cleanup = makeDraggable(div, { onMove: d => { delta = d; } }); + ptr(div, "pointerdown", { button: 0, clientX: 10, clientY: 10 }); + ptr(document, "pointermove", { clientX: 30, clientY: 25 }); + expect(delta).toEqual({ x: 20, y: 15 }); + ptr(document, "pointerup", { clientX: 30, clientY: 25 }); + cleanup(); + }); + + it("calls onEnd with final delta", () => { + const div = el(); + let endDelta: Transform | null = null; + const cleanup = makeDraggable(div, { onEnd: d => { endDelta = d; } }); + ptr(div, "pointerdown", { button: 0, clientX: 0, clientY: 0 }); + ptr(document, "pointerup", { clientX: 5, clientY: 10 }); + expect(endDelta).toEqual({ x: 5, y: 10 }); + cleanup(); + }); + + it("calls onEnd on pointercancel and stops tracking", () => { + const div = el(); + let endCalls = 0; + let moveCalls = 0; + const cleanup = makeDraggable(div, { + onMove: () => moveCalls++, + onEnd: () => endCalls++, + }); + ptr(div, "pointerdown", { button: 0, clientX: 0, clientY: 0 }); + ptr(document, "pointercancel", { clientX: 5, clientY: 10 }); + expect(endCalls).toBe(1); + // tracking must have stopped — a move after cancel should not fire onMove + ptr(document, "pointermove", { clientX: 50, clientY: 50 }); + expect(moveCalls).toBe(0); + cleanup(); + }); + + it("no-ops when disabled", () => { + const div = el(); + let started = false; + const cleanup = makeDraggable(div, { onStart: () => { started = true; }, disabled: true }); + ptr(div, "pointerdown", { button: 0 }); + expect(started).toBe(false); + cleanup(); + }); + + it("stops firing after cleanup", () => { + const div = el(); + let count = 0; + const cleanup = makeDraggable(div, { onStart: () => count++ }); + cleanup(); + ptr(div, "pointerdown", { button: 0 }); + expect(count).toBe(0); + }); + + it("can be used outside a Solid owner", () => { + expect(() => { + const cleanup = makeDraggable(el()); + cleanup(); + }).not.toThrow(); + }); + + it("compensates onMove delta for scroll that happens mid-drag", () => { + const div = el(); + let delta: Transform | null = null; + const cleanup = makeDraggable(div, { onMove: d => { delta = d; } }); + ptr(div, "pointerdown", { button: 0, clientX: 10, clientY: 10 }); + mockScroll(0, 50); + // Pointer hasn't moved, but the page scrolled — reported delta should include it. + ptr(document, "pointermove", { clientX: 10, clientY: 10 }); + expect(delta).toEqual({ x: 0, y: 50 }); + ptr(document, "pointerup", { clientX: 10, clientY: 10 }); + cleanup(); + }); + + it("compensates onEnd delta for scroll that happens mid-drag", () => { + const div = el(); + let endDelta: Transform | null = null; + const cleanup = makeDraggable(div, { onEnd: d => { endDelta = d; } }); + ptr(div, "pointerdown", { button: 0, clientX: 10, clientY: 10 }); + mockScroll(20, 0); + ptr(document, "pointerup", { clientX: 15, clientY: 10 }); + expect(endDelta).toEqual({ x: 25, y: 0 }); + cleanup(); + }); +}); + +// ── makeDroppable ───────────────────────────────────────────────────────────── + +describe("makeDroppable", () => { + it("calls onEnter on pointerenter", () => { + const div = el(); + let entered = false; + const cleanup = makeDroppable(div, { onEnter: () => { entered = true; } }); + ptr(div, "pointerenter"); + expect(entered).toBe(true); + cleanup(); + }); + + it("calls onLeave on pointerleave", () => { + const div = el(); + let left = false; + const cleanup = makeDroppable(div, { onLeave: () => { left = true; } }); + ptr(div, "pointerleave"); + expect(left).toBe(true); + cleanup(); + }); + + it("calls onDrop on pointerup", () => { + const div = el(); + let dropped = false; + const cleanup = makeDroppable(div, { onDrop: () => { dropped = true; } }); + ptr(div, "pointerup"); + expect(dropped).toBe(true); + cleanup(); + }); + + it("no-ops when disabled", () => { + const div = el(); + let entered = false; + const cleanup = makeDroppable(div, { onEnter: () => { entered = true; }, disabled: true }); + ptr(div, "pointerenter"); + expect(entered).toBe(false); + cleanup(); + }); + + it("stops firing after cleanup", () => { + const div = el(); + let count = 0; + const cleanup = makeDroppable(div, { onEnter: () => count++ }); + cleanup(); + ptr(div, "pointerenter"); + expect(count).toBe(0); + }); +}); + +// ── makeNativeDroppable ─────────────────────────────────────────────────────── + +describe("makeNativeDroppable", () => { + it("calls onEnter on first dragenter", () => { + const div = el(); + let entered = false; + const cleanup = makeNativeDroppable(div, { onEnter: () => { entered = true; } }); + drag(div, "dragenter"); + expect(entered).toBe(true); + cleanup(); + }); + + it("calls onLeave only when depth reaches zero", () => { + const div = el(); + let leaveCount = 0; + const cleanup = makeNativeDroppable(div, { onLeave: () => leaveCount++ }); + drag(div, "dragenter"); // depth = 1 + drag(div, "dragenter"); // depth = 2 (child) + drag(div, "dragleave"); // depth = 1 — no leave yet + expect(leaveCount).toBe(0); + drag(div, "dragleave"); // depth = 0 — fires + expect(leaveCount).toBe(1); + cleanup(); + }); + + it("calls onDrop and resets depth", () => { + const div = el(); + let dropped = false; + const cleanup = makeNativeDroppable(div, { onDrop: () => { dropped = true; } }); + drag(div, "dragenter"); + drag(div, "drop"); + expect(dropped).toBe(true); + cleanup(); + }); + + it("suppresses onDrop when accept returns false", () => { + const div = el(); + let dropped = false; + const cleanup = makeNativeDroppable(div, { + accept: () => false, + onDrop: () => { dropped = true; }, + }); + drag(div, "drop"); + expect(dropped).toBe(false); + cleanup(); + }); + + it("calls preventDefault on dragover", () => { + const div = el(); + const cleanup = makeNativeDroppable(div); + let prevented = false; + div.addEventListener("dragover", e => { if (e.defaultPrevented) prevented = true; }); + drag(div, "dragover"); + expect(prevented).toBe(true); + cleanup(); + }); + + it("suppresses onEnter when accept returns false", () => { + const div = el(); + let entered = false; + const cleanup = makeNativeDroppable(div, { + accept: () => false, + onEnter: () => { entered = true; }, + }); + drag(div, "dragenter"); + expect(entered).toBe(false); + cleanup(); + }); + + it("no-ops when disabled", () => { + const div = el(); + let entered = false; + const cleanup = makeNativeDroppable(div, { onEnter: () => { entered = true; }, disabled: true }); + drag(div, "dragenter"); + expect(entered).toBe(false); + cleanup(); + }); + + it("a rejected dragenter+dragleave does not corrupt depth for a later accepted enter", () => { + const div = el(); + let rejectNext = true; + let entered = false; + const cleanup = makeNativeDroppable(div, { + accept: () => !rejectNext, + onEnter: () => { entered = true; }, + }); + drag(div, "dragenter"); // rejected — depth must stay at 0, not go negative + drag(div, "dragleave"); + expect(entered).toBe(false); + + rejectNext = false; + drag(div, "dragenter"); // now accepted — must still fire onEnter + expect(entered).toBe(true); + cleanup(); + }); +}); + +// ── createDraggable ─────────────────────────────────────────────────────────── + +describe("createDraggable (standalone — no context)", () => { + it("isDragging starts false and transform starts null", () => { + createRoot(dispose => { + const d = createDraggable("x"); + expect(d.isDragging()).toBe(false); + expect(d.transform()).toBeNull(); + expect(d.id).toBe("x"); + dispose(); + }); + }); + + it("applies static style and class via ref", () => { + createRoot(dispose => { + const div = el(); + const d = createDraggable("x", undefined, { + style: { userSelect: "none" }, + class: "draggable my-item", + }); + d.ref(div); + expect(div.style.userSelect).toBe("none"); + expect(div.classList.contains("draggable")).toBe(true); + expect(div.classList.contains("my-item")).toBe(true); + dispose(); + }); + }); + + it("becomes isDragging true on pointerdown and resets on pointerup", () => { + createRoot(dispose => { + const div = el(); + const d = createDraggable("x"); + d.ref(div); + flush(); + ptr(div, "pointerdown", { button: 0, clientX: 0, clientY: 0 }); + flush(); + expect(d.isDragging()).toBe(true); + ptr(document, "pointerup", {}); + flush(); + expect(d.isDragging()).toBe(false); + dispose(); + }); + }); + + it("becomes isDragging true on pointerdown and resets on pointercancel", () => { + createRoot(dispose => { + const div = el(); + const d = createDraggable("x"); + d.ref(div); + flush(); + ptr(div, "pointerdown", { button: 0, clientX: 0, clientY: 0 }); + flush(); + expect(d.isDragging()).toBe(true); + ptr(document, "pointercancel", {}); + flush(); + expect(d.isDragging()).toBe(false); + expect(d.transform()).toBeNull(); + // tracking must have stopped — a move after cancel should not resurrect transform + ptr(document, "pointermove", { clientX: 50, clientY: 50 }); + flush(); + expect(d.transform()).toBeNull(); + dispose(); + }); + }); + + it("tracks transform during drag", () => { + createRoot(dispose => { + const div = el(); + const d = createDraggable("x"); + d.ref(div); + flush(); + ptr(div, "pointerdown", { button: 0, clientX: 10, clientY: 10 }); + flush(); + ptr(document, "pointermove", { clientX: 25, clientY: 40 }); + flush(); + expect(d.transform()).toEqual({ x: 15, y: 30 }); + ptr(document, "pointerup", {}); + flush(); + expect(d.transform()).toBeNull(); + dispose(); + }); + }); + + it("applies draggingStyle and draggingClass while dragging", () => { + createRoot(dispose => { + const div = el(); + const d = createDraggable("x", undefined, { + draggingStyle: { opacity: "0.5" }, + draggingClass: "is-dragging", + }); + d.ref(div); + flush(); + ptr(div, "pointerdown", { button: 0 }); + flush(); + expect(div.style.opacity).toBe("0.5"); + expect(div.classList.contains("is-dragging")).toBe(true); + ptr(document, "pointerup", {}); + flush(); + expect(div.style.opacity).toBe(""); + expect(div.classList.contains("is-dragging")).toBe(false); + dispose(); + }); + }); + + it("ignores non-left-button events", () => { + createRoot(dispose => { + const div = el(); + const d = createDraggable("x"); + d.ref(div); + flush(); + ptr(div, "pointerdown", { button: 2 }); + flush(); + expect(d.isDragging()).toBe(false); + dispose(); + }); + }); + + it("compensates transform for scroll that happens mid-drag", () => { + createRoot(dispose => { + const div = el(); + const d = createDraggable("x"); + d.ref(div); + flush(); + ptr(div, "pointerdown", { button: 0, clientX: 10, clientY: 10 }); + flush(); + mockScroll(0, 40); + ptr(document, "pointermove", { clientX: 10, clientY: 10 }); + flush(); + expect(d.transform()).toEqual({ x: 0, y: 40 }); + ptr(document, "pointerup", {}); + flush(); + dispose(); + }); + }); + + it("sets tabindex/role/aria-roledescription via ref unless already present", () => { + createRoot(dispose => { + const div = el(); + const d = createDraggable("x"); + d.ref(div); + expect(div.tabIndex).toBe(0); + expect(div.getAttribute("role")).toBe("button"); + expect(div.getAttribute("aria-roledescription")).toBe("draggable"); + dispose(); + }); + }); + + it("does not override an explicitly set tabindex or role", () => { + createRoot(dispose => { + const div = el(); + div.setAttribute("tabindex", "-1"); + div.setAttribute("role", "listitem"); + const d = createDraggable("x"); + d.ref(div); + expect(div.getAttribute("tabindex")).toBe("-1"); + expect(div.getAttribute("role")).toBe("listitem"); + dispose(); + }); + }); + + it("keyboard: Space picks up, arrow keys nudge, Space drops", () => { + createRoot(dispose => { + const div = el(); + const d = createDraggable("x"); + d.ref(div); + flush(); + + key(div, " "); + flush(); + expect(d.isDragging()).toBe(true); + expect(d.transform()).toEqual({ x: 0, y: 0 }); + + key(div, "ArrowRight"); + flush(); + expect(d.transform()).toEqual({ x: 25, y: 0 }); + + key(div, "ArrowDown"); + flush(); + expect(d.transform()).toEqual({ x: 25, y: 25 }); + + key(div, " "); + flush(); + expect(d.isDragging()).toBe(false); + expect(d.transform()).toBeNull(); + + dispose(); + }); + }); + + it("keyboard: Escape cancels an active keyboard drag", () => { + createRoot(dispose => { + const div = el(); + const d = createDraggable("x"); + d.ref(div); + flush(); + + key(div, "Enter"); + flush(); + expect(d.isDragging()).toBe(true); + + key(div, "Escape"); + flush(); + expect(d.isDragging()).toBe(false); + expect(d.transform()).toBeNull(); + + dispose(); + }); + }); + + it("keyboard: respects a custom keyboardStep", () => { + createRoot(dispose => { + const div = el(); + const d = createDraggable("x", undefined, { keyboardStep: 5 }); + d.ref(div); + flush(); + + key(div, " "); + flush(); + key(div, "ArrowRight"); + flush(); + expect(d.transform()).toEqual({ x: 5, y: 0 }); + + key(div, " "); + dispose(); + }); + }); + + it("keyboard: ignores key events while disabled", () => { + createRoot(dispose => { + const div = el(); + const d = createDraggable("x", undefined, { disabled: true }); + d.ref(div); + flush(); + + key(div, " "); + flush(); + expect(d.isDragging()).toBe(false); + + dispose(); + }); + }); +}); + +// ── createDroppable ─────────────────────────────────────────────────────────── + +describe("createDroppable (standalone — no context)", () => { + it("isOver starts false and active starts null", () => { + createRoot(dispose => { + const drop = createDroppable("zone"); + expect(drop.isOver()).toBe(false); + expect(drop.active()).toBeNull(); + expect(drop.id).toBe("zone"); + dispose(); + }); + }); + + it("applies static style and class via ref", () => { + createRoot(dispose => { + const div = el(); + const drop = createDroppable("zone", undefined, { + style: { border: "2px dashed transparent" }, + class: "dropzone", + }); + drop.ref(div); + expect(div.style.border).toBe("2px dashed transparent"); + expect(div.classList.contains("dropzone")).toBe(true); + dispose(); + }); + }); + + it("warns in dev mode when used without a createDragContext ancestor", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + createRoot(dispose => { + createDroppable("zone"); + dispose(); + }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("createDragContext")); + warnSpy.mockRestore(); + }); +}); + +// ── createDragContext ───────────────────────────────────────────────────────── + +describe("createDragContext", () => { + it("active, over, and transform start null/null/null", () => { + createRoot(dispose => { + const ctx = createDragContext(); + expect(ctx.active()).toBeNull(); + expect(ctx.over()).toBeNull(); + expect(ctx.transform()).toBeNull(); + dispose(); + }); + }); + + it("coordinates draggable and droppable", () => { + const dragEl = el(); + const dropEl = el(); + mockRect(dragEl, { left: 0, top: 0, right: 50, bottom: 50 }); + mockRect(dropEl, { left: 200, top: 0, right: 300, bottom: 100 }); + + let ctx!: ReturnType; + let drag!: DraggableReturn; + let drop!: DroppableReturn; + + const container = el(); + const dispose = render( + () => { + ctx = createDragContext(); + return (ctx.Provider as (p: { children: unknown }) => unknown)({ + get children() { + drag = createDraggable("a", "data-a"); + drop = createDroppable("b", "data-b"); + drag.ref(dragEl); + drop.ref(dropEl); + return null; + }, + }); + }, + container, + ); + flush(); + + ptr(dragEl, "pointerdown", { button: 0, clientX: 25, clientY: 25 }); + flush(); + expect(ctx.active()?.id).toBe("a"); + expect(drag.isDragging()).toBe(true); + + ptr(document, "pointermove", { clientX: 250, clientY: 50 }); + flush(); + expect(ctx.over()?.id).toBe("b"); + expect(drop.isOver()).toBe(true); + expect(drop.active()?.id).toBe("a"); + + ptr(document, "pointerup", { clientX: 250, clientY: 50 }); + flush(); + expect(ctx.active()).toBeNull(); + expect(ctx.over()).toBeNull(); + expect(drop.isOver()).toBe(false); + + dispose(); + }); + + it("fires lifecycle callbacks in order", () => { + const events: string[] = []; + let drag!: DraggableReturn; + const dragEl = el(); + + const container = el(); + const dispose = render( + () => { + const ctx = createDragContext({ + onDragStart: () => events.push("start"), + onDragMove: () => events.push("move"), + onDragEnd: () => events.push("end"), + }); + return (ctx.Provider as (p: { children: unknown }) => unknown)({ + get children() { + drag = createDraggable("x"); + drag.ref(dragEl); + return null; + }, + }); + }, + container, + ); + flush(); + + ptr(dragEl, "pointerdown", { button: 0 }); + ptr(document, "pointermove", { clientX: 5, clientY: 5 }); + ptr(document, "pointerup", {}); + + expect(events).toEqual(["start", "move", "end"]); + dispose(); + }); + + it("fires onDragCancel on Escape", () => { + let cancelled = false; + let drag!: DraggableReturn; + const dragEl = el(); + + const container = el(); + const dispose = render( + () => { + const ctx = createDragContext({ onDragCancel: () => { cancelled = true; } }); + return (ctx.Provider as (p: { children: unknown }) => unknown)({ + get children() { + drag = createDraggable("x"); + drag.ref(dragEl); + return null; + }, + }); + }, + container, + ); + flush(); + + ptr(dragEl, "pointerdown", { button: 0 }); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(cancelled).toBe(true); + dispose(); + }); + + it("fires onDragCancel (not onDragEnd) on pointercancel, and stops tracking", () => { + let cancelled = false; + let ended = false; + let drag!: DraggableReturn; + const dragEl = el(); + + const container = el(); + const dispose = render( + () => { + const ctx = createDragContext({ + onDragCancel: () => { cancelled = true; }, + onDragEnd: () => { ended = true; }, + }); + return (ctx.Provider as (p: { children: unknown }) => unknown)({ + get children() { + drag = createDraggable("x"); + drag.ref(dragEl); + return null; + }, + }); + }, + container, + ); + flush(); + + ptr(dragEl, "pointerdown", { button: 0 }); + ptr(document, "pointercancel", {}); + expect(cancelled).toBe(true); + expect(ended).toBe(false); + expect(drag.isDragging()).toBe(false); + + dispose(); + }); + + it("isDragging/isOver only flip for the specific ids involved, not siblings", () => { + const dragEls = [el(), el(), el()]; + const dropEls = [el(), el(), el()]; + mockRect(dragEls[0]!, { left: 0, top: 0, right: 50, bottom: 50 }); + mockRect(dropEls[0]!, { left: 200, top: 0, right: 300, bottom: 100 }); + mockRect(dropEls[1]!, { left: 400, top: 0, right: 500, bottom: 100 }); + mockRect(dropEls[2]!, { left: 600, top: 0, right: 700, bottom: 100 }); + + const drags: DraggableReturn[] = []; + const drops: DroppableReturn[] = []; + + const container = el(); + const dispose = render( + () => { + const ctx = createDragContext(); + return (ctx.Provider as (p: { children: unknown }) => unknown)({ + get children() { + for (let i = 0; i < 3; i++) { + const d = createDraggable(`d${i}`); + d.ref(dragEls[i]!); + drags.push(d); + const drop = createDroppable(`z${i}`); + drop.ref(dropEls[i]!); + drops.push(drop); + } + return null; + }, + }); + }, + container, + ); + flush(); + + // Start dragging only the first draggable. + ptr(dragEls[0]!, "pointerdown", { button: 0, clientX: 25, clientY: 25 }); + flush(); + expect(drags[0]!.isDragging()).toBe(true); + expect(drags[1]!.isDragging()).toBe(false); + expect(drags[2]!.isDragging()).toBe(false); + + // Hover over only the second drop zone. + ptr(document, "pointermove", { clientX: 450, clientY: 50 }); + flush(); + expect(drops[0]!.isOver()).toBe(false); + expect(drops[1]!.isOver()).toBe(true); + expect(drops[2]!.isOver()).toBe(false); + + // Move to the third drop zone — the second should flip back off. + ptr(document, "pointermove", { clientX: 650, clientY: 50 }); + flush(); + expect(drops[0]!.isOver()).toBe(false); + expect(drops[1]!.isOver()).toBe(false); + expect(drops[2]!.isOver()).toBe(true); + + ptr(document, "pointerup", { clientX: 650, clientY: 50 }); + flush(); + expect(drags[0]!.isDragging()).toBe(false); + expect(drops[2]!.isOver()).toBe(false); + + dispose(); + }); + + it("excludes self from collision when using createSortable", () => { + const div = el(); + mockRect(div, { left: 0, top: 0, right: 100, bottom: 100 }); + + let s!: ReturnType; + const container = el(); + const dispose = render( + () => { + const ctx = createDragContext(); + return (ctx.Provider as (p: { children: unknown }) => unknown)({ + get children() { + s = createSortable("s1"); + s.ref(div); + return null; + }, + }); + }, + container, + ); + flush(); + + ptr(div, "pointerdown", { button: 0, clientX: 50, clientY: 50 }); + flush(); + ptr(document, "pointermove", { clientX: 50, clientY: 50 }); + flush(); + expect(s.isOver()).toBe(false); + + ptr(document, "pointerup", {}); + dispose(); + }); + + it("does not warn when the droppable has a createDragContext ancestor", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const div = el(); + const container = el(); + const dispose = render( + () => { + const ctx = createDragContext(); + return (ctx.Provider as (p: { children: unknown }) => unknown)({ + get children() { + const drop = createDroppable("zone"); + drop.ref(div); + return null; + }, + }); + }, + container, + ); + flush(); + expect(warnSpy).not.toHaveBeenCalled(); + dispose(); + warnSpy.mockRestore(); + }); + + it("compensates transform for scroll that happens mid-drag", () => { + const dragEl = el(); + mockRect(dragEl, { left: 0, top: 0, right: 50, bottom: 50 }); + + let ctx!: ReturnType; + const container = el(); + const dispose = render( + () => { + ctx = createDragContext(); + return (ctx.Provider as (p: { children: unknown }) => unknown)({ + get children() { + const drag = createDraggable("a"); + drag.ref(dragEl); + return null; + }, + }); + }, + container, + ); + flush(); + + ptr(dragEl, "pointerdown", { button: 0, clientX: 25, clientY: 25 }); + flush(); + mockScroll(0, 30); + ptr(document, "pointermove", { clientX: 25, clientY: 25 }); + flush(); + // Pointer hasn't moved relative to the page, but the page scrolled by 30 — + // the reported transform should track it so the element stays under the pointer. + expect(ctx.transform()).toEqual({ x: 0, y: 30 }); + + ptr(document, "pointerup", {}); + dispose(); + }); + + describe("keyboard sensor", () => { + it("Space picks up, arrow keys move + trigger collision, Space drops", () => { + const dragEl = el(); + const dropEl = el(); + mockRect(dragEl, { left: 0, top: 0, right: 50, bottom: 50 }); + mockRect(dropEl, { left: 200, top: 0, right: 300, bottom: 100 }); + + let ctx!: ReturnType; + let drag!: DraggableReturn; + let drop!: DroppableReturn; + + const container = el(); + const dispose = render( + () => { + ctx = createDragContext(); + return (ctx.Provider as (p: { children: unknown }) => unknown)({ + get children() { + // dragEl's center is (25, 25); a single 200px step lands well inside dropEl. + drag = createDraggable("a", "data-a", { keyboardStep: 200 }); + drop = createDroppable("b", "data-b"); + drag.ref(dragEl); + drop.ref(dropEl); + return null; + }, + }); + }, + container, + ); + flush(); + + key(dragEl, "Enter"); + flush(); + expect(ctx.active()?.id).toBe("a"); + expect(drag.isDragging()).toBe(true); + + key(dragEl, "ArrowRight"); + flush(); + expect(ctx.over()?.id).toBe("b"); + expect(drop.isOver()).toBe(true); + + key(dragEl, "Enter"); + flush(); + expect(ctx.active()).toBeNull(); + expect(drag.isDragging()).toBe(false); + + dispose(); + }); + + it("Escape cancels a keyboard-initiated drag", () => { + let cancelled = false; + let drag!: DraggableReturn; + const dragEl = el(); + mockRect(dragEl, { left: 0, top: 0, right: 50, bottom: 50 }); + + const container = el(); + const dispose = render( + () => { + const ctx = createDragContext({ onDragCancel: () => { cancelled = true; } }); + return (ctx.Provider as (p: { children: unknown }) => unknown)({ + get children() { + drag = createDraggable("x"); + drag.ref(dragEl); + return null; + }, + }); + }, + container, + ); + flush(); + + key(dragEl, " "); + flush(); + expect(drag.isDragging()).toBe(true); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + flush(); + expect(cancelled).toBe(true); + expect(drag.isDragging()).toBe(false); + + dispose(); + }); + }); + + describe("autoScroll", () => { + it("scrolls the window when the pointer nears a viewport edge", () => { + const dragEl = el(); + mockRect(dragEl, { left: 0, top: 0, right: 50, bottom: 50 }); + Object.defineProperty(window, "innerWidth", { value: 1000, configurable: true }); + Object.defineProperty(window, "innerHeight", { value: 800, configurable: true }); + const scrollBySpy = vi.spyOn(window, "scrollBy").mockImplementation(() => {}); + + const container = el(); + const dispose = render( + () => { + const ctx = createDragContext({ autoScroll: true }); + return (ctx.Provider as (p: { children: unknown }) => unknown)({ + get children() { + const drag = createDraggable("a"); + drag.ref(dragEl); + return null; + }, + }); + }, + container, + ); + flush(); + + ptr(dragEl, "pointerdown", { button: 0, clientX: 25, clientY: 25 }); + flush(); + // Near the top-left corner, inside the default 60px threshold. + ptr(document, "pointermove", { clientX: 10, clientY: 10 }); + flush(); + + expect(scrollBySpy).toHaveBeenCalled(); + const [dx, dy] = scrollBySpy.mock.calls[0] as [number, number]; + expect(dx).toBeLessThan(0); + expect(dy).toBeLessThan(0); + + ptr(document, "pointerup", {}); + scrollBySpy.mockRestore(); + dispose(); + }); + + it("does not scroll when autoScroll is not set", () => { + const dragEl = el(); + mockRect(dragEl, { left: 0, top: 0, right: 50, bottom: 50 }); + const scrollBySpy = vi.spyOn(window, "scrollBy").mockImplementation(() => {}); + + const container = el(); + const dispose = render( + () => { + const ctx = createDragContext(); + return (ctx.Provider as (p: { children: unknown }) => unknown)({ + get children() { + const drag = createDraggable("a"); + drag.ref(dragEl); + return null; + }, + }); + }, + container, + ); + flush(); + + ptr(dragEl, "pointerdown", { button: 0, clientX: 25, clientY: 25 }); + flush(); + ptr(document, "pointermove", { clientX: 1, clientY: 1 }); + flush(); + + expect(scrollBySpy).not.toHaveBeenCalled(); + + ptr(document, "pointerup", {}); + scrollBySpy.mockRestore(); + dispose(); + }); + }); +}); + +// ── createNativeDroppable ───────────────────────────────────────────────────── + +describe("createNativeDroppable", () => { + it("isOver starts false", () => { + createRoot(dispose => { + const drop = createNativeDroppable(); + expect(drop.isOver()).toBe(false); + dispose(); + }); + }); + + it("isOver becomes true on dragenter and false on dragleave", () => { + createRoot(dispose => { + const div = el(); + const drop = createNativeDroppable(); + drop.ref(div); + + drag(div, "dragenter"); + flush(); + expect(drop.isOver()).toBe(true); + + drag(div, "dragleave"); + flush(); + expect(drop.isOver()).toBe(false); + + dispose(); + }); + }); + + it("handles child element depth correctly", () => { + createRoot(dispose => { + const div = el(); + const drop = createNativeDroppable(); + drop.ref(div); + + drag(div, "dragenter"); // depth = 1 + drag(div, "dragenter"); // depth = 2 + flush(); + expect(drop.isOver()).toBe(true); + + drag(div, "dragleave"); // depth = 1 + flush(); + expect(drop.isOver()).toBe(true); + + drag(div, "dragleave"); // depth = 0 + flush(); + expect(drop.isOver()).toBe(false); + + dispose(); + }); + }); + + it("calls onEnter only once per zone entry, not once per child dragenter", () => { + createRoot(dispose => { + const div = el(); + let enterCount = 0; + const drop = createNativeDroppable({ onEnter: () => enterCount++ }); + drop.ref(div); + + drag(div, "dragenter"); // depth = 1 — fires + drag(div, "dragenter"); // depth = 2 (bubbled from a child) — must not fire again + flush(); + expect(enterCount).toBe(1); + + dispose(); + }); + }); + + it("a rejected dragenter+dragleave does not corrupt depth for a later accepted enter", () => { + createRoot(dispose => { + const div = el(); + let rejectNext = true; + const drop = createNativeDroppable({ accept: () => !rejectNext }); + drop.ref(div); + + drag(div, "dragenter"); // rejected — depth must stay at 0, not go negative + drag(div, "dragleave"); + flush(); + expect(drop.isOver()).toBe(false); + + rejectNext = false; + drag(div, "dragenter"); // now accepted — must still set isOver + flush(); + expect(drop.isOver()).toBe(true); + + dispose(); + }); + }); + + it("isOver resets to false on drop", () => { + createRoot(dispose => { + const div = el(); + const drop = createNativeDroppable(); + drop.ref(div); + + drag(div, "dragenter"); + flush(); + expect(drop.isOver()).toBe(true); + drag(div, "drop"); + flush(); + expect(drop.isOver()).toBe(false); + + dispose(); + }); + }); + + it("calls onDrop callback", () => { + createRoot(dispose => { + const div = el(); + let dropped = false; + const drop = createNativeDroppable({ onDrop: () => { dropped = true; } }); + drop.ref(div); + drag(div, "drop"); + expect(dropped).toBe(true); + dispose(); + }); + }); + + it("suppresses onDrop when accept returns false", () => { + createRoot(dispose => { + const div = el(); + let dropped = false; + const drop = createNativeDroppable({ accept: () => false, onDrop: () => { dropped = true; } }); + drop.ref(div); + drag(div, "drop"); + expect(dropped).toBe(false); + dispose(); + }); + }); +}); + +// ── createSortable ──────────────────────────────────────────────────────────── + +describe("createSortable", () => { + it("exposes all expected fields", () => { + createRoot(dispose => { + const s = createSortable("s1"); + expect(typeof s.isDragging).toBe("function"); + expect(typeof s.isOver).toBe("function"); + expect(typeof s.isActiveDropzone).toBe("function"); + expect(typeof s.transform).toBe("function"); + expect(typeof s.active).toBe("function"); + expect(s.id).toBe("s1"); + dispose(); + }); + }); + + it("isDragging and isActiveDropzone start false", () => { + createRoot(dispose => { + const s = createSortable("s1"); + expect(s.isDragging()).toBe(false); + expect(s.isActiveDropzone()).toBe(false); + dispose(); + }); + }); +}); + +// ── arrayMove ───────────────────────────────────────────────────────────────── + +describe("arrayMove", () => { + it("moves an item forward", () => { + expect(arrayMove(["a", "b", "c", "d"], 0, 2)).toEqual(["b", "c", "a", "d"]); + }); + + it("moves an item backward", () => { + expect(arrayMove(["a", "b", "c", "d"], 3, 0)).toEqual(["d", "a", "b", "c"]); + }); + + it("returns an unmodified copy when indices are equal", () => { + const items = ["a", "b", "c"]; + const result = arrayMove(items, 1, 1); + expect(result).toEqual(items); + expect(result).not.toBe(items); + }); + + it("returns an unmodified copy when an index is out of range", () => { + const items = ["a", "b", "c"]; + expect(arrayMove(items, -1, 1)).toEqual(items); + expect(arrayMove(items, 1, 5)).toEqual(items); + }); + + it("returns an unmodified copy when an index is NaN", () => { + // NaN fails every `<`/`>=`/`===` comparison, so without an explicit integer + // check it silently bypasses the range guard — Array#splice then coerces + // NaN to 0 internally, corrupting the array from index 0. + const items = ["a", "b", "c"]; + const result = arrayMove(items, NaN, 1); + expect(result).toEqual(items); + expect(result).not.toBe(items); + expect(arrayMove(items, 0, NaN)).toEqual(items); + }); + + it("returns an unmodified copy when an index is fractional", () => { + const items = ["a", "b", "c"]; + expect(arrayMove(items, 1.5, 2)).toEqual(items); + expect(arrayMove(items, 0, 1.5)).toEqual(items); + }); + + it("does not mutate the input array", () => { + const items = ["a", "b", "c"]; + arrayMove(items, 0, 2); + expect(items).toEqual(["a", "b", "c"]); + }); +}); + +// ── Collision strategies ────────────────────────────────────────────────────── + +function makeRect(left: number, top: number, right: number, bottom: number): DOMRect { + return { + left, top, right, bottom, + width: right - left, height: bottom - top, + x: left, y: top, toJSON: () => ({}), + } as DOMRect; +} + +const draggableRect = { id: "drag", rect: makeRect(0, 0, 50, 50) }; + +describe("closestCenter", () => { + it("returns id of droppable whose center is nearest the pointer", () => { + const droppables = [ + { id: "a", rect: makeRect(100, 0, 200, 100) }, // center (150, 50) + { id: "b", rect: makeRect(300, 0, 400, 100) }, // center (350, 50) + ]; + expect(closestCenter(draggableRect, droppables, { x: 160, y: 50 })).toBe("a"); + expect(closestCenter(draggableRect, droppables, { x: 340, y: 50 })).toBe("b"); + }); + + it("returns null for empty droppables", () => { + expect(closestCenter(draggableRect, [], { x: 0, y: 0 })).toBeNull(); + }); +}); + +describe("closestCorners", () => { + it("returns id of droppable with nearest corner", () => { + const droppables = [ + { id: "a", rect: makeRect(100, 0, 200, 100) }, + { id: "b", rect: makeRect(300, 0, 400, 100) }, + ]; + expect(closestCorners(draggableRect, droppables, { x: 95, y: 5 })).toBe("a"); + expect(closestCorners(draggableRect, droppables, { x: 305, y: 5 })).toBe("b"); + }); +}); + +describe("rectIntersection", () => { + it("returns id of droppable with largest overlap area", () => { + const drag = { id: "drag", rect: makeRect(50, 0, 150, 100) }; + const droppables = [ + { id: "a", rect: makeRect(100, 0, 200, 100) }, // overlap 50×100 = 5000 + { id: "b", rect: makeRect(120, 0, 200, 100) }, // overlap 30×100 = 3000 + ]; + expect(rectIntersection(drag, droppables, { x: 0, y: 0 })).toBe("a"); + }); + + it("returns null when no overlap", () => { + const droppables = [{ id: "a", rect: makeRect(200, 0, 300, 100) }]; + expect(rectIntersection(draggableRect, droppables, { x: 0, y: 0 })).toBeNull(); + }); +}); + +describe("pointerWithin", () => { + it("returns id of droppable containing the pointer", () => { + const droppables = [ + { id: "a", rect: makeRect(0, 0, 100, 100) }, + { id: "b", rect: makeRect(200, 0, 300, 100) }, + ]; + expect(pointerWithin(draggableRect, droppables, { x: 50, y: 50 })).toBe("a"); + expect(pointerWithin(draggableRect, droppables, { x: 250, y: 50 })).toBe("b"); + expect(pointerWithin(draggableRect, droppables, { x: 150, y: 50 })).toBeNull(); + }); + + it("prefers topmost (last in array) when overlapping", () => { + const droppables = [ + { id: "a", rect: makeRect(0, 0, 100, 100) }, + { id: "b", rect: makeRect(0, 0, 100, 100) }, + ]; + expect(pointerWithin(draggableRect, droppables, { x: 50, y: 50 })).toBe("b"); + }); + + it("returns null when pointer is outside all droppables", () => { + const droppables = [{ id: "a", rect: makeRect(200, 0, 300, 100) }]; + expect(pointerWithin(draggableRect, droppables, { x: 0, y: 0 })).toBeNull(); + }); +}); diff --git a/packages/drag-drop/test/server.test.ts b/packages/drag-drop/test/server.test.ts new file mode 100644 index 000000000..f8c034457 --- /dev/null +++ b/packages/drag-drop/test/server.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { + makeDraggable, + makeDroppable, + makeNativeDroppable, + createDraggable, + createDroppable, + createNativeDroppable, + createDragContext, + createSortable, +} from "../src/index.js"; + +describe("SSR safety", () => { + it("makeDraggable returns a noop cleanup", () => { + expect(() => { + const cleanup = makeDraggable({} as HTMLElement); + cleanup(); + }).not.toThrow(); + }); + + it("makeDroppable returns a noop cleanup", () => { + expect(() => { + const cleanup = makeDroppable({} as HTMLElement); + cleanup(); + }).not.toThrow(); + }); + + it("makeNativeDroppable returns a noop cleanup", () => { + expect(() => { + const cleanup = makeNativeDroppable({} as HTMLElement); + cleanup(); + }).not.toThrow(); + }); + + it("createDraggable returns stub accessors", () => { + const d = createDraggable("x"); + expect(d.isDragging()).toBe(false); + expect(d.transform()).toBeNull(); + expect(d.id).toBe("x"); + expect(() => d.ref({} as HTMLElement)).not.toThrow(); + }); + + it("createDroppable returns stub accessors", () => { + const d = createDroppable("zone"); + expect(d.isOver()).toBe(false); + expect(d.active()).toBeNull(); + expect(d.id).toBe("zone"); + expect(() => d.ref({} as HTMLElement)).not.toThrow(); + }); + + it("createNativeDroppable returns stub accessors", () => { + const d = createNativeDroppable(); + expect(d.isOver()).toBe(false); + expect(() => d.ref({} as HTMLElement)).not.toThrow(); + }); + + it("createDragContext returns stub state and passthrough Provider", () => { + const ctx = createDragContext(); + expect(ctx.active()).toBeNull(); + expect(ctx.over()).toBeNull(); + expect(ctx.transform()).toBeNull(); + expect(() => + ctx.Provider({ get children() { return null; } } as never), + ).not.toThrow(); + }); + + it("createSortable returns stub accessors", () => { + const s = createSortable("s1"); + expect(s.isDragging()).toBe(false); + expect(s.isOver()).toBe(false); + expect(s.isActiveDropzone()).toBe(false); + expect(s.transform()).toBeNull(); + expect(s.active()).toBeNull(); + expect(s.id).toBe("s1"); + }); +}); diff --git a/packages/drag-drop/test/setup.ts b/packages/drag-drop/test/setup.ts new file mode 100644 index 000000000..10a7e24a7 --- /dev/null +++ b/packages/drag-drop/test/setup.ts @@ -0,0 +1,56 @@ +// jsdom doesn't implement PointerEvent or DragEvent — shim them on top of MouseEvent. + +class PointerEventShim extends MouseEvent { + readonly pointerId: number; + readonly width: number; + readonly height: number; + readonly pressure: number; + readonly tiltX: number; + readonly tiltY: number; + readonly pointerType: string; + readonly isPrimary: boolean; + + constructor(type: string, init: PointerEventInit = {}) { + super(type, init); + this.pointerId = init.pointerId ?? 1; + this.width = init.width ?? 1; + this.height = init.height ?? 1; + this.pressure = init.pressure ?? 0; + this.tiltX = init.tiltX ?? 0; + this.tiltY = init.tiltY ?? 0; + this.pointerType = init.pointerType ?? "mouse"; + this.isPrimary = init.isPrimary ?? true; + } +} + +class DragEventShim extends MouseEvent { + readonly dataTransfer: DataTransfer | null; + + constructor(type: string, init: DragEventInit = {}) { + super(type, init); + this.dataTransfer = init.dataTransfer ?? null; + } +} + +// Install globally so test files can use `new PointerEvent(...)` / `new DragEvent(...)` +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).PointerEvent = PointerEventShim; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).DragEvent = DragEventShim; + +// jsdom doesn't implement pointer capture — shim as no-ops. +if (!HTMLElement.prototype.setPointerCapture) { + HTMLElement.prototype.setPointerCapture = () => {}; + HTMLElement.prototype.releasePointerCapture = () => {}; + HTMLElement.prototype.hasPointerCapture = () => false; +} + +// Make requestAnimationFrame synchronous in jsdom so pointermove tests don't need to advance frames. +// The context code uses `rafPending` (not the return value) as the dedup guard, so this is safe. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).requestAnimationFrame = (cb: FrameRequestCallback): number => { + cb(performance.now()); + return 0; +}; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).cancelAnimationFrame = (_id: number): void => {}; diff --git a/packages/drag-drop/tsconfig.json b/packages/drag-drop/tsconfig.json new file mode 100644 index 000000000..674e85d9a --- /dev/null +++ b/packages/drag-drop/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} \ No newline at end of file diff --git a/packages/upload/README.md b/packages/upload/README.md index 075a6f659..cd1df9ab4 100644 --- a/packages/upload/README.md +++ b/packages/upload/README.md @@ -228,7 +228,7 @@ If `onError` is omitted, a rejection from `userCallback` propagates as an unhand ## `createDropzone` -A reactive drag-and-drop zone. Attach it to any element via the `ref` callback and respond to the full set of drag lifecycle events. +A reactive drag-and-drop zone. Attach it to any element via the `ref` callback and respond to drag lifecycle events. Implemented on top of `createNativeDroppable` from `@solid-primitives/drag-drop` — the depth-counter logic, `isOver` tracking, and element cleanup are all handled by the underlying primitive. ```tsx import { createDropzone, createFileUploader, fileSender } from "@solid-primitives/upload"; @@ -272,17 +272,14 @@ const { ref, files, isDragging, error } = createDropzone({ **Options (all optional):** -| Callback | Fires when… | -| ------------- | -------------------------------------------------------- | -| `onDrop` | Files are dropped; `isLoading` is `true` while it awaits | -| `onDragStart` | A drag operation begins | -| `onDragEnter` | A dragged item enters the element | -| `onDragEnd` | A drag operation ends | -| `onDragLeave` | A dragged item leaves the element | -| `onDragOver` | An item is dragged continuously over the element | -| `onDrag` | Any drag event fires on the element | +| Callback | Signature | Fires when… | +| ------------- | ----------------------------------------------------- | ---------------------------------------------------------- | +| `onDrop` | `(files: UploadFile[]) => void \| Promise` | Files are dropped; `isLoading` is `true` while it awaits | +| `onDragEnter` | `(event: DragEvent) => void \| Promise` | A dragged item enters the element | +| `onDragLeave` | `(event: DragEvent) => void \| Promise` | A dragged item leaves the element | +| `onDragOver` | `(event: DragEvent) => void \| Promise` | An item is dragged continuously over the element | -All callbacks have signature `(files: UploadFile[]) => void | Promise`. `isLoading` tracks only the `onDrop` callback — drag-movement events are fire-and-forget. +Only `onDrop` receives `UploadFile[]` — the browser only exposes real `File` objects on `dataTransfer.files` at drop time, so `onDragEnter`/`onDragLeave`/`onDragOver` receive the raw `DragEvent` instead (inspect `event.dataTransfer.items` / `.types` for metadata about what's being dragged). `isLoading` tracks only the `onDrop` callback — drag-movement callbacks are fire-and-forget. ## `dropzone` @@ -292,11 +289,10 @@ A ref callback factory variant of `createDropzone`. Returns a single value that import { dropzone, createFileUploader, fileSender } from "@solid-primitives/upload"; const { upload, progress, status } = createFileUploader(fileSender("/api/upload")); +const dz = dropzone({ onDrop: files => upload(files) });
upload(files), - })} + ref={dz} style={{ background: dz.isDragging() ? "lightblue" : "lightgray", padding: "2rem", @@ -346,6 +342,10 @@ type FileUploadEntry = { type UserCallback = (files: UploadFile[]) => void | Promise; +// Drag-movement callbacks (onDragEnter/onDragLeave/onDragOver) receive the raw +// DragEvent instead of UploadFile[] — dataTransfer.files is only populated at drop. +type DragEventCallback = (event: DragEvent) => void | Promise; + type FilePickerOptions = { accept?: string; multiple?: boolean; diff --git a/packages/upload/package.json b/packages/upload/package.json index 271a590f0..ab62ab195 100644 --- a/packages/upload/package.json +++ b/packages/upload/package.json @@ -14,11 +14,11 @@ "stage": 3, "list": [ "createFilePicker", + "createDropzone", + "dropzone", "createFileUploader", "fileSender", - "fileUploader", - "createDropzone", - "dropzone" + "fileUploader" ], "category": "Browser APIs", "gzip": 2321 @@ -58,7 +58,7 @@ "test:ssr": "pnpm run vitest --mode ssr" }, "dependencies": { - "@solid-primitives/event-listener": "workspace:*" + "@solid-primitives/drag-drop": "workspace:*" }, "peerDependencies": { "@solidjs/web": "catalog:peer", diff --git a/packages/upload/src/createDropzone.ts b/packages/upload/src/createDropzone.ts index c287ad022..097ec9152 100644 --- a/packages/upload/src/createDropzone.ts +++ b/packages/upload/src/createDropzone.ts @@ -1,30 +1,29 @@ -import { createSignal, flush } from "solid-js"; +import { createSignal } from "solid-js"; import { isServer } from "@solidjs/web"; -import { createEventListenerMap } from "@solid-primitives/event-listener"; +import { createNativeDroppable } from "@solid-primitives/drag-drop"; import { transformFiles } from "./helpers.ts"; import type { UploadFile, Dropzone, DropzoneOptions } from "./types.ts"; /** - * Primitive to make working with dropzones easier. + * Reactive drop zone for receiving files dragged from the OS or browser. + * Composes `createNativeDroppable` from `@solid-primitives/drag-drop` for the + * drag-state tracking and attaches file-specific state (`files`, `isLoading`, + * `error`, `removeFile`, `clearFiles`) on top. * - * @returns `ref` - * @returns `files` - * @returns `error` - Reactive error from the last drag callback, cleared on next drop - * @returns `isLoading` - True while the `onDrop` callback is pending - * @returns `isDragging` - * @returns `removeFile` - * @returns `clearFiles` + * @returns `ref` — attach to any element via `ref={ref}` + * @returns `files` — dropped files as `UploadFile[]` + * @returns `isDragging` — true while a drag is over the zone + * @returns `isLoading` — true while the async `onDrop` callback is pending + * @returns `error` — error thrown by the last `onDrop` callback + * @returns `removeFile` — remove a single file by name + * @returns `clearFiles` — clear all files * * @example - * ```ts - * const { ref: dropzoneRef, files: droppedFiles, error } = createDropzone({ - * onDrop: async files => { - * await doStuff(2); - * files.forEach(f => console.log(f)); - * }, - * onDragStart: files => files.forEach(f => console.log(f)), - * onDragOver: files => console.log("drag over") + * ```tsx + * const { ref, files, isDragging } = createDropzone({ + * onDrop: async files => { await upload(files); }, * }); + *
Drop files here
* ``` */ function createDropzone( @@ -41,98 +40,46 @@ function createDropzone( clearFiles: () => {}, }; } + const [files, setFiles] = createSignal([]); const [error, setError] = createSignal(null); const [isLoading, setIsLoading] = createSignal(false); - const [isDragging, setIsDragging] = createSignal(false); - - const runCallback = async ( - callback: ((files: UploadFile[]) => void | Promise) | undefined, - parsedFiles: UploadFile[], - ) => { - try { - await callback?.(parsedFiles); - } catch (err) { - setError(err); - } - }; - - const onDragStart = (event: DragEvent) => { - void runCallback(options?.onDragStart, transformFiles(event.dataTransfer?.files || null)); - }; - const onDragEnd = (event: DragEvent) => { - void runCallback(options?.onDragEnd, transformFiles(event.dataTransfer?.files || null)); - }; - const onDragEnter = (event: DragEvent) => { - setIsDragging(true); - void runCallback(options?.onDragEnter, transformFiles(event.dataTransfer?.files || null)); - }; - const onDragLeave = (event: DragEvent) => { - setIsDragging(false); - void runCallback(options?.onDragLeave, transformFiles(event.dataTransfer?.files || null)); - }; - const onDragOver = (event: DragEvent) => { - event.preventDefault(); - void runCallback(options?.onDragOver, transformFiles(event.dataTransfer?.files || null)); - }; - const onDrag = (event: DragEvent) => { - void runCallback(options?.onDrag, transformFiles(event.dataTransfer?.files || null)); - }; - const onDrop = (event: DragEvent) => { - event.preventDefault(); - setIsDragging(false); - const parsedFiles = transformFiles(event.dataTransfer?.files || null); - setFiles(parsedFiles); - setError(null); - setIsLoading(true); - void (async () => { - try { - await options?.onDrop?.(parsedFiles); - } catch (err) { - setError(err); - } finally { - setIsLoading(false); - } - })(); - }; - const [refTarget, setRefTarget] = createSignal(undefined); - - createEventListenerMap(refTarget as () => T, { - dragstart: onDragStart, - dragenter: onDragEnter, - dragend: onDragEnd, - dragleave: onDragLeave, - dragover: onDragOver, - drag: onDrag, - drop: onDrop, + const drop = createNativeDroppable({ + onEnter: e => options?.onDragEnter?.(e), + onLeave: e => options?.onDragLeave?.(e), + onOver: e => options?.onDragOver?.(e), + onDrop: e => { + const parsedFiles = transformFiles(e.dataTransfer?.files ?? null); + setFiles(parsedFiles); + setError(null); + setIsLoading(true); + void (async () => { + try { + await options?.onDrop?.(parsedFiles); + } catch (err) { + setError(err); + } finally { + setIsLoading(false); + } + })(); + }, }); - const ref = (el: T) => { setRefTarget(() => el); flush(); }; - - const removeFile = (fileName: string) => { - setFiles(prev => prev.filter(f => f.name !== fileName)); - }; - - const clearFiles = () => { - setFiles([]); - }; - return { - ref, + ref: drop.ref as (el: T) => void, files, error, isLoading, - isDragging, - removeFile, - clearFiles, + isDragging: drop.isOver, + removeFile: (fileName: string) => setFiles(prev => prev.filter(f => f.name !== fileName)), + clearFiles: () => setFiles([]), }; } /** - * Ref callback factory for dropzone elements. Returns a single value that is - * both a ref callback and a reactive state object — use it directly as a `ref` - * while reading `.files`, `.isDragging`, etc. from the same reference. + * Ref callback factory that merges the drop zone state into the ref function + * itself. Use as both a `ref` and a reactive state object. * * @example * ```tsx diff --git a/packages/upload/src/index.ts b/packages/upload/src/index.ts index cceabdf4a..201696ecb 100644 --- a/packages/upload/src/index.ts +++ b/packages/upload/src/index.ts @@ -1,7 +1,7 @@ export { createFilePicker } from "./createFilePicker.ts"; +export { createDropzone, dropzone } from "./createDropzone.ts"; export { createFileUploader } from "./createFileUploader.ts"; export { fileSender } from "./fileSender.ts"; export type { FileSenderOptions } from "./fileSender.ts"; -export { createDropzone, dropzone } from "./createDropzone.ts"; export { fileUploader } from "./fileUploader.ts"; export * from "./types.ts"; diff --git a/packages/upload/src/types.ts b/packages/upload/src/types.ts index 3be0ab6c0..5e2284b87 100644 --- a/packages/upload/src/types.ts +++ b/packages/upload/src/types.ts @@ -21,6 +21,14 @@ export type FilePickerOptions = { export type UserCallback = (files: UploadFile[]) => void | Promise; +/** + * Fired for drag-movement events (enter/leave/over). The browser only exposes real + * `File` objects on `dataTransfer.files` at `drop` time — during movement events it's + * always an empty `FileList` — so these receive the raw `DragEvent` instead. Inspect + * `event.dataTransfer.items` / `.types` for metadata about what's being dragged. + */ +export type DragEventCallback = (event: DragEvent) => void | Promise; + export interface FilePicker { files: Accessor; error: Accessor; @@ -46,18 +54,11 @@ export interface Dropzone { clearFiles: () => void; } -/** - * @property `accept` - Comma-separated list of one or more file types, or unique file type specifiers - * @link `accept` - https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept - */ export interface DropzoneOptions { onDrop?: UserCallback; - onDragStart?: UserCallback; - onDragEnter?: UserCallback; - onDragEnd?: UserCallback; - onDragLeave?: UserCallback; - onDragOver?: UserCallback; - onDrag?: UserCallback; + onDragEnter?: DragEventCallback; + onDragLeave?: DragEventCallback; + onDragOver?: DragEventCallback; } export type UploadStatus = "idle" | "uploading" | "success" | "error" | "aborted"; diff --git a/packages/upload/test/index.test.tsx b/packages/upload/test/index.test.tsx index 571d38190..30194a923 100644 --- a/packages/upload/test/index.test.tsx +++ b/packages/upload/test/index.test.tsx @@ -289,12 +289,9 @@ describe("createDropzone", () => { const dispose = createRoot(dispose => { createDropzone({ onDrop: vi.fn(), - onDragStart: vi.fn(), onDragEnter: vi.fn(), - onDragEnd: vi.fn(), onDragLeave: vi.fn(), onDragOver: vi.fn(), - onDrag: vi.fn(), }); return dispose; }); @@ -302,13 +299,47 @@ describe("createDropzone", () => { }).not.toThrow(); }); + it("onDragEnter/onDragLeave/onDragOver receive the raw DragEvent, not UploadFile[]", () => { + // dataTransfer.files is always empty during movement events in real browsers — + // these callbacks get the event itself so consumers can read dataTransfer.items/.types. + let enterArg: unknown, leaveArg: unknown, overArg: unknown; + const { ref, dispose } = createRoot(dispose => ({ + ...createDropzone({ + onDragEnter: e => { enterArg = e; }, + onDragLeave: e => { leaveArg = e; }, + onDragOver: e => { overArg = e; }, + }), + dispose, + })); + + const div = document.createElement("div"); + ref(div); + + const withDataTransfer = (type: string) => { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { value: { files: makeFileList() }, configurable: true }); + return event; + }; + + const enterEvent = withDataTransfer("dragenter"); + div.dispatchEvent(enterEvent); + const overEvent = withDataTransfer("dragover"); + div.dispatchEvent(overEvent); + const leaveEvent = withDataTransfer("dragleave"); + div.dispatchEvent(leaveEvent); + + expect(enterArg).toBe(enterEvent); + expect(overArg).toBe(overEvent); + expect(leaveArg).toBe(leaveEvent); + expect(Array.isArray(enterArg)).toBe(false); + + dispose(); + }); + it("isLoading is true while onDrop callback is pending, false after it resolves", async () => { let resolve!: () => void; - const blocker = new Promise(r => { - resolve = r; - }); + const blocker = new Promise(r => { resolve = r; }); - // Capture isLoading via closure — onDrop is passed before the primitive returns let getIsLoading!: () => boolean; const { ref, dispose } = createRoot(dispose => { const dz = createDropzone({ onDrop: () => blocker }); @@ -325,13 +356,13 @@ describe("createDropzone", () => { configurable: true, }); div.dispatchEvent(dropEvent); - await Promise.resolve(); // flush setIsLoading(true) + await Promise.resolve(); expect(getIsLoading()).toBe(true); resolve(); await blocker; - await Promise.resolve(); // flush setIsLoading(false) from finally + await Promise.resolve(); expect(getIsLoading()).toBe(false); dispose(); @@ -381,16 +412,13 @@ describe("createDropzone", () => { flush(); expect(isDragging()).toBe(false); - // Re-assign — el1 listeners should be removed, el2 should become active ref(el2); expect(removeSpy).toHaveBeenCalled(); - // el1 events no longer affect state el1.dispatchEvent(new Event("dragenter")); flush(); expect(isDragging()).toBe(false); - // el2 events now drive state el2.dispatchEvent(new Event("dragenter")); flush(); expect(isDragging()).toBe(true); @@ -401,18 +429,13 @@ describe("createDropzone", () => { it("error() captures a thrown onDrop callback", async () => { const boom = new Error("drop failed"); const { error, ref, dispose } = createRoot(dispose => ({ - ...createDropzone({ - onDrop: async () => { - throw boom; - }, - }), + ...createDropzone({ onDrop: async () => { throw boom; } }), dispose, })); const div = document.createElement("div"); ref(div); - // jsdom does not implement DragEvent — dispatch a plain Event with stubbed dataTransfer const dropEvent = new Event("drop", { bubbles: true, cancelable: true }); Object.defineProperty(dropEvent, "dataTransfer", { value: { files: makeFileList(makeFile()) }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dc60dcec..d2011c3c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -402,6 +402,19 @@ importers: specifier: 'catalog:' version: 2.0.0-rc.0 + packages/drag-drop: + dependencies: + '@solid-primitives/utils': + specifier: workspace:^ + version: link:../utils + devDependencies: + '@solidjs/web': + specifier: 'catalog:' + version: 2.0.0-rc.0(solid-js@2.0.0-rc.0) + solid-js: + specifier: 'catalog:' + version: 2.0.0-rc.0 + packages/event-bus: dependencies: '@solid-primitives/utils': @@ -1388,9 +1401,9 @@ importers: packages/upload: dependencies: - '@solid-primitives/event-listener': + '@solid-primitives/drag-drop': specifier: workspace:* - version: link:../event-listener + version: link:../drag-drop devDependencies: '@solidjs/web': specifier: 'catalog:'