From 75d2a1f9e0feec4dc5a913edc5a53d876b1e0336 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:32:51 -0400 Subject: [PATCH 01/17] Initial commit --- .changeset/drag-new-package.md | 22 + packages/drag-drop/DESIGN.md | 731 +++++++++++++++++++ packages/drag-drop/LICENSE | 21 + packages/drag-drop/README.md | 213 ++++++ packages/drag-drop/package.json | 77 ++ packages/drag-drop/src/collision.ts | 78 ++ packages/drag-drop/src/context.tsx | 160 ++++ packages/drag-drop/src/dom.ts | 27 + packages/drag-drop/src/draggable.ts | 186 +++++ packages/drag-drop/src/droppable.ts | 312 ++++++++ packages/drag-drop/src/index.ts | 36 + packages/drag-drop/src/sortable.ts | 69 ++ packages/drag-drop/src/types.ts | 115 +++ packages/drag-drop/stories/index.stories.tsx | 490 +++++++++++++ packages/drag-drop/stories/tsconfig.json | 4 + packages/drag-drop/test/index.test.ts | 694 ++++++++++++++++++ packages/drag-drop/test/server.test.ts | 76 ++ packages/drag-drop/test/setup.ts | 39 + packages/drag-drop/tsconfig.json | 16 + packages/upload/package.json | 8 +- packages/upload/src/createDropzone.ts | 142 ++-- packages/upload/src/index.ts | 2 +- packages/upload/src/types.ts | 7 - packages/upload/test/index.test.tsx | 24 +- packages/upload/tsconfig.json | 2 +- pnpm-lock.yaml | 35 +- 26 files changed, 3439 insertions(+), 147 deletions(-) create mode 100644 .changeset/drag-new-package.md create mode 100644 packages/drag-drop/DESIGN.md create mode 100644 packages/drag-drop/LICENSE create mode 100644 packages/drag-drop/README.md create mode 100644 packages/drag-drop/package.json create mode 100644 packages/drag-drop/src/collision.ts create mode 100644 packages/drag-drop/src/context.tsx create mode 100644 packages/drag-drop/src/dom.ts create mode 100644 packages/drag-drop/src/draggable.ts create mode 100644 packages/drag-drop/src/droppable.ts create mode 100644 packages/drag-drop/src/index.ts create mode 100644 packages/drag-drop/src/sortable.ts create mode 100644 packages/drag-drop/src/types.ts create mode 100644 packages/drag-drop/stories/index.stories.tsx create mode 100644 packages/drag-drop/stories/tsconfig.json create mode 100644 packages/drag-drop/test/index.test.ts create mode 100644 packages/drag-drop/test/server.test.ts create mode 100644 packages/drag-drop/test/setup.ts create mode 100644 packages/drag-drop/tsconfig.json diff --git a/.changeset/drag-new-package.md b/.changeset/drag-new-package.md new file mode 100644 index 000000000..a5d5fc83f --- /dev/null +++ b/.changeset/drag-new-package.md @@ -0,0 +1,22 @@ +--- +"@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 +- `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 + +**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) diff --git a/packages/drag-drop/DESIGN.md b/packages/drag-drop/DESIGN.md new file mode 100644 index 000000000..f9fc66d25 --- /dev/null +++ b/packages/drag-drop/DESIGN.md @@ -0,0 +1,731 @@ +# Drag & Drop Primitive — Design Document + +## Overview + +A minimal, composable drag-and-drop primitive for Solid 2.0. Inspired by +dnd-kit's architecture but redesigned for Solid's reactive model: no global +singleton, ref-factory API, context-isolated state, and SSR-safe stubs +throughout. + +--- + +## Goals + +- **Tight surface area** — expose only what can't be trivially composed by + the user +- **No opinion on transforms** — report delta/position as signals; the user + applies CSS +- **No global state** — each `createDragContext` scope is fully isolated +- **Sensor-agnostic at the core** — pointer sensor built-in, pattern open for + keyboard / touch extensions +- **SSR-safe** — every reactive primitive returns a noop stub on the server +- **Solid 2.0 idiomatic** — ref factories (not `use:` directives), split + `createEffect`, `ownedWrite`, no `batch` + +--- + +## Primitive Set + +### Level 1 — Raw DOM (`make*`) + +Non-reactive. No Solid owner required. Return a cleanup function. + +```ts +makeDraggable( + el: HTMLElement, + options: MakeDraggableOptions +): VoidFunction + +makeDroppable( + el: HTMLElement, + options: MakeDroppableOptions +): VoidFunction +``` + +`makeDraggable` attaches `pointerdown` / `pointermove` / `pointerup` listeners +to `el`. It emits `onStart`, `onMove`, and `onEnd` callbacks with a `DragEvent` +payload carrying `{ id, data, delta: {x, y}, position: {x, y} }`. + +`makeDroppable` monitors pointer position against the element's bounding rect. +It emits `onEnter`, `onLeave`, and `onDrop` callbacks. An optional `accept` +predicate filters which draggables are considered. + +These two primitives are intentionally decoupled — `makeDroppable` does not +depend on `makeDraggable`. + +### Level 2 — Reactive (`create*`) + +Reactive wrappers that integrate with Solid's ownership model. All signals use +`INTERNAL_OPTIONS` (`ownedWrite: true`) where written from event callbacks. + +```ts +createDraggable( + id: string | number, + data?: T, + options?: CreateDraggableOptions +): DraggableReturn +``` + +Returns: +| Property | Type | Description | +|---|---|---| +| `ref` | `(el: HTMLElement) => void` | Attach to JSX via `ref={draggable.ref}` | +| `isDragging` | `Accessor` | True while the item is being dragged | +| `transform` | `Accessor` | Current `{x, y}` delta during drag | +| `id` | `string \| number` | Stable identifier | + +**Style options** — applied directly to the element so the user doesn't +repeat reactive style bindings in JSX: + +```ts +type CreateDraggableOptions = { + disabled?: boolean | Accessor + /** Styles applied to the element as soon as ref is attached. */ + style?: Partial + /** Styles merged in while isDragging is true, removed when false. */ + draggingStyle?: Partial + /** Class names added to the element as soon as ref is attached. */ + class?: string + /** Class names toggled on while isDragging is true, removed when false. */ + draggingClass?: string +} +``` + +Usage: +```tsx +const drag = createDraggable("item-1", myData, { + style: { userSelect: "none" }, + draggingStyle: { opacity: "0.5" }, + class: "draggable", + draggingClass: "dragging ring-2 ring-indigo-500", +}) +
drag me
+``` + +`style` and `class` are applied once in the `ref` callback. `draggingStyle` +and `draggingClass` are toggled via a split `createEffect` tracking +`isDragging()`. Style properties are set/deleted individually; class names +are added/removed via `el.classList` so other classes are not clobbered. +Both `class` and `draggingClass` accept a space-separated string of tokens. + +```ts +createDroppable( + id: string | number, + data?: T, + options?: CreateDroppableOptions +): DroppableReturn +``` + +Returns: +| Property | Type | Description | +|---|---|---| +| `ref` | `(el: HTMLElement) => void` | Attach to JSX via `ref={droppable.ref}` | +| `isOver` | `Accessor` | True when active draggable hovers this zone | +| `active` | `Accessor \| null>` | The currently hovering draggable's data | +| `id` | `string \| number` | Stable identifier | + +**Style and class options:** + +```ts +type CreateDroppableOptions = { + disabled?: boolean | Accessor + accept?: (draggable: DragItem) => boolean + /** Styles applied to the element as soon as ref is attached. */ + style?: Partial + /** Styles merged in while isOver is true, removed when false. */ + overStyle?: Partial + /** Class names added to the element as soon as ref is attached. */ + class?: string + /** Class names toggled on while isOver is true, removed when false. */ + overClass?: string +} +``` + +Usage: +```tsx +const drop = createDroppable("zone-1", undefined, { + class: "dropzone", + overClass: "dropzone--active ring-2 ring-indigo-500", +}) +
drop here
+``` + +These primitives read drag state from the nearest `DragContext` via +`useContext`. They register themselves on mount and deregister on cleanup. + +### Level 3 — Context + +```ts +createDragContext(options?: DragContextOptions): DragContextReturn +``` + +Returns a `{ Provider, useDragContext }` pair. `Provider` is a Solid component; +`useDragContext` returns the shared drag store from any child scope. + +The context store tracks: +- `active: DragItem | null` — the currently dragged item +- `over: DroppableItem | null` — the droppable currently under the pointer +- `transform: Transform | null` — running delta `{x, y}` + +Events surfaced on the context: +- `onDragStart(item)` — fired when drag begins +- `onDragMove(item, transform)` — fired on every pointer move +- `onDragEnd(item, over)` — fired on pointer up +- `onDragCancel(item)` — fired on Escape or pointer cancel + +### Level 4 — Sortable (composition) + +```ts +createSortable( + id: string | number, + data?: T +): SortableReturn +``` + +A convenience primitive that combines `createDraggable` + `createDroppable` on +the same element. Used for building sortable lists. + +Returns all fields of both + `isActiveDropzone: Accessor` (true when +this element is the active drop target in a sort operation). + +--- + +## Collision Detection + +Pluggable strategy functions — pure, no Solid dependencies: + +```ts +type CollisionDetector = ( + draggable: DragRect, + droppables: DroppableRect[], + pointerPosition: Point +) => string | number | null // ID of best match, or null +``` + +Built-in strategies: + +| Name | Strategy | +|---|---| +| `closestCenter` | Minimizes distance from pointer to droppable center | +| `closestCorners` | Minimizes distance to nearest droppable corner | +| `rectIntersection` | Returns droppable with largest overlap area | +| `pointerWithin` | Returns the topmost droppable containing the pointer | + +`DragContextOptions.collisionDetection` defaults to `pointerWithin`. + +--- + +## Types + +```ts +type Transform = { x: number; y: number } +type Point = { x: number; y: number } +type DragRect = { id: string | number; rect: DOMRect } +type DroppableRect = { id: string | number; rect: DOMRect } + +type DragItem = { + id: string | number + data: T + element: HTMLElement +} + +type DroppableItem = { + id: string | number + data: T + element: HTMLElement +} + +type MakeDraggableOptions = { + data?: T + onStart?: (item: DragItem, event: PointerEvent) => void + onMove?: (item: DragItem, transform: Transform, event: PointerEvent) => void + onEnd?: (item: DragItem, transform: Transform, event: PointerEvent) => void + disabled?: boolean +} + +type MakeDroppableOptions = { + data?: T + accept?: (draggable: DragItem) => boolean + onEnter?: (draggable: DragItem, event: PointerEvent) => void + onLeave?: (draggable: DragItem, event: PointerEvent) => void + onDrop?: (draggable: DragItem, event: PointerEvent) => void + disabled?: boolean +} + +type CreateDraggableOptions = { + disabled?: boolean | Accessor + style?: Partial + draggingStyle?: Partial + class?: string + draggingClass?: string +} + +type CreateDroppableOptions = { + disabled?: boolean | Accessor + accept?: (draggable: DragItem) => boolean + style?: Partial + overStyle?: Partial + class?: string + overClass?: string +} + +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 +} +``` + +--- + +## File Structure + +``` +packages/drag/ +├── src/ +│ ├── index.ts # re-exports +│ ├── types.ts # all shared types +│ ├── context.ts # DragContext, createDragContext +│ ├── draggable.ts # makeDraggable, createDraggable +│ ├── droppable.ts # makeDroppable, createDroppable, makeNativeDroppable, createNativeDroppable +│ ├── sortable.ts # createSortable +│ └── collision.ts # closestCenter, closestCorners, rectIntersection, pointerWithin +├── test/ +│ ├── index.test.ts # browser tests (jsdom + vitest) +│ └── server.test.ts # SSR safety tests +├── dev/ +│ └── index.tsx # interactive demo component +├── stories/ +│ └── index.stories.tsx # Storybook stories +├── CHANGELOG.md +├── DESIGN.md +├── LICENSE +├── README.md +├── package.json +└── tsconfig.json +``` + +--- + +## Solid 2.0 Compliance Notes + +### Directives → Ref Factories +`use:draggable` does not exist — all attachment is via `ref`: +```tsx +const drag = createDraggable("item-1", myData) +
+``` + +### Signal Writes +All signals updated from DOM event callbacks are created with `INTERNAL_OPTIONS` +(`{ ownedWrite: true }`). This satisfies Solid 2.0's restriction on writing +to signals inside owned scopes. + +### Effects +All effects use the split compute/apply form: +```ts +createEffect( + () => disabled(), // compute — reactive reads only + (isDisabled) => { // apply — DOM side effects + el.setAttribute("aria-disabled", String(isDisabled)) + } +) +``` + +### `isServer` +Imported from `@solidjs/web`. Every `make*` and `create*` primitive short- +circuits with a noop/stub return when `isServer === true`. + +### No `batch`, No `createComputed` +- Signal writes from event handlers are inherently async-batched +- No `createComputed` — derivations use `createMemo` + +### Context Registration +Draggables and droppables call `useContext(DragContext)` at creation time. +If no provider is present they operate in standalone mode (useful for simple +single-target use cases without a context). + +--- + +## Context Coordination Flow + +``` +pointerdown on draggable element + → makeDraggable emits onStart + → createDraggable writes isDragging = true (INTERNAL_OPTIONS) + → context.active = DragItem + +pointermove anywhere on document + → context.transform updates (delta from start) + → collision detection runs against registered droppables + → context.over = winner | null + → matching createDroppable writes isOver = true + +pointerup anywhere on document + → context fires onDragEnd(active, over) + → createDraggable writes isDragging = false, transform = null + → createDroppable writes isOver = false + → context.active = null, context.over = null +``` + +--- + +## Test Plan + +### Browser Tests (`index.test.ts`) + +**`makeDraggable`:** +- calls onStart with correct id/data on pointerdown +- calls onMove with accumulated delta on pointermove +- calls onEnd on pointerup, resets state +- no-ops when disabled + +**`makeDroppable`:** +- calls onEnter when pointer moves into bounding rect +- calls onLeave when pointer exits +- calls onDrop on pointerup inside rect +- accept predicate filters droppables + +**`createDraggable`:** +- ref attaches to element on mount +- isDragging() false initially, true during drag, false after +- transform() null initially, {x,y} during drag, null after +- `style` properties applied to element immediately on ref attachment +- `draggingStyle` properties applied when isDragging becomes true, removed when false +- does not clobber unrelated inline styles when removing draggingStyle +- `class` tokens added to element immediately on ref attachment +- `draggingClass` tokens added via classList when isDragging becomes true, removed when false +- does not clobber unrelated classes when removing draggingClass tokens +- cleans up event listeners on dispose + +**`createDroppable`:** +- isOver() false initially, true when active drag enters +- active() null initially, carries DragItem during hover +- `style` properties applied to element immediately on ref attachment +- `overStyle` properties applied when isOver becomes true, removed when false +- does not clobber unrelated inline styles when removing overStyle +- `class` tokens added to element immediately on ref attachment +- `overClass` tokens added via classList when isOver becomes true, removed when false +- does not clobber unrelated classes when removing overClass tokens +- cleans up on dispose + +**`createDragContext`:** +- active() and over() track current drag state +- onDragStart/onDragEnd callbacks fire in order +- collision detection resolves correct droppable + +**`createSortable`:** +- acts as both draggable and droppable on same element +- isActiveDropzone distinguishes self vs other + +**Collision strategies:** +- closestCenter returns correct winner +- rectIntersection returns correct winner +- pointerWithin returns topmost containing element + +### SSR Tests (`server.test.ts`) +- make* functions return [stub, noop] on server +- create* functions return accessor stubs (no DOM access) +- No errors thrown during server render + +--- + +## Package Metadata + +```json +{ + "primitive": { + "name": "drag", + "stage": 0, + "list": [ + "makeDraggable", + "makeDroppable", + "makeNativeDroppable", + "createDraggable", + "createDroppable", + "createNativeDroppable", + "createSortable", + "createDragContext", + "closestCenter", + "closestCorners", + "rectIntersection", + "pointerWithin" + ], + "category": "Interaction" + } +} +``` + +--- + +## Integration with `createDropzone` (upload package) + +### Why a Second Drop-Zone Variant Is Needed + +The `createDropzone` primitive in `packages/upload` handles **OS file drops** — +the user drags a file from Finder or Explorer and releases it onto a browser +element. The browser delivers this via native HTML5 `DragEvent`s on the target +element, carrying `event.dataTransfer.files`. This mechanism is completely +distinct from pointer-event-based UI DnD: + +| | Pointer-based DnD (this package's default) | Native HTML5 DnD | +|---|---|---| +| Events | `pointerdown` / `pointermove` / `pointerup` | `dragenter` / `dragover` / `dragleave` / `drop` | +| Source | `makeDraggable` / `createDraggable` | OS file manager or `draggable="true"` elements | +| Position tracking | Manual (delta from start point) | Browser-managed ghost image | +| Data transfer | Arbitrary JS value attached to `DragItem` | `event.dataTransfer` (files, MIME types, text) | +| Touch support | Yes (pointer events cover touch) | Inconsistent on mobile | +| Custom drag image | Via CSS transforms on source element | Via `dataTransfer.setDragImage` | + +`createDropzone` currently calls `createEventListenerMap` directly for native +drag events. The plan is for it to use `makeNativeDroppable` / +`createNativeDroppable` from this package instead. + +### New Primitives: `makeNativeDroppable` / `createNativeDroppable` + +Added to `src/droppable.ts` alongside the pointer-based variants. + +#### `makeNativeDroppable` + +```ts +makeNativeDroppable( + el: HTMLElement, + options: MakeNativeDroppableOptions +): VoidFunction +``` + +Listens to `dragenter`, `dragleave`, `dragover`, and `drop` on `el`. Calls +`event.preventDefault()` automatically on `dragover` and `drop` (required to +allow drops). Returns a cleanup function that removes all listeners. + +```ts +type MakeNativeDroppableOptions = { + /** Called when a dragged item enters the element bounds. */ + onEnter?: (event: DragEvent) => void + /** Called when the dragged item leaves the element bounds. */ + onLeave?: (event: DragEvent) => void + /** Called on each dragover tick. preventDefault is called before this. */ + onOver?: (event: DragEvent) => void + /** Called when the item is released over the element. */ + onDrop?: (event: DragEvent) => void + /** + * Return false to reject the drop. Useful for filtering by + * event.dataTransfer.types (e.g. only accept "Files"). + * Defaults to () => true. + */ + accept?: (event: DragEvent) => boolean + disabled?: boolean +} +``` + +The `accept` predicate runs before `onEnter` and on every `dragover`. When it +returns `false`, `dropEffect` is set to `"none"` and `onEnter` / `onOver` are +not called. + +#### `createNativeDroppable` + +```ts +createNativeDroppable( + options?: CreateNativeDroppableOptions +): NativeDroppableReturn +``` + +Reactive wrapper. Attaches via a `ref` factory and exposes an `isOver` signal +that is true while a dragged item hovers the element. + +```ts +type CreateNativeDroppableOptions = MakeNativeDroppableOptions & { + /** Reactive disabled flag. */ + disabled?: boolean | Accessor +} + +type NativeDroppableReturn = { + /** Attach to a JSX element: ref={droppable.ref} */ + ref: (el: HTMLElement) => void + /** True while a dragged item is over this element. */ + isOver: Accessor +} +``` + +Signals use `INTERNAL_OPTIONS` (`ownedWrite: true`) — they are written from +`dragenter` / `dragleave` / `drop` callbacks outside any owned scope. + +### How `createDropzone` (upload) Uses This + +`createDropzone` becomes a thin layer on top of `createNativeDroppable`. It +delegates all DOM event wiring to the primitive and adds file extraction and +async callback orchestration on top. + +**Dependency**: `packages/upload/package.json` gains: +```json +{ + "dependencies": { + "@solid-primitives/drag": "workspace:^" + } +} +``` + +**Refactored `createDropzone.ts`** (showing the structural change): + +```ts +// Before — manages raw DOM events directly +import { createEventListenerMap } from "@solid-primitives/event-listener" + +const [refTarget, setRefTarget] = createSignal(undefined) +createEventListenerMap(refTarget as () => T, { + dragenter: onDragEnter, + dragleave: onDragLeave, + dragover: onDragOver, + drag: onDrag, + dragstart: onDragStart, + dragend: onDragEnd, + drop: onDrop, +}) +const ref = (el: T) => { setRefTarget(() => el); flush() } +``` + +```ts +// After — delegates drop-zone wiring to createNativeDroppable +import { createNativeDroppable } from "@solid-primitives/drag" + +const droppable = createNativeDroppable({ + onEnter: event => { + void runCallback(options?.onDragEnter, transformFiles(event.dataTransfer?.files ?? null)) + }, + onLeave: event => { + void runCallback(options?.onDragLeave, transformFiles(event.dataTransfer?.files ?? null)) + }, + onOver: event => { + void runCallback(options?.onDragOver, transformFiles(event.dataTransfer?.files ?? null)) + }, + onDrop: event => { + 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) } + })() + }, + accept: event => event.dataTransfer?.types.includes("Files") ?? true, +}) + +// droppable.isOver replaces the internal [isDragging, setIsDragging] signal +// droppable.ref replaces the manual ref + setRefTarget pattern +return { + ref: droppable.ref, + isDragging: droppable.isOver, // isOver from native droppable = isDragging in dropzone API + files, error, isLoading, + removeFile, clearFiles, +} +``` + +Note: The upload package's `isDragging` and the drag package's `isOver` are +semantically the same concept — "a dragged item is currently over this element". +The naming difference is intentional: `isOver` is the canonical name in the +drag primitive (consistent with `createDroppable`); `isDragging` is preserved +in `createDropzone` for API backwards-compatibility. + +### What `onDrag`, `onDragStart`, `onDragEnd` Become + +The current `createDropzone` exposes five extra event hooks (`onDrag`, +`onDragStart`, `onDragEnd`) that fire on the *dropzone element itself*, not on +a dragged source item. After the refactor: + +- `onDragStart` / `onDragEnd` / `onDrag` fire on the element when the element + or one of its descendants initiates a drag. These are not relevant to file + drop flows but are preserved for API compatibility. `makeNativeDroppable` + does **not** handle them — `createDropzone` keeps its own `dragstart`, + `dragend`, `drag` listeners via `createEventListenerMap` for these three. +- `onDragEnter` / `onDragLeave` / `onDragOver` / `onDrop` are delegated to + `createNativeDroppable` entirely. + +### Type Additions to `src/types.ts` + +```ts +type MakeNativeDroppableOptions = { + onEnter?: (event: DragEvent) => void + onLeave?: (event: DragEvent) => void + onOver?: (event: DragEvent) => void + onDrop?: (event: DragEvent) => void + accept?: (event: DragEvent) => boolean + disabled?: boolean +} + +type CreateNativeDroppableOptions = MakeNativeDroppableOptions & { + disabled?: boolean | Accessor +} + +type NativeDroppableReturn = { + ref: (el: HTMLElement) => void + isOver: Accessor +} +``` + +### Updated Package Metadata + +```json +{ + "primitive": { + "name": "drag", + "stage": 0, + "list": [ + "makeDraggable", + "makeDroppable", + "makeNativeDroppable", + "createDraggable", + "createDroppable", + "createNativeDroppable", + "createSortable", + "createDragContext", + "closestCenter", + "closestCorners", + "rectIntersection", + "pointerWithin" + ], + "category": "Interaction" + } +} +``` + +### Test Additions for Native Variants + +**Browser tests (`index.test.ts`):** + +`makeNativeDroppable`: +- fires `onEnter` and sets `isOver` true on `dragenter` +- fires `onLeave` and sets `isOver` false on `dragleave` +- calls `preventDefault` on `dragover` +- fires `onDrop` and sets `isOver` false on `drop` +- `accept` returning false suppresses `onEnter` / `onOver` and sets `dropEffect = "none"` +- disabled flag suppresses all callbacks + +`createNativeDroppable`: +- `isOver()` starts false, becomes true on `dragenter`, false on `dragleave` +- reactive `disabled` accessor stops callbacks while true +- cleans up all listeners on scope dispose + +**Integration test** (lives in `packages/upload/test/`): +- `createDropzone` using `createNativeDroppable` produces same `isDragging` + transitions as the current implementation +- `files()` signal is populated after a synthetic drop event carrying a + `FileList` + +--- + +## Open Questions / Future Work + +1. **Keyboard sensor** — full keyboard navigation (Tab, Space, Arrow keys) is + a non-trivial scope extension. Design leaves room via the sensor pattern but + does not implement it in v0. +2. **Drag overlay** — rendering a portal overlay during drag requires a + separate component. Punted to a follow-up primitive or userland composition. +3. **Auto-scroll** — scrolling containers when dragging near edges. Punted. +4. **Touch** — `pointer` events cover touch natively on modern browsers; no + separate touch sensor needed. +5. **Accessibility** — ARIA live regions and `aria-grabbed` are documented in + README as a user responsibility with guidance on how to wire them. diff --git a/packages/drag-drop/LICENSE b/packages/drag-drop/LICENSE new file mode 100644 index 000000000..38b41d975 --- /dev/null +++ b/packages/drag-drop/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 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. \ No newline at end of file diff --git a/packages/drag-drop/README.md b/packages/drag-drop/README.md new file mode 100644 index 000000000..d207d07ba --- /dev/null +++ b/packages/drag-drop/README.md @@ -0,0 +1,213 @@ +

+ Solid Primitives drag +

+ +# @solid-primitives/drag + +[![turborepo](https://img.shields.io/badge/built%20with-turborepo-cc00ff.svg?style=for-the-badge&logo=turborepo)](https://turborepo.org/) +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/drag?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/drag) +[![version](https://img.shields.io/npm/v/@solid-primitives/drag?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/drag) +[![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 for Solid 2.0. + +Two separate drag systems are provided: + +- **Pointer-event DnD** (`makeDraggable`, `makeDroppable`, `createDraggable`, `createDroppable`, `createSortable`, `createDragContext`) — for UI elements moved by the user. +- **Native HTML5 DnD** (`makeNativeDroppable`, `createNativeDroppable`) — for OS file drops and `draggable="true"` elements; designed to compose with `@solid-primitives/upload`'s `createDropzone`. + +## Installation + +```bash +npm install @solid-primitives/drag +# or +pnpm add @solid-primitives/drag +``` + +## Primitives + +### `makeDraggable` + +Non-reactive base. Attaches pointer listeners to an element. No Solid owner required. + +```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 = ""), +}); +// later +cleanup(); +``` + +### `makeDroppable` + +Non-reactive base. Marks an element as a drop target for pointer-event drags. + +```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. + +```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 | + +| 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. + +```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"} +
+``` + +### `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`. + +```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), +}); + + + + + +``` + +| Return | Description | +|---|---| +| `Provider` | Wrap your DnD 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` | + +### `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} +
+ ); + }} +
+``` + +## 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 distance | +| `rectIntersection` | Droppable with largest overlap area | +| `pointerWithin` | Topmost droppable containing the pointer (default) | + +```ts +import { createDragContext, closestCenter } from "@solid-primitives/drag"; + +const ctx = createDragContext({ collisionDetection: closestCenter }); +``` + +You can also write a custom detector: + +```ts +import type { CollisionDetector } from "@solid-primitives/drag"; + +const myDetector: CollisionDetector = (draggable, droppables, pointer) => { + // Return the id of the winning droppable, or null + return droppables[0]?.id ?? null; +}; +``` + +## Integration with `@solid-primitives/upload` + +`createNativeDroppable` is designed to serve as the drop-zone backend for the upload package's `createDropzone`. Pass the `ref` and `isOver` signal to the upload primitive for a seamless file-upload DnD experience. + +```tsx +const drop = createNativeDroppable({ + accept: e => e.dataTransfer?.types.includes("Files") ?? false, +}); + +// Pass to createDropzone from @solid-primitives/upload +const dropzone = createDropzone(drop); +``` + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md). diff --git a/packages/drag-drop/package.json b/packages/drag-drop/package.json new file mode 100644 index 000000000..82fbd1fe6 --- /dev/null +++ b/packages/drag-drop/package.json @@ -0,0 +1,77 @@ +{ + "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": "Interaction" + }, + "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" + } + }, + "typesVersions": {}, + "scripts": { + "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts", + "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": "2.0.0-beta.14", + "solid-js": "2.0.0-beta.14" + }, + "peerDependencies": { + "@solidjs/web": "^2.0.0-beta.14", + "solid-js": "^2.0.0-beta.14" + } +} diff --git a/packages/drag-drop/src/collision.ts b/packages/drag-drop/src/collision.ts new file mode 100644 index 000000000..1db150f72 --- /dev/null +++ b/packages/drag-drop/src/collision.ts @@ -0,0 +1,78 @@ +import type { CollisionDetector, Point } from "./types.js"; + +function dist(a: Point, b: Point): number { + return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); +} + +function center(rect: DOMRect): Point { + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; +} + +function corners(rect: DOMRect): Point[] { + return [ + { x: rect.left, y: rect.top }, + { x: rect.right, y: rect.top }, + { x: rect.left, y: rect.bottom }, + { x: rect.right, y: rect.bottom }, + ]; +} + +/** Returns the droppable whose center is nearest to the pointer. */ +export const closestCenter: CollisionDetector = (_draggable, droppables, pointer) => { + let best: string | number | null = null; + let bestDist = Infinity; + for (const d of droppables) { + const distance = dist(pointer, center(d.rect)); + if (distance < bestDist) { + bestDist = distance; + best = d.id; + } + } + return best; +}; + +/** Returns the droppable whose nearest corner is closest to the pointer. */ +export const closestCorners: CollisionDetector = (_draggable, droppables, pointer) => { + let best: string | number | null = null; + let bestDist = Infinity; + for (const d of droppables) { + for (const corner of corners(d.rect)) { + const distance = dist(pointer, corner); + if (distance < bestDist) { + bestDist = distance; + 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) => { + for (let i = droppables.length - 1; i >= 0; i--) { + const d = droppables[i]!; + const r = d.rect; + if (pointer.x >= r.left && pointer.x <= r.right && pointer.y >= r.top && pointer.y <= 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..21834ee32 --- /dev/null +++ b/packages/drag-drop/src/context.tsx @@ -0,0 +1,160 @@ +import { createContext, 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.js"; +import type { DragContextOptions, DragContextReturn, DragItem, DroppableItem, Point, Transform } from "./types.js"; + +type DroppableEntry = { + element: HTMLElement; + data: unknown; + accept?: (draggable: DragItem) => boolean; +}; + +export type DragContextValue = { + active: () => DragItem | null; + over: () => DroppableItem | null; + transform: () => Transform | null; + _registerDraggable: (id: string | number, element: HTMLElement, data: unknown) => void; + _unregisterDraggable: (id: string | number) => void; + _registerDroppable: ( + id: string | number, + element: HTMLElement, + data: unknown, + accept?: (draggable: DragItem) => boolean, + ) => void; + _unregisterDroppable: (id: string | number) => void; + _startDrag: (id: string | number, element: HTMLElement, data: unknown, event: PointerEvent) => void; +}; + +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 draggables = new Map(); + 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); + + // Local reference for event handlers — avoids depending on signal flush timing + let currentDrag: DragItem | null = null; + let startX = 0; + let startY = 0; + + const collide = options.collisionDetection ?? pointerWithin; + + const resolveOver = (activeItem: DragItem, pointer: Point): DroppableItem | null => { + const draggableRect = { id: activeItem.id, rect: activeItem.element.getBoundingClientRect() }; + const droppableRects = [...droppables.entries()] + .filter(([id]) => id !== activeItem.id) + .map(([id, entry]) => ({ id, rect: entry.element.getBoundingClientRect() })); + + const winnerId = collide(draggableRect, droppableRects, pointer); + if (winnerId === null) return null; + + const winner = droppables.get(winnerId); + if (!winner) return null; + if (winner.accept && !winner.accept(activeItem)) return null; + + return { id: winnerId, data: winner.data, element: winner.element }; + }; + + const onPointerMove = (event: PointerEvent) => { + if (!currentDrag) return; + + const t: Transform = { x: event.clientX - startX, y: event.clientY - startY }; + setTransform(t); + + const winner = resolveOver(currentDrag, { x: event.clientX, y: event.clientY }); + setOver(winner); + + options.onDragMove?.(currentDrag, t); + }; + + const onPointerUp = (_event: PointerEvent) => { + if (!currentDrag) return; + + const overItem = untrack(over); + removeDocListeners(); + const item = currentDrag; + currentDrag = null; + options.onDragEnd?.(item, overItem); + + setActive(null); + setOver(null); + setTransform(null); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + if (!currentDrag) return; + + removeDocListeners(); + const item = currentDrag; + currentDrag = null; + options.onDragCancel?.(item); + + setActive(null); + setOver(null); + setTransform(null); + }; + + function removeDocListeners() { + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + document.removeEventListener("keydown", onKeyDown); + } + + const _startDrag = ( + id: string | number, + element: HTMLElement, + data: unknown, + event: PointerEvent, + ) => { + startX = event.clientX; + startY = event.clientY; + + 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("keydown", onKeyDown); + + options.onDragStart?.(item); + }; + + onCleanup(removeDocListeners); + + const contextValue: DragContextValue = { + active, + over, + transform, + _registerDraggable: (id, element, data) => draggables.set(id, { element, data }), + _unregisterDraggable: id => draggables.delete(id), + _registerDroppable: (id, element, data, accept) => droppables.set(id, { element, data, accept }), + _unregisterDroppable: id => droppables.delete(id), + _startDrag, + }; + + const Provider = (props: { children: Element }): Element => ( + {props.children} + ); + + return { Provider, active, over, transform }; +} diff --git a/packages/drag-drop/src/dom.ts b/packages/drag-drop/src/dom.ts new file mode 100644 index 000000000..dc4af52fe --- /dev/null +++ b/packages/drag-drop/src/dom.ts @@ -0,0 +1,27 @@ +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); + } +} diff --git a/packages/drag-drop/src/draggable.ts b/packages/drag-drop/src/draggable.ts new file mode 100644 index 000000000..f5dc3ef62 --- /dev/null +++ b/packages/drag-drop/src/draggable.ts @@ -0,0 +1,186 @@ +import { createEffect, createMemo, createSignal, flush } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { access, INTERNAL_OPTIONS, noop } from "@solid-primitives/utils"; +import { useDragContext } from "./context.js"; +import { applyClass, applyStyle, removeClass, removeStyle } from "./dom.js"; +import type { CreateDraggableOptions, DraggableReturn, MakeDraggableOptions, Transform } from "./types.js"; + +/** + * Attaches pointer-based drag behaviour to an existing element. + * Non-reactive — no Solid owner required. Returns a cleanup function. + * + * @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; + + const onPointerMove = (event: PointerEvent) => { + const delta: Transform = { x: event.clientX - startX, y: event.clientY - startY }; + options.onMove?.(delta, event); + }; + + const onPointerUp = (event: PointerEvent) => { + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + const delta: Transform = { x: event.clientX - startX, y: event.clientY - startY }; + 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; + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", onPointerUp); + options.onStart?.(event); + }; + + el.addEventListener("pointerdown", onPointerDown); + + return () => { + el.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + }; +} + +/** + * 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. + * + * @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); + + let isDragging: () => boolean; + let currentTransform: () => Transform | null; + + if (ctx) { + isDragging = createMemo(() => ctx.active()?.id === 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; + + const onPointerMove = (event: PointerEvent) => { + setTransform({ x: event.clientX - startX, y: event.clientY - startY }); + }; + + const onPointerUp = () => { + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + setIsDragging(false); + setTransform(null); + }; + + createEffect( + () => elSignal(), + el => { + if (!el) return; + const onPointerDown = (event: PointerEvent) => { + if (access(options.disabled)) return; + if (event.button !== 0) return; + event.preventDefault(); + startX = event.clientX; + startY = event.clientY; + setIsDragging(true); + setTransform({ x: 0, y: 0 }); + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", onPointerUp); + }; + el.addEventListener("pointerdown", onPointerDown); + return () => { + el.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + }; + }, + ); + } + + // Context mode: attach pointerdown + register + if (ctx) { + createEffect( + () => elSignal(), + el => { + if (!el) return; + ctx._registerDraggable(id, el, data as unknown); + const onPointerDown = (event: PointerEvent) => { + if (access(options.disabled)) return; + if (event.button !== 0) return; + event.preventDefault(); + ctx._startDrag(id, el, data as unknown, event); + }; + el.addEventListener("pointerdown", onPointerDown); + return () => { + el.removeEventListener("pointerdown", onPointerDown); + ctx._unregisterDraggable(id); + }; + }, + ); + } + + // 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); + 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..88468a1ff --- /dev/null +++ b/packages/drag-drop/src/droppable.ts @@ -0,0 +1,312 @@ +import { createEffect, createMemo, createSignal, flush } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { access, INTERNAL_OPTIONS, noop } from "@solid-primitives/utils"; +import { useDragContext } from "./context.js"; +import { applyClass, applyStyle, removeClass, removeStyle } from "./dom.js"; +import type { + CreateDroppableOptions, + CreateNativeDroppableOptions, + DragItem, + DroppableReturn, + MakeDroppableOptions, + MakeNativeDroppableOptions, + NativeDroppableReturn, +} from "./types.js"; + +/** + * 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 no pointer capture is + * active on the dragging element, so `pointerenter`/`pointerleave` fire naturally. + * + * @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. + * + * @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(); + const [elSignal, setElSignal] = createSignal(undefined, INTERNAL_OPTIONS); + + const isOver: () => boolean = ctx + ? createMemo(() => ctx.over()?.id === id) + : () => false; + + const active: () => DragItem | null = ctx + ? createMemo((): DragItem | null => (isOver() ? ctx.active() : null)) + : () => null; + + if (ctx) { + createEffect( + () => elSignal(), + el => { + if (!el) return; + ctx._registerDroppable(id, el, data as unknown, 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); + } + }, + ); + + // Reactive disabled: re-register with updated accept when disabled changes + if (ctx) { + createEffect( + () => access(options.disabled), + disabled => { + const el = elSignal(); + if (!el) return; + if (disabled) { + ctx._unregisterDroppable(id); + } else { + ctx._registerDroppable(id, el, data as unknown, options.accept); + } + }, + ); + } + + 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; + 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; + 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; + 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; + 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..3d2bfaad4 --- /dev/null +++ b/packages/drag-drop/src/index.ts @@ -0,0 +1,36 @@ +export type { + Transform, + Point, + DragItem, + DroppableItem, + DragRect, + DroppableRect, + CollisionDetector, + MakeDraggableOptions, + MakeDroppableOptions, + MakeNativeDroppableOptions, + CreateDraggableOptions, + CreateDroppableOptions, + CreateNativeDroppableOptions, + DraggableReturn, + DroppableReturn, + NativeDroppableReturn, + SortableReturn, + DragContextOptions, + DragContextReturn, +} from "./types.js"; + +export { closestCenter, closestCorners, rectIntersection, pointerWithin } from "./collision.js"; + +export { createDragContext } from "./context.js"; + +export { makeDraggable, createDraggable } from "./draggable.js"; + +export { + makeDroppable, + createDroppable, + makeNativeDroppable, + createNativeDroppable, +} from "./droppable.js"; + +export { createSortable } from "./sortable.js"; diff --git a/packages/drag-drop/src/sortable.ts b/packages/drag-drop/src/sortable.ts new file mode 100644 index 000000000..94332e0aa --- /dev/null +++ b/packages/drag-drop/src/sortable.ts @@ -0,0 +1,69 @@ +import { createMemo } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { noop } from "@solid-primitives/utils"; +import { createDraggable } from "./draggable.js"; +import { createDroppable } from "./droppable.js"; +import type { SortableReturn } from "./types.js"; + +/** + * 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); + + // True when another item is being dragged over this element. + // Self-hover is excluded by the context's collision detection filter. + const isActiveDropzone = createMemo(() => droppable.isOver()); + + 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, + id, + }; +} diff --git a/packages/drag-drop/src/types.ts b/packages/drag-drop/src/types.ts new file mode 100644 index 000000000..cea14f38a --- /dev/null +++ b/packages/drag-drop/src/types.ts @@ -0,0 +1,115 @@ +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 DragRect = { id: string | number; rect: DOMRect }; +export type DroppableRect = { id: string | number; rect: DOMRect }; + +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 and dragover. */ + accept?: (event: DragEvent) => boolean; + disabled?: boolean; +}; + +export type CreateDraggableOptions = { + disabled?: boolean | Accessor; + style?: Partial; + draggingStyle?: Partial; + class?: string; + draggingClass?: string; +}; + +export type CreateDroppableOptions = { + disabled?: boolean | Accessor; + accept?: (draggable: DragItem) => boolean; + 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; +}; + +export type DragContextReturn = { + Provider: (props: { children: Element }) => Element; + active: Accessor; + over: Accessor; + transform: Accessor; +}; diff --git a/packages/drag-drop/stories/index.stories.tsx b/packages/drag-drop/stories/index.stories.tsx new file mode 100644 index 000000000..0dbb2058f --- /dev/null +++ b/packages/drag-drop/stories/index.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: "Interaction/Drag", + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: readme, + }, + }, + }, +}); + +export default meta; + +// ── Story 1: createDraggable standalone ─────────────────────────────────────── + +export const StandaloneDrag = meta.story({ + name: "createDraggable — 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: "createDragContext — drag and drop", + 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: "createSortable — 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: "createNativeDroppable — file drop", + 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..fb2e382c2 --- /dev/null +++ b/packages/drag-drop/test/index.test.ts @@ -0,0 +1,694 @@ +import "./setup.js"; +import { createRoot, flush } from "solid-js"; +import { render } from "@solidjs/web"; +import { describe, it, expect } from "vitest"; +import { + makeDraggable, + makeDroppable, + makeNativeDroppable, + createDraggable, + createDroppable, + createNativeDroppable, + createDragContext, + createSortable, + 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, + }); +} + +// ── 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("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(); + }); +}); + +// ── 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("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(); + }); +}); + +// ── 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("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(); + }); + }); +}); + +// ── 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(); + }); + }); +}); + +// ── 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("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(); + }); +}); + +// ── 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("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(); + }); + }); +}); + +// ── 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(); + }); + }); +}); + +// ── 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..bd07f540c --- /dev/null +++ b/packages/drag-drop/test/setup.ts @@ -0,0 +1,39 @@ +// 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; diff --git a/packages/drag-drop/tsconfig.json b/packages/drag-drop/tsconfig.json new file mode 100644 index 000000000..dc1970e16 --- /dev/null +++ b/packages/drag-drop/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { + "path": "../utils" + } + ], + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/packages/upload/package.json b/packages/upload/package.json index b1dd91d40..39982c14b 100644 --- a/packages/upload/package.json +++ b/packages/upload/package.json @@ -14,11 +14,11 @@ "stage": 0, "list": [ "createFilePicker", + "createDropzone", + "dropzone", "createFileUploader", "fileSender", - "fileUploader", - "createDropzone", - "dropzone" + "fileUploader" ], "category": "Browser APIs", "gzip": 1035 @@ -54,7 +54,7 @@ "test:ssr": "pnpm run vitest --mode ssr" }, "dependencies": { - "@solid-primitives/event-listener": "workspace:*" + "@solid-primitives/drag-drop": "workspace:*" }, "peerDependencies": { "@solidjs/web": "^2.0.0-beta.14", diff --git a/packages/upload/src/createDropzone.ts b/packages/upload/src/createDropzone.ts index 498f1867e..f5295a698 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.js"; import type { UploadFile, Dropzone, DropzoneOptions } from "./types.js"; /** - * 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,49 @@ 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?.(transformFiles(e.dataTransfer?.files ?? null)), + onLeave: e => + options?.onDragLeave?.(transformFiles(e.dataTransfer?.files ?? null)), + onOver: e => + options?.onDragOver?.(transformFiles(e.dataTransfer?.files ?? null)), + 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 60dc4812c..98337bfb5 100644 --- a/packages/upload/src/index.ts +++ b/packages/upload/src/index.ts @@ -1,7 +1,7 @@ export { createFilePicker } from "./createFilePicker.js"; +export { createDropzone, dropzone } from "./createDropzone.js"; export { createFileUploader } from "./createFileUploader.js"; export { fileSender } from "./fileSender.js"; export type { FileSenderOptions } from "./fileSender.js"; -export { createDropzone, dropzone } from "./createDropzone.js"; export { fileUploader } from "./fileUploader.js"; export * from "./types.js"; diff --git a/packages/upload/src/types.ts b/packages/upload/src/types.ts index 3be0ab6c0..02c587ee9 100644 --- a/packages/upload/src/types.ts +++ b/packages/upload/src/types.ts @@ -46,18 +46,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; } 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 e9b5bbc89..10539b191 100644 --- a/packages/upload/test/index.test.tsx +++ b/packages/upload/test/index.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { createRoot, flush } from "solid-js"; -import { createFilePicker, createFileUploader, fileSender, createDropzone, dropzone, fileUploader } from "../src/index.js"; +import { createFilePicker, createDropzone, dropzone, createFileUploader, fileSender, fileUploader } from "../src/index.js"; import { transformFiles } from "../src/helpers.js"; import type { UploadFile } from "../src/types.js"; @@ -290,12 +290,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; }); @@ -305,11 +302,8 @@ describe("createDropzone", () => { 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 }); @@ -326,13 +320,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(); @@ -378,16 +372,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); @@ -398,18 +389,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/packages/upload/tsconfig.json b/packages/upload/tsconfig.json index 9cfacb5f9..fbf7a0dda 100644 --- a/packages/upload/tsconfig.json +++ b/packages/upload/tsconfig.json @@ -7,7 +7,7 @@ }, "references": [ { - "path": "../event-listener" + "path": "../drag-drop" } ], "include": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0f196ceb..8b4caa054 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,6 +328,19 @@ importers: specifier: 2.0.0-beta.14 version: 2.0.0-beta.14 + packages/drag-drop: + dependencies: + '@solid-primitives/utils': + specifier: workspace:^ + version: link:../utils + devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14(solid-js@2.0.0-beta.14) + solid-js: + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14 + packages/event-bus: dependencies: '@solid-primitives/utils': @@ -808,22 +821,6 @@ importers: specifier: 2.0.0-beta.13 version: 2.0.0-beta.13 - packages/page-visibility: - dependencies: - '@solid-primitives/event-listener': - specifier: workspace:^ - version: link:../event-listener - '@solid-primitives/rootless': - specifier: workspace:^ - version: link:../rootless - '@solid-primitives/utils': - specifier: workspace:^ - version: link:../utils - devDependencies: - solid-js: - specifier: ^1.9.7 - version: 1.9.7 - packages/pagination: dependencies: '@solid-primitives/utils': @@ -1235,9 +1232,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: 2.0.0-beta.14 @@ -12458,7 +12455,7 @@ snapshots: solid-js@2.0.0-beta.14: dependencies: '@solidjs/signals': 2.0.0-beta.14 - csstype: 3.1.3 + csstype: 3.2.3 seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) From 4ea923f30d16d1a03aef71100bf3b126fab104a4 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:49:37 -0400 Subject: [PATCH 02/17] Improvements related to point events --- packages/drag-drop/README.md | 30 +++--- packages/drag-drop/src/collision.ts | 65 +++++-------- packages/drag-drop/src/context.tsx | 142 +++++++++++++++++++++------- packages/drag-drop/src/draggable.ts | 15 +-- packages/drag-drop/src/droppable.ts | 31 ++---- packages/drag-drop/src/sortable.ts | 7 +- packages/drag-drop/test/setup.ts | 17 ++++ 7 files changed, 184 insertions(+), 123 deletions(-) diff --git a/packages/drag-drop/README.md b/packages/drag-drop/README.md index d207d07ba..d8fe73a10 100644 --- a/packages/drag-drop/README.md +++ b/packages/drag-drop/README.md @@ -1,27 +1,27 @@

- Solid Primitives drag + Solid Primitives drag

-# @solid-primitives/drag +# @solid-primitives/drag-drop [![turborepo](https://img.shields.io/badge/built%20with-turborepo-cc00ff.svg?style=for-the-badge&logo=turborepo)](https://turborepo.org/) -[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/drag?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/drag) -[![version](https://img.shields.io/npm/v/@solid-primitives/drag?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/drag) +[![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?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 for Solid 2.0. Two separate drag systems are provided: -- **Pointer-event DnD** (`makeDraggable`, `makeDroppable`, `createDraggable`, `createDroppable`, `createSortable`, `createDragContext`) — for UI elements moved by the user. -- **Native HTML5 DnD** (`makeNativeDroppable`, `createNativeDroppable`) — for OS file drops and `draggable="true"` elements; designed to compose with `@solid-primitives/upload`'s `createDropzone`. +- **Pointer-events** (`makeDraggable`, `makeDroppable`, `createDraggable`, `createDroppable`, `createSortable`, `createDragContext`) — for UI elements moved by the user. +- **Native HTML5 API** (`makeNativeDroppable`, `createNativeDroppable`) — for OS file drops and `draggable="true"` elements; designed to compose with `@solid-primitives/upload`'s `createDropzone`. ## Installation ```bash -npm install @solid-primitives/drag +npm install @solid-primitives/drag-drop # or -pnpm add @solid-primitives/drag +pnpm add @solid-primitives/drag-drop ``` ## Primitives @@ -130,22 +130,22 @@ const drop = createNativeDroppable({ Coordinates draggables and droppables. Provide it as a context via `ctx.Provider`. ```tsx -const ctx = createDragContext({ +const DragContext = 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), }); - + - + ``` | Return | Description | |---|---| -| `Provider` | Wrap your DnD tree in this component | +| `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` | @@ -179,7 +179,7 @@ All four are exported as pure functions — pass any of them as `collisionDetect | `pointerWithin` | Topmost droppable containing the pointer (default) | ```ts -import { createDragContext, closestCenter } from "@solid-primitives/drag"; +import { createDragContext, closestCenter } from "@solid-primitives/drag-drop"; const ctx = createDragContext({ collisionDetection: closestCenter }); ``` @@ -187,7 +187,7 @@ const ctx = createDragContext({ collisionDetection: closestCenter }); You can also write a custom detector: ```ts -import type { CollisionDetector } from "@solid-primitives/drag"; +import type { CollisionDetector } from "@solid-primitives/drag-drop"; const myDetector: CollisionDetector = (draggable, droppables, pointer) => { // Return the id of the winning droppable, or null @@ -197,7 +197,7 @@ const myDetector: CollisionDetector = (draggable, droppables, pointer) => { ## Integration with `@solid-primitives/upload` -`createNativeDroppable` is designed to serve as the drop-zone backend for the upload package's `createDropzone`. Pass the `ref` and `isOver` signal to the upload primitive for a seamless file-upload DnD experience. +`createNativeDroppable` is designed to serve as the drop-zone backend for the upload package's `createDropzone`. Pass the `ref` and `isOver` signal to the upload primitive for a seamless file-upload drag-and-drop experience. ```tsx const drop = createNativeDroppable({ diff --git a/packages/drag-drop/src/collision.ts b/packages/drag-drop/src/collision.ts index 1db150f72..29c9886a3 100644 --- a/packages/drag-drop/src/collision.ts +++ b/packages/drag-drop/src/collision.ts @@ -1,48 +1,35 @@ -import type { CollisionDetector, Point } from "./types.js"; +import type { CollisionDetector } from "./types.js"; -function dist(a: Point, b: Point): number { - return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); -} - -function center(rect: DOMRect): Point { - return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; -} - -function corners(rect: DOMRect): Point[] { - return [ - { x: rect.left, y: rect.top }, - { x: rect.right, y: rect.top }, - { x: rect.left, y: rect.bottom }, - { x: rect.right, y: rect.bottom }, - ]; -} - -/** Returns the droppable whose center is nearest to the pointer. */ +/** 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 bestDist = Infinity; + let bestDistSq = Infinity; + const px = pointer.x, py = pointer.y; for (const d of droppables) { - const distance = dist(pointer, center(d.rect)); - if (distance < bestDist) { - bestDist = distance; - best = d.id; - } + const dx = px - (d.rect.left + d.rect.width * 0.5); + const dy = py - (d.rect.top + d.rect.height * 0.5); + 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. */ +/** 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 bestDist = Infinity; + let bestDistSq = Infinity; + const px = pointer.x, py = pointer.y; for (const d of droppables) { - for (const corner of corners(d.rect)) { - const distance = dist(pointer, corner); - if (distance < bestDist) { - bestDist = distance; - best = d.id; - } - } + const { left, right, top, bottom } = d.rect; + let dx: number, dy: number, distSq: number; + // top-left + dx = px - left; dy = py - top; distSq = dx * dx + dy * dy; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } + // top-right + dx = px - right; dy = py - top; distSq = dx * dx + dy * dy; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } + // bottom-left + dx = px - left; dy = py - bottom; distSq = dx * dx + dy * dy; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } + // bottom-right + dx = px - right; dy = py - bottom; distSq = dx * dx + dy * dy; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } } return best; }; @@ -57,22 +44,18 @@ export const rectIntersection: CollisionDetector = (draggable, droppables) => { 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; - } + 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 (pointer.x >= r.left && pointer.x <= r.right && pointer.y >= r.top && pointer.y <= r.bottom) { - return d.id; - } + 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 index 21834ee32..8a2b2b5a3 100644 --- a/packages/drag-drop/src/context.tsx +++ b/packages/drag-drop/src/context.tsx @@ -2,7 +2,15 @@ import { createContext, createSignal, onCleanup, useContext, untrack, type Eleme import { isServer } from "@solidjs/web"; import { INTERNAL_OPTIONS } from "@solid-primitives/utils"; import { pointerWithin } from "./collision.js"; -import type { DragContextOptions, DragContextReturn, DragItem, DroppableItem, Point, Transform } from "./types.js"; +import type { + DragContextOptions, + DragContextReturn, + DragItem, + DragRect, + DroppableItem, + DroppableRect, + Transform, +} from "./types.js"; type DroppableEntry = { element: HTMLElement; @@ -14,8 +22,6 @@ export type DragContextValue = { active: () => DragItem | null; over: () => DroppableItem | null; transform: () => Transform | null; - _registerDraggable: (id: string | number, element: HTMLElement, data: unknown) => void; - _unregisterDraggable: (id: string | number) => void; _registerDroppable: ( id: string | number, element: HTMLElement, @@ -42,55 +48,103 @@ export function createDragContext(options: DragContextOptions = {}): DragContext return { Provider, active: () => null, over: () => null, transform: () => null }; } - const draggables = new Map(); 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); - // Local reference for event handlers — avoids depending on signal flush timing let currentDrag: DragItem | null = null; let startX = 0; let startY = 0; - const collide = options.collisionDetection ?? pointerWithin; + // 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 resolveOver = (activeItem: DragItem, pointer: Point): DroppableItem | null => { - const draggableRect = { id: activeItem.id, rect: activeItem.element.getBoundingClientRect() }; - const droppableRects = [...droppables.entries()] - .filter(([id]) => id !== activeItem.id) - .map(([id, entry]) => ({ id, rect: entry.element.getBoundingClientRect() })); + const collide = options.collisionDetection ?? pointerWithin; - const winnerId = collide(draggableRect, droppableRects, pointer); - if (winnerId === null) return null; + // 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; - const winner = droppables.get(winnerId); - if (!winner) return null; - if (winner.accept && !winner.accept(activeItem)) return null; + const processMove = () => { + rafPending = false; + if (!currentDrag) return; - return { id: winnerId, data: winner.data, element: winner.element }; + const tx = pendingX - startX; + const ty = pendingY - startY; + setTransform({ x: tx, y: ty }); + + // 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, + } as unknown as DOMRect, + }; + + 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 onPointerMove = (event: PointerEvent) => { if (!currentDrag) return; + pendingX = event.clientX; + pendingY = event.clientY; + if (!rafPending) { + rafPending = true; + rafId = requestAnimationFrame(() => { + rafPending = false; + processMove(); + }); + } + }; - const t: Transform = { x: event.clientX - startX, y: event.clientY - startY }; - setTransform(t); - - const winner = resolveOver(currentDrag, { x: event.clientX, y: event.clientY }); - setOver(winner); - - options.onDragMove?.(currentDrag, t); + const cancelPendingMove = () => { + if (rafPending) { cancelAnimationFrame(rafId); rafPending = false; } }; const onPointerUp = (_event: PointerEvent) => { if (!currentDrag) return; + cancelPendingMove(); const overItem = untrack(over); - removeDocListeners(); const item = currentDrag; currentDrag = null; + cleanupDrag(); options.onDragEnd?.(item, overItem); setActive(null); @@ -99,12 +153,12 @@ export function createDragContext(options: DragContextOptions = {}): DragContext }; const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - if (!currentDrag) return; + if (event.key !== "Escape" || !currentDrag) return; + cancelPendingMove(); - removeDocListeners(); const item = currentDrag; currentDrag = null; + cleanupDrag(); options.onDragCancel?.(item); setActive(null); @@ -112,10 +166,15 @@ export function createDragContext(options: DragContextOptions = {}): DragContext setTransform(null); }; - function removeDocListeners() { + const onScroll = () => { + if (currentDrag) snapshotRects(currentDrag.id); + }; + + function cleanupDrag() { document.removeEventListener("pointermove", onPointerMove); document.removeEventListener("pointerup", onPointerUp); document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("scroll", onScroll, { capture: true }); } const _startDrag = ( @@ -126,6 +185,16 @@ export function createDragContext(options: DragContextOptions = {}): DragContext ) => { startX = event.clientX; startY = event.clientY; + pendingX = startX; + pendingY = startY; + + // 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; @@ -135,20 +204,25 @@ export function createDragContext(options: DragContextOptions = {}): DragContext document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); document.addEventListener("keydown", onKeyDown); + document.addEventListener("scroll", onScroll, { passive: true, capture: true }); options.onDragStart?.(item); }; - onCleanup(removeDocListeners); + onCleanup(cleanupDrag); const contextValue: DragContextValue = { active, over, transform, - _registerDraggable: (id, element, data) => draggables.set(id, { element, data }), - _unregisterDraggable: id => draggables.delete(id), - _registerDroppable: (id, element, data, accept) => droppables.set(id, { element, data, accept }), - _unregisterDroppable: id => droppables.delete(id), + _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, }; diff --git a/packages/drag-drop/src/draggable.ts b/packages/drag-drop/src/draggable.ts index f5dc3ef62..6f90db639 100644 --- a/packages/drag-drop/src/draggable.ts +++ b/packages/drag-drop/src/draggable.ts @@ -9,6 +9,11 @@ import type { CreateDraggableOptions, DraggableReturn, MakeDraggableOptions, Tra * 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, { @@ -121,6 +126,7 @@ export function createDraggable( if (access(options.disabled)) return; if (event.button !== 0) return; event.preventDefault(); + el.setPointerCapture(event.pointerId); startX = event.clientX; startY = event.clientY; setIsDragging(true); @@ -138,24 +144,21 @@ export function createDraggable( ); } - // Context mode: attach pointerdown + register + // Context mode: attach pointerdown listener only (registration handled by droppables). if (ctx) { createEffect( () => elSignal(), el => { if (!el) return; - ctx._registerDraggable(id, el, data as unknown); 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 as unknown, event); }; el.addEventListener("pointerdown", onPointerDown); - return () => { - el.removeEventListener("pointerdown", onPointerDown); - ctx._unregisterDraggable(id); - }; + return () => el.removeEventListener("pointerdown", onPointerDown); }, ); } diff --git a/packages/drag-drop/src/droppable.ts b/packages/drag-drop/src/droppable.ts index 88468a1ff..c749304d8 100644 --- a/packages/drag-drop/src/droppable.ts +++ b/packages/drag-drop/src/droppable.ts @@ -17,8 +17,9 @@ import type { * 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 no pointer capture is - * active on the dragging element, so `pointerenter`/`pointerleave` fire naturally. + * 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 @@ -93,11 +94,15 @@ export function createDroppable( ? createMemo((): DragItem | null => (isOver() ? ctx.active() : null)) : () => null; + // Single effect tracks both element and disabled — avoids stale element reads. if (ctx) { createEffect( - () => elSignal(), - el => { - if (!el) return; + () => ({ el: elSignal(), disabled: access(options.disabled) }), + ({ el, disabled }) => { + if (!el || disabled) { + ctx._unregisterDroppable(id); + return; + } ctx._registerDroppable(id, el, data as unknown, options.accept); return () => ctx._unregisterDroppable(id); }, @@ -119,22 +124,6 @@ export function createDroppable( }, ); - // Reactive disabled: re-register with updated accept when disabled changes - if (ctx) { - createEffect( - () => access(options.disabled), - disabled => { - const el = elSignal(); - if (!el) return; - if (disabled) { - ctx._unregisterDroppable(id); - } else { - ctx._registerDroppable(id, el, data as unknown, options.accept); - } - }, - ); - } - const ref = (el: HTMLElement) => { setElSignal(() => el); applyStyle(el, options.style); diff --git a/packages/drag-drop/src/sortable.ts b/packages/drag-drop/src/sortable.ts index 94332e0aa..4bf5bd07b 100644 --- a/packages/drag-drop/src/sortable.ts +++ b/packages/drag-drop/src/sortable.ts @@ -1,4 +1,3 @@ -import { createMemo } from "solid-js"; import { isServer } from "@solidjs/web"; import { noop } from "@solid-primitives/utils"; import { createDraggable } from "./draggable.js"; @@ -48,10 +47,6 @@ export function createSortable( const draggable = createDraggable(id, data); const droppable = createDroppable(id, data); - // True when another item is being dragged over this element. - // Self-hover is excluded by the context's collision detection filter. - const isActiveDropzone = createMemo(() => droppable.isOver()); - const ref = (el: HTMLElement) => { draggable.ref(el); droppable.ref(el); @@ -63,7 +58,7 @@ export function createSortable( transform: draggable.transform, isOver: droppable.isOver, active: droppable.active, - isActiveDropzone, + isActiveDropzone: droppable.isOver, id, }; } diff --git a/packages/drag-drop/test/setup.ts b/packages/drag-drop/test/setup.ts index bd07f540c..10a7e24a7 100644 --- a/packages/drag-drop/test/setup.ts +++ b/packages/drag-drop/test/setup.ts @@ -37,3 +37,20 @@ class DragEventShim extends MouseEvent { (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 => {}; From f0926aa4465f2562e02ae08ca8c9774d313358eb Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:58:58 -0400 Subject: [PATCH 03/17] Fixed types and docs --- packages/drag-drop/README.md | 54 ++++++++++++++++++++++------- packages/drag-drop/src/context.tsx | 2 +- packages/drag-drop/src/draggable.ts | 2 +- packages/drag-drop/src/droppable.ts | 2 +- packages/drag-drop/src/index.ts | 1 + packages/drag-drop/src/types.ts | 14 ++++++-- packages/upload/README.md | 12 +++---- 7 files changed, 61 insertions(+), 26 deletions(-) diff --git a/packages/drag-drop/README.md b/packages/drag-drop/README.md index d8fe73a10..b61771dfb 100644 --- a/packages/drag-drop/README.md +++ b/packages/drag-drop/README.md @@ -6,15 +6,15 @@ [![turborepo](https://img.shields.io/badge/built%20with-turborepo-cc00ff.svg?style=for-the-badge&logo=turborepo)](https://turborepo.org/) [![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?style=for-the-badge)](https://www.npmjs.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 for Solid 2.0. +Composable, tree-shakeable drag-and-drop primitives. Two separate drag systems are provided: - **Pointer-events** (`makeDraggable`, `makeDroppable`, `createDraggable`, `createDroppable`, `createSortable`, `createDragContext`) — for UI elements moved by the user. -- **Native HTML5 API** (`makeNativeDroppable`, `createNativeDroppable`) — for OS file drops and `draggable="true"` elements; designed to compose with `@solid-primitives/upload`'s `createDropzone`. +- **Native HTML5 API** (`makeNativeDroppable`, `createNativeDroppable`) — for OS file drops and `draggable="true"` elements; used internally by `@solid-primitives/upload`'s `createDropzone`. ## Installation @@ -30,11 +30,19 @@ pnpm add @solid-primitives/drag-drop 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 => console.log("start", e.clientX), + 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.transform = ""), + onEnd: () => { + el.style.pointerEvents = ""; + el.style.transform = ""; + }, }); // later cleanup(); @@ -44,6 +52,8 @@ cleanup(); 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"), @@ -68,6 +78,8 @@ const cleanup = makeNativeDroppable(el, { 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. + ```tsx const drag = createDraggable("card-1", myData, { class: "cursor-grab", @@ -129,18 +141,20 @@ const drop = createNativeDroppable({ 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. + ```tsx -const DragContext = createDragContext({ +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), }); - + - + ``` | Return | Description | @@ -174,7 +188,7 @@ All four are exported as pure functions — pass any of them as `collisionDetect | Strategy | Description | |---|---| | `closestCenter` | Nearest droppable by center-to-center distance | -| `closestCorners` | Nearest droppable by minimum corner distance | +| `closestCorners` | Nearest droppable by minimum corner-to-pointer distance | | `rectIntersection` | Droppable with largest overlap area | | `pointerWithin` | Topmost droppable containing the pointer (default) | @@ -197,15 +211,29 @@ const myDetector: CollisionDetector = (draggable, droppables, pointer) => { ## Integration with `@solid-primitives/upload` -`createNativeDroppable` is designed to serve as the drop-zone backend for the upload package's `createDropzone`. Pass the `ref` and `isOver` signal to the upload primitive for a seamless file-upload drag-and-drop experience. +`@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), }); - -// Pass to createDropzone from @solid-primitives/upload -const dropzone = createDropzone(drop); ``` ## Changelog diff --git a/packages/drag-drop/src/context.tsx b/packages/drag-drop/src/context.tsx index 8a2b2b5a3..6df393f4f 100644 --- a/packages/drag-drop/src/context.tsx +++ b/packages/drag-drop/src/context.tsx @@ -101,7 +101,7 @@ export function createDragContext(options: DragContextOptions = {}): DragContext height: dragStartHeight, x: dragStartLeft + tx, y: dragStartTop + ty, - } as unknown as DOMRect, + }, }; const winnerId = collide(draggableRect, cachedRects, { x: pendingX, y: pendingY }); diff --git a/packages/drag-drop/src/draggable.ts b/packages/drag-drop/src/draggable.ts index 6f90db639..c527581d1 100644 --- a/packages/drag-drop/src/draggable.ts +++ b/packages/drag-drop/src/draggable.ts @@ -155,7 +155,7 @@ export function createDraggable( if (event.button !== 0) return; event.preventDefault(); el.setPointerCapture(event.pointerId); - ctx._startDrag(id, el, data as unknown, event); + ctx._startDrag(id, el, data, event); }; el.addEventListener("pointerdown", onPointerDown); return () => el.removeEventListener("pointerdown", onPointerDown); diff --git a/packages/drag-drop/src/droppable.ts b/packages/drag-drop/src/droppable.ts index c749304d8..b441791b4 100644 --- a/packages/drag-drop/src/droppable.ts +++ b/packages/drag-drop/src/droppable.ts @@ -103,7 +103,7 @@ export function createDroppable( ctx._unregisterDroppable(id); return; } - ctx._registerDroppable(id, el, data as unknown, options.accept); + ctx._registerDroppable(id, el, data, options.accept); return () => ctx._unregisterDroppable(id); }, ); diff --git a/packages/drag-drop/src/index.ts b/packages/drag-drop/src/index.ts index 3d2bfaad4..32e472077 100644 --- a/packages/drag-drop/src/index.ts +++ b/packages/drag-drop/src/index.ts @@ -1,6 +1,7 @@ export type { Transform, Point, + Rect, DragItem, DroppableItem, DragRect, diff --git a/packages/drag-drop/src/types.ts b/packages/drag-drop/src/types.ts index cea14f38a..b01a2edbd 100644 --- a/packages/drag-drop/src/types.ts +++ b/packages/drag-drop/src/types.ts @@ -15,8 +15,18 @@ export type DroppableItem = { element: HTMLElement; }; -export type DragRect = { id: string | number; rect: DOMRect }; -export type DroppableRect = { id: string | number; rect: DOMRect }; +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, diff --git a/packages/upload/README.md b/packages/upload/README.md index bb2d53805..3444b2c66 100644 --- a/packages/upload/README.md +++ b/packages/upload/README.md @@ -223,7 +223,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"; @@ -270,14 +270,11 @@ const { ref, files, isDragging, error } = createDropzone({ | 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 | -All callbacks have signature `(files: UploadFile[]) => void | Promise`. `isLoading` tracks only the `onDrop` callback — drag-movement events are fire-and-forget. +All callbacks have signature `(files: UploadFile[]) => void | Promise`. `isLoading` tracks only the `onDrop` callback — drag-movement callbacks are fire-and-forget. ## `dropzone` @@ -287,11 +284,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", From e514bd919dfebccc4cd35b7e4792514a86439dd5 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:08:30 -0400 Subject: [PATCH 04/17] Improvements and fixed stories/naming --- .changeset/drag-new-package.md | 10 +- packages/drag-drop/DESIGN.md | 28 +- packages/drag-drop/README.md | 54 ++- packages/drag-drop/src/context.tsx | 97 +++- packages/drag-drop/src/dom.ts | 30 ++ packages/drag-drop/src/draggable.ts | 90 +++- packages/drag-drop/src/droppable.ts | 15 +- packages/drag-drop/src/index.ts | 3 +- packages/drag-drop/src/sortable.ts | 30 ++ packages/drag-drop/src/types.ts | 15 +- ...ndex.stories.tsx => drag-drop.stories.tsx} | 10 +- packages/drag-drop/test/index.test.ts | 420 +++++++++++++++++- 12 files changed, 767 insertions(+), 35 deletions(-) rename packages/drag-drop/stories/{index.stories.tsx => drag-drop.stories.tsx} (98%) diff --git a/.changeset/drag-new-package.md b/.changeset/drag-new-package.md index a5d5fc83f..cad4f1ee1 100644 --- a/.changeset/drag-new-package.md +++ b/.changeset/drag-new-package.md @@ -9,10 +9,11 @@ New package: `@solid-primitives/drag-drop` — composable drag-and-drop primitiv **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 +- `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 +- `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 @@ -20,3 +21,8 @@ New package: `@solid-primitives/drag-drop` — composable drag-and-drop primitiv **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. diff --git a/packages/drag-drop/DESIGN.md b/packages/drag-drop/DESIGN.md index f9fc66d25..f5c5afcda 100644 --- a/packages/drag-drop/DESIGN.md +++ b/packages/drag-drop/DESIGN.md @@ -165,7 +165,7 @@ Returns a `{ Provider, useDragContext }` pair. `Provider` is a Solid component; The context store tracks: - `active: DragItem | null` — the currently dragged item - `over: DroppableItem | null` — the droppable currently under the pointer -- `transform: Transform | null` — running delta `{x, y}` +- `transform: Transform | null` — running delta `{x, y}`, scroll-compensated (see below) Events surfaced on the context: - `onDragStart(item)` — fired when drag begins @@ -173,6 +173,25 @@ Events surfaced on the context: - `onDragEnd(item, over)` — fired on pointer up - `onDragCancel(item)` — fired on Escape or pointer cancel +**Keyboard sensor.** `_startKeyboardDrag`/`_moveBy`/`_endDrag` are internal context +methods (not part of the public `DragContextReturn`) that `createDraggable` wires up +to `Space`/`Enter`/arrow-key/`Escape` handling. A keyboard-initiated drag anchors its +synthetic pointer position to the draggable element's center (`getBoundingClientRect`), +then reuses the exact same collision/transform pipeline as a pointer drag — there is no +separate keyboard code path for collision detection. + +**Scroll compensation.** The collision rect (`dragStartLeft + tx`, etc.) is intentionally +left uncompensated — it represents where the element *should* end up once the reported +`transform` is applied. The reported `transform` itself gets `window.scrollX/Y` delta +(since drag start) added on top of the raw pointer delta, because the dragged element is +normal-flow content that scrolls with the page like anything else; without this, it would +visually drift away from the pointer if the page scrolls mid-drag (e.g. via `autoScroll`). + +**Auto-scroll.** `DragContextOptions.autoScroll` scrolls `window` (not an arbitrary nested +overflow container) when the pointer nears a viewport edge, checked once per `processMove` +tick (already rAF-throttled). It composes for free with scroll compensation above and with +the existing `scroll` listener that re-snapshots droppable rects. + ### Level 4 — Sortable (composition) ```ts @@ -188,6 +207,10 @@ the same element. Used for building sortable lists. Returns all fields of both + `isActiveDropzone: Accessor` (true when this element is the active drop target in a sort operation). +`arrayMove(array, from, to)` (exported alongside it, in `sortable.ts`) is a pure, +immutable reorder helper with no Solid dependency — pairs with `onDragEnd` to +reorder the backing array without every consumer hand-rolling the same splice logic. + --- ## Collision Detection @@ -258,6 +281,7 @@ type CreateDraggableOptions = { draggingStyle?: Partial class?: string draggingClass?: string + keyboardStep?: number // pixels per arrow-key press; default 25 } type CreateDroppableOptions = { @@ -275,6 +299,8 @@ type DragContextOptions = { onDragMove?: (item: DragItem, transform: Transform) => void onDragEnd?: (item: DragItem, over: DroppableItem | null) => void onDragCancel?: (item: DragItem) => void + keyboardStep?: number // pixels per arrow-key press; default 25 + autoScroll?: boolean | { threshold?: number; speed?: number } } ``` diff --git a/packages/drag-drop/README.md b/packages/drag-drop/README.md index 97d9ed57e..5807fb0dd 100644 --- a/packages/drag-drop/README.md +++ b/packages/drag-drop/README.md @@ -1,5 +1,5 @@

- Solid Primitives drag + Solid Primitives Drag & Drop

# @solid-primitives/drag-drop @@ -10,6 +10,8 @@ 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** (`makeDraggable`, `makeDroppable`, `createDraggable`, `createDroppable`, `createSortable`, `createDragContext`) — for UI elements moved by the user. @@ -77,7 +79,9 @@ const cleanup = makeNativeDroppable(el, { 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. +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, { @@ -98,6 +102,7 @@ const drag = createDraggable("card-1", myData, { | `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 | |---|---| @@ -108,7 +113,7 @@ const drag = createDraggable("card-1", myData, { ### `createDroppable` -Reactive drop target. Requires a `createDragContext` ancestor to coordinate collision detection. +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, { @@ -121,6 +126,15 @@ const drop = createDroppable("zone-1", zoneData, {
``` +`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. @@ -148,6 +162,8 @@ const ctx = createDragContext({ 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, }); @@ -156,6 +172,13 @@ const ctx = createDragContext({ ``` +| 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 | @@ -163,6 +186,8 @@ const ctx = createDragContext({ | `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. @@ -180,6 +205,8 @@ Combines `createDraggable` and `createDroppable` on the same element. When anoth ``` +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`. @@ -208,6 +235,27 @@ const myDetector: CollisionDetector = (draggable, droppables, pointer) => { }; ``` +## `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: diff --git a/packages/drag-drop/src/context.tsx b/packages/drag-drop/src/context.tsx index 405bb57d8..2927eaff1 100644 --- a/packages/drag-drop/src/context.tsx +++ b/packages/drag-drop/src/context.tsx @@ -3,6 +3,7 @@ import { isServer } from "@solidjs/web"; import { INTERNAL_OPTIONS } from "@solid-primitives/utils"; import { pointerWithin } from "./collision.ts"; import type { + AcceptPredicate, DragContextOptions, DragContextReturn, DragItem, @@ -12,10 +13,14 @@ import type { 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?: (draggable: DragItem) => boolean; + accept?: AcceptPredicate; }; export type DragContextValue = { @@ -26,10 +31,16 @@ export type DragContextValue = { id: string | number, element: HTMLElement, data: unknown, - accept?: (draggable: DragItem) => boolean, + 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; }; const DragCtx = createContext(); @@ -57,6 +68,8 @@ export function createDragContext(options: DragContextOptions = {}): DragContext 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. @@ -81,13 +94,41 @@ export function createDragContext(options: DragContextOptions = {}): DragContext let pendingX = 0; let pendingY = 0; - const processMove = () => { - rafPending = false; + // 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; - setTransform({ x: tx, y: ty }); + + // 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 = { @@ -120,6 +161,12 @@ export function createDragContext(options: DragContextOptions = {}): DragContext options.onDragMove?.(currentDrag, { x: tx, y: ty }); }; + const processMove = () => { + rafPending = false; + applyMove(); + maybeAutoScroll(); + }; + const onPointerMove = (event: PointerEvent) => { if (!currentDrag) return; pendingX = event.clientX; @@ -137,7 +184,7 @@ export function createDragContext(options: DragContextOptions = {}): DragContext if (rafPending) { cancelAnimationFrame(rafId); rafPending = false; } }; - const onPointerUp = (_event: PointerEvent) => { + const finishDrag = () => { if (!currentDrag) return; cancelPendingMove(); @@ -152,6 +199,8 @@ export function createDragContext(options: DragContextOptions = {}): DragContext setTransform(null); }; + const onPointerUp = (_event: PointerEvent) => finishDrag(); + const onKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape" || !currentDrag) return; cancelPendingMove(); @@ -177,16 +226,19 @@ export function createDragContext(options: DragContextOptions = {}): DragContext document.removeEventListener("scroll", onScroll, { capture: true }); } - const _startDrag = ( + const beginDrag = ( id: string | number, element: HTMLElement, data: unknown, - event: PointerEvent, + clientX: number, + clientY: number, ) => { - startX = event.clientX; - startY = event.clientY; - pendingX = startX; - pendingY = startY; + 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(); @@ -209,6 +261,22 @@ export function createDragContext(options: DragContextOptions = {}): DragContext 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 = { @@ -224,6 +292,9 @@ export function createDragContext(options: DragContextOptions = {}): DragContext if (currentDrag) snapshotRects(currentDrag.id); }, _startDrag, + _startKeyboardDrag, + _moveBy, + _endDrag: finishDrag, }; const Provider = (props: { children: Element }): Element => ( @@ -232,3 +303,5 @@ export function createDragContext(options: DragContextOptions = {}): DragContext 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 index dc4af52fe..042cc6a57 100644 --- a/packages/drag-drop/src/dom.ts +++ b/packages/drag-drop/src/dom.ts @@ -1,3 +1,23 @@ +/** + * 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)) { @@ -25,3 +45,13 @@ export function removeClass(el: HTMLElement, classes: string | undefined): void 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 index d9cac5930..7663d5ad7 100644 --- a/packages/drag-drop/src/draggable.ts +++ b/packages/drag-drop/src/draggable.ts @@ -1,8 +1,8 @@ import { createEffect, createMemo, createSignal, 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 { 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"; /** @@ -31,16 +31,18 @@ export function makeDraggable( let startX = 0; let startY = 0; + let startScrollX = 0; + let startScrollY = 0; const onPointerMove = (event: PointerEvent) => { - const delta: Transform = { x: event.clientX - startX, y: event.clientY - startY }; + const delta = scrollCompensatedDelta(event.clientX, event.clientY, startX, startY, startScrollX, startScrollY); options.onMove?.(delta, event); }; const onPointerUp = (event: PointerEvent) => { document.removeEventListener("pointermove", onPointerMove); document.removeEventListener("pointerup", onPointerUp); - const delta: Transform = { x: event.clientX - startX, y: event.clientY - startY }; + const delta = scrollCompensatedDelta(event.clientX, event.clientY, startX, startY, startScrollX, startScrollY); options.onEnd?.(delta, event); }; @@ -50,6 +52,8 @@ export function makeDraggable( event.preventDefault(); startX = event.clientX; startY = event.clientY; + startScrollX = window.scrollX; + startScrollY = window.scrollY; document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); options.onStart?.(event); @@ -71,6 +75,11 @@ export function makeDraggable( * 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" }, { @@ -91,6 +100,7 @@ export function createDraggable( const ctx = useDragContext(); const [elSignal, setElSignal] = createSignal(undefined, INTERNAL_OPTIONS); + const step = () => options.keyboardStep ?? DEFAULT_KEYBOARD_STEP; let isDragging: () => boolean; let currentTransform: () => Transform | null; @@ -106,9 +116,11 @@ export function createDraggable( let startX = 0; let startY = 0; + let startScrollX = 0; + let startScrollY = 0; const onPointerMove = (event: PointerEvent) => { - setTransform({ x: event.clientX - startX, y: event.clientY - startY }); + setTransform(scrollCompensatedDelta(event.clientX, event.clientY, startX, startY, startScrollX, startScrollY)); }; const onPointerUp = () => { @@ -118,6 +130,37 @@ export function createDraggable( setTransform(null); }; + 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 => { @@ -129,14 +172,18 @@ export function createDraggable( 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); }; el.addEventListener("pointerdown", onPointerDown); + el.addEventListener("keydown", onKeyDown); return () => { el.removeEventListener("pointerdown", onPointerDown); + el.removeEventListener("keydown", onKeyDown); document.removeEventListener("pointermove", onPointerMove); document.removeEventListener("pointerup", onPointerUp); }; @@ -144,7 +191,7 @@ export function createDraggable( ); } - // Context mode: attach pointerdown listener only (registration handled by droppables). + // Context mode: attach pointerdown/keydown listeners only (registration handled by droppables). if (ctx) { createEffect( () => elSignal(), @@ -157,8 +204,36 @@ export function createDraggable( 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); - return () => el.removeEventListener("pointerdown", onPointerDown); + el.addEventListener("keydown", onKeyDown); + return () => { + el.removeEventListener("pointerdown", onPointerDown); + el.removeEventListener("keydown", onKeyDown); + }; }, ); } @@ -182,6 +257,7 @@ export function createDraggable( setElSignal(() => el); applyStyle(el, options.style); applyClass(el, options.class); + markAsDraggable(el); flush(); }; diff --git a/packages/drag-drop/src/droppable.ts b/packages/drag-drop/src/droppable.ts index 3a730d95d..bf137daa5 100644 --- a/packages/drag-drop/src/droppable.ts +++ b/packages/drag-drop/src/droppable.ts @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, flush } from "solid-js"; +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"; @@ -63,7 +63,8 @@ export function makeDroppable(el: HTMLElement, options: MakeDroppableOptions = { * 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. + * 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 @@ -84,6 +85,16 @@ export function createDroppable( } 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); const isOver: () => boolean = ctx diff --git a/packages/drag-drop/src/index.ts b/packages/drag-drop/src/index.ts index a2ba6ad0e..5b35fc216 100644 --- a/packages/drag-drop/src/index.ts +++ b/packages/drag-drop/src/index.ts @@ -7,6 +7,7 @@ export type { DragRect, DroppableRect, CollisionDetector, + AcceptPredicate, MakeDraggableOptions, MakeDroppableOptions, MakeNativeDroppableOptions, @@ -34,4 +35,4 @@ export { createNativeDroppable, } from "./droppable.ts"; -export { createSortable } from "./sortable.ts"; +export { createSortable, arrayMove } from "./sortable.ts"; diff --git a/packages/drag-drop/src/sortable.ts b/packages/drag-drop/src/sortable.ts index 2f02b265d..e4132b835 100644 --- a/packages/drag-drop/src/sortable.ts +++ b/packages/drag-drop/src/sortable.ts @@ -4,6 +4,36 @@ 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 (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. diff --git a/packages/drag-drop/src/types.ts b/packages/drag-drop/src/types.ts index b01a2edbd..f60b6ff03 100644 --- a/packages/drag-drop/src/types.ts +++ b/packages/drag-drop/src/types.ts @@ -65,11 +65,16 @@ export type CreateDraggableOptions = { 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; - accept?: (draggable: DragItem) => boolean; + /** Called fresh on every collision check — read a signal inside it for reactive accept logic. */ + accept?: AcceptPredicate; style?: Partial; overStyle?: Partial; class?: string; @@ -115,6 +120,14 @@ export type DragContextOptions = { 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 = { diff --git a/packages/drag-drop/stories/index.stories.tsx b/packages/drag-drop/stories/drag-drop.stories.tsx similarity index 98% rename from packages/drag-drop/stories/index.stories.tsx rename to packages/drag-drop/stories/drag-drop.stories.tsx index 0dbb2058f..529878488 100644 --- a/packages/drag-drop/stories/index.stories.tsx +++ b/packages/drag-drop/stories/drag-drop.stories.tsx @@ -23,7 +23,7 @@ import { import type { CollisionDetector } from "@solid-primitives/drag-drop"; const meta = preview.meta({ - title: "Interaction/Drag", + title: "Interaction/Drag & Drop", tags: ["autodocs"], parameters: { layout: "centered", @@ -40,7 +40,7 @@ export default meta; // ── Story 1: createDraggable standalone ─────────────────────────────────────── export const StandaloneDrag = meta.story({ - name: "createDraggable — floating card", + name: "Floating card", parameters: { layout: "centered", docs: { @@ -129,7 +129,7 @@ const ZONES = [ ]; export const DragAndDrop = meta.story({ - name: "createDragContext — drag and drop", + name: "Drop zones with collision strategies", parameters: { layout: "centered", docs: { @@ -270,7 +270,7 @@ const INITIAL_ITEMS: SortItem[] = [ const SORTABLE_STRIDE = 50; export const SortableList = meta.story({ - name: "createSortable — reorderable list", + name: "Reorderable list", parameters: { docs: { description: { @@ -378,7 +378,7 @@ export const SortableList = meta.story({ // ── Story 4: createNativeDroppable ──────────────────────────────────────────── export const NativeFileDrop = meta.story({ - name: "createNativeDroppable — file drop", + name: "File drop zone", parameters: { docs: { description: { diff --git a/packages/drag-drop/test/index.test.ts b/packages/drag-drop/test/index.test.ts index fb2e382c2..ec3c75ac5 100644 --- a/packages/drag-drop/test/index.test.ts +++ b/packages/drag-drop/test/index.test.ts @@ -1,7 +1,7 @@ import "./setup.js"; import { createRoot, flush } from "solid-js"; import { render } from "@solidjs/web"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { makeDraggable, makeDroppable, @@ -11,6 +11,7 @@ import { createNativeDroppable, createDragContext, createSortable, + arrayMove, closestCenter, closestCorners, rectIntersection, @@ -44,6 +45,19 @@ function mockRect(element: HTMLElement, rect: Partial) { }); } +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", () => { @@ -110,6 +124,30 @@ describe("makeDraggable", () => { 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 ───────────────────────────────────────────────────────────── @@ -323,6 +361,131 @@ describe("createDraggable (standalone — no context)", () => { 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 ─────────────────────────────────────────────────────────── @@ -351,6 +514,16 @@ describe("createDroppable (standalone — no context)", () => { 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 ───────────────────────────────────────────────────────── @@ -504,6 +677,220 @@ describe("createDragContext", () => { 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 ───────────────────────────────────────────────────── @@ -614,6 +1001,37 @@ describe("createSortable", () => { }); }); +// ── 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("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 { From 0c521e133d7e8ba2b5a6c83e3213ce6994235e76 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:13:35 -0400 Subject: [PATCH 05/17] Added JSR definition --- packages/drag-drop/deno.jsonc | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 packages/drag-drop/deno.jsonc 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" + ] + } +} From 6f0782982da239c00ec1f1f3b10aed40c2313385 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:25:22 -0400 Subject: [PATCH 06/17] Leveraged createProjection --- .changeset/drag-new-package.md | 1 + packages/drag-drop/DESIGN.md | 8 ++++ packages/drag-drop/README.md | 2 +- packages/drag-drop/src/context.tsx | 31 +++++++++++++- packages/drag-drop/src/draggable.ts | 4 +- packages/drag-drop/src/droppable.ts | 6 +-- packages/drag-drop/test/index.test.ts | 62 +++++++++++++++++++++++++++ 7 files changed, 108 insertions(+), 6 deletions(-) diff --git a/.changeset/drag-new-package.md b/.changeset/drag-new-package.md index cad4f1ee1..8b7bf5043 100644 --- a/.changeset/drag-new-package.md +++ b/.changeset/drag-new-package.md @@ -26,3 +26,4 @@ New package: `@solid-primitives/drag-drop` — composable drag-and-drop primitiv - 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/packages/drag-drop/DESIGN.md b/packages/drag-drop/DESIGN.md index f5c5afcda..0f6ffadfe 100644 --- a/packages/drag-drop/DESIGN.md +++ b/packages/drag-drop/DESIGN.md @@ -173,6 +173,14 @@ Events surfaced on the context: - `onDragEnd(item, over)` — fired on pointer up - `onDragCancel(item)` — fired on Escape or pointer cancel +**Fine-grained membership lookups.** `_isActive(id)`/`_isOver(id)` (internal, not part of +the public `DragContextReturn`) back `createDraggable`'s `isDragging` and `createDroppable`'s +`isOver`. They're implemented with `createProjection` rather than a plain `active()?.id === id` +/ `over()?.id === id` memo per instance: with N draggables/droppables in a list (e.g. a +`createSortable` list), a plain-memo approach marks all N as stale on every drag start/end/hover +change, even though at most two ever actually flip value. `createProjection` only notifies the +specific ids whose membership changed. + **Keyboard sensor.** `_startKeyboardDrag`/`_moveBy`/`_endDrag` are internal context methods (not part of the public `DragContextReturn`) that `createDraggable` wires up to `Space`/`Enter`/arrow-key/`Escape` handling. A keyboard-initiated drag anchors its diff --git a/packages/drag-drop/README.md b/packages/drag-drop/README.md index 5807fb0dd..eecb4b299 100644 --- a/packages/drag-drop/README.md +++ b/packages/drag-drop/README.md @@ -154,7 +154,7 @@ const drop = createNativeDroppable({ 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. +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({ diff --git a/packages/drag-drop/src/context.tsx b/packages/drag-drop/src/context.tsx index 2927eaff1..f5044f34d 100644 --- a/packages/drag-drop/src/context.tsx +++ b/packages/drag-drop/src/context.tsx @@ -1,4 +1,4 @@ -import { createContext, createSignal, onCleanup, useContext, untrack, type Element } from "solid-js"; +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"; @@ -41,6 +41,14 @@ export type DragContextValue = { _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(); @@ -65,6 +73,25 @@ export function createDragContext(options: DragContextOptions = {}): DragContext 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; @@ -295,6 +322,8 @@ export function createDragContext(options: DragContextOptions = {}): DragContext _startKeyboardDrag, _moveBy, _endDrag: finishDrag, + _isActive: id => !!isActiveId[id], + _isOver: id => !!isOverId[id], }; const Provider = (props: { children: Element }): Element => ( diff --git a/packages/drag-drop/src/draggable.ts b/packages/drag-drop/src/draggable.ts index 7663d5ad7..3443ff629 100644 --- a/packages/drag-drop/src/draggable.ts +++ b/packages/drag-drop/src/draggable.ts @@ -106,7 +106,9 @@ export function createDraggable( let currentTransform: () => Transform | null; if (ctx) { - isDragging = createMemo(() => ctx.active()?.id === id); + // `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); diff --git a/packages/drag-drop/src/droppable.ts b/packages/drag-drop/src/droppable.ts index bf137daa5..4f2c1feea 100644 --- a/packages/drag-drop/src/droppable.ts +++ b/packages/drag-drop/src/droppable.ts @@ -97,9 +97,9 @@ export function createDroppable( const [elSignal, setElSignal] = createSignal(undefined, INTERNAL_OPTIONS); - const isOver: () => boolean = ctx - ? createMemo(() => ctx.over()?.id === id) - : () => false; + // `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)) diff --git a/packages/drag-drop/test/index.test.ts b/packages/drag-drop/test/index.test.ts index ec3c75ac5..6b34be322 100644 --- a/packages/drag-drop/test/index.test.ts +++ b/packages/drag-drop/test/index.test.ts @@ -647,6 +647,68 @@ describe("createDragContext", () => { 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 }); From be0d81717dea92e7d7db1510b94eae6bd53817b3 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:30:26 -0400 Subject: [PATCH 07/17] Weclassify primitive as UI Patterns --- packages/drag-drop/DESIGN.md | 8 ++++---- packages/drag-drop/package.json | 2 +- packages/drag-drop/stories/drag-drop.stories.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/drag-drop/DESIGN.md b/packages/drag-drop/DESIGN.md index 0f6ffadfe..f664add1b 100644 --- a/packages/drag-drop/DESIGN.md +++ b/packages/drag-drop/DESIGN.md @@ -471,7 +471,7 @@ pointerup anywhere on document ```json { "primitive": { - "name": "drag", + "name": "drag-drop", "stage": 0, "list": [ "makeDraggable", @@ -487,7 +487,7 @@ pointerup anywhere on document "rectIntersection", "pointerWithin" ], - "category": "Interaction" + "category": "UI Patterns" } } ``` @@ -705,7 +705,7 @@ type NativeDroppableReturn = { ```json { "primitive": { - "name": "drag", + "name": "drag-drop", "stage": 0, "list": [ "makeDraggable", @@ -721,7 +721,7 @@ type NativeDroppableReturn = { "rectIntersection", "pointerWithin" ], - "category": "Interaction" + "category": "UI Patterns" } } ``` diff --git a/packages/drag-drop/package.json b/packages/drag-drop/package.json index af4e22da5..4e9291ac2 100644 --- a/packages/drag-drop/package.json +++ b/packages/drag-drop/package.json @@ -30,7 +30,7 @@ "rectIntersection", "pointerWithin" ], - "category": "Interaction" + "category": "UI Patterns" }, "keywords": [ "solid", diff --git a/packages/drag-drop/stories/drag-drop.stories.tsx b/packages/drag-drop/stories/drag-drop.stories.tsx index 529878488..ab8648abc 100644 --- a/packages/drag-drop/stories/drag-drop.stories.tsx +++ b/packages/drag-drop/stories/drag-drop.stories.tsx @@ -23,7 +23,7 @@ import { import type { CollisionDetector } from "@solid-primitives/drag-drop"; const meta = preview.meta({ - title: "Interaction/Drag & Drop", + title: "UI Patterns/Drag & Drop", tags: ["autodocs"], parameters: { layout: "centered", From 6fc7759d792c77e3b4b06d0d2a1da5cea6c2d62b Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:55:32 -0400 Subject: [PATCH 08/17] Removed design --- packages/drag-drop/DESIGN.md | 765 ----------------------------------- 1 file changed, 765 deletions(-) delete mode 100644 packages/drag-drop/DESIGN.md diff --git a/packages/drag-drop/DESIGN.md b/packages/drag-drop/DESIGN.md deleted file mode 100644 index f664add1b..000000000 --- a/packages/drag-drop/DESIGN.md +++ /dev/null @@ -1,765 +0,0 @@ -# Drag & Drop Primitive — Design Document - -## Overview - -A minimal, composable drag-and-drop primitive for Solid 2.0. Inspired by -dnd-kit's architecture but redesigned for Solid's reactive model: no global -singleton, ref-factory API, context-isolated state, and SSR-safe stubs -throughout. - ---- - -## Goals - -- **Tight surface area** — expose only what can't be trivially composed by - the user -- **No opinion on transforms** — report delta/position as signals; the user - applies CSS -- **No global state** — each `createDragContext` scope is fully isolated -- **Sensor-agnostic at the core** — pointer sensor built-in, pattern open for - keyboard / touch extensions -- **SSR-safe** — every reactive primitive returns a noop stub on the server -- **Solid 2.0 idiomatic** — ref factories (not `use:` directives), split - `createEffect`, `ownedWrite`, no `batch` - ---- - -## Primitive Set - -### Level 1 — Raw DOM (`make*`) - -Non-reactive. No Solid owner required. Return a cleanup function. - -```ts -makeDraggable( - el: HTMLElement, - options: MakeDraggableOptions -): VoidFunction - -makeDroppable( - el: HTMLElement, - options: MakeDroppableOptions -): VoidFunction -``` - -`makeDraggable` attaches `pointerdown` / `pointermove` / `pointerup` listeners -to `el`. It emits `onStart`, `onMove`, and `onEnd` callbacks with a `DragEvent` -payload carrying `{ id, data, delta: {x, y}, position: {x, y} }`. - -`makeDroppable` monitors pointer position against the element's bounding rect. -It emits `onEnter`, `onLeave`, and `onDrop` callbacks. An optional `accept` -predicate filters which draggables are considered. - -These two primitives are intentionally decoupled — `makeDroppable` does not -depend on `makeDraggable`. - -### Level 2 — Reactive (`create*`) - -Reactive wrappers that integrate with Solid's ownership model. All signals use -`INTERNAL_OPTIONS` (`ownedWrite: true`) where written from event callbacks. - -```ts -createDraggable( - id: string | number, - data?: T, - options?: CreateDraggableOptions -): DraggableReturn -``` - -Returns: -| Property | Type | Description | -|---|---|---| -| `ref` | `(el: HTMLElement) => void` | Attach to JSX via `ref={draggable.ref}` | -| `isDragging` | `Accessor` | True while the item is being dragged | -| `transform` | `Accessor` | Current `{x, y}` delta during drag | -| `id` | `string \| number` | Stable identifier | - -**Style options** — applied directly to the element so the user doesn't -repeat reactive style bindings in JSX: - -```ts -type CreateDraggableOptions = { - disabled?: boolean | Accessor - /** Styles applied to the element as soon as ref is attached. */ - style?: Partial - /** Styles merged in while isDragging is true, removed when false. */ - draggingStyle?: Partial - /** Class names added to the element as soon as ref is attached. */ - class?: string - /** Class names toggled on while isDragging is true, removed when false. */ - draggingClass?: string -} -``` - -Usage: -```tsx -const drag = createDraggable("item-1", myData, { - style: { userSelect: "none" }, - draggingStyle: { opacity: "0.5" }, - class: "draggable", - draggingClass: "dragging ring-2 ring-indigo-500", -}) -
drag me
-``` - -`style` and `class` are applied once in the `ref` callback. `draggingStyle` -and `draggingClass` are toggled via a split `createEffect` tracking -`isDragging()`. Style properties are set/deleted individually; class names -are added/removed via `el.classList` so other classes are not clobbered. -Both `class` and `draggingClass` accept a space-separated string of tokens. - -```ts -createDroppable( - id: string | number, - data?: T, - options?: CreateDroppableOptions -): DroppableReturn -``` - -Returns: -| Property | Type | Description | -|---|---|---| -| `ref` | `(el: HTMLElement) => void` | Attach to JSX via `ref={droppable.ref}` | -| `isOver` | `Accessor` | True when active draggable hovers this zone | -| `active` | `Accessor \| null>` | The currently hovering draggable's data | -| `id` | `string \| number` | Stable identifier | - -**Style and class options:** - -```ts -type CreateDroppableOptions = { - disabled?: boolean | Accessor - accept?: (draggable: DragItem) => boolean - /** Styles applied to the element as soon as ref is attached. */ - style?: Partial - /** Styles merged in while isOver is true, removed when false. */ - overStyle?: Partial - /** Class names added to the element as soon as ref is attached. */ - class?: string - /** Class names toggled on while isOver is true, removed when false. */ - overClass?: string -} -``` - -Usage: -```tsx -const drop = createDroppable("zone-1", undefined, { - class: "dropzone", - overClass: "dropzone--active ring-2 ring-indigo-500", -}) -
drop here
-``` - -These primitives read drag state from the nearest `DragContext` via -`useContext`. They register themselves on mount and deregister on cleanup. - -### Level 3 — Context - -```ts -createDragContext(options?: DragContextOptions): DragContextReturn -``` - -Returns a `{ Provider, useDragContext }` pair. `Provider` is a Solid component; -`useDragContext` returns the shared drag store from any child scope. - -The context store tracks: -- `active: DragItem | null` — the currently dragged item -- `over: DroppableItem | null` — the droppable currently under the pointer -- `transform: Transform | null` — running delta `{x, y}`, scroll-compensated (see below) - -Events surfaced on the context: -- `onDragStart(item)` — fired when drag begins -- `onDragMove(item, transform)` — fired on every pointer move -- `onDragEnd(item, over)` — fired on pointer up -- `onDragCancel(item)` — fired on Escape or pointer cancel - -**Fine-grained membership lookups.** `_isActive(id)`/`_isOver(id)` (internal, not part of -the public `DragContextReturn`) back `createDraggable`'s `isDragging` and `createDroppable`'s -`isOver`. They're implemented with `createProjection` rather than a plain `active()?.id === id` -/ `over()?.id === id` memo per instance: with N draggables/droppables in a list (e.g. a -`createSortable` list), a plain-memo approach marks all N as stale on every drag start/end/hover -change, even though at most two ever actually flip value. `createProjection` only notifies the -specific ids whose membership changed. - -**Keyboard sensor.** `_startKeyboardDrag`/`_moveBy`/`_endDrag` are internal context -methods (not part of the public `DragContextReturn`) that `createDraggable` wires up -to `Space`/`Enter`/arrow-key/`Escape` handling. A keyboard-initiated drag anchors its -synthetic pointer position to the draggable element's center (`getBoundingClientRect`), -then reuses the exact same collision/transform pipeline as a pointer drag — there is no -separate keyboard code path for collision detection. - -**Scroll compensation.** The collision rect (`dragStartLeft + tx`, etc.) is intentionally -left uncompensated — it represents where the element *should* end up once the reported -`transform` is applied. The reported `transform` itself gets `window.scrollX/Y` delta -(since drag start) added on top of the raw pointer delta, because the dragged element is -normal-flow content that scrolls with the page like anything else; without this, it would -visually drift away from the pointer if the page scrolls mid-drag (e.g. via `autoScroll`). - -**Auto-scroll.** `DragContextOptions.autoScroll` scrolls `window` (not an arbitrary nested -overflow container) when the pointer nears a viewport edge, checked once per `processMove` -tick (already rAF-throttled). It composes for free with scroll compensation above and with -the existing `scroll` listener that re-snapshots droppable rects. - -### Level 4 — Sortable (composition) - -```ts -createSortable( - id: string | number, - data?: T -): SortableReturn -``` - -A convenience primitive that combines `createDraggable` + `createDroppable` on -the same element. Used for building sortable lists. - -Returns all fields of both + `isActiveDropzone: Accessor` (true when -this element is the active drop target in a sort operation). - -`arrayMove(array, from, to)` (exported alongside it, in `sortable.ts`) is a pure, -immutable reorder helper with no Solid dependency — pairs with `onDragEnd` to -reorder the backing array without every consumer hand-rolling the same splice logic. - ---- - -## Collision Detection - -Pluggable strategy functions — pure, no Solid dependencies: - -```ts -type CollisionDetector = ( - draggable: DragRect, - droppables: DroppableRect[], - pointerPosition: Point -) => string | number | null // ID of best match, or null -``` - -Built-in strategies: - -| Name | Strategy | -|---|---| -| `closestCenter` | Minimizes distance from pointer to droppable center | -| `closestCorners` | Minimizes distance to nearest droppable corner | -| `rectIntersection` | Returns droppable with largest overlap area | -| `pointerWithin` | Returns the topmost droppable containing the pointer | - -`DragContextOptions.collisionDetection` defaults to `pointerWithin`. - ---- - -## Types - -```ts -type Transform = { x: number; y: number } -type Point = { x: number; y: number } -type DragRect = { id: string | number; rect: DOMRect } -type DroppableRect = { id: string | number; rect: DOMRect } - -type DragItem = { - id: string | number - data: T - element: HTMLElement -} - -type DroppableItem = { - id: string | number - data: T - element: HTMLElement -} - -type MakeDraggableOptions = { - data?: T - onStart?: (item: DragItem, event: PointerEvent) => void - onMove?: (item: DragItem, transform: Transform, event: PointerEvent) => void - onEnd?: (item: DragItem, transform: Transform, event: PointerEvent) => void - disabled?: boolean -} - -type MakeDroppableOptions = { - data?: T - accept?: (draggable: DragItem) => boolean - onEnter?: (draggable: DragItem, event: PointerEvent) => void - onLeave?: (draggable: DragItem, event: PointerEvent) => void - onDrop?: (draggable: DragItem, event: PointerEvent) => void - disabled?: boolean -} - -type CreateDraggableOptions = { - disabled?: boolean | Accessor - style?: Partial - draggingStyle?: Partial - class?: string - draggingClass?: string - keyboardStep?: number // pixels per arrow-key press; default 25 -} - -type CreateDroppableOptions = { - disabled?: boolean | Accessor - accept?: (draggable: DragItem) => boolean - style?: Partial - overStyle?: Partial - class?: string - overClass?: string -} - -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 - keyboardStep?: number // pixels per arrow-key press; default 25 - autoScroll?: boolean | { threshold?: number; speed?: number } -} -``` - ---- - -## File Structure - -``` -packages/drag/ -├── src/ -│ ├── index.ts # re-exports -│ ├── types.ts # all shared types -│ ├── context.ts # DragContext, createDragContext -│ ├── draggable.ts # makeDraggable, createDraggable -│ ├── droppable.ts # makeDroppable, createDroppable, makeNativeDroppable, createNativeDroppable -│ ├── sortable.ts # createSortable -│ └── collision.ts # closestCenter, closestCorners, rectIntersection, pointerWithin -├── test/ -│ ├── index.test.ts # browser tests (jsdom + vitest) -│ └── server.test.ts # SSR safety tests -├── dev/ -│ └── index.tsx # interactive demo component -├── stories/ -│ └── index.stories.tsx # Storybook stories -├── CHANGELOG.md -├── DESIGN.md -├── LICENSE -├── README.md -├── package.json -└── tsconfig.json -``` - ---- - -## Solid 2.0 Compliance Notes - -### Directives → Ref Factories -`use:draggable` does not exist — all attachment is via `ref`: -```tsx -const drag = createDraggable("item-1", myData) -
-``` - -### Signal Writes -All signals updated from DOM event callbacks are created with `INTERNAL_OPTIONS` -(`{ ownedWrite: true }`). This satisfies Solid 2.0's restriction on writing -to signals inside owned scopes. - -### Effects -All effects use the split compute/apply form: -```ts -createEffect( - () => disabled(), // compute — reactive reads only - (isDisabled) => { // apply — DOM side effects - el.setAttribute("aria-disabled", String(isDisabled)) - } -) -``` - -### `isServer` -Imported from `@solidjs/web`. Every `make*` and `create*` primitive short- -circuits with a noop/stub return when `isServer === true`. - -### No `batch`, No `createComputed` -- Signal writes from event handlers are inherently async-batched -- No `createComputed` — derivations use `createMemo` - -### Context Registration -Draggables and droppables call `useContext(DragContext)` at creation time. -If no provider is present they operate in standalone mode (useful for simple -single-target use cases without a context). - ---- - -## Context Coordination Flow - -``` -pointerdown on draggable element - → makeDraggable emits onStart - → createDraggable writes isDragging = true (INTERNAL_OPTIONS) - → context.active = DragItem - -pointermove anywhere on document - → context.transform updates (delta from start) - → collision detection runs against registered droppables - → context.over = winner | null - → matching createDroppable writes isOver = true - -pointerup anywhere on document - → context fires onDragEnd(active, over) - → createDraggable writes isDragging = false, transform = null - → createDroppable writes isOver = false - → context.active = null, context.over = null -``` - ---- - -## Test Plan - -### Browser Tests (`index.test.ts`) - -**`makeDraggable`:** -- calls onStart with correct id/data on pointerdown -- calls onMove with accumulated delta on pointermove -- calls onEnd on pointerup, resets state -- no-ops when disabled - -**`makeDroppable`:** -- calls onEnter when pointer moves into bounding rect -- calls onLeave when pointer exits -- calls onDrop on pointerup inside rect -- accept predicate filters droppables - -**`createDraggable`:** -- ref attaches to element on mount -- isDragging() false initially, true during drag, false after -- transform() null initially, {x,y} during drag, null after -- `style` properties applied to element immediately on ref attachment -- `draggingStyle` properties applied when isDragging becomes true, removed when false -- does not clobber unrelated inline styles when removing draggingStyle -- `class` tokens added to element immediately on ref attachment -- `draggingClass` tokens added via classList when isDragging becomes true, removed when false -- does not clobber unrelated classes when removing draggingClass tokens -- cleans up event listeners on dispose - -**`createDroppable`:** -- isOver() false initially, true when active drag enters -- active() null initially, carries DragItem during hover -- `style` properties applied to element immediately on ref attachment -- `overStyle` properties applied when isOver becomes true, removed when false -- does not clobber unrelated inline styles when removing overStyle -- `class` tokens added to element immediately on ref attachment -- `overClass` tokens added via classList when isOver becomes true, removed when false -- does not clobber unrelated classes when removing overClass tokens -- cleans up on dispose - -**`createDragContext`:** -- active() and over() track current drag state -- onDragStart/onDragEnd callbacks fire in order -- collision detection resolves correct droppable - -**`createSortable`:** -- acts as both draggable and droppable on same element -- isActiveDropzone distinguishes self vs other - -**Collision strategies:** -- closestCenter returns correct winner -- rectIntersection returns correct winner -- pointerWithin returns topmost containing element - -### SSR Tests (`server.test.ts`) -- make* functions return [stub, noop] on server -- create* functions return accessor stubs (no DOM access) -- No errors thrown during server render - ---- - -## Package Metadata - -```json -{ - "primitive": { - "name": "drag-drop", - "stage": 0, - "list": [ - "makeDraggable", - "makeDroppable", - "makeNativeDroppable", - "createDraggable", - "createDroppable", - "createNativeDroppable", - "createSortable", - "createDragContext", - "closestCenter", - "closestCorners", - "rectIntersection", - "pointerWithin" - ], - "category": "UI Patterns" - } -} -``` - ---- - -## Integration with `createDropzone` (upload package) - -### Why a Second Drop-Zone Variant Is Needed - -The `createDropzone` primitive in `packages/upload` handles **OS file drops** — -the user drags a file from Finder or Explorer and releases it onto a browser -element. The browser delivers this via native HTML5 `DragEvent`s on the target -element, carrying `event.dataTransfer.files`. This mechanism is completely -distinct from pointer-event-based UI DnD: - -| | Pointer-based DnD (this package's default) | Native HTML5 DnD | -|---|---|---| -| Events | `pointerdown` / `pointermove` / `pointerup` | `dragenter` / `dragover` / `dragleave` / `drop` | -| Source | `makeDraggable` / `createDraggable` | OS file manager or `draggable="true"` elements | -| Position tracking | Manual (delta from start point) | Browser-managed ghost image | -| Data transfer | Arbitrary JS value attached to `DragItem` | `event.dataTransfer` (files, MIME types, text) | -| Touch support | Yes (pointer events cover touch) | Inconsistent on mobile | -| Custom drag image | Via CSS transforms on source element | Via `dataTransfer.setDragImage` | - -`createDropzone` currently calls `createEventListenerMap` directly for native -drag events. The plan is for it to use `makeNativeDroppable` / -`createNativeDroppable` from this package instead. - -### New Primitives: `makeNativeDroppable` / `createNativeDroppable` - -Added to `src/droppable.ts` alongside the pointer-based variants. - -#### `makeNativeDroppable` - -```ts -makeNativeDroppable( - el: HTMLElement, - options: MakeNativeDroppableOptions -): VoidFunction -``` - -Listens to `dragenter`, `dragleave`, `dragover`, and `drop` on `el`. Calls -`event.preventDefault()` automatically on `dragover` and `drop` (required to -allow drops). Returns a cleanup function that removes all listeners. - -```ts -type MakeNativeDroppableOptions = { - /** Called when a dragged item enters the element bounds. */ - onEnter?: (event: DragEvent) => void - /** Called when the dragged item leaves the element bounds. */ - onLeave?: (event: DragEvent) => void - /** Called on each dragover tick. preventDefault is called before this. */ - onOver?: (event: DragEvent) => void - /** Called when the item is released over the element. */ - onDrop?: (event: DragEvent) => void - /** - * Return false to reject the drop. Useful for filtering by - * event.dataTransfer.types (e.g. only accept "Files"). - * Defaults to () => true. - */ - accept?: (event: DragEvent) => boolean - disabled?: boolean -} -``` - -The `accept` predicate runs before `onEnter` and on every `dragover`. When it -returns `false`, `dropEffect` is set to `"none"` and `onEnter` / `onOver` are -not called. - -#### `createNativeDroppable` - -```ts -createNativeDroppable( - options?: CreateNativeDroppableOptions -): NativeDroppableReturn -``` - -Reactive wrapper. Attaches via a `ref` factory and exposes an `isOver` signal -that is true while a dragged item hovers the element. - -```ts -type CreateNativeDroppableOptions = MakeNativeDroppableOptions & { - /** Reactive disabled flag. */ - disabled?: boolean | Accessor -} - -type NativeDroppableReturn = { - /** Attach to a JSX element: ref={droppable.ref} */ - ref: (el: HTMLElement) => void - /** True while a dragged item is over this element. */ - isOver: Accessor -} -``` - -Signals use `INTERNAL_OPTIONS` (`ownedWrite: true`) — they are written from -`dragenter` / `dragleave` / `drop` callbacks outside any owned scope. - -### How `createDropzone` (upload) Uses This - -`createDropzone` becomes a thin layer on top of `createNativeDroppable`. It -delegates all DOM event wiring to the primitive and adds file extraction and -async callback orchestration on top. - -**Dependency**: `packages/upload/package.json` gains: -```json -{ - "dependencies": { - "@solid-primitives/drag": "workspace:^" - } -} -``` - -**Refactored `createDropzone.ts`** (showing the structural change): - -```ts -// Before — manages raw DOM events directly -import { createEventListenerMap } from "@solid-primitives/event-listener" - -const [refTarget, setRefTarget] = createSignal(undefined) -createEventListenerMap(refTarget as () => T, { - dragenter: onDragEnter, - dragleave: onDragLeave, - dragover: onDragOver, - drag: onDrag, - dragstart: onDragStart, - dragend: onDragEnd, - drop: onDrop, -}) -const ref = (el: T) => { setRefTarget(() => el); flush() } -``` - -```ts -// After — delegates drop-zone wiring to createNativeDroppable -import { createNativeDroppable } from "@solid-primitives/drag" - -const droppable = createNativeDroppable({ - onEnter: event => { - void runCallback(options?.onDragEnter, transformFiles(event.dataTransfer?.files ?? null)) - }, - onLeave: event => { - void runCallback(options?.onDragLeave, transformFiles(event.dataTransfer?.files ?? null)) - }, - onOver: event => { - void runCallback(options?.onDragOver, transformFiles(event.dataTransfer?.files ?? null)) - }, - onDrop: event => { - 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) } - })() - }, - accept: event => event.dataTransfer?.types.includes("Files") ?? true, -}) - -// droppable.isOver replaces the internal [isDragging, setIsDragging] signal -// droppable.ref replaces the manual ref + setRefTarget pattern -return { - ref: droppable.ref, - isDragging: droppable.isOver, // isOver from native droppable = isDragging in dropzone API - files, error, isLoading, - removeFile, clearFiles, -} -``` - -Note: The upload package's `isDragging` and the drag package's `isOver` are -semantically the same concept — "a dragged item is currently over this element". -The naming difference is intentional: `isOver` is the canonical name in the -drag primitive (consistent with `createDroppable`); `isDragging` is preserved -in `createDropzone` for API backwards-compatibility. - -### What `onDrag`, `onDragStart`, `onDragEnd` Become - -The current `createDropzone` exposes five extra event hooks (`onDrag`, -`onDragStart`, `onDragEnd`) that fire on the *dropzone element itself*, not on -a dragged source item. After the refactor: - -- `onDragStart` / `onDragEnd` / `onDrag` fire on the element when the element - or one of its descendants initiates a drag. These are not relevant to file - drop flows but are preserved for API compatibility. `makeNativeDroppable` - does **not** handle them — `createDropzone` keeps its own `dragstart`, - `dragend`, `drag` listeners via `createEventListenerMap` for these three. -- `onDragEnter` / `onDragLeave` / `onDragOver` / `onDrop` are delegated to - `createNativeDroppable` entirely. - -### Type Additions to `src/types.ts` - -```ts -type MakeNativeDroppableOptions = { - onEnter?: (event: DragEvent) => void - onLeave?: (event: DragEvent) => void - onOver?: (event: DragEvent) => void - onDrop?: (event: DragEvent) => void - accept?: (event: DragEvent) => boolean - disabled?: boolean -} - -type CreateNativeDroppableOptions = MakeNativeDroppableOptions & { - disabled?: boolean | Accessor -} - -type NativeDroppableReturn = { - ref: (el: HTMLElement) => void - isOver: Accessor -} -``` - -### Updated Package Metadata - -```json -{ - "primitive": { - "name": "drag-drop", - "stage": 0, - "list": [ - "makeDraggable", - "makeDroppable", - "makeNativeDroppable", - "createDraggable", - "createDroppable", - "createNativeDroppable", - "createSortable", - "createDragContext", - "closestCenter", - "closestCorners", - "rectIntersection", - "pointerWithin" - ], - "category": "UI Patterns" - } -} -``` - -### Test Additions for Native Variants - -**Browser tests (`index.test.ts`):** - -`makeNativeDroppable`: -- fires `onEnter` and sets `isOver` true on `dragenter` -- fires `onLeave` and sets `isOver` false on `dragleave` -- calls `preventDefault` on `dragover` -- fires `onDrop` and sets `isOver` false on `drop` -- `accept` returning false suppresses `onEnter` / `onOver` and sets `dropEffect = "none"` -- disabled flag suppresses all callbacks - -`createNativeDroppable`: -- `isOver()` starts false, becomes true on `dragenter`, false on `dragleave` -- reactive `disabled` accessor stops callbacks while true -- cleans up all listeners on scope dispose - -**Integration test** (lives in `packages/upload/test/`): -- `createDropzone` using `createNativeDroppable` produces same `isDragging` - transitions as the current implementation -- `files()` signal is populated after a synthetic drop event carrying a - `FileList` - ---- - -## Open Questions / Future Work - -1. **Keyboard sensor** — full keyboard navigation (Tab, Space, Arrow keys) is - a non-trivial scope extension. Design leaves room via the sensor pattern but - does not implement it in v0. -2. **Drag overlay** — rendering a portal overlay during drag requires a - separate component. Punted to a follow-up primitive or userland composition. -3. **Auto-scroll** — scrolling containers when dragging near edges. Punted. -4. **Touch** — `pointer` events cover touch natively on modern browsers; no - separate touch sensor needed. -5. **Accessibility** — ARIA live regions and `aria-grabbed` are documented in - README as a user responsibility with guidance on how to wire them. From 5dd1f8e9df4769634b939cf938d4a3f4a2caca4d Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:00:31 -0400 Subject: [PATCH 09/17] Added better guarding --- packages/drag-drop/src/droppable.ts | 6 +++-- packages/drag-drop/test/index.test.ts | 39 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/drag-drop/src/droppable.ts b/packages/drag-drop/src/droppable.ts index 4f2c1feea..4795d833c 100644 --- a/packages/drag-drop/src/droppable.ts +++ b/packages/drag-drop/src/droppable.ts @@ -184,7 +184,8 @@ export function makeNativeDroppable( const onDragLeave = (event: DragEvent) => { if (options.disabled) return; - depth--; + if (options.accept && !options.accept(event)) return; + if (depth > 0) depth--; if (depth === 0) options.onLeave?.(event); }; @@ -264,7 +265,8 @@ export function createNativeDroppable( const onDragLeave = (event: DragEvent) => { if (access(options.disabled)) return; - depth--; + if (options.accept && !options.accept(event)) return; + if (depth > 0) depth--; if (depth === 0) { setIsOver(false); options.onLeave?.(event); diff --git a/packages/drag-drop/test/index.test.ts b/packages/drag-drop/test/index.test.ts index 6b34be322..73fad2ca9 100644 --- a/packages/drag-drop/test/index.test.ts +++ b/packages/drag-drop/test/index.test.ts @@ -264,6 +264,24 @@ describe("makeNativeDroppable", () => { 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 ─────────────────────────────────────────────────────────── @@ -1007,6 +1025,27 @@ describe("createNativeDroppable", () => { }); }); + 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(); From ff465446a043159c2ed33299b4ce2a722484c888 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:05:27 -0400 Subject: [PATCH 10/17] Added pointercancel --- packages/drag-drop/src/context.tsx | 15 +++++- packages/drag-drop/src/draggable.ts | 33 +++++++++--- packages/drag-drop/test/index.test.ts | 72 +++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/packages/drag-drop/src/context.tsx b/packages/drag-drop/src/context.tsx index f5044f34d..17a05c11c 100644 --- a/packages/drag-drop/src/context.tsx +++ b/packages/drag-drop/src/context.tsx @@ -228,8 +228,11 @@ export function createDragContext(options: DragContextOptions = {}): DragContext const onPointerUp = (_event: PointerEvent) => finishDrag(); - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape" || !currentDrag) return; + // 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; @@ -242,6 +245,12 @@ export function createDragContext(options: DragContextOptions = {}): DragContext setTransform(null); }; + const onPointerCancel = (_event: PointerEvent) => cancelDrag(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") cancelDrag(); + }; + const onScroll = () => { if (currentDrag) snapshotRects(currentDrag.id); }; @@ -249,6 +258,7 @@ export function createDragContext(options: DragContextOptions = {}): DragContext function cleanupDrag() { document.removeEventListener("pointermove", onPointerMove); document.removeEventListener("pointerup", onPointerUp); + document.removeEventListener("pointercancel", onPointerCancel); document.removeEventListener("keydown", onKeyDown); document.removeEventListener("scroll", onScroll, { capture: true }); } @@ -282,6 +292,7 @@ export function createDragContext(options: DragContextOptions = {}): DragContext document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); + document.addEventListener("pointercancel", onPointerCancel); document.addEventListener("keydown", onKeyDown); document.addEventListener("scroll", onScroll, { passive: true, capture: true }); diff --git a/packages/drag-drop/src/draggable.ts b/packages/drag-drop/src/draggable.ts index 3443ff629..1db4dd1e7 100644 --- a/packages/drag-drop/src/draggable.ts +++ b/packages/drag-drop/src/draggable.ts @@ -39,9 +39,24 @@ export function makeDraggable( options.onMove?.(delta, event); }; - const onPointerUp = (event: PointerEvent) => { + // 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); }; @@ -56,6 +71,7 @@ export function makeDraggable( startScrollY = window.scrollY; document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); + document.addEventListener("pointercancel", onPointerCancel); options.onStart?.(event); }; @@ -63,8 +79,7 @@ export function makeDraggable( return () => { el.removeEventListener("pointerdown", onPointerDown); - document.removeEventListener("pointermove", onPointerMove); - document.removeEventListener("pointerup", onPointerUp); + stopTracking(); }; } @@ -125,13 +140,19 @@ export function createDraggable( setTransform(scrollCompensatedDelta(event.clientX, event.clientY, startX, startY, startScrollX, startScrollY)); }; - const onPointerUp = () => { + // 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 })); }; @@ -180,14 +201,14 @@ export function createDraggable( 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); - document.removeEventListener("pointermove", onPointerMove); - document.removeEventListener("pointerup", onPointerUp); + stopTracking(); }; }, ); diff --git a/packages/drag-drop/test/index.test.ts b/packages/drag-drop/test/index.test.ts index 73fad2ca9..129b46529 100644 --- a/packages/drag-drop/test/index.test.ts +++ b/packages/drag-drop/test/index.test.ts @@ -100,6 +100,23 @@ describe("makeDraggable", () => { 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; @@ -328,6 +345,27 @@ describe("createDraggable (standalone — no context)", () => { }); }); + 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(); @@ -665,6 +703,40 @@ describe("createDragContext", () => { 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()]; From 703516edd26e96bfef61e498a3f6d2b5824294ea Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:06:42 -0400 Subject: [PATCH 11/17] createNativeDroppable fires onEnter for every dragenter, including child elements --- packages/drag-drop/src/droppable.ts | 6 ++++-- packages/drag-drop/test/index.test.ts | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/drag-drop/src/droppable.ts b/packages/drag-drop/src/droppable.ts index 4795d833c..4742cc5b6 100644 --- a/packages/drag-drop/src/droppable.ts +++ b/packages/drag-drop/src/droppable.ts @@ -259,8 +259,10 @@ export function createNativeDroppable( return; } depth++; - if (depth === 1) setIsOver(true); - options.onEnter?.(event); + if (depth === 1) { + setIsOver(true); + options.onEnter?.(event); + } }; const onDragLeave = (event: DragEvent) => { diff --git a/packages/drag-drop/test/index.test.ts b/packages/drag-drop/test/index.test.ts index 129b46529..73b3d45eb 100644 --- a/packages/drag-drop/test/index.test.ts +++ b/packages/drag-drop/test/index.test.ts @@ -1097,6 +1097,22 @@ describe("createNativeDroppable", () => { }); }); + 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(); From 24971deba61621e2ba68f3f82766e0817f199419 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:07:56 -0400 Subject: [PATCH 12/17] Reject non-integer indexes --- packages/drag-drop/src/sortable.ts | 10 +++++++++- packages/drag-drop/test/index.test.ts | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/drag-drop/src/sortable.ts b/packages/drag-drop/src/sortable.ts index e4132b835..c6e8da587 100644 --- a/packages/drag-drop/src/sortable.ts +++ b/packages/drag-drop/src/sortable.ts @@ -26,7 +26,15 @@ import type { SortableReturn } from "./types.ts"; */ export function arrayMove(array: readonly T[], from: number, to: number): T[] { const next = array.slice(); - if (from < 0 || from >= array.length || to < 0 || to >= array.length || from === to) { + 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); diff --git a/packages/drag-drop/test/index.test.ts b/packages/drag-drop/test/index.test.ts index 73b3d45eb..630d605b8 100644 --- a/packages/drag-drop/test/index.test.ts +++ b/packages/drag-drop/test/index.test.ts @@ -1214,6 +1214,23 @@ describe("arrayMove", () => { 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); From b7534750d2d330995c7162264071e71f42631623 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:10:09 -0400 Subject: [PATCH 13/17] Reject disallowed drops before onDrop --- packages/drag-drop/src/droppable.ts | 2 ++ packages/drag-drop/src/types.ts | 2 +- packages/drag-drop/test/index.test.ts | 24 ++++++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/drag-drop/src/droppable.ts b/packages/drag-drop/src/droppable.ts index 4742cc5b6..7175afbcf 100644 --- a/packages/drag-drop/src/droppable.ts +++ b/packages/drag-drop/src/droppable.ts @@ -203,6 +203,7 @@ export function makeNativeDroppable( event.preventDefault(); depth = 0; if (options.disabled) return; + if (options.accept && !options.accept(event)) return; options.onDrop?.(event); }; @@ -290,6 +291,7 @@ export function createNativeDroppable( depth = 0; setIsOver(false); if (access(options.disabled)) return; + if (options.accept && !options.accept(event)) return; options.onDrop?.(event); }; diff --git a/packages/drag-drop/src/types.ts b/packages/drag-drop/src/types.ts index f60b6ff03..8314b0d16 100644 --- a/packages/drag-drop/src/types.ts +++ b/packages/drag-drop/src/types.ts @@ -54,7 +54,7 @@ export type MakeNativeDroppableOptions = { onLeave?: (event: DragEvent) => void; onOver?: (event: DragEvent) => void; onDrop?: (event: DragEvent) => void; - /** Return false to reject; checked on dragenter and dragover. */ + /** Return false to reject; checked on dragenter, dragover, and drop. */ accept?: (event: DragEvent) => boolean; disabled?: boolean; }; diff --git a/packages/drag-drop/test/index.test.ts b/packages/drag-drop/test/index.test.ts index 630d605b8..b343a35ba 100644 --- a/packages/drag-drop/test/index.test.ts +++ b/packages/drag-drop/test/index.test.ts @@ -251,6 +251,18 @@ describe("makeNativeDroppable", () => { 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); @@ -1162,6 +1174,18 @@ describe("createNativeDroppable", () => { 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 ──────────────────────────────────────────────────────────── From 7d80681150d3e6db71ff373d3c4247b0ada7bd25 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:12:07 -0400 Subject: [PATCH 14/17] Guard s.transform() before dereferencing it --- packages/drag-drop/stories/drag-drop.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/drag-drop/stories/drag-drop.stories.tsx b/packages/drag-drop/stories/drag-drop.stories.tsx index ab8648abc..2144cc4b7 100644 --- a/packages/drag-drop/stories/drag-drop.stories.tsx +++ b/packages/drag-drop/stories/drag-drop.stories.tsx @@ -331,7 +331,7 @@ export const SortableList = meta.story({ style={{ position: "relative", "z-index": s.isDragging() ? 10 : 1, - transform: s.isDragging() + transform: s.transform() ? `translate(${s.transform()!.x}px, ${s.transform()!.y}px)` : `translateY(${shift()}px)`, transition: s.isDragging() ? "none" : "transform 0.2s ease", From 19e02639e7dac93c37c4d0b8960bbda124354a6f Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:15:46 -0400 Subject: [PATCH 15/17] Keep drag-movement callbacks separate from file-drop data --- packages/upload/README.md | 18 ++++++++----- packages/upload/src/createDropzone.ts | 9 +++---- packages/upload/src/types.ts | 14 +++++++--- packages/upload/test/index.test.tsx | 37 +++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 16 deletions(-) diff --git a/packages/upload/README.md b/packages/upload/README.md index 9cd336b57..cd1df9ab4 100644 --- a/packages/upload/README.md +++ b/packages/upload/README.md @@ -272,14 +272,14 @@ const { ref, files, isDragging, error } = createDropzone({ **Options (all optional):** -| Callback | Fires when… | -| ------------- | -------------------------------------------------------- | -| `onDrop` | Files are dropped; `isLoading` is `true` while it awaits | -| `onDragEnter` | A dragged item enters the element | -| `onDragLeave` | A dragged item leaves the element | -| `onDragOver` | An item is dragged continuously over 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 callbacks 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` @@ -342,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/src/createDropzone.ts b/packages/upload/src/createDropzone.ts index 67aa24e9c..097ec9152 100644 --- a/packages/upload/src/createDropzone.ts +++ b/packages/upload/src/createDropzone.ts @@ -46,12 +46,9 @@ function createDropzone( const [isLoading, setIsLoading] = createSignal(false); const drop = createNativeDroppable({ - onEnter: e => - options?.onDragEnter?.(transformFiles(e.dataTransfer?.files ?? null)), - onLeave: e => - options?.onDragLeave?.(transformFiles(e.dataTransfer?.files ?? null)), - onOver: e => - options?.onDragOver?.(transformFiles(e.dataTransfer?.files ?? null)), + 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); diff --git a/packages/upload/src/types.ts b/packages/upload/src/types.ts index 02c587ee9..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; @@ -48,9 +56,9 @@ export interface Dropzone { export interface DropzoneOptions { onDrop?: UserCallback; - onDragEnter?: UserCallback; - onDragLeave?: UserCallback; - onDragOver?: 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 a433a8a8b..30194a923 100644 --- a/packages/upload/test/index.test.tsx +++ b/packages/upload/test/index.test.tsx @@ -299,6 +299,43 @@ 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; }); From 76016273a9749321ac408cdc2ba4c0e60c83a86b Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:15:21 -0400 Subject: [PATCH 16/17] Better documentation, collission calculation and LICENSE date --- packages/drag-drop/LICENSE | 4 ++-- packages/drag-drop/README.md | 4 ++-- packages/drag-drop/src/collision.ts | 20 +++++++++++++------- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/drag-drop/LICENSE b/packages/drag-drop/LICENSE index 38b41d975..ba8f69c3d 100644 --- a/packages/drag-drop/LICENSE +++ b/packages/drag-drop/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Solid Primitives Working Group +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 @@ -18,4 +18,4 @@ 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. \ No newline at end of file +SOFTWARE. diff --git a/packages/drag-drop/README.md b/packages/drag-drop/README.md index eecb4b299..51284c0e1 100644 --- a/packages/drag-drop/README.md +++ b/packages/drag-drop/README.md @@ -14,8 +14,8 @@ Composable, tree-shakeable drag-and-drop primitives. Two separate drag systems are provided: -- **Pointer-events** (`makeDraggable`, `makeDroppable`, `createDraggable`, `createDroppable`, `createSortable`, `createDragContext`) — for UI elements moved by the user. -- **Native HTML5 API** (`makeNativeDroppable`, `createNativeDroppable`) — for OS file drops and `draggable="true"` elements; used internally by `@solid-primitives/upload`'s `createDropzone`. +- **[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 diff --git a/packages/drag-drop/src/collision.ts b/packages/drag-drop/src/collision.ts index 87c1345fd..2a0821fcb 100644 --- a/packages/drag-drop/src/collision.ts +++ b/packages/drag-drop/src/collision.ts @@ -6,8 +6,10 @@ export const closestCenter: CollisionDetector = (_draggable, droppables, pointer let bestDistSq = Infinity; const px = pointer.x, py = pointer.y; for (const d of droppables) { - const dx = px - (d.rect.left + d.rect.width * 0.5); - const dy = py - (d.rect.top + d.rect.height * 0.5); + // >> 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; } } @@ -21,15 +23,19 @@ export const closestCorners: CollisionDetector = (_draggable, droppables, pointe const px = pointer.x, py = pointer.y; for (const d of droppables) { const { left, right, top, bottom } = d.rect; - let dx: number, dy: number, distSq: number; + // 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 - dx = px - left; dy = py - top; distSq = dx * dx + dy * dy; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } + distSq = dxls + dyts; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } // top-right - dx = px - right; dy = py - top; distSq = dx * dx + dy * dy; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } + distSq = dxrs + dyts; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } // bottom-left - dx = px - left; dy = py - bottom; distSq = dx * dx + dy * dy; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } + distSq = dxls + dybs; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } // bottom-right - dx = px - right; dy = py - bottom; distSq = dx * dx + dy * dy; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } + distSq = dxrs + dybs; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; } } return best; }; From 8322a26294ffe499f2da7d1cb33cdc80bb231a41 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:16:51 -0400 Subject: [PATCH 17/17] Fixed lock file --- deno.lock | 54 +++++++++++++++++++----------------------------------- 1 file changed, 19 insertions(+), 35 deletions(-) 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": [