pyreon

@pyreon/dnd is signal-driven drag and drop. It wraps @atlaskit/pragmatic-drag-and-drop — the framework-agnostic engine behind Trello and Jira — and exposes it as a set of Pyreon hooks whose state is fine-grained signals and whose teardown is wired into Pyreon's component lifecycle.

@pyreon/dndstable

Installation

npm install @pyreon/dnd
bun add @pyreon/dnd
pnpm add @pyreon/dnd
yarn add @pyreon/dnd

@pyreon/dnd declares @pyreon/core and @pyreon/reactivity as peer dependencies — install them alongside (they're already present in any Pyreon app).

The @atlaskit/pragmatic-drag-and-drop engine (and its -auto-scroll and -hitbox companion packages) are regular dependencies of @pyreon/dnd, so your package manager installs them automatically. You never import them directly — you only touch the Pyreon hooks.

Drag-to-reorder — useSortable distilled

Why a hook layer over Pragmatic DnD?

Pragmatic DnD is a callback-and-cleanup API: draggable({ element, onDragStart, onDrop }) returns a teardown function you're responsible for calling. @pyreon/dnd adapts that to how Pyreon apps actually work:

// ❌ Raw Pragmatic DnD — manual element capture, manual state, manual cleanup
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/adapter/element-adapter'

let dragging = false // not reactive — your UI won't update
const cleanup = draggable({
  element: cardEl,
  onDragStart: () => { dragging = true },
  onDrop: () => { dragging = false },
})
// you must remember to call cleanup() on unmount
// ✅ @pyreon/dnd — signal state, lifecycle-wired teardown
import { useDraggable } from '@pyreon/dnd'

let el: HTMLElement | null = null
const { isDragging } = useDraggable({
  element: () => el,
  data: { id: card.id, type: 'card' },
})

// isDragging() is a signal — JSX, effects, and computeds re-run automatically.
// Teardown is registered via onCleanup — it fires when the component unmounts.
<div ref={(r) => (el = r)} class={isDragging() ? 'opacity-50' : ''} />

Every hook in this package follows three conventions:

  • Elements are refs or getters. Each hook returns a ref callback — attach it (<div ref={ref}>) and the hook registers on mount, follows a swapped element, and disposes on unmount. Alternatively pass element: () => el (and handle: () => handleEl); the getter is resolved on the next microtask, so the element only has to exist once the component has mounted. Use one or the other.

  • State is a signal accessor. isDragging, isOver, activeId, etc. are all () => T accessors. Read them in JSX / effects / computeds for fine-grained reactivity — only the bound text node or class re-evaluates, never the whole component.

  • Cleanup is automatic. Each hook registers its Pragmatic DnD teardown with onCleanup, so it's released when the owning component unmounts. You never manage the returned cleanup function yourself.

// The ref form — no element variable needed:
const { ref, isDragging } = useDraggable({ data: { id: 'card-1' } })
const view = <div ref={ref} class={isDragging() ? 'opacity-50' : ''} />

useDraggable

Make an element draggable and track its drag state as a signal.

// @check
import { useDraggable } from '@pyreon/dnd'

interface Card {
  id: string
  title: string
  locked: boolean
}

function DraggableCard(props: { card: Card }) {
  let el: HTMLElement | null = null
  let handleEl: HTMLElement | null = null

  const { isDragging } = useDraggable({
    element: () => el,
    data: { id: props.card.id, type: 'card' },
    handle: () => handleEl, // only this sub-element starts a drag
    disabled: () => props.card.locked, // reactive — re-evaluated on each drag attempt
    onDragStart: () => console.log('picked up', props.card.id),
    onDragEnd: () => console.log('released'),
  })

  return (
    <div ref={(r) => (el = r)} class={isDragging() ? 'card card--dragging' : 'card'}>
      <span ref={(r) => (handleEl = r)} class="drag-handle">
        ☰
      </span>
      {props.card.title}
    </div>
  )
}

Dynamic drag data

data can be a value or a getter. Use a getter when the transferred payload depends on current state — it's resolved fresh each time the drag starts (getInitialData), not captured once at hook call time:

const { isDragging } = useDraggable({
  element: () => el,
  data: () => ({ id: card.id, position: index(), selected: isSelected() }),
})

Drag handle

By default the whole element is draggable. Pass handle: () => handleEl to restrict drag initiation to a sub-element (a grip icon, a title bar) while keeping the rest of the card interactive (buttons, links, inputs inside it stay clickable):

<div ref={(r) => (cardEl = r)}>
  <header ref={(r) => (handleEl = r)}>☰ Drag me</header>
  <button onClick={edit}>Edit</button> {/* still clickable */}
</div>

useDraggable options

OptionTypeRequiredDescription
element() => HTMLElement | nullnoGetter for the draggable element, resolved on the next microtask. Omit when you attach the returned ref.
dataT | (() => T)yesPayload transferred on drag. A getter is resolved fresh per drag.
handle() => HTMLElement | nullnoRestrict drag initiation to this sub-element.
disabledboolean | (() => boolean)noDisable dragging. A getter is re-evaluated on each drag attempt.
onDragStart() => voidnoFired when the drag begins.
onDragEnd() => voidnoFired when the drag ends — on either a successful drop or cancel.
previewDragPreviewOptionsnoCustom native drag preview: { render(container) } draws into a container (return a cleanup), plus an offset preset.

useDraggable result

PropertyTypeDescription
isDragging() => booleantrue while this element is being dragged (reactive signal accessor).
ref(el: HTMLElement | null) => voidRef callback for the draggable element (alternative to element).

useDroppable

Make an element a drop target and track hover state.

import { useDroppable } from '@pyreon/dnd'
import type { DragData } from '@pyreon/dnd'

function DropColumn(props: { onAdd: (id: string) => void }) {
  let el: HTMLElement | null = null

  const { isOver } = useDroppable({
    element: () => el,
    data: { columnId: props.id }, // attached to this target; readable by the monitor
    canDrop: (source) => source.type === 'card', // reject anything that isn't a card
    onDragEnter: (source) => console.log('card entered:', source.id),
    onDragLeave: () => console.log('left'),
    onDrop: (source) => props.onAdd(source.id as string),
  })

  return (
    <div ref={(r) => (el = r)} class={isOver() ? 'column column--over' : 'column'}>
      Drop a card here
    </div>
  )
}

The callbacks receive the source draggable's data (typed as DragData). Use canDrop to reject incompatible payloads — when it returns false, the target won't highlight, onDragEnter won't fire, and a drop won't land.

useDroppable options

OptionTypeRequiredDescription
element() => HTMLElement | nullnoGetter for the drop-target element. Omit when you attach the returned ref.
dataT | (() => T)noData attached to this target — surfaced to a useDragMonitor's onDrop target arg.
canDrop(sourceData: DragData) => booleannoFilter what can drop here. Omit to accept everything.
onDragEnter(sourceData: DragData) => voidnoFired when an accepted draggable enters.
onDragLeave() => voidnoFired when the draggable leaves.
onDrop(sourceData: DragData, location: DropLocation) => voidnoFired when an accepted draggable is released over this target. location is { edge, data }: the closest configured edge (null without edges) and this target's data.
edgesDropEdge[]noOpt into closest-edge detection (e.g. ['top', 'bottom']); read it from overEdge.
stickybooleannoKeep drop-target status while the pointer crosses gaps between targets.

useDroppable result

PropertyTypeDescription
isOver() => booleantrue while an accepted draggable is hovering this target (reactive).
overEdge() => DropEdge | nullClosest configured edge while hovered; always null without edges.
ref(el: HTMLElement | null) => voidRef callback for the drop target (alternative to element).

useSortable

A full reorderable list — pointer dragging, auto-scroll, closest-edge detection, keyboard reordering, and ARIA wiring — driven from a single reactive items() signal.

import { signal } from '@pyreon/reactivity'
import { For } from '@pyreon/core'
import { useSortable } from '@pyreon/dnd'

function TodoList() {
  const items = signal([
    { id: '1', name: 'Alice' },
    { id: '2', name: 'Bob' },
    { id: '3', name: 'Charlie' },
  ])

  const { containerRef, itemRef, activeId, overId, overEdge } = useSortable({
    items,
    by: (item) => item.id,
    onReorder: (next) => items.set(next),
    axis: 'vertical',
  })

  return (
    <ul ref={containerRef}>
      <For each={items()} by={(item) => item.id}>
        {(item) => (
          <li
            ref={itemRef(item.id)}
            class={activeId() === item.id ? 'sortable sortable--dragging' : 'sortable'}
            style={
              overId() === item.id
                ? `border-${overEdge()}: 2px solid var(--accent)`
                : ''
            }
          >
            {item.name}
          </li>
        )}
      </For>
    </ul>
  )
}

How the pieces connect

  • containerRef goes on the scroll container (the <ul>). It registers auto-scroll, the reorder-finalizing drop target, and the keyboard handler.

  • itemRef(key) returns a ref callback you spread onto each row — it must be called with the same key your by extractor returns and your <For by> uses. This makes each row both a draggable and a closest-edge drop target.

  • activeId() is the key of the row currently being dragged — use it to dim/elevate the dragged row.

  • overId() + overEdge() tell you which row the pointer is over and which edge ('top'/'bottom' for vertical, 'left'/'right' for horizontal) the drop would land against. Render an insertion indicator from this pair.

When a drop completes, the hook computes the reordered array and calls onReorder(next) — you commit it (items.set(next)) and the keyed <For> reconciles the DOM. The hook never mutates your list directly.

Horizontal lists

Set axis: 'horizontal' for a row layout. This switches edge detection to 'left'/'right' and remaps the keyboard shortcuts to Alt+ArrowLeft / Alt+ArrowRight:

const { containerRef, itemRef } = useSortable({
  items,
  by: (item) => item.id,
  onReorder: (next) => items.set(next),
  axis: 'horizontal',
})

Keyboard reordering & accessibility

useSortable is keyboard-operable out of the box. Each item ref:

  • sets role="listitem" and aria-roledescription="sortable item",

  • sets tabindex="0" (only if you haven't set one already),

  • tags the element with a data-pyreon-sort-key attribute for focus tracking.

A focused item can be moved with Alt+ArrowUp / Alt+ArrowDown (vertical) or Alt+ArrowLeft / Alt+ArrowRight (horizontal). Each press swaps the focused item with its neighbour, calls onReorder, and restores focus to the moved item after the DOM updates — so a keyboard user can reorder the whole list without touching a pointer.

Cross-list boards (groupId)

Two useSortable instances that share the same groupId form one drop universe — items can be dragged from one list into the other. This is the Trello / Notion / Linear board shape. Without groupId, each sortable is a private universe and rejects drags from other lists.

When an item crosses lists:

  • the destination sortable's onCrossListReceive(item, targetIndex) fires — insert the moved item into that list at targetIndex;

  • the source sortable's onCrossListDrop(item) fires — remove the item from that list.

import { signal } from '@pyreon/reactivity'
import { For } from '@pyreon/core'
import { useSortable } from '@pyreon/dnd'

type Card = { id: string; title: string }

function Column(props: { title: string; cards: () => Card[]; setCards: (c: Card[]) => void }) {
  const { containerRef, itemRef, activeId } = useSortable<Card>({
    items: props.cards,
    by: (c) => c.id,
    onReorder: (next) => props.setCards(next),
    groupId: 'kanban-board', // ← same id across every column

    // An item left THIS column for a sibling — drop it from here.
    onCrossListDrop: (card) => props.setCards(props.cards().filter((c) => c.id !== card.id)),

    // An item arrived from a sibling column — insert it here.
    onCrossListReceive: (card, index) => {
      const next = [...props.cards()]
      next.splice(index, 0, card)
      props.setCards(next)
    },
  })

  return (
    <section class="column">
      <h3>{props.title}</h3>
      <ul ref={containerRef}>
        <For each={props.cards()} by={(c) => c.id}>
          {(card) => (
            <li ref={itemRef(card.id)} class={activeId() === card.id ? 'dragging' : ''}>
              {card.title}
            </li>
          )}
        </For>
      </ul>
    </section>
  )
}

function Board() {
  const todo = signal<Card[]>([{ id: 'a', title: 'Write docs' }])
  const doing = signal<Card[]>([{ id: 'b', title: 'Ship dnd' }])
  const done = signal<Card[]>([])

  return (
    <div class="board">
      <Column title="To do" cards={todo} setCards={todo.set} />
      <Column title="Doing" cards={doing} setCards={doing.set} />
      <Column title="Done" cards={done} setCards={done.set} />
    </div>
  )
}

Dropping an item onto another column's item inserts it at that item's edge; dropping onto the column background appends it to the end of that list (targetIndex === items().length). The source/destination split means each column owns exactly one mutation — no shared board state, no double-inserts.

useSortable options

OptionTypeRequiredDescription
items() => T[]yesReactive list to sort (pass the signal directly — it's callable).
by(item: T) => string | numberyesStable key extractor. Must match the key your <For by> uses.
onReorder(items: T[]) => voidyesCalled with the reordered array after a same-list drop or keyboard move. You commit it.
axis'vertical' | 'horizontal'noSort axis. Default 'vertical'. Controls edge detection + keyboard arrow mapping.
groupIdstringnoOpt-in cross-list universe. Sortables sharing a groupId accept drags from one another.
onCrossListDrop(item: T) => voidnoFired on the source when one of its items is dropped on a sibling group. Remove the item.
onCrossListReceive(item: T, targetIndex: number) => voidnoFired on the destination when a sibling-group item drops on it. Insert at targetIndex.
label(item: T) => stringnoName used in screen-reader announcements. Defaults to String(by(item)).
disabledboolean | (() => boolean)noDisable reordering (pointer and keyboard).

useSortable result

PropertyTypeDescription
containerRef(el: HTMLElement | null) => voidRef for the scroll container — registers auto-scroll, reorder drop target, and keyboard handling.
itemRef(key: string | number) => (el: HTMLElement | null) => voidFactory — call with the item's key, spread the result as the row's ref.
activeId() => string | number | nullKey of the row currently being dragged (null when idle).
overId() => string | number | nullKey of the row the pointer is over (null when not over a row).
overEdge() => DropEdge | nullClosest edge of the hovered row: 'top'/'bottom' or 'left'/'right'.
itemHandleRef(key: string | number) => (el: HTMLElement | null) => voidOptional per-item drag handle (a grip): dragging starts only from that element.
isActive(key: string | number) => booleantrue only for the dragging row. Prefer it over activeId() === key in row templates — it notifies two rows per change instead of all of them.
isOverKey(key: string | number) => booleanSame, for the hovered row.

useFileDrop

A native-file drop zone with MIME-type and count filtering. Built on Pragmatic DnD's external/file adapter, so it accepts files dragged in from the OS — not in-page draggables.

import { useFileDrop } from '@pyreon/dnd'

function ImageUploader(props: { onFiles: (files: File[]) => void }) {
  let zone: HTMLElement | null = null

  const { isOver, isDraggingFiles } = useFileDrop({
    element: () => zone,
    accept: ['image/*', '.pdf'], // MIME globs OR extensions
    maxFiles: 5,
    disabled: () => props.uploading,
    onDrop: (files) => props.onFiles(files),
  })

  return (
    <div
      ref={(r) => (zone = r)}
      class={isOver() ? 'dropzone dropzone--over' : isDraggingFiles() ? 'dropzone dropzone--ready' : 'dropzone'}
    >
      {isOver()
        ? 'Release to upload'
        : isDraggingFiles()
          ? 'Drop files here'
          : 'Drag images or PDFs to upload'}
    </div>
  )
}

useFileDrop exposes two signals so you can stage the UI:

  • isDraggingFiles() flips true the moment files are dragged anywhere on the page — use it to reveal/highlight every drop zone (a page-wide "drop ready" affordance).

  • isOver() flips true only while files hover this element — use it for the active "release here" state.

Filtering

accept filters the dropped files before onDrop fires. Each pattern is matched as:

  • an extension if it starts with . ('.pdf' → matches files whose name ends in .pdf, case-insensitive),

  • a MIME glob if it ends in /* ('image/*' → matches image/png, image/jpeg, …),

  • an exact MIME type otherwise ('application/pdf').

maxFiles truncates the result to the first N files. After filtering and truncation, onDrop only fires if at least one file remains — a drop of nothing-but-rejected files is silently dropped.

// Accept up to 3 spreadsheets, by extension or exact MIME
useFileDrop({
  element: () => zone,
  accept: ['.csv', '.xlsx', 'application/vnd.ms-excel'],
  maxFiles: 3,
  onDrop: (files) => importSpreadsheets(files),
})

useFileDrop options

OptionTypeRequiredDescription
element() => HTMLElement | nullnoGetter for the drop-zone element. Omit when you attach the returned ref.
onDrop(files: File[]) => voidyesCalled with the filtered, count-limited files (only if ≥1 survives).
acceptstring[]noExtensions ('.pdf'), MIME globs ('image/*'), or exact MIME types.
maxFilesnumbernoKeep at most this many files (the excess goes to onReject).
onReject(rejected: File[], reason: FileRejectReason) => voidnoFiles not delivered and why: 'accept' or 'maxFiles'. Fired once per reason per drop.
disabledboolean | (() => boolean)noDisable drops. A getter is re-evaluated on each drag-over.

useFileDrop result

PropertyTypeDescription
isOver() => booleanFiles are hovering this drop zone (reactive).
isDraggingFiles() => booleanFiles are being dragged anywhere on the page (reactive).
ref(el: HTMLElement | null) => voidRef callback for the drop zone (alternative to element).

useDragMonitor

Observe every drag on the page without owning a draggable or a drop target. Use it for global overlays, cross-region coordination, and analytics.

import { useDragMonitor } from '@pyreon/dnd'
import { Show } from '@pyreon/core'

function GlobalDragOverlay() {
  const { isDragging, dragData } = useDragMonitor({
    canMonitor: (data) => data.type === 'card', // ignore non-card drags
    onDragStart: (data) => analytics.track('drag_start', { id: data.id }),
    onDrop: (source, target) => analytics.track('drag_drop', { from: source.id, to: target.columnId }),
  })

  return (
    <Show when={isDragging()}>
      <div class="drag-overlay">Moving: {() => String(dragData()?.id ?? '')}</div>
    </Show>
  )
}

canMonitor filters which drags this monitor reacts to — return false to ignore a drag entirely (it won't flip isDragging, won't set dragData, and won't fire the callbacks). It's optional; omit it to monitor everything.

onDrop receives two payloads: the dragged source's data, and the drop target's data (the object you passed as useDroppable's / useSortable's data). When a drag ends with no drop target (a cancel, or a release over empty space), the target arg is an empty object {} rather than undefined — so destructuring it is always safe.

useDragMonitor options

OptionTypeRequiredDescription
canMonitor(data: DragData) => booleannoFilter which drags to observe. Omit to observe all.
onDragStart(data: DragData) => voidnoFired on any monitored drag start.
onDrop(sourceData: DragData, targetData: DragData) => voidnoFired on any monitored drop. targetData is {} if there was no drop target.

useDragMonitor result

PropertyTypeDescription
isDragging() => booleantrue while any monitored element is being dragged.
dragData() => DragData | nullThe current monitored drag's data, or null when not dragging.

Server-side rendering

Every hook is SSR-safe. On the server (typeof document === 'undefined') each hook short-circuits before touching the DOM and returns inert results:

HookServer return
useDraggable{ isDragging: () => false }
useDroppable{ isOver: () => false }
useSortableno-op containerRef / itemRef; activeId / overId / overEdge → null
useFileDrop{ isOver: () => false, isDraggingFiles: () => false }
useDragMonitor{ isDragging: () => false, dragData: () => null }

So an SSR page renders cleanly — your ref callbacks still wire up, but no Pragmatic DnD registration happens until the component hydrates in the browser, where the hook re-runs with a live document. There's nothing to guard manually.

Lifecycle & timing

The element-bound hooks (useDraggable, useDroppable, useFileDrop, useSortable) defer their registration to the next microtask (queueMicrotask). This is why you can call the hook before the element exists — the hook reads options.element() on the microtask, by which time your ref callback has populated it. If a component unmounts before that microtask fires, the hook detects it (its onCleanup already ran) and skips registration, so there's never a leaked target.

useSortable disposes at a finer grain than component unmount. Each per-item registration is torn down individually when its itemRef fires with null (an item leaves the <For>) or re-registers with a new element — so a churning list never accumulates dead registrations. The container registration (auto-scroll + the reorder drop-target + the keyboard handler) is disposed the same way: a <ul ref={containerRef}> behind a <Show> (with the hook in the parent) can be collapsed and re-expanded any number of times without leaking listeners on the detached elements.

Performance — the wrapper tax

@pyreon/dnd is a thin adapter: Pragmatic DnD owns the native-event lifecycle and hit-testing; the hooks add a signal per state field and route every teardown into onCleanup. A package-level benchmark (bun run bench from packages/fundamentals/dnd) measures the wrapper tax — how much JS the ergonomic hook adds over a hand-rolled Pyreon+pdnd integration that wires the same reactive state, deferred registration, and cleanup by hand. It runs the real Pragmatic DnD build under happy-dom, with per-(op × impl) process isolation and a bootstrap CI95 (the repo bench standard).

The honest verdict is near-zero tax — a signal-driven layer at ~raw-pdnd cost:

Op (mount → unmount lifecycle)Wrapper tax vs hand-rolled raw pdnd
useDraggablenone (CI overlap)
useDroppablenone (CI overlap)
useSortable itemnone (CI overlap)
useDragMonitornone (CI overlap)
per drag-event dispatch+5ns (one optional-callback hop)

The useSortable item's per-item registration also writes the ARIA attributes (role="listitem", aria-roledescription, tabindex) for you — accessibility the raw path would have to add itself. Real pointer-driven drag gesture timing is browser-dependent and out of scope for this micro-benchmark (this measures the wrapper's own JS, not the OS drag loop). Figures from the 2026-09-23 full run (BENCHMARKS.md §10), which also measured a 24× row-enter fan-out win for the selector path over a naive per-row subscription. Author-judge disclosed.

TypeScript

All option/result interfaces and the shared types are exported:

import type {
  DragData, // Record<string, unknown> — the transferred payload shape
  DropEdge, // 'top' | 'bottom' | 'left' | 'right'
  DropLocation, // { edge: DropEdge | null; data: DragData }
  UseDraggableOptions,
  UseDraggableResult,
  UseDroppableOptions,
  UseDroppableResult,
  UseSortableOptions,
  UseSortableResult,
  UseFileDropOptions,
  UseFileDropResult,
  UseDragMonitorOptions,
  UseDragMonitorResult,
} from '@pyreon/dnd'

useDraggable<T> and useDroppable<T> are generic over your DragData subtype, so you can type the payload at the call site:

type CardData = { id: string; type: 'card'; column: string }

const { isDragging } = useDraggable<CardData>({
  element: () => el,
  data: { id, type: 'card', column },
})

useSortable<T> is generic over your item type (T), not a DragData shape — the item value flows through by, onReorder, onCrossListDrop, and onCrossListReceive fully typed.

Multiplatform

useSortable lowers to the native PyreonSortableState engine on iOS and Android (PMTC) — list reorder through SwiftUI .draggable / .dropDestination and a Compose long-press drag. useDraggable, useDroppable, useDragMonitor and useFileDrop are DOM-shaped and stay web.

API Reference

Hooks

ExportSignatureDescription
useDraggable<T extends DragData>(options: UseDraggableOptions<T>) => UseDraggableResultMake an element draggable; tracks isDragging.
useDroppable<T extends DragData>(options: UseDroppableOptions<T>) => UseDroppableResultMake an element a drop target; tracks isOver.
useSortable<T>(options: UseSortableOptions<T>) => UseSortableResultReorderable list with auto-scroll, edges, keyboard, and cross-list support.
useFileDrop(options: UseFileDropOptions) => UseFileDropResultNative-file drop zone with MIME/count filtering.
useDragMonitor(options?: UseDragMonitorOptions) => UseDragMonitorResultObserve every drag on the page (options optional).

Shared types

TypeDefinitionNotes
DragDataRecord<string, unknown>The transferred payload. Default generic for the data-bearing hooks.
DropEdge'top' | 'bottom' | 'left' | 'right'Closest-edge result for sortable hover.
DropLocation{ edge: DropEdge | null; data: DragData }Drop-position descriptor (edge + target data).

Option & result types

TypeShape
UseDraggableOptions<T>{ element; data; handle?; disabled?; onDragStart?; onDragEnd? }
UseDraggableResult{ isDragging: () => boolean }
UseDroppableOptions<T>{ element; data?; canDrop?; onDragEnter?; onDragLeave?; onDrop? }
UseDroppableResult{ isOver: () => boolean }
UseSortableOptions<T>{ items; by; onReorder; axis?; groupId?; onCrossListDrop?; onCrossListReceive? }
UseSortableResult{ containerRef; itemRef; activeId; overId; overEdge }
UseFileDropOptions{ element; onDrop; accept?; maxFiles?; disabled? }
UseFileDropResult{ isOver: () => boolean; isDraggingFiles: () => boolean }
UseDragMonitorOptions{ onDragStart?; onDrop?; canMonitor? }
UseDragMonitorResult{ isDragging: () => boolean; dragData: () => DragData | null }
Drag & Drop