@vielzeug/codex 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/catalog.json +137 -129
- package/data/llms-full.txt +13088 -17651
- package/data/llms.txt +12 -11
- package/data/manifest.json +1 -1
- package/data/packages/arsenal.json +1 -1
- package/data/packages/assay.json +1 -1
- package/data/packages/clockwork.json +2 -2
- package/data/packages/codex.json +1 -1
- package/data/packages/coins.json +1 -1
- package/data/packages/conduit.json +1 -1
- package/data/packages/courier.json +1 -1
- package/data/packages/dnd.json +14 -12
- package/data/packages/familiar.json +26 -16
- package/data/packages/flux.json +1 -1
- package/data/packages/forge.json +1 -1
- package/data/packages/herald.json +19 -33
- package/data/packages/keymap.json +13 -19
- package/data/packages/ledger.json +28 -25
- package/data/packages/lingua.json +2 -2
- package/data/packages/necromancer.json +50 -0
- package/data/packages/orbit.json +34 -39
- package/data/packages/ore.json +1 -1
- package/data/packages/prism.json +37 -40
- package/data/packages/pulse.json +26 -24
- package/data/packages/refine.json +1 -1
- package/data/packages/ripple.json +1 -1
- package/data/packages/rune.json +6 -7
- package/data/packages/sandbox.json +7 -6
- package/data/packages/scout.json +10 -10
- package/data/packages/scroll.json +18 -17
- package/data/packages/sourcerer.json +1 -1
- package/data/packages/spell.json +1 -1
- package/data/packages/tempo.json +49 -81
- package/data/packages/vault.json +37 -40
- package/data/packages/ward.json +5 -17
- package/data/packages/wayfinder.json +9 -9
- package/data/refine.json +4902 -4902
- package/data/search.json +205 -206
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/http.js +46 -6
- package/dist/http.js.map +1 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/index.js +13 -5
- package/dist/tools/index.js.map +1 -1
- package/package.json +4 -4
package/data/packages/dnd.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"apiSource": "export * from './drop-zone';\nexport { DndError, DndScopeError } from './errors';\nexport * from './sortable';\nexport * from './
|
|
2
|
+
"apiSource": "export * from './drop-zone';\nexport { DndError, DndScopeError } from './errors';\nexport * from './sortable';\nexport * from './types';\n",
|
|
3
3
|
"docs": {
|
|
4
|
-
"index": "---\ntitle: Dnd — Drag-and-drop primitives for the DOM\ndescription: Framework-agnostic drag-and-drop. Drop zones with MIME filtering, sortable lists with drag handles, and explicit connected scopes — zero dependencies.\npackage: dnd\ncategory: ui-interaction\nkeywords: [drag-drop, sortable, file-upload, drop-zone, dnd, reorder]\nrelated: [ore, scroll, refine]\nexports: [createDropZone, createSortable, createSortableScope,
|
|
5
|
-
"api": "---\ntitle: Dnd — API Reference\ndescription: Complete API reference for Dnd.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| -------------------------- | -------------------------------------------- | -------------- | ----------------------------------------------------------------- |\n| `createDropZone()` | Create a typed drop-zone controller | Sync | Remember to destroy the controller during teardown |\n| `createSortable()` | Add sortable drag-and-drop behavior to lists | Sync | Provide stable item identity for reorder operations |\n| `createSortableScope()` | Create a shared scope for connected lists | Sync | Each set of connected containers needs its own scope instance |\n| `createTouchDragShim()` | Bridge touch gestures to synthetic DragEvents | Sync | Create once per app — it's a single `document`-level listener set |\n| `applyReorder()` | Apply ordered IDs to data arrays | Sync | Unknown IDs are skipped; non-mentioned items are appended |\n| `DropZoneOptions.accept` | Filter file types before processing | Sync | Mismatch between MIME and extension can reject files unexpectedly |\n| `DropZoneOptions.maxFiles` | Cap accepted files per drop | Sync | Excess accepted files become rejected; `onDropRejected` is called |\n| `matchesAccept()` | Test a single `File` against an accept list | Sync | Extension patterns are case-insensitive; empty list accepts all |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --------------- | ---------------------- |\n| `@vielzeug/dnd` | Main exports and types |\n\n## Types\n\n### `Disposable`\n\n```ts\ninterface Disposable {\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n [Symbol.dispose](): void;\n}\n```\n\n### `DropZoneOptions`\n\n```ts\ninterface DropZoneOptions {\n element: HTMLElement;\n accept?: string[];\n maxFiles?: number;\n onValidate?: (files: File[]) => boolean | Promise<boolean>;\n disabled?: boolean;\n dropEffect?: DataTransfer['dropEffect'];\n onDrop?: (files: File[]) => void;\n onDropRejected?: (files: File[]) => void;\n onHoverChange?: (hovered: boolean) => void;\n onValidatingChange?: (validating: boolean) => void;\n paste?: boolean;\n onPaste?: (files: File[]) => void;\n}\n```\n\n### `DropZone`\n\n```ts\ninterface DropZone extends Disposable {\n readonly hovered: boolean;\n readonly validating: boolean;\n}\n```\n\n### `SortableOptions`\n\n```ts\ninterface SortableOptions {\n element: HTMLElement;\n getKey: (element: HTMLElement) => string;\n scope?: SortableScope;\n handle?: string;\n keyboard?: boolean;\n axis?: 'vertical' | 'horizontal';\n autoScroll?: boolean | AutoScrollOptions;\n dragImage?: HTMLElement | ((id: string, item: HTMLElement, event: DragEvent) => HTMLElement | null | undefined);\n dragImageOffset?: [number, number];\n placeholderClass?: string;\n disabled?: boolean;\n onDragStart?: (id: string, event: DragEvent) => void;\n onDragEnd?: (id: string, event: DragEvent) => void;\n onBeforeReorder?: (from: string[], to: string[]) => void;\n onReorder?: (event: ReorderEvent) => void;\n}\n```\n\n### `AutoScrollOptions`\n\n```ts\ninterface AutoScrollOptions {\n edgeThreshold?: number;\n speed?: number;\n container?: boolean;\n viewport?: boolean;\n}\n```\n\n### `Sortable`\n\n```ts\ninterface Sortable extends Disposable {\n readonly isDragging: boolean;\n revert(): void;\n sync(): void;\n}\n```\n\n### `SortableScope`\n\n```ts\ndeclare function createSortableScope(): SortableScope;\n```\n\nCreates an explicit connection scope for multi-container sorting. Containers only exchange items when they share the same scope instance.\n\n### `TouchDragOptions`\n\n```ts\ninterface TouchDragOptions {\n disabled?: boolean;\n draggableSelector?: string;\n}\n```\n\n## `createDropZone()`\n\n```ts\ndeclare function createDropZone(options: DropZoneOptions): DropZone;\n```\n\nAttaches drag-and-drop file handling to a DOM element. Returns a `DropZone` handle.\n\n| Option | Type | Default | Description |\n| ---------------- | ------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `element` | `HTMLElement` | — | **Required.** The element to attach drag listeners to. |\n| `accept` | `string[]` | `[]` | Accepted file types. Empty array accepts everything. Each entry is a MIME type (`'image/png'`), MIME wildcard (`'image/*'`), or file extension (`'.pdf'`). |\n| `maxFiles` | `number` | — | Maximum files accepted per drop. Files beyond this limit are passed to `onDropRejected`. When omitted there is no limit. |\n| `onValidate` | `(files: File[]) => boolean \\| Promise<boolean>` | — | Optional async gating step. Called after type/`accept`/`maxFiles` filtering, before `onDrop`. Return or resolve `false` to reject all accepted files. `zone.validating` is `true` while a promise is pending. Only receives type-accepted files. |\n| `disabled` | `boolean` | — | When `true`, all drag and paste events are ignored. A disabled zone does not call `preventDefault` on `dragenter`, `dragover`, `drop`, or `paste`, so underlying elements (text editors, etc.) receive them normally. |\n| `dropEffect` | `'copy' \\| 'move' \\| 'link' \\| 'none'` | `'copy'` | The `dropEffect` set on `dataTransfer` during `dragover`. Controls the cursor indicator. |\n| `onDrop` | `(files: File[]) => void` | — | Called with accepted files only. Not called if all dropped files are rejected. Also receives paste events when `paste: true` and `onPaste` is omitted. |\n| `onDropRejected` | `(files: File[]) => void` | — | Called with files that did not match `accept`, exceeded `maxFiles`, or were rejected by `onValidate`. |\n| `onHoverChange` | `(hovered: boolean) => void` | — | Called when hover state toggles. Use this callback for drag-over styling. |\n| `paste` | `boolean` | `false` | When `true`, attaches a `paste` listener to `window`. Pasted files run through the same `accept`, `maxFiles`, and `onValidate` pipeline as dropped files. |\n| `onPaste` | `(files: File[]) => void` | — | Called when files are pasted from the clipboard. Falls back to `onDrop` when omitted. Only active when `paste: true`. |\n\n**Returns:** `DropZone`\n\nNotes:\n\n- Extension accept patterns are approximate during pre-check (`DataTransferItem` has no filename); exact filtering is applied at drop time.\n- Hover state (`hovered`) only becomes `true` when the dragged payload passes the `accept` filter. Drags carrying rejected file types enter and leave the zone without triggering `onHoverChange`.\n- Hover state is reset on element drop and also global `window` `drop`/`dragend` to avoid stuck hover state when drags leave the viewport.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*', '.pdf'],\n onDrop: (files) => {\n upload(files);\n },\n onDropRejected: (files) => {\n showError(`${files.length} rejected`);\n },\n onHoverChange: (hovered) => {\n dropEl.classList.toggle('drag-over', hovered);\n },\n});\n```\n\n## `DropZone` Interface\n\n### `zone.hovered`\n\n`readonly hovered: boolean`\n\n`true` when a drag is currently over the zone. Updated synchronously by the internal counter — safe to read at any time.\n\n### `zone.validating`\n\n`readonly validating: boolean`\n\n`true` while an `onValidate` promise is pending. Use this to render a loading indicator between file selection and the acceptance/rejection callbacks firing.\n\n```ts\nconsole.log(zone.validating); // true between drop and onValidate resolution\n```\n\n### `zone.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called. Safe to read at any time.\n\n### `zone.disposalSignal`\n\n`readonly disposalSignal: AbortSignal`\n\nAn `AbortSignal` that fires when `dispose()` is called. Use it to cancel in-flight requests tied to the zone's lifetime.\n\n### `zone.dispose()`\n\n`dispose(): void`\n\nRemoves all event listeners from the element, resets the drag counter and hover state, and clears the `hovered` flag. Idempotent — safe to call multiple times.\n\n```ts\nzone.dispose();\n```\n\n### `zone[Symbol.dispose]()`\n\n`[Symbol.dispose](): void`\n\nAlias for `dispose()`. Called automatically when used with the `using` keyword.\n\n```ts\n{\n using zone = createDropZone({ element: dropEl, onDrop: handleFiles });\n} // zone.dispose() runs here\n```\n\n## `createSortable()`\n\n```ts\ndeclare function createSortable(options: SortableOptions): Sortable;\n```\n\nMakes the direct children of a container element reorderable via drag. Returns a `Sortable` handle.\n\n`createSortable` sets `draggable=\"true\"`, `role=\"listitem\"`, and `touch-action: none` (inline style) on qualifying children and sets `role=\"list\"` on the container at initialization. After DOM mutations, call `sortable.sync()` to re-apply sortable attributes explicitly.\n\n- `element`: `HTMLElement`, required. The container whose children become sortable.\n- `getKey`: `(element: HTMLElement) => string`, required. Maps each item element to its stable string identity. Children for which `getKey` returns a falsy value are skipped.\n- `scope`: `SortableScope`, default private scope. Connects sortable lists explicitly; containers only exchange items when they share the same scope instance.\n- `handle`: `string`. CSS selector for a drag handle inside each item. When omitted, the whole item is draggable.\n- `keyboard`: `boolean`, default `true`. Enables keyboard reordering with arrow keys plus `Home` and `End`.\n- `axis`: `'vertical' | 'horizontal'`, default `'vertical'`. Controls midpoint calculation for placeholder insertion.\n- `autoScroll`: `boolean | AutoScrollOptions`, default `true`. Scrolls the container near its edges; enable viewport scrolling with `autoScroll.viewport`.\n- `dragImage`: `HTMLElement | ((id, item, event) => HTMLElement | null | undefined)`. Custom native drag preview passed to `dataTransfer.setDragImage()`. A `null` or `undefined` return skips `setDragImage` entirely.\n- `dragImageOffset`: `[number, number]`, default `[0, 0]`. The `[x, y]` hotspot offset passed to `setDragImage`. Controls which point of the preview image follows the cursor.\n- `placeholderClass`: `string`, default `'dnd-placeholder'`. CSS class applied to the generated placeholder element.\n- `disabled`: `boolean`. Blocks drag interactions. If a list becomes disabled mid-drag, Dnd cancels the drag and restores the original order.\n- `onDragStart`: `(id: string, event: DragEvent) => void`. Called when a drag starts.\n- `onDragEnd`: `(id: string, event: DragEvent) => void`. Called when a drag ends, whether completed or cancelled.\n- `onBeforeReorder`: `(from: string[], to: string[]) => void`. Called with the before/after order snapshots just before a successful reorder commits — for both drag and keyboard. Items are still in their pre-commit positions at the time of the call, making it ideal for FLIP animation setup.\n- `onReorder`: `(event: ReorderEvent) => void`. Called after a successful reorder (drag or keyboard), only when the order changed. Use `event.setRevert(fn)` to register a revert function that `sortable.revert()` will invoke.\n\n**Returns:** `Sortable`\n\n```ts\nconst boardScope = createSortableScope();\n\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.id!,\n handle: '.drag-handle',\n onDragStart: (id) => {\n listEl.classList.add('sorting');\n },\n onDragEnd: (id) => {\n listEl.classList.remove('sorting');\n },\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n saveOrder(ids);\n setRevert(() => saveOrder(prev));\n },\n scope: boardScope,\n});\n```\n\n### `createSortableScope()`\n\n```ts\ndeclare function createSortableScope(): SortableScope;\n```\n\nUse one scope per connected set of containers. Sortables without an explicit scope use a private scope and remain isolated.\n\n## `Sortable` Interface\n\n### `sortable.isDragging`\n\n`readonly isDragging: boolean`\n\n`true` while an item drag is in progress.\n\n### `sortable.revert()`\n\n`revert(): void`\n\nCalls the revert function registered via `setRevert` in the last `onReorder` invocation (if any) and clears it. A no-op when no revert function was registered or it has already been consumed. Works for both drag-based and keyboard-based reorders.\n\nOnly the most recent reorder can be reverted — a new reorder overwrites the stored function.\n\n```ts\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n setOrder(ids);\n setRevert(() => setOrder(prev)); // ← enable revert\n },\n});\n\n// On server error:\ntry {\n await api.saveOrder(ids);\n} catch {\n sortable.revert();\n}\n```\n\n### `sortable.sync()`\n\n`sync(): void`\n\nRe-applies `draggable`, `role`, and handle attributes after DOM mutations. Call it after adding, removing, or replacing sortable children.\n\n### `sortable.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called.\n\n### `sortable.disposalSignal`\n\n`readonly disposalSignal: AbortSignal`\n\nAn `AbortSignal` that fires when `dispose()` is called.\n\n### `sortable.dispose()`\n\n`dispose(): void`\n\nRemoves all event listeners from the container, strips sortable attributes from items and handles, and cancels any in-progress drag by restoring the original order. Idempotent — safe to call multiple times.\n\n### `sortable[Symbol.dispose]()`\n\n`[Symbol.dispose](): void`\n\nAlias for `dispose()`.\n\n## DOM Attributes\n\nDnd reads and writes the following DOM attributes:\n\n- `data-dnd-item`: internal marker applied by `createSortable` to children that return a truthy key from `getKey`. Removed by `dispose()`.\n- `draggable`: set by `createSortable` and `sortable.sync()`, removed by `dispose()`. Enables native drag on each item or handle.\n- `role=\"list\"`: set by `createSortable`, removed by `dispose()`. Accessibility role on the container.\n- `role=\"listitem\"`: set by `createSortable` and `sortable.sync()`, removed by `dispose()`. Accessibility role on each item.\n- `data-dragging`: set during drag, removed on `dragend` or `dispose()`. Use it as your styling hook for drag state.\n- `data-dnd-handle`: internal marker set by `createSortable` and `sortable.sync()`, removed by `dispose()`. Lets Dnd clean up only the handle attributes it applied.\n- `aria-hidden=\"true\"`: set on placeholder creation and removed with the placeholder. Applied to the `.dnd-placeholder` element.\n- `style.touchAction = 'none'` (inline style): set by `createSortable` and `sortable.sync()` on the item (or the handle, when `handle` is set), cleared by `dispose()`. Opts the element out of the browser's default touch gestures (scroll/pan/zoom) so a mobile browser never hijacks a drag gesture as a page scroll before `createTouchDragShim`'s own logic runs — see Usage's \"Why draggable items get `touch-action: none`\". No effect on mouse/pointer input.\n\n## CSS Classes\n\n| Class | Applied to | When |\n| ----------------- | ---------------------------- | ------------------------------------------------------------- |\n| `dnd-placeholder` | `<div>` inserted by sortable | While an item is being dragged, in the placeholder's position |\n\n## `createTouchDragShim()`\n\n```ts\ndeclare function createTouchDragShim(options?: TouchDragOptions): Disposable;\n```\n\nBridges touch gestures to the synthetic `DragEvent` sequence `createSortable()`/`createDropZone()` already listen for — `touchstart`/`touchmove`/`touchend`/`touchcancel` become `dragstart`/`dragover`/`drop`/`dragend` on the same `document`. HTML5 drag-and-drop has no native touch equivalent otherwise.\n\n| Option | Type | Default | Description |\n| -------------------- | --------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `disabled` | `boolean` | — | When `true`, touch gestures are ignored. Read live off the same options object on each `touchstart`, like `SortableOptions.disabled`. |\n| `draggableSelector` | `string` | `'[draggable=\"true\"]'` | CSS selector identifying draggable elements under the touch point. The default matches what `createSortable`/`createDropZone` already set. |\n| `showDragPreview` | `boolean` | `true` | Renders a floating clone of the dragged element that follows the touch point for the whole gesture. A native mouse drag gets this for free from the browser's own drag image; this shim's `dragstart` is synthetic, so without it the dragged element would simply vanish (hidden by `createSortable`'s own `scheduleHide()`) with no visual feedback at all. Set to `false` to render fully custom feedback instead. |\n\n**Returns:** `Disposable`\n\nNotes:\n\n- Listens at the `document` level — create one instance per app, not one per sortable/drop-zone.\n- Dispatched events carry a plain object as `dataTransfer` (`dropEffect`/`effectAllowed`/`getData`/`setData`/`setDragImage`), never a real `DataTransfer` — a genuine `DataTransfer` created outside an active native drag is permanently in the spec's \"disabled mode\", where `dropEffect` writes are silently ignored, which `createSortable`/`createDropZone`'s own commit-vs-cancel check would otherwise always read as a cancellation.\n- The floating preview is a `cloneNode(true)` of the dragged element — it only clones light-DOM content, so an item whose visible content lives inside a shadow root will preview as an empty shell.\n\n```ts\nimport { createTouchDragShim } from '@vielzeug/dnd';\n\nusing touchDrag = createTouchDragShim();\n```\n\n## `matchesAccept()`\n\n```ts\ndeclare function matchesAccept(file: File, accept: string[]): boolean;\n```\n\nTests whether a `File` matches an accept pattern list. Each pattern can be:\n\n- A MIME type: `'image/png'`\n- A MIME wildcard: `'image/*'`\n- A file extension: `'.pdf'`\n\nAn empty list accepts everything. Extension matching is case-insensitive.\n\n```ts\nimport { matchesAccept } from '@vielzeug/dnd';\n\nmatchesAccept(file, ['image/*', '.pdf']); // true or false\n```\n\n## `applyReorder()`\n\n```ts\ndeclare function applyReorder<T>(items: T[], ids: string[], getKey: (item: T) => string): T[];\n```\n\nApplies a DOM reorder result (`orderedIds`) to your backing array.\n\n- IDs missing from `items` are ignored.\n- Items not listed in `ids` are appended in original order.\n- Duplicate IDs in `ids` — first occurrence wins, later occurrences are ignored.\n\n```ts\nconst next = applyReorder(items, orderedIds, (item) => item.id);\n```\n",
|
|
6
|
-
"usage": "---\ntitle: Dnd — Usage Guide\ndescription: Drop zones, sortable lists, explicit connected scopes, keyboard sorting, and cleanup patterns with Dnd.\n---\n\n[[toc]]\n\n## Basic Usage\n\n`createDropZone` attaches drag-and-drop behavior to any DOM element and keeps hover state stable with a counter.\n\n```ts\nimport { createDropZone } from '@vielzeug/dnd';\n\nconst zone = createDropZone({\n element: document.getElementById('dropzone')!,\n onDrop: (files) => {\n uploadFiles(files);\n },\n});\n```\n\n### Accept filtering\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*', '.pdf', 'application/json'],\n onDrop: (files) => {\n // accepted files only\n },\n onDropRejected: (files) => {\n showToast(`${files.length} file(s) not accepted`);\n },\n});\n```\n\nThe `accept` list is read at drop-time, so mutating the array dynamically adjusts what is accepted for the next drop.\n\n### Hover state\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n onHoverChange: (hovered) => {\n dropEl.classList.toggle('drag-over', hovered);\n },\n});\n```\n\nRead zone state imperatively:\n\n```ts\nconsole.log(zone.hovered);\nconsole.log(zone.validating);\n```\n\n### Drop effect\n\n```ts\ncreateDropZone({\n element: dropEl,\n dropEffect: 'move',\n onDrop: (files) => {\n // ...\n },\n});\n```\n\n### Disabled state\n\n```ts\nconst options = { disabled: false, element: dropEl, onDrop: handleFiles };\nconst zone = createDropZone(options);\n\n// options.disabled is read live on each event — mutate to toggle:\noptions.disabled = isReadOnly;\n```\n\n### File limit\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*'],\n maxFiles: 5,\n onDrop: (files) => {\n // 1-5 accepted files\n },\n onDropRejected: (files) => {\n showToast(`Only 5 files at a time. ${files.length} were ignored.`);\n },\n});\n```\n\n### Cleanup\n\n```ts\nzone.dispose();\n// or:\nusing zone = createDropZone({ element: dropEl, onDrop: handleFiles });\n```\n\n### Async validation\n\nGate drops behind an async check with `onValidate`. The zone sets `validating: true` while the promise is pending; on resolution, accepted files go to `onDrop` and rejected files go to `onDropRejected`.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*'],\n onValidate: async (files) => {\n const ok = await checkServerQuota(files);\n return ok; // false → all files forwarded to onDropRejected\n },\n onDrop: (files) => uploadFiles(files),\n onDropRejected: (files) => showError('Quota exceeded'),\n});\n\n// show a spinner while checking\nconsole.log(zone.validating); // true during pending check\n```\n\nA synchronous boolean return skips the microtask queue entirely:\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n onValidate: (files) => files.every((f) => f.size < 5_000_000), // sync\n onDrop: handleFiles,\n});\n```\n\n### Clipboard paste\n\nSet `paste: true` to accept files pasted from the clipboard. The same `accept`, `maxFiles`, and `onValidate` pipeline applies.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n paste: true,\n accept: ['image/*'],\n onPaste: (files) => {\n uploadFiles(files);\n },\n onDropRejected: (files) => {\n showError(`${files.length} file(s) not accepted`);\n },\n});\n```\n\nWhen `onPaste` is omitted, accepted pasted files fall through to `onDrop`.\n\n## Sortable\n\n`createSortable` makes direct children of a container reorderable via drag.\n\n### Setup\n\n```html\n<ul id=\"task-list\">\n <li data-sort-id=\"task-1\">Design</li>\n <li data-sort-id=\"task-2\">Develop</li>\n <li data-sort-id=\"task-3\">Review</li>\n</ul>\n```\n\n```ts\nconst sortable = createSortable({\n element: document.getElementById('task-list')!,\n getKey: (el) => el.dataset.sortId!,\n axis: 'vertical',\n onReorder: ({ ids }) => {\n saveTaskOrder(ids);\n },\n});\n```\n\nDnd automatically sets:\n\n- `draggable=\"true\"` on sortable nodes (or handles)\n- `role=\"listitem\"` on each item\n- `role=\"list\"` on the container\n- `tabindex=\"0\"` on each item for keyboard reordering\n\n### Drag handles\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n handle: '.drag-handle',\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Keyboard reordering\n\nFocus an item and use arrow keys to move it. `Home` and `End` move to the boundary positions.\n\nWhen an item is already at the first or last position, the boundary key press is not consumed — the browser handles it normally (for example, scrolling the page). Only keys that actually move an item call `preventDefault`.\n\n### Connected lists\n\nCreate a shared scope when items should move between containers:\n\n```ts\nconst boardScope = createSortableScope();\n\ncreateSortable({\n element: todoEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => saveTodoOrder(ids),\n scope: boardScope,\n});\ncreateSortable({\n element: doneEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => saveDoneOrder(ids),\n scope: boardScope,\n});\n```\n\n### Auto-scroll and drag preview\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n autoScroll: { edgeThreshold: 40, speed: 24, viewport: true },\n dragImage: (id, item) => item,\n dragImageOffset: [8, 8],\n});\n```\n\nViewport scrolling is opt-in. Container scrolling stays enabled by default.\n\n### Lifecycle hooks\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onDragStart: (id) => {\n listEl.classList.add('sorting');\n },\n onDragEnd: (id) => {\n listEl.classList.remove('sorting');\n },\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Custom identity function\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.getAttribute('data-id')!,\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Dynamic lists\n\nCall `sortable.sync()` after adding, removing, or replacing sortable items.\n\n```ts\nconst item = document.createElement('li');\nitem.dataset.sortId = 'task-4';\nitem.textContent = 'Deploy';\nlistEl.appendChild(item);\nsortable.sync();\n```\n\n### Disabled state\n\n```ts\nimport { createSortable, type SortableOptions } from '@vielzeug/dnd';\n\nconst options: SortableOptions = {\n disabled: false,\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => saveOrder(ids),\n};\nconst sortable = createSortable(options);\n\n// options.disabled is read live on each event — mutate to toggle:\noptions.disabled = isLocked;\n```\n\n### Placeholder styling\n\n```css\n.dnd-placeholder {\n background: var(--color-primary-50);\n border: 2px dashed var(--color-primary-300);\n border-radius: 4px;\n box-sizing: border-box;\n}\n\n[data-dragging] {\n opacity: 0.35;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n}\n```\n\n### Mapping DOM order back to data\n\n```ts\nimport { applyReorder, createSortable } from '@vielzeug/dnd';\n\nlet items = [\n { id: 'task-1', title: 'Design' },\n { id: 'task-2', title: 'Develop' },\n { id: 'task-3', title: 'Review' },\n];\n\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => {\n items = applyReorder(items, ids, (item) => item.id);\n },\n});\n```\n\n### Cleanup\n\n```ts\nsortable.dispose();\n// or:\nusing sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### FLIP animation hook\n\n`onBeforeReorder` fires just before the DOM reorder commits, for both drag and keyboard moves. At the time of the call items are still in their pre-commit positions, making it the right place to record element bounds for FLIP animations.\n\n```ts\nconst sortable = createSortable({\n element: listEl,\n onBeforeReorder: (from, to) => {\n // snapshot bounds before the DOM moves\n const snapshots = new Map(getItems().map((el) => [el.dataset.sortId!, el.getBoundingClientRect()]));\n\n requestAnimationFrame(() => {\n // animate from snapshot to new position\n for (const [id, before] of snapshots) {\n const el = listEl.querySelector(`[data-sort-id=\"${id}\"]`) as HTMLElement;\n const after = el.getBoundingClientRect();\n const dy = before.top - after.top;\n if (dy === 0) continue;\n el.style.transform = `translateY(${dy}px)`;\n el.style.transition = 'none';\n requestAnimationFrame(() => {\n el.style.transition = 'transform 200ms ease';\n el.style.transform = '';\n });\n }\n });\n },\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Optimistic updates and revert\n\nCall `sortable.revert()` to roll back the most recent reorder. Register a revert function via `setRevert` inside `onReorder`.\n\n```ts\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n setOrder(ids); // optimistic update\n setRevert(() => setOrder(prev)); // registered for sortable.revert()\n },\n});\n\n// On server error:\ntry {\n await api.saveOrder(currentOrder);\n} catch {\n sortable.revert();\n}\n```\n\n## Touch Support\n\nHTML5 drag-and-drop has no native touch story — touch devices never fire `dragstart`/`dragover`/`drop`. `createTouchDragShim` bridges `touchstart`/`touchmove`/`touchend`/`touchcancel` into that same synthetic `DragEvent` sequence at the `document` level, so `createSortable`/`createDropZone` work on touch with no per-instance wiring.\n\n```ts\nimport { createTouchDragShim } from '@vielzeug/dnd';\n\n// Call once at app startup — one instance covers the whole page.\nusing touchDrag = createTouchDragShim();\n```\n\n### Custom draggable selector\n\nDefaults to `[draggable=\"true\"]` — the attribute `createSortable`/`createDropZone` already set on managed elements. Override it if you're bridging touch to elements you manage draggability on yourself.\n\n```ts\ncreateTouchDragShim({ draggableSelector: '.my-drag-handle' });\n```\n\n### Drag preview\n\nA native mouse-driven drag gets a floating drag image for free — the browser snapshots the dragged element the moment `dragstart` fires and keeps that image under the cursor for the whole gesture. `createTouchDragShim`'s `dragstart` is a synthetic event, so no such snapshot ever exists; without a preview of its own, the dragged element would simply disappear (hidden by `createSortable`'s own scheduled hide) with no visual feedback until the drop. `createTouchDragShim` renders one automatically — a `cloneNode(true)` of the dragged element, positioned `fixed` and translated to follow the touch point — enabled by default.\n\n```ts\n// Opt out to render fully custom feedback instead (e.g. toggling a class from your own\n// dragstart/dragend listeners):\ncreateTouchDragShim({ showDragPreview: false });\n```\n\nNote the preview only clones light-DOM content — an item whose visible content lives inside a shadow root will preview as an empty shell.\n\n### Why draggable items get `touch-action: none`\n\n`createSortable` sets `touch-action: none` on every element it marks as draggable (the item itself, or the handle when `handle` is set) — no configuration needed. Without it, a mobile browser can decide the very first bit of finger movement on a draggable item is a page scroll/pan — a decision made independently of, and before, `createTouchDragShim`'s own drag-start threshold and `preventDefault()` calls ever run — and hand the rest of the gesture to native scrolling. Once that happens the item never receives the `dragover` sequence needed to update the drop target, so the drop commits back to wherever it started, which looks identical to the drop simply reverting. This is most visible dragging between two containers that require any real finger travel (e.g. a Kanban column stacked below the source column on a narrow viewport) — a short in-place reorder rarely travels far enough to trigger the browser's scroll-intent heuristic, which is why this class of bug can pass casual same-container testing and only show up cross-container.\n\nThis has no effect on mouse/pointer input — `touch-action` is touch-only — so it's safe even for `createSortable` instances that never pair with `createTouchDragShim`.\n\n### Disabled state\n\n```ts\nconst options = { disabled: false };\nconst touchDrag = createTouchDragShim(options);\n\n// options.disabled is read live on each touch event — mutate to toggle:\noptions.disabled = true;\n```\n\n### Cleanup\n\n```ts\ntouchDrag.dispose();\n// or:\nusing touchDrag = createTouchDragShim();\n```\n\n## Framework Integration\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useRef } from 'react';\nimport { createSortable, applyReorder } from '@vielzeug/dnd';\n\nfunction SortableList({ initialItems }: { initialItems: { id: string; text: string }[] }) {\n const listRef = useRef<HTMLUListElement>(null);\n const items = useRef(initialItems);\n\n useEffect(() => {\n const sortable = createSortable({\n element: listRef.current!,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => {\n items.current = applyReorder(items.current, ids, (i) => i.id);\n },\n });\n return () => sortable.dispose();\n }, []);\n\n return (\n <ul ref={listRef}>\n {initialItems.map((item) => (\n <li key={item.id} data-sort-id={item.id}>\n {item.text}\n </li>\n ))}\n </ul>\n );\n}\n```\n\n```ts [Vue 3]\nimport { ref, onMounted, onUnmounted } from 'vue';\nimport { createSortable, applyReorder, type Sortable } from '@vielzeug/dnd';\n\nfunction useSortable(items: { id: string; text: string }[]) {\n const listRef = ref<HTMLElement | null>(null);\n const orderedItems = ref(items);\n let sortable: Sortable | null = null;\n\n onMounted(() => {\n sortable = createSortable({\n element: listRef.value!,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => {\n orderedItems.value = applyReorder(orderedItems.value, ids, (i) => i.id);\n },\n });\n });\n\n onUnmounted(() => sortable?.dispose());\n return { listRef, orderedItems };\n}\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { onMount } from 'svelte';\n import { createSortable, applyReorder } from '@vielzeug/dnd';\n\n export let initialItems: { id: string; text: string }[] = [];\n let items = initialItems;\n let listEl: HTMLUListElement;\n\n onMount(() => {\n const sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => { items = applyReorder(items, ids, (i) => i.id); },\n });\n return () => sortable.dispose();\n });\n</script>\n\n<ul bind:this={listEl}>\n {#each items as item (item.id)}\n <li data-sort-id={item.id}>{item.text}</li>\n {/each}\n</ul>\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### With Ore\n\nUse Dnd in custom web components by attaching behavior in component lifecycle hooks.\n\n```ts\nimport { createSortable } from '@vielzeug/dnd';\nimport { define, getHost, html, onMounted } from '@vielzeug/ore';\n\ndefine('task-list', {\n setup(_props) {\n const el = getHost();\n\n onMounted(() => {\n const sortable = createSortable({\n element: el,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => save(ids),\n });\n return () => sortable.dispose();\n });\n\n return html`<slot></slot>`;\n },\n});\n```\n\n## Best Practices\n\n- Attach `createDropZone` and `createSortable` after the container element is in the DOM — use `onMounted` in component frameworks.\n- Call `.dispose()` in the cleanup phase of your framework (useEffect return, onUnmounted, onDestroy) to prevent memory leaks.\n- Use `data-sort-id` attributes that match your data's identity field — do not use DOM index as an identifier.\n- Prefer `applyReorder()` over manual array splicing to keep your data array in sync with DOM order.\n- Use `createSortableScope()` only when items should genuinely move between containers.\n- Use drag handles (`.handle` selector) when the full item surface area conflicts with other interactions such as text selection.\n- Test keyboard reordering explicitly — Dnd sets `tabindex` on items and supports arrow keys by default.\n- Call `createTouchDragShim()` once at app startup if you support touch devices — it's a single `document`-level bridge, not something to attach per `createSortable`/`createDropZone` instance.\n",
|
|
4
|
+
"index": "---\ntitle: Dnd — Drag-and-drop primitives for the DOM\ndescription: Framework-agnostic drag-and-drop. Drop zones with MIME filtering, sortable lists with drag handles, and explicit connected scopes — zero dependencies.\npackage: dnd\ncategory: ui-interaction\nkeywords: [drag-drop, sortable, file-upload, drop-zone, dnd, reorder]\nrelated: [ore, scroll, refine]\nexports: [createDropZone, createSortable, createSortableScope, applyReorder, matchesAccept]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"dnd\" />\n\n## Why Dnd?\n\nThe HTML5 Drag & Drop API requires careful counter tracking to avoid hover state flicker, has no MIME type pre-filtering, and provides no sortable list abstraction.\n\n```ts\n// Before — raw HTML5 Drag & Drop\nlet enterCount = 0;\ndropzone.addEventListener('dragenter', () => {\n enterCount++;\n dropzone.classList.add('over');\n});\ndropzone.addEventListener('dragleave', () => {\n if (--enterCount === 0) dropzone.classList.remove('over');\n});\ndropzone.addEventListener('dragover', (e) => e.preventDefault());\ndropzone.addEventListener('drop', (e) => {\n e.preventDefault();\n enterCount = 0;\n const files = [...e.dataTransfer!.files];\n if (!files.every((f) => f.type.startsWith('image/'))) return showError('Images only');\n uploadFiles(files);\n});\n\n// After — Dnd\nimport { createDropZone } from '@vielzeug/dnd';\nconst zone = createDropZone({\n element: dropzone,\n accept: ['image/*'],\n onDrop: (files) => uploadFiles(files),\n onDropRejected: (files) => showError(`${files.length} file(s) not accepted`),\n onHoverChange: (hovered) => dropzone.classList.toggle('over', hovered),\n});\n```\n\n| Feature | DND | SortableJS | dnd-kit |\n| ------------------- | -------------------------------------------------------- | ------------------------------------------ | ------------------------------------------ |\n| Bundle size | <PackageInfo package=\"dnd\" type=\"size\" /> | ~15 kB | ~30 kB |\n| Framework agnostic | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| MIME type filtering | <ore-icon name=\"check\" size=\"16\"></ore-icon> Pre-validated | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Counter-based hover | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | N/A |\n| Sortable lists | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Drag handles | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| `using` support | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Touch support | <ore-icon name=\"check\" size=\"16\"></ore-icon> Scoped opt-in | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Dnd when** you need reliable file drop zones with MIME filtering or sortable lists in a framework-agnostic environment.\n\n**Consider dnd-kit** if you are building a React app and need complex multi-container drag interactions or accessibility-first sortable trees.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/dnd\n```\n\n```sh [npm]\nnpm install @vielzeug/dnd\n```\n\n```sh [yarn]\nyarn add @vielzeug/dnd\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createDropZone, createSortable } from '@vielzeug/dnd';\n\n// File drop zone — with async validation and paste support\nconst dropzone = document.getElementById('dropzone')!;\n\nusing zone = createDropZone({\n element: dropzone,\n accept: ['image/*', '.pdf'],\n paste: true,\n onValidate: (files) => files.every((file) => file.size <= 5_000_000),\n onDrop: (files) => console.log('Upload', files),\n onDropRejected: (files) => {\n console.warn(`${files.length} file(s) rejected`);\n },\n onHoverChange: (hovered) => {\n dropzone.classList.toggle('drag-over', hovered);\n },\n});\n\n// Sortable list — with revert support for optimistic updates\nlet currentOrder = ['a', 'b', 'c'];\n\nusing sortable = createSortable({\n element: document.getElementById('list')!,\n keyboard: true,\n onBeforeReorder: (from, to) => {\n // record positions here before the DOM commits (for FLIP animations)\n },\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n currentOrder = ids;\n setRevert(() => {\n currentOrder = prev;\n });\n },\n});\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- **Counter-based hover state** — `onHoverChange` stays accurate when dragging over child elements; hover only activates when the drag payload passes the `accept` filter, with symmetric enter/leave pairing to prevent flicker\n- **MIME type pre-validation** — queries `dataTransfer.items` during drag to set `dropEffect='none'` before the drop; confirmed against `File.type` on drop\n- **Flexible accept patterns** — MIME types (`image/png`), wildcards (`image/*`), and file extensions (`.pdf`)\n- **`maxFiles` limit** — cap the number of accepted files per drop; excess files are forwarded to `onDropRejected`\n- **`onValidate` async gating** — optional cancellable async step after type filtering; `zone.validating` remains `true` until every pending validation settles\n- **Clipboard paste support** — `paste: true` routes pasted files through the same `accept`, `maxFiles`, and `onValidate` pipeline; `onPaste` provides a separate callback; paste rejections are forwarded to `onDropRejected` with the same `(files: File[]) => void` signature as drop rejections\n- **`onDropRejected`** — separate callback for files that didn't match `accept`, exceeded `maxFiles`, or were rejected by `onValidate`; event type reflects whether the rejection came from a drop or a paste\n- **Sortable lists** — reorders DOM children with a placeholder indicator; fires `onReorder` only when the order actually changes\n- **Drag handles** — scope dragging to a child selector via `handle`; whole item is draggable when omitted\n- **Custom drag preview** — pass an element or a `(id, item, event) => element | null` factory; control hotspot with `dragImageOffset`\n- **`onBeforeReorder` FLIP hook** — fires before commit for both drag and keyboard moves; pair it with [`captureLayout()`](/necromancer/api.md#capturelayout) for lifecycle-owned FLIP animation\n- **`sortable.revert()`** — register a revert function via `event.setRevert(fn)` inside `onReorder`; `sortable.revert()` invokes it and clears it for rolling back optimistic updates on server failure\n- **Boundary-safe keyboard reordering** — arrow keys at the first/last item no longer suppress `preventDefault`, so the browser can scroll the page normally\n- **Transactional connected scopes** — one `onMove` callback receives each cross-list transfer with both final orders\n- **Scoped touch support** — `createSortableScope({ touch: true })` handles only items registered to that scope and uses an inert outline preview\n- **Explicit DOM sync** — call `sortable.sync()` after DOM mutations instead of relying on hidden observers\n- **`[Symbol.dispose]`** — both primitives support the `using` keyword for automatic cleanup\n- **Reactive-friendly options** — `disabled` is re-read on each event (reassign `options.disabled = true` to toggle); `accept` captures the array reference, so push/splice mutations are reflected without recreating the zone\n- **Zero dependencies** — <PackageInfo package=\"dnd\" type=\"size\" /> gzipped, <PackageInfo package=\"dnd\" type=\"dependencies\" /> dependencies\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Orbit](/orbit/) — floating element positioning; use alongside Dnd to anchor drag previews and drop-zone indicators to precise positions\n- [Ore](/ore/) — web-component authoring framework; build draggable custom elements with Dnd's pointer event primitives\n- [Refine](/refine/) — accessible web components; Dnd powers the drag-and-drop inside Refine's sortable list and kanban components\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
+
"api": "---\ntitle: Dnd — API Reference\ndescription: Complete API reference for Dnd.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| -------------------------- | -------------------------------------------- | -------------- | ----------------------------------------------------------------- |\n| `createDropZone()` | Create a typed drop-zone controller | Sync | Dispose the controller during teardown |\n| `createSortable()` | Add sortable drag-and-drop behavior to lists | Sync | Provide stable item identity for reorder operations |\n| `createSortableScope()` | Create a shared scope for connected lists | Sync | Each set of connected containers needs its own scope instance |\n| `applyReorder()` | Apply ordered IDs to data arrays | Sync | Unknown IDs are skipped; non-mentioned items are appended |\n| `DropZoneOptions.accept` | Filter file types before processing | Sync | Mismatch between MIME and extension can reject files unexpectedly |\n| `DropZoneOptions.maxFiles` | Cap accepted files per drop | Sync | Excess accepted files become rejected; `onDropRejected` is called |\n| `matchesAccept()` | Test a single `File` against an accept list | Sync | Extension patterns are case-insensitive; empty list accepts all |\n| `DndError` | Base class for Dnd errors | Sync | Use `DndError.is()` to narrow unknown errors |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --------------- | ---------------------- |\n| `@vielzeug/dnd` | Main exports and types |\n\n## Types\n\n### `Disposable`\n\n```ts\ninterface Disposable {\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n [Symbol.dispose](): void;\n}\n```\n\n### `DropZoneOptions`\n\n```ts\ninterface DropZoneOptions {\n element: HTMLElement;\n accept?: string[];\n maxFiles?: number;\n onValidate?: (files: File[], context: DropValidationContext) => boolean | Promise<boolean>;\n disabled?: boolean;\n dropEffect?: DataTransfer['dropEffect'];\n onDrop?: (files: File[]) => void;\n onDropRejected?: (files: File[]) => void;\n onHoverChange?: (hovered: boolean) => void;\n onValidatingChange?: (validating: boolean) => void;\n paste?: boolean;\n onPaste?: (files: File[]) => void;\n}\n```\n\n### `DropZone`\n\n```ts\ninterface DropZone extends Disposable {\n readonly hovered: boolean;\n readonly validating: boolean;\n}\n```\n\n### `DropValidationContext`\n\n```ts\ninterface DropValidationContext {\n readonly signal: AbortSignal;\n}\n```\n\n### `SortableOptions`\n\n```ts\ninterface SortableOptions {\n element: HTMLElement;\n getKey: (element: HTMLElement) => string;\n scope?: SortableScope;\n handle?: string;\n keyboard?: boolean;\n axis?: 'vertical' | 'horizontal';\n autoScroll?: boolean | AutoScrollOptions;\n dragImage?: HTMLElement | ((id: string, item: HTMLElement, event: DragEvent) => HTMLElement | null | undefined);\n dragImageOffset?: [number, number];\n placeholderClass?: string;\n disabled?: boolean;\n onDragStart?: (id: string, event: DragEvent) => void;\n onDragEnd?: (id: string, event: DragEvent) => void;\n onBeforeReorder?: (from: string[], to: string[]) => void;\n onReorder?: (event: ReorderEvent) => void;\n}\n```\n\n### `AutoScrollOptions`\n\n```ts\ninterface AutoScrollOptions {\n edgeThreshold?: number;\n speed?: number;\n container?: boolean;\n viewport?: boolean;\n}\n```\n\n### `ReorderEvent`\n\n```ts\ninterface ReorderEvent {\n ids: string[];\n setRevert(fn: () => void): void;\n}\n```\n\n### `Sortable`\n\n```ts\ninterface Sortable extends Disposable {\n readonly isDragging: boolean;\n revert(): void;\n sync(): void;\n}\n```\n\n### `SortableScope`\n\n```ts\ninterface SortableScope extends Disposable {\n readonly isDragging: boolean;\n revert(): void;\n}\n```\n\n### `SortableScopeOptions`\n\n```ts\ninterface SortableScopeOptions {\n onMove?: (event: SortableMoveEvent) => void;\n touch?: boolean | SortableTouchOptions;\n}\n```\n\n### `SortableMoveEvent`\n\n```ts\ninterface SortableMoveEvent {\n readonly itemId: string;\n readonly source: HTMLElement;\n readonly sourceIds: string[];\n readonly target: HTMLElement;\n readonly targetIds: string[];\n setRevert(fn: () => void): void;\n}\n```\n\n### `SortableTouchOptions`\n\n```ts\ninterface SortableTouchOptions {\n preview?: false | ((item: HTMLElement) => HTMLElement | null);\n}\n```\n\n`preview` returns a template that Dnd clones before mounting it as a transient touch preview, so returning an element from the sortable item does not reparent or remove caller-owned DOM. Return `false` to disable the preview.\n\n## `createDropZone()`\n\n```ts\ndeclare function createDropZone(options: DropZoneOptions): DropZone;\n```\n\nAttaches drag-and-drop file handling to a DOM element. Returns a `DropZone` handle.\n\n| Option | Type | Default | Description |\n| ---------------- | ------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `element` | `HTMLElement` | — | **Required.** The element to attach drag listeners to. |\n| `accept` | `string[]` | `[]` | Accepted file types. Empty array accepts everything. Each entry is a MIME type (`'image/png'`), MIME wildcard (`'image/*'`), or file extension (`'.pdf'`). |\n| `maxFiles` | `number` | — | Maximum files accepted per drop. Files beyond this limit are passed to `onDropRejected`. When omitted there is no limit. |\n| `onValidate` | `(files, { signal }) => boolean \\| Promise<boolean>` | — | Optional async gating step. Return or resolve `false` to reject all accepted files. `validating` remains true until every operation settles; `signal` aborts on disposal. |\n| `disabled` | `boolean` | — | When `true`, all drag and paste events are ignored. A disabled zone does not call `preventDefault` on `dragenter`, `dragover`, `drop`, or `paste`, so underlying elements (text editors, etc.) receive them normally. |\n| `dropEffect` | `'copy' \\| 'move' \\| 'link' \\| 'none'` | `'copy'` | The `dropEffect` set on `dataTransfer` during `dragover`. Controls the cursor indicator. |\n| `onDrop` | `(files: File[]) => void` | — | Called with accepted files only. Not called if all dropped files are rejected. Also receives paste events when `paste: true` and `onPaste` is omitted. |\n| `onDropRejected` | `(files: File[]) => void` | — | Called with files that did not match `accept`, exceeded `maxFiles`, or were rejected by `onValidate`. |\n| `onHoverChange` | `(hovered: boolean) => void` | — | Called when hover state toggles. Use this callback for drag-over styling. |\n| `onValidatingChange` | `(validating: boolean) => void` | — | Called whenever the aggregate async validation state changes. |\n| `paste` | `boolean` | `false` | When `true`, attaches a `paste` listener to `window`. Pasted files run through the same `accept`, `maxFiles`, and `onValidate` pipeline as dropped files. |\n| `onPaste` | `(files: File[]) => void` | — | Called when files are pasted from the clipboard. Falls back to `onDrop` when omitted. Only active when `paste: true`. |\n\n**Returns:** `DropZone`\n\nNotes:\n\n- Extension accept patterns are approximate during pre-check (`DataTransferItem` has no filename); exact filtering is applied at drop time.\n- Hover state (`hovered`) only becomes `true` when the dragged payload passes the `accept` filter. Drags carrying rejected file types enter and leave the zone without triggering `onHoverChange`.\n- Hover state is reset on element drop and also global `window` `drop`/`dragend` to avoid stuck hover state when drags leave the viewport.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*', '.pdf'],\n onDrop: (files) => {\n upload(files);\n },\n onDropRejected: (files) => {\n showError(`${files.length} rejected`);\n },\n onHoverChange: (hovered) => {\n dropEl.classList.toggle('drag-over', hovered);\n },\n});\n```\n\n## `DropZone` Interface\n\n### `zone.hovered`\n\n`readonly hovered: boolean`\n\n`true` when a drag is currently over the zone. Updated synchronously by the internal counter — safe to read at any time.\n\n### `zone.validating`\n\n`readonly validating: boolean`\n\n`true` while an `onValidate` promise is pending. Use this to render a loading indicator between file selection and the acceptance/rejection callbacks firing.\n\n```ts\nconsole.log(zone.validating); // true between drop and onValidate resolution\n```\n\n### `zone.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called. Safe to read at any time.\n\n### `zone.disposalSignal`\n\n`readonly disposalSignal: AbortSignal`\n\nAn `AbortSignal` that fires when `dispose()` is called. Use it to cancel in-flight requests tied to the zone's lifetime.\n\n### `zone.dispose()`\n\n`dispose(): void`\n\nRemoves all event listeners from the element, resets the drag counter and hover state, and clears the `hovered` flag. Idempotent — safe to call multiple times.\n\n```ts\nzone.dispose();\n```\n\n### `zone[Symbol.dispose]()`\n\n`[Symbol.dispose](): void`\n\nAlias for `dispose()`. Called automatically when used with the `using` keyword.\n\n```ts\n{\n using zone = createDropZone({ element: dropEl, onDrop: handleFiles });\n} // zone.dispose() runs here\n```\n\n## `createSortable()`\n\n```ts\ndeclare function createSortable(options: SortableOptions): Sortable;\n```\n\nMakes the direct children of a container element reorderable via drag. Returns a `Sortable` handle.\n\n`createSortable` adds drag and keyboard defaults only when callers have not already supplied semantics. Every changed attribute and inline style is restored to its prior value on disposal.\n\n- `element`: `HTMLElement`, required. The container whose children become sortable.\n- `getKey`: `(element: HTMLElement) => string`, required. Maps each item element to its stable string identity. Children for which `getKey` returns a falsy value are skipped.\n- `scope`: `SortableScope`, default private scope. Connects sortable lists explicitly; containers only exchange items when they share the same scope instance.\n- `handle`: `string`. CSS selector for a drag handle inside each item. When omitted, the whole item is draggable.\n- `keyboard`: `boolean`, default `true`. Enables keyboard reordering with arrow keys plus `Home` and `End`.\n- `axis`: `'vertical' | 'horizontal'`, default `'vertical'`. Controls midpoint calculation for placeholder insertion.\n- `autoScroll`: `boolean | AutoScrollOptions`, default `true`. Scrolls the container near its edges; enable viewport scrolling with `autoScroll.viewport`.\n- `dragImage`: `HTMLElement | ((id, item, event) => HTMLElement | null | undefined)`. Custom native drag preview passed to `dataTransfer.setDragImage()`. A `null` or `undefined` return skips `setDragImage` entirely.\n- `dragImageOffset`: `[number, number]`, default `[0, 0]`. The `[x, y]` hotspot offset passed to `setDragImage`. Controls which point of the preview image follows the cursor.\n- `placeholderClass`: `string`, default `'dnd-placeholder'`. CSS class applied to the generated placeholder element.\n- `disabled`: `boolean`. Blocks drag interactions. If a list becomes disabled mid-drag, Dnd cancels the drag and restores the original order.\n- `onDragStart`: `(id: string, event: DragEvent) => void`. Called when a drag starts.\n- `onDragEnd`: `(id: string, event: DragEvent) => void`. Called when a drag ends, whether completed or cancelled.\n- `onBeforeReorder`: `(from: string[], to: string[]) => void`. Called with the before/after order snapshots just before a successful reorder commits — for both drag and keyboard. Items are still in their pre-commit positions at the time of the call, making it ideal for [`captureLayout()`](/necromancer/api.md#capturelayout) setup.\n- `onReorder`: `(event: ReorderEvent) => void`. Called after a successful reorder (drag or keyboard), only when the order changed. Use `event.setRevert(fn)` to register a revert function that `sortable.revert()` will invoke.\n\n**Returns:** `Sortable`\n\n```ts\nconst boardScope = createSortableScope({\n onMove: ({ itemId, sourceIds, targetIds }) => saveMove(itemId, sourceIds, targetIds),\n touch: true,\n});\n\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.id!,\n handle: '.drag-handle',\n onDragStart: (id) => {\n listEl.classList.add('sorting');\n },\n onDragEnd: (id) => {\n listEl.classList.remove('sorting');\n },\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n saveOrder(ids);\n setRevert(() => saveOrder(prev));\n },\n scope: boardScope,\n});\n```\n\n### `createSortableScope()`\n\n```ts\ndeclare function createSortableScope(options?: SortableScopeOptions): SortableScope;\n```\n\nUse one scope per connected set of containers. `onMove` fires once for cross-list moves with both final orders; local reorders continue to call the sortable's `onReorder`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options` | `SortableScopeOptions` | Optional cross-list move callback and scope-owned touch configuration |\n\n**Returns:** `SortableScope`.\n\n```ts\nimport { createSortableScope } from '@vielzeug/dnd';\n\nconst scope = createSortableScope({\n onMove: ({ itemId, sourceIds, targetIds }) => {\n persistMove(itemId, sourceIds, targetIds);\n },\n touch: true,\n});\n```\n\n## `Sortable` Interface\n\n### `sortable.isDragging`\n\n`readonly isDragging: boolean`\n\n`true` while an item drag is in progress.\n\n### `sortable.revert()`\n\n`revert(): void`\n\nCalls the revert function registered via `setRevert` in the last `onReorder` invocation (if any) and clears it. A no-op when no revert function was registered or it has already been consumed. Works for both drag-based and keyboard-based reorders.\n\nOnly the most recent reorder can be reverted — a new reorder overwrites the stored function.\n\n```ts\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n setOrder(ids);\n setRevert(() => setOrder(prev)); // ← enable revert\n },\n});\n\n// On server error:\ntry {\n await api.saveOrder(ids);\n} catch {\n sortable.revert();\n}\n```\n\n### `sortable.sync()`\n\n`sync(): void`\n\nRe-applies `draggable`, `role`, and handle attributes after DOM mutations. Call it after adding, removing, or replacing sortable children.\n\n### `sortable.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called.\n\n### `sortable.disposalSignal`\n\n`readonly disposalSignal: AbortSignal`\n\nAn `AbortSignal` that fires when `dispose()` is called.\n\n### `sortable.dispose()`\n\n`dispose(): void`\n\nRemoves all event listeners from the container, strips sortable attributes from items and handles, and cancels any in-progress drag by restoring the original order. Idempotent — safe to call multiple times.\n\n### `sortable[Symbol.dispose]()`\n\n`[Symbol.dispose](): void`\n\nAlias for `dispose()`.\n\n## `SortableScope` Interface\n\n### `scope.isDragging`\n\n`readonly isDragging: boolean`\n\n`true` while any sortable registered to the scope is dragging.\n\n### `scope.revert()`\n\n`revert(): void`\n\nCalls and clears the rollback registered with `SortableMoveEvent.setRevert()` for the latest cross-list move. It is a no-op when no rollback is registered.\n\n### `scope.dispose()`\n\n`dispose(): void`\n\nDisposes scope-owned touch input and prevents registered lists from participating in future connected moves.\n\n## DOM Attributes\n\nDnd reads and writes the following DOM attributes:\n\n- `data-dnd-item`: internal marker applied by `createSortable` to children that return a truthy key from `getKey`. Restored on `dispose()`.\n- `draggable`, roles, tabindex, and `touchAction`: managed only as needed and restored to their exact prior values on `dispose()`.\n- `data-dragging`: set during drag, removed on `dragend` or `dispose()`. Use it as your styling hook for drag state.\n- `data-dnd-handle`: internal marker set by `createSortable` and `sortable.sync()`, removed by `dispose()`. Lets Dnd clean up only the handle attributes it applied.\n- `aria-hidden=\"true\"`: set on placeholder creation and removed with the placeholder. Applied to the `.dnd-placeholder` element.\n- `style.touchAction = 'none'` (inline style): set by `createSortable` and `sortable.sync()` on the item (or the handle, when `handle` is set), then restored on `dispose()`.\n\n## CSS Classes\n\n| Class | Applied to | When |\n| ----------------- | ---------------------------- | ------------------------------------------------------------- |\n| `dnd-placeholder` | `<div>` inserted by sortable | While an item is being dragged, in the placeholder's position |\n\n## `matchesAccept()`\n\n```ts\ndeclare function matchesAccept(file: File, accept: string[]): boolean;\n```\n\nTests whether a `File` matches an accept pattern list. Each pattern can be:\n\n- A MIME type: `'image/png'`\n- A MIME wildcard: `'image/*'`\n- A file extension: `'.pdf'`\n\nAn empty list accepts everything. Extension matching is case-insensitive.\n\n**Returns:** `true` when the file matches at least one pattern, or when `accept` is empty.\n\n```ts\nimport { matchesAccept } from '@vielzeug/dnd';\n\nmatchesAccept(file, ['image/*', '.pdf']); // true or false\n```\n\n## `applyReorder()`\n\n```ts\ndeclare function applyReorder<T>(items: T[], ids: string[], getKey: (item: T) => string): T[];\n```\n\nApplies a DOM reorder result (`orderedIds`) to your backing array.\n\n- IDs missing from `items` are ignored.\n- Items not listed in `ids` are appended in original order.\n- Duplicate IDs in `ids` — first occurrence wins, later occurrences are ignored.\n\n**Returns:** A new array ordered by `ids`, with omitted items appended in their original order.\n\n```ts\nconst next = applyReorder(items, orderedIds, (item) => item.id);\n```\n\n## Errors\n\n| Error | Trigger | Notable property |\n| --- | --- | --- |\n| `DndError` | Base class for package errors | `DndError.is(error)` |\n| `DndScopeError` | A sortable receives a scope not created by `createSortableScope()` | — |\n",
|
|
6
|
+
"usage": "---\ntitle: Dnd — Usage Guide\ndescription: Drop zones, sortable lists, explicit connected scopes, keyboard sorting, and cleanup patterns with Dnd.\n---\n\n[[toc]]\n\n## Basic Usage\n\n`createDropZone` attaches drag-and-drop behavior to any DOM element and keeps hover state stable with a counter.\n\n```ts\nimport { createDropZone } from '@vielzeug/dnd';\n\nconst dropzone = document.getElementById('dropzone')!;\n\nconst zone = createDropZone({\n element: dropzone,\n onDrop: (files) => {\n console.log('Accepted files:', files);\n },\n});\n```\n\n### Accept filtering\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*', '.pdf', 'application/json'],\n onDrop: (files) => {\n // accepted files only\n },\n onDropRejected: (files) => {\n showToast(`${files.length} file(s) not accepted`);\n },\n});\n```\n\nThe `accept` list is read at drop-time, so mutating the array dynamically adjusts what is accepted for the next drop.\n\n### Hover state\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n onHoverChange: (hovered) => {\n dropEl.classList.toggle('drag-over', hovered);\n },\n});\n```\n\nRead zone state imperatively:\n\n```ts\nconsole.log(zone.hovered);\nconsole.log(zone.validating);\n```\n\n### Drop effect\n\n```ts\ncreateDropZone({\n element: dropEl,\n dropEffect: 'move',\n onDrop: (files) => {\n // ...\n },\n});\n```\n\n### Disabled state\n\n```ts\nconst options = { disabled: false, element: dropEl, onDrop: handleFiles };\nconst zone = createDropZone(options);\n\n// options.disabled is read live on each event — mutate to toggle:\noptions.disabled = isReadOnly;\n```\n\n### File limit\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*'],\n maxFiles: 5,\n onDrop: (files) => {\n // 1-5 accepted files\n },\n onDropRejected: (files) => {\n showToast(`Only 5 files at a time. ${files.length} were ignored.`);\n },\n});\n```\n\n### Cleanup\n\n```ts\nzone.dispose();\n// or:\nusing zone = createDropZone({ element: dropEl, onDrop: handleFiles });\n```\n\n### Async validation\n\nGate drops behind an async check with `onValidate`. The zone remains `validating: true` until every pending validation settles, and disposal aborts each validation signal.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*'],\n onValidate: async (files, { signal }) => {\n const ok = await checkServerQuota(files, { signal });\n return ok; // false → all files forwarded to onDropRejected\n },\n onDrop: (files) => uploadFiles(files),\n onDropRejected: (files) => showError('Quota exceeded'),\n});\n\n// show a spinner while checking\nconsole.log(zone.validating); // true during pending check\n```\n\nA synchronous boolean return skips the microtask queue entirely:\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n onValidate: (files) => files.every((f) => f.size < 5_000_000), // sync\n onDrop: handleFiles,\n});\n```\n\n### Clipboard paste\n\nSet `paste: true` to accept files pasted from the clipboard. The same `accept`, `maxFiles`, and `onValidate` pipeline applies.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n paste: true,\n accept: ['image/*'],\n onPaste: (files) => {\n uploadFiles(files);\n },\n onDropRejected: (files) => {\n showError(`${files.length} file(s) not accepted`);\n },\n});\n```\n\nWhen `onPaste` is omitted, accepted pasted files fall through to `onDrop`.\n\n## Sortable\n\n`createSortable` makes direct children of a container reorderable via drag.\n\n### Setup\n\n```html\n<ul id=\"task-list\">\n <li data-sort-id=\"task-1\">Design</li>\n <li data-sort-id=\"task-2\">Develop</li>\n <li data-sort-id=\"task-3\">Review</li>\n</ul>\n```\n\n```ts\nconst sortable = createSortable({\n element: document.getElementById('task-list')!,\n getKey: (el) => el.dataset.sortId!,\n axis: 'vertical',\n onReorder: ({ ids }) => {\n saveTaskOrder(ids);\n },\n});\n```\n\nDnd automatically sets:\n\n- `draggable=\"true\"` on sortable nodes (or handles)\n- `role=\"listitem\"` on each item\n- `role=\"list\"` on the container\n- `tabindex=\"0\"` on each item for keyboard reordering\n\n### Drag handles\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n handle: '.drag-handle',\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Keyboard reordering\n\nFocus an item and use arrow keys to move it. `Home` and `End` move to the boundary positions.\n\nWhen an item is already at the first or last position, the boundary key press is not consumed — the browser handles it normally (for example, scrolling the page). Only keys that actually move an item call `preventDefault`.\n\n### Connected lists\n\nCreate a shared scope when items should move between containers:\n\n```ts\nconst boardScope = createSortableScope({\n onMove: ({ itemId, sourceIds, targetIds }) => {\n persistMove(itemId, sourceIds, targetIds);\n },\n touch: true,\n});\n\ncreateSortable({\n element: todoEl,\n getKey: (el) => el.dataset.sortId!,\n scope: boardScope,\n});\ncreateSortable({\n element: doneEl,\n getKey: (el) => el.dataset.sortId!,\n scope: boardScope,\n});\n```\n\n### Auto-scroll and drag preview\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n autoScroll: { edgeThreshold: 40, speed: 24, viewport: true },\n dragImage: (id, item) => item,\n dragImageOffset: [8, 8],\n});\n```\n\nViewport scrolling is opt-in. Container scrolling stays enabled by default.\n\n### Lifecycle hooks\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onDragStart: (id) => {\n listEl.classList.add('sorting');\n },\n onDragEnd: (id) => {\n listEl.classList.remove('sorting');\n },\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Custom identity function\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.getAttribute('data-id')!,\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Dynamic lists\n\nCall `sortable.sync()` after adding, removing, or replacing sortable items.\n\n```ts\nconst item = document.createElement('li');\nitem.dataset.sortId = 'task-4';\nitem.textContent = 'Deploy';\nlistEl.appendChild(item);\nsortable.sync();\n```\n\n### Disabled state\n\n```ts\nimport { createSortable, type SortableOptions } from '@vielzeug/dnd';\n\nconst options: SortableOptions = {\n disabled: false,\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => saveOrder(ids),\n};\nconst sortable = createSortable(options);\n\n// options.disabled is read live on each event — mutate to toggle:\noptions.disabled = isLocked;\n```\n\n### Placeholder styling\n\n```css\n.dnd-placeholder {\n background: var(--color-primary-50);\n border: 2px dashed var(--color-primary-300);\n border-radius: 4px;\n box-sizing: border-box;\n}\n\n[data-dragging] {\n opacity: 0.35;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n}\n```\n\n### Mapping DOM order back to data\n\n```ts\nimport { applyReorder, createSortable } from '@vielzeug/dnd';\n\nlet items = [\n { id: 'task-1', title: 'Design' },\n { id: 'task-2', title: 'Develop' },\n { id: 'task-3', title: 'Review' },\n];\n\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => {\n items = applyReorder(items, ids, (item) => item.id);\n },\n});\n```\n\n### Cleanup\n\n```ts\nsortable.dispose();\n// or:\nusing sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### FLIP animation hook\n\n`onBeforeReorder` fires just before the DOM reorder commits, for both drag and keyboard moves. Pair it with [`captureLayout()`](/necromancer/api.md#capturelayout) to animate the resulting layout without managing rectangles, transforms, or animation frames yourself.\n\n```ts\nimport { captureLayout, type LayoutTransition } from '@vielzeug/necromancer';\n\nlet layout: LayoutTransition | undefined;\n\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onBeforeReorder: () => {\n layout = captureLayout(listEl.querySelectorAll('[data-sort-id]'), {\n getKey: (el) => el.dataset.sortId!,\n });\n },\n onReorder: ({ ids }) => {\n saveOrder(ids); // Commit a framework render here when needed.\n layout?.animate({\n duration: 200,\n easing: 'ease-out',\n elements: listEl.querySelectorAll('[data-sort-id]'),\n });\n layout = undefined;\n },\n});\n```\n\nIf `saveOrder()` triggers a render that replaces list items, call `layout?.animate({ elements: committedItems })` after that render commits. When DnD's own reordered elements remain in the DOM, call `layout?.animate()` directly. DnD stays dependency-free: the application chooses to install and import Necromancer when it wants this integration.\n\n### Optimistic updates and revert\n\nCall `sortable.revert()` to roll back the most recent reorder. Register a revert function via `setRevert` inside `onReorder`.\n\n```ts\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n setOrder(ids); // optimistic update\n setRevert(() => setOrder(prev)); // registered for sortable.revert()\n },\n});\n\n// On server error:\ntry {\n await api.saveOrder(currentOrder);\n} catch {\n sortable.revert();\n}\n```\n\n## Touch Support\n\nHTML5 drag-and-drop has no native touch story. Enable touch on a sortable scope; it only recognizes items registered to that scope, never unrelated `draggable` elements.\n\n```ts\nimport { createSortable, createSortableScope } from '@vielzeug/dnd';\n\nusing scope = createSortableScope({ touch: true });\nusing sortable = createSortable({ element: listEl, getKey: (el) => el.dataset.id!, scope });\n```\n\n### Touch preview\n\nTouch uses an inert outline by default, avoiding cloned application DOM. Provide a preview factory or opt out when your item styling supplies its own feedback.\n\n```ts\nconst scope = createSortableScope({\n touch: {\n // The returned element is cloned before Dnd mounts it as a transient preview.\n preview: (item) => item.querySelector<HTMLElement>('.drag-preview'),\n },\n});\n```\n\n### Why draggable items get `touch-action: none`\n\n`createSortable` sets `touch-action: none` on every element it marks as draggable (the item itself, or the handle when `handle` is set). This prevents a mobile browser from treating the initial movement as page scrolling before the scope controller can start the drag.\n\nThis has no effect on mouse/pointer input.\n\n## Testing\n\nTest observable callbacks and controller state with your DOM test runner. Construct the zone in each test, dispatch a real `drop` event, then dispose it during teardown.\n\n```ts\nimport { afterEach, expect, it, vi } from 'vitest';\nimport { createDropZone } from '@vielzeug/dnd';\n\nconst zones: Array<{ dispose(): void }> = [];\n\nafterEach(() => zones.splice(0).forEach((zone) => zone.dispose()));\n\nit('forwards accepted files', async () => {\n const element = document.createElement('div');\n const onDrop = vi.fn();\n const zone = createDropZone({ element, onDrop });\n zones.push(zone);\n const file = new File(['content'], 'readme.txt', { type: 'text/plain' });\n const event = new Event('drop') as DragEvent;\n\n Object.defineProperty(event, 'dataTransfer', { value: { files: [file] } });\n element.dispatchEvent(event);\n\n await Promise.resolve();\n\n expect(onDrop).toHaveBeenCalledWith([file]);\n expect(zone.disposed).toBe(false);\n});\n```\n\n## Framework Integration\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useRef } from 'react';\nimport { createSortable, applyReorder } from '@vielzeug/dnd';\n\nfunction SortableList({ initialItems }: { initialItems: { id: string; text: string }[] }) {\n const listRef = useRef<HTMLUListElement>(null);\n const items = useRef(initialItems);\n\n useEffect(() => {\n const sortable = createSortable({\n element: listRef.current!,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => {\n items.current = applyReorder(items.current, ids, (i) => i.id);\n },\n });\n return () => sortable.dispose();\n }, []);\n\n return (\n <ul ref={listRef}>\n {initialItems.map((item) => (\n <li key={item.id} data-sort-id={item.id}>\n {item.text}\n </li>\n ))}\n </ul>\n );\n}\n```\n\n```ts [Vue 3]\nimport { ref, onMounted, onUnmounted } from 'vue';\nimport { createSortable, applyReorder, type Sortable } from '@vielzeug/dnd';\n\nfunction useSortable(items: { id: string; text: string }[]) {\n const listRef = ref<HTMLElement | null>(null);\n const orderedItems = ref(items);\n let sortable: Sortable | null = null;\n\n onMounted(() => {\n sortable = createSortable({\n element: listRef.value!,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => {\n orderedItems.value = applyReorder(orderedItems.value, ids, (i) => i.id);\n },\n });\n });\n\n onUnmounted(() => sortable?.dispose());\n return { listRef, orderedItems };\n}\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { onMount } from 'svelte';\n import { createSortable, applyReorder } from '@vielzeug/dnd';\n\n export let initialItems: { id: string; text: string }[] = [];\n let items = initialItems;\n let listEl: HTMLUListElement;\n\n onMount(() => {\n const sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => { items = applyReorder(items, ids, (i) => i.id); },\n });\n return () => sortable.dispose();\n });\n</script>\n\n<ul bind:this={listEl}>\n {#each items as item (item.id)}\n <li data-sort-id={item.id}>{item.text}</li>\n {/each}\n</ul>\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### With Ore\n\nUse Dnd in custom web components by attaching behavior in component lifecycle hooks.\n\n```ts\nimport { createSortable } from '@vielzeug/dnd';\nimport { define, getHost, html, onMounted } from '@vielzeug/ore';\n\ndefine('task-list', {\n setup(_props) {\n const el = getHost();\n\n onMounted(() => {\n const sortable = createSortable({\n element: el,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => save(ids),\n });\n return () => sortable.dispose();\n });\n\n return html`<slot></slot>`;\n },\n});\n```\n\n## Best Practices\n\n- Attach `createDropZone` and `createSortable` after the container element is in the DOM — use `onMounted` in component frameworks.\n- Call `.dispose()` in the cleanup phase of your framework (useEffect return, onUnmounted, onDestroy) to prevent memory leaks.\n- Use `data-sort-id` attributes that match your data's identity field — do not use DOM index as an identifier.\n- Prefer `applyReorder()` over manual array splicing to keep your data array in sync with DOM order.\n- Use `createSortableScope()` only when items should genuinely move between containers.\n- Use drag handles (`.handle` selector) when the full item surface area conflicts with other interactions such as text selection.\n- Test keyboard reordering explicitly — Dnd sets `tabindex` on items and supports arrow keys by default.\n- Enable `touch: true` only on scopes that own touch-sortable lists.\n",
|
|
7
7
|
"examples": "---\ntitle: Dnd — Examples\ndescription: Practical examples and recipes for dnd.\n---\n\n## Examples\n\n- [Sortable List](./examples/sortable-list.md)\n- [Touch-Enabled Sortable List](./examples/touch-enabled-sortable-list.md)\n- [File Upload Drop Zone](./examples/file-upload-drop-zone.md)\n- [Optimistic Reorder with Revert and FLIP Animation](./examples/optimistic-reorder-with-revert.md)\n- [Combined Sortable With Inline Editing](./examples/combined-sortable-with-inline-editing.md)\n- [Connected Kanban Keyboard Sorting](./examples/connected-kanban-keyboard-sorting.md)\n- [Web Component With Ore](./examples/web-component-with-craft.md)\n- [Using `using` for scoped cleanup](./examples/using-using-for-scoped-cleanup.md)\n"
|
|
8
8
|
},
|
|
9
9
|
"examples": [
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
"id": "sortable-connected",
|
|
37
|
-
"code": "import { applyReorder, createSortable, createSortableScope } from '@vielzeug/dnd'\n\nconst scope = createSortableScope()\n\nconst wrapper = document.createElement('div')\nwrapper.style.cssText = 'display:flex;gap:24px;align-items:flex-start;'\ndocument.body.appendChild(wrapper)\n\nlet todoItems = [\n { id: 'task-a', title: 'Design' },\n { id: 'task-b', title: 'Develop' },\n { id: 'task-c', title: 'Review' },\n]\nlet doneItems = [\n { id: 'task-d', title: 'Planning' },\n]\n\nconst itemStyle = 'padding:8px 12px;background:#fff;border:1px solid #e5e7eb;border-radius:6px;cursor:grab;font-size:14px;'\nconst listStyle = 'list-style:none;padding:8px;margin:0;min-height:48px;width:160px;background:#f9fafb;border:2px dashed #d1d5db;border-radius:8px;display:flex;flex-direction:column;gap:6px;'\n\nfunction makeColumn(label) {\n const col = document.createElement('div')\n col.style.cssText = 'display:flex;flex-direction:column;gap:8px;'\n const heading = document.createElement('strong')\n heading.style.cssText = 'font-size:13px;color:#374151;'\n heading.textContent = label\n const ul = document.createElement('ul')\n ul.style.cssText = listStyle\n col.append(heading, ul)\n wrapper.appendChild(col)\n return ul\n}\n\nconst todoEl = makeColumn('To Do')\nconst doneEl = makeColumn('Done')\n\nfunction renderList(ul, items) {\n ul.innerHTML = ''\n items.forEach(item => {\n const li = document.createElement('li')\n li.dataset.id = item.id\n li.style.cssText = itemStyle\n li.textContent = item.title\n ul.appendChild(li)\n })\n}\n\nrenderList(todoEl, todoItems)\nrenderList(doneEl, doneItems)\n\nconst getKey = (el) => el.dataset.id ?? ''\n\nconst todoSortable = createSortable({\n element: todoEl,\n getKey,\n scope,\n
|
|
37
|
+
"code": "import { applyReorder, createSortable, createSortableScope } from '@vielzeug/dnd'\n\nconst scope = createSortableScope({\n onMove: ({ source, sourceIds, target, targetIds }) => {\n if (source === todoEl) todoItems = applyReorder(todoItems, sourceIds, i => i.id)\n if (target === todoEl) todoItems = applyReorder(todoItems, targetIds, i => i.id)\n if (source === doneEl) doneItems = applyReorder(doneItems, sourceIds, i => i.id)\n if (target === doneEl) doneItems = applyReorder(doneItems, targetIds, i => i.id)\n console.log('Moved item between lists')\n },\n})\n\nconst wrapper = document.createElement('div')\nwrapper.style.cssText = 'display:flex;gap:24px;align-items:flex-start;'\ndocument.body.appendChild(wrapper)\n\nlet todoItems = [\n { id: 'task-a', title: 'Design' },\n { id: 'task-b', title: 'Develop' },\n { id: 'task-c', title: 'Review' },\n]\nlet doneItems = [\n { id: 'task-d', title: 'Planning' },\n]\n\nconst itemStyle = 'padding:8px 12px;background:#fff;border:1px solid #e5e7eb;border-radius:6px;cursor:grab;font-size:14px;'\nconst listStyle = 'list-style:none;padding:8px;margin:0;min-height:48px;width:160px;background:#f9fafb;border:2px dashed #d1d5db;border-radius:8px;display:flex;flex-direction:column;gap:6px;'\n\nfunction makeColumn(label) {\n const col = document.createElement('div')\n col.style.cssText = 'display:flex;flex-direction:column;gap:8px;'\n const heading = document.createElement('strong')\n heading.style.cssText = 'font-size:13px;color:#374151;'\n heading.textContent = label\n const ul = document.createElement('ul')\n ul.style.cssText = listStyle\n col.append(heading, ul)\n wrapper.appendChild(col)\n return ul\n}\n\nconst todoEl = makeColumn('To Do')\nconst doneEl = makeColumn('Done')\n\nfunction renderList(ul, items) {\n ul.innerHTML = ''\n items.forEach(item => {\n const li = document.createElement('li')\n li.dataset.id = item.id\n li.style.cssText = itemStyle\n li.textContent = item.title\n ul.appendChild(li)\n })\n}\n\nrenderList(todoEl, todoItems)\nrenderList(doneEl, doneItems)\n\nconst getKey = (el) => el.dataset.id ?? ''\n\nconst todoSortable = createSortable({\n element: todoEl,\n getKey,\n scope,\n})\n\nconst doneSortable = createSortable({\n element: doneEl,\n getKey,\n scope,\n})\n\nconsole.log('Connected lists ready — drag items between columns')\nconsole.log('Scope is shared:', typeof scope)",
|
|
38
38
|
"name": "createSortableScope - Connected Lists"
|
|
39
39
|
},
|
|
40
40
|
{
|
|
@@ -57,19 +57,21 @@
|
|
|
57
57
|
"DndError": "export { DndError, DndScopeError } from './errors';",
|
|
58
58
|
"DndScopeError": "export { DndError, DndScopeError } from './errors';",
|
|
59
59
|
"matchesAccept": "export function matchesAccept(file: File, accept: string[]): boolean {\n if (!accept.length) return true;\n\n return accept.some((pattern) => {\n const p = pattern.trim();\n\n if (p.startsWith('.')) return file.name.toLowerCase().endsWith(p.toLowerCase());\n\n if (p.endsWith('/*')) return file.type.startsWith(p.slice(0, -1));\n\n return file.type === p;\n });\n}",
|
|
60
|
-
"DropZoneOptions": "export interface DropZoneOptions {\n /** The element to attach drag listeners to. */\n element: HTMLElement;\n /**\n * Accepted file types. Each entry may be:\n * - A MIME type: 'image/png'\n * - A MIME wildcard: 'image/*'\n * - A file extension: '.pdf'\n *\n * When empty the zone accepts everything.\n */\n accept?: string[];\n /**\n * Maximum number of files accepted per drop. Files beyond this limit are\n * treated as rejected and forwarded to `onDropRejected`.\n *\n * When omitted there is no limit.\n */\n maxFiles?: number;\n /**\n * Optional async file gating. Called after type/extension filtering, before `onDrop`.\n * Return (or resolve) `false` to move all type-accepted files to `onDropRejected`.\n *\n * Only receives type-accepted files (after `accept` and `maxFiles` filtering).\n * Files already rejected by the `accept` filter are forwarded to `onDropRejected`\n * unconditionally and are not passed to this function.\n *\n * While validation is in progress `zone.validating` is `true` and `onValidatingChange`\n * is called with `true`.\n *\n * @example\n * ```ts\n * onValidate: async (files) => {\n * const ok = await checkServerQuota(files);\n * return ok;\n * }\n * ```\n */\n onValidate?: (files: File[]) => boolean | Promise<boolean>;\n /**\n * When `true`, all drag events are ignored and hover state does not change.\n *\n * Note: a disabled zone does not call `preventDefault` on drag or paste events,\n * so underlying elements (such as text editors) will still receive them.\n */\n disabled?: boolean;\n /**\n * The `dropEffect` to set on `dataTransfer` during `dragover`.\n * @default 'copy'\n */\n dropEffect?: DataTransfer['dropEffect'];\n /** Called when files are dropped or pasted (when `paste: true` and `onPaste` is omitted). Receives accepted files only. */\n onDrop?: (files: File[]) => void;\n /**\n * Called when dropped or pasted files are rejected by the `accept` filter, `maxFiles` limit, or `onValidate`.\n */\n onDropRejected?: (files: File[]) => void;\n /**\n * Called whenever hover state toggles.\n * Use this for drag-over styling.\n */\n onHoverChange?: (hovered: boolean) => void;\n /**\n * Called whenever the async validation state changes.\n * Use this to drive loading spinners.\n *\n * @example\n * ```ts\n * onValidatingChange: (v) => { spinnerEl.hidden = !v; }\n * ```\n */\n onValidatingChange?: (validating: boolean) => void;\n /**\n * When `true`, a `paste` event listener is added to `window`. Pasted files run\n * through the same `accept`, `maxFiles`, and `onValidate` pipeline as dropped files.\n * @default false\n */\n paste?: boolean;\n /**\n * Called when files are pasted via the clipboard. Falls back to `onDrop` when omitted.\n * Only active when `paste: true`.\n */\n onPaste?: (files: File[]) => void;\n}",
|
|
60
|
+
"DropZoneOptions": "export interface DropZoneOptions {\n /** The element to attach drag listeners to. */\n element: HTMLElement;\n /**\n * Accepted file types. Each entry may be:\n * - A MIME type: 'image/png'\n * - A MIME wildcard: 'image/*'\n * - A file extension: '.pdf'\n *\n * When empty the zone accepts everything.\n */\n accept?: string[];\n /**\n * Maximum number of files accepted per drop. Files beyond this limit are\n * treated as rejected and forwarded to `onDropRejected`.\n *\n * When omitted there is no limit.\n */\n maxFiles?: number;\n /**\n * Optional async file gating. Called after type/extension filtering, before `onDrop`.\n * Return (or resolve) `false` to move all type-accepted files to `onDropRejected`.\n *\n * Only receives type-accepted files (after `accept` and `maxFiles` filtering).\n * Files already rejected by the `accept` filter are forwarded to `onDropRejected`\n * unconditionally and are not passed to this function.\n *\n * While validation is in progress `zone.validating` is `true` and `onValidatingChange`\n * is called with `true`.\n *\n * @example\n * ```ts\n * onValidate: async (files, { signal }) => {\n * const ok = await checkServerQuota(files, { signal });\n * return ok;\n * }\n * ```\n */\n onValidate?: (files: File[], context: DropValidationContext) => boolean | Promise<boolean>;\n /**\n * When `true`, all drag events are ignored and hover state does not change.\n *\n * Note: a disabled zone does not call `preventDefault` on drag or paste events,\n * so underlying elements (such as text editors) will still receive them.\n */\n disabled?: boolean;\n /**\n * The `dropEffect` to set on `dataTransfer` during `dragover`.\n * @default 'copy'\n */\n dropEffect?: DataTransfer['dropEffect'];\n /** Called when files are dropped or pasted (when `paste: true` and `onPaste` is omitted). Receives accepted files only. */\n onDrop?: (files: File[]) => void;\n /**\n * Called when dropped or pasted files are rejected by the `accept` filter, `maxFiles` limit, or `onValidate`.\n */\n onDropRejected?: (files: File[]) => void;\n /**\n * Called whenever hover state toggles.\n * Use this for drag-over styling.\n */\n onHoverChange?: (hovered: boolean) => void;\n /**\n * Called whenever the async validation state changes.\n * Use this to drive loading spinners.\n *\n * @example\n * ```ts\n * onValidatingChange: (v) => { spinnerEl.hidden = !v; }\n * ```\n */\n onValidatingChange?: (validating: boolean) => void;\n /**\n * When `true`, a `paste` event listener is added to `window`. Pasted files run\n * through the same `accept`, `maxFiles`, and `onValidate` pipeline as dropped files.\n * @default false\n */\n paste?: boolean;\n /**\n * Called when files are pasted via the clipboard. Falls back to `onDrop` when omitted.\n * Only active when `paste: true`.\n */\n onPaste?: (files: File[]) => void;\n}",
|
|
61
|
+
"DropValidationContext": "export interface DropValidationContext {\n /** Aborts when the zone is disposed. Pass this to validation requests. */\n readonly signal: AbortSignal;\n}",
|
|
61
62
|
"DropZone": "export interface DropZone extends Disposable {\n /** Whether the pointer is currently dragging over the zone. */\n readonly hovered: boolean;\n /** `true` while an `onValidate` promise is pending. */\n readonly validating: boolean;\n}",
|
|
62
|
-
"createDropZone": "export function createDropZone(options: DropZoneOptions): DropZone {\n const {\n accept = [],\n dropEffect = 'copy',\n element,\n maxFiles,\n onDrop,\n onDropRejected,\n onHoverChange,\n onValidatingChange,\n } = options;\n\n let dragCounter = 0;\n // Whether the *current* drag's payload passes the accept filter.\n // Determined on the first dragenter and held for the duration of the drag.\n let dragAccepted = false;\n let validating = false;\n\n const setValidating = (next: boolean): void => {\n validating = next;\n onValidatingChange?.(next);\n };\n\n const updateCounter = (next: number): void => {\n const wasHovered = dragCounter > 0 && dragAccepted;\n\n dragCounter = Math.max(0, next);\n\n // Reset acceptance state when the drag fully leaves so the next drag starts clean.\n if (dragCounter === 0) dragAccepted = false;\n\n const hovered = dragCounter > 0 && dragAccepted;\n\n if (hovered !== wasHovered) onHoverChange?.(hovered);\n };\n\n const resetCounter = (): void => {\n updateCounter(0);\n };\n\n const disposable = createDisposable(resetCounter);\n\n // Settle the final accepted/rejected split and fire callbacks.\n const settle = (acceptedFiles: File[], rejectedFiles: File[]): void => {\n if (acceptedFiles.length > 0) onDrop?.(acceptedFiles);\n\n if (rejectedFiles.length > 0) onDropRejected?.(rejectedFiles);\n };\n\n // Settle for paste events (which may use onPaste instead of onDrop).\n const settleForPaste = (acceptedFiles: File[], rejectedFiles: File[]): void => {\n if (acceptedFiles.length > 0) {\n if (options.onPaste) {\n options.onPaste(acceptedFiles);\n } else {\n onDrop?.(acceptedFiles);\n }\n }\n\n if (rejectedFiles.length > 0) onDropRejected?.(rejectedFiles);\n };\n\n // Run accept/maxFiles filter, then async onValidate, then settle.\n const dispatchWithValidation = (rawFiles: File[], settleFn: (accepted: File[], rejected: File[]) => void): void => {\n const { accepted, rejected: rej } = applyFileFilters(rawFiles, accept, maxFiles);\n\n if (
|
|
63
|
-
"SortableScope": "export interface SortableScope extends Disposable {\n /** `true` while any sortable in this scope is actively dragging. */\n readonly isDragging: boolean;\n readonly [SCOPE_BRAND]: true;\n}",
|
|
63
|
+
"createDropZone": "export function createDropZone(options: DropZoneOptions): DropZone {\n const {\n accept = [],\n dropEffect = 'copy',\n element,\n maxFiles,\n onDrop,\n onDropRejected,\n onHoverChange,\n onValidatingChange,\n } = options;\n\n let dragCounter = 0;\n // Whether the *current* drag's payload passes the accept filter.\n // Determined on the first dragenter and held for the duration of the drag.\n let dragAccepted = false;\n let validating = false;\n const validationControllers = new Set<AbortController>();\n\n const setValidating = (next: boolean): void => {\n if (validating === next) return;\n\n validating = next;\n onValidatingChange?.(next);\n };\n\n const updateCounter = (next: number): void => {\n const wasHovered = dragCounter > 0 && dragAccepted;\n\n dragCounter = Math.max(0, next);\n\n // Reset acceptance state when the drag fully leaves so the next drag starts clean.\n if (dragCounter === 0) dragAccepted = false;\n\n const hovered = dragCounter > 0 && dragAccepted;\n\n if (hovered !== wasHovered) onHoverChange?.(hovered);\n };\n\n const resetCounter = (): void => {\n updateCounter(0);\n };\n\n const disposable = createDisposable(() => {\n for (const controller of validationControllers) controller.abort();\n\n validationControllers.clear();\n resetCounter();\n });\n\n // Settle the final accepted/rejected split and fire callbacks.\n const settle = (acceptedFiles: File[], rejectedFiles: File[]): void => {\n if (acceptedFiles.length > 0) onDrop?.(acceptedFiles);\n\n if (rejectedFiles.length > 0) onDropRejected?.(rejectedFiles);\n };\n\n // Settle for paste events (which may use onPaste instead of onDrop).\n const settleForPaste = (acceptedFiles: File[], rejectedFiles: File[]): void => {\n if (acceptedFiles.length > 0) {\n if (options.onPaste) {\n options.onPaste(acceptedFiles);\n } else {\n onDrop?.(acceptedFiles);\n }\n }\n\n if (rejectedFiles.length > 0) onDropRejected?.(rejectedFiles);\n };\n\n // Run accept/maxFiles filter, then async onValidate, then settle.\n const dispatchWithValidation = (rawFiles: File[], settleFn: (accepted: File[], rejected: File[]) => void): void => {\n const { accepted, rejected: rej } = applyFileFilters(rawFiles, accept, maxFiles);\n const onValidate = options.onValidate;\n const validationController = onValidate && accepted.length > 0 ? new AbortController() : null;\n\n if (validationController) {\n validationControllers.add(validationController);\n setValidating(true);\n }\n\n const finishValidation = (): void => {\n if (!validationController) return;\n\n validationControllers.delete(validationController);\n\n if (!disposable.disposed) setValidating(validationControllers.size > 0);\n };\n\n let validation: boolean | Promise<boolean>;\n\n try {\n validation =\n validationController && onValidate ? onValidate(accepted, { signal: validationController.signal }) : true;\n } catch (error) {\n validation = Promise.reject(error);\n }\n\n void Promise.resolve(validation)\n .then((valid) => {\n finishValidation();\n\n if (disposable.disposed) return;\n\n if (valid) {\n settleFn(accepted, rej);\n } else {\n // validation failed — all type-accepted files become rejected\n settleFn([], [...rej, ...accepted]);\n }\n })\n .catch(() => {\n finishValidation();\n\n if (disposable.disposed) return;\n\n settleFn([], [...rej, ...accepted]);\n });\n };\n\n const handleDragEnter = (e: DragEvent): void => {\n if (resolveDisabled(options.disabled)) return;\n\n e.preventDefault();\n\n // Evaluate the filter once per drag (on first entry) — the payload is\n // constant for the lifetime of a drag operation.\n if (dragCounter === 0) {\n const items = e.dataTransfer?.items;\n\n dragAccepted = !accept.length || !items?.length || itemsMatchAccept(items, accept);\n }\n\n if (!dragAccepted && e.dataTransfer) {\n e.dataTransfer.dropEffect = 'none';\n }\n\n // Always increment so every dragenter is paired with its dragleave,\n // regardless of acceptance. This prevents counter under-runs.\n updateCounter(dragCounter + 1);\n };\n\n const handleDragOver = (e: DragEvent): void => {\n if (resolveDisabled(options.disabled)) return;\n\n e.preventDefault();\n\n if (e.dataTransfer) e.dataTransfer.dropEffect = dragAccepted ? dropEffect : 'none';\n };\n\n const handleDragLeave = (_e: DragEvent): void => {\n // Always decrement to balance the paired dragenter — disabling after enter\n // must not leave the counter permanently incremented.\n updateCounter(dragCounter - 1);\n };\n\n const handleDrop = (e: DragEvent): void => {\n // Reset counter first (idempotent at 0) so hover never sticks even when disabled.\n resetCounter();\n\n if (resolveDisabled(options.disabled)) return;\n\n e.preventDefault();\n\n const raw = e.dataTransfer?.files;\n\n if (!raw) return;\n\n dispatchWithValidation(Array.from(raw), settle);\n };\n\n const handlePaste = (e: ClipboardEvent): void => {\n if (resolveDisabled(options.disabled)) return;\n\n const clipFiles = e.clipboardData?.files;\n\n if (!clipFiles?.length) return;\n\n e.preventDefault();\n dispatchWithValidation(Array.from(clipFiles), settleForPaste);\n };\n\n element.addEventListener('dragenter', handleDragEnter, { signal: disposable.disposalSignal });\n element.addEventListener('dragover', handleDragOver, { signal: disposable.disposalSignal });\n element.addEventListener('dragleave', handleDragLeave, { signal: disposable.disposalSignal });\n element.addEventListener('drop', handleDrop, { signal: disposable.disposalSignal });\n\n if (options.paste) window.addEventListener('paste', handlePaste, { signal: disposable.disposalSignal });\n\n // These global listeners catch drags that end outside the zone.\n // The window 'drop' also fires for in-zone drops, but resetCounter() is idempotent at counter=0.\n window.addEventListener('dragend', resetCounter, { signal: disposable.disposalSignal });\n window.addEventListener('drop', resetCounter, { signal: disposable.disposalSignal });\n\n return {\n get disposalSignal() {\n return disposable.disposalSignal;\n },\n dispose: disposable.dispose,\n get disposed() {\n return disposable.disposed;\n },\n get hovered() {\n return dragCounter > 0 && dragAccepted;\n },\n [Symbol.dispose]: disposable[Symbol.dispose],\n get validating() {\n return validating;\n },\n };\n}",
|
|
64
|
+
"SortableScope": "export interface SortableScope extends Disposable {\n /** `true` while any sortable in this scope is actively dragging. */\n readonly isDragging: boolean;\n readonly [SCOPE_BRAND]: true;\n /**\n * Calls the revert function registered for the most recent cross-container move.\n * A no-op when no move registered a revert function.\n */\n revert(): void;\n}",
|
|
64
65
|
"AutoScrollOptions": "export interface AutoScrollOptions {\n /** Distance in pixels from an edge that triggers auto-scroll. @default 32 */\n edgeThreshold?: number;\n /** Pixels scrolled per dragover frame while near an edge. @default 18 */\n speed?: number;\n /** Scroll the sortable container while dragging near its edges. @default true */\n container?: boolean;\n /** Scroll the viewport while dragging near the window edges. @default false */\n viewport?: boolean;\n}",
|
|
65
66
|
"ReorderEvent": "export interface ReorderEvent {\n /** The new ordered list of item keys after the reorder. */\n ids: string[];\n /**\n * Register a revert function that will be called when `sortable.revert()` is invoked.\n * Useful for rolling back optimistic UI updates on server error.\n * Only the most recent `setRevert` registration is retained — a new reorder overwrites it.\n *\n * @example\n * ```ts\n * onReorder: ({ ids, setRevert }) => {\n * const prev = order;\n * setOrder(ids);\n * setRevert(() => setOrder(prev));\n * },\n * ```\n */\n setRevert(fn: () => void): void;\n}",
|
|
67
|
+
"SortableMoveEvent": "export interface SortableMoveEvent {\n /** Stable identity of the moved item. */\n readonly itemId: string;\n /** Source container before the move. */\n readonly source: HTMLElement;\n /** Ordered source item IDs after the move. */\n readonly sourceIds: string[];\n /** Target container after the move. */\n readonly target: HTMLElement;\n /** Ordered target item IDs after the move. */\n readonly targetIds: string[];\n /** Registers a rollback for the most recent scope move. */\n setRevert(fn: () => void): void;\n}",
|
|
68
|
+
"SortableScopeOptions": "export interface SortableScopeOptions {\n /**\n * Called exactly once for every successful cross-container move.\n * Local reorders continue to use each sortable's `onReorder` callback.\n */\n onMove?: (event: SortableMoveEvent) => void;\n /**\n * Enables touch input for sortable items registered to this scope.\n * The controller ignores unrelated document draggables.\n */\n touch?: boolean | TouchInputOptions;\n}",
|
|
69
|
+
"SortableTouchOptions": "export type SortableTouchOptions = TouchInputOptions;",
|
|
66
70
|
"SortableOptions": "export interface SortableOptions {\n /** Container element whose direct-child items are sortable. */\n element: HTMLElement;\n /** Shared scope for connected sortable containers. Containers only exchange items within the same scope. */\n scope?: SortableScope;\n /**\n * Selector for the drag handle inside each item.\n * When omitted the whole item is the handle.\n */\n handle?: string;\n /**\n * Enables keyboard-based reordering using arrow keys plus Home/End.\n * @default true\n */\n keyboard?: boolean;\n /**\n * Returns the identity key for a given item element.\n * This separates the \"what is this item?\" concern (yours) from the \"which children\n * are sortable?\" concern (ours — marked with `data-dnd-item`).\n *\n * @example\n * ```ts\n * getKey: (el) => el.dataset.taskId!\n * ```\n */\n getKey: (element: HTMLElement) => string;\n /** Sorting axis used to compute insertion position. @default 'vertical' */\n axis?: 'vertical' | 'horizontal';\n /** Auto-scrolls the container (and viewport) near edges while dragging. @default true */\n autoScroll?: boolean | AutoScrollOptions;\n /** Optional custom drag preview element. */\n dragImage?: HTMLElement | ((id: string, item: HTMLElement, event: DragEvent) => HTMLElement | null | undefined);\n /** CSS class applied to the placeholder element. @default 'dnd-placeholder' */\n placeholderClass?: string;\n /**\n * Called with a {@link ReorderEvent} after a successful reorder, only when the order changed.\n *\n * @example\n * ```ts\n * onReorder: ({ ids, setRevert }) => {\n * const prev = order;\n * setOrder(ids);\n * setRevert(() => setOrder(prev));\n * },\n * ```\n */\n onReorder?: (event: ReorderEvent) => void;\n /**\n * Called just before a successful drag commit with the before and after order snapshots.\n * Use this hook to set up FLIP animations — the source items are still in their\n * pre-commit positions at the time of the call.\n *\n * @example\n * ```ts\n * onBeforeReorder: (from, to) => {\n * // record element positions here, then animate after the next microtask\n * }\n * ```\n */\n onBeforeReorder?: (from: string[], to: string[]) => void;\n /**\n * When `true`, drag interactions are ignored.\n *\n * Note: if `disabled` transitions to `true` while a drag is in progress the\n * drag is treated as a cancellation — the item snaps back to its original\n * position rather than committing the last placeholder location.\n */\n disabled?: boolean;\n /** Called when the user starts dragging an item. */\n onDragStart?: (id: string, event: DragEvent) => void;\n /** Called when a drag ends (whether dropped or cancelled). */\n onDragEnd?: (id: string, event: DragEvent) => void;\n /**\n * Hotspot offset `[x, y]` passed to `setDragImage`.\n * Controls which point of the preview image follows the cursor.\n * @default [0, 0]\n */\n dragImageOffset?: [number, number];\n}",
|
|
67
71
|
"Sortable": "export interface Sortable extends Disposable {\n readonly isDragging: boolean;\n /**\n * Calls the revert function registered via `setRevert` in the last `onReorder` invocation (if any) and clears it.\n * A no-op when no revert function was registered or has already been consumed.\n *\n * Works for both drag-based and keyboard-based reorders.\n * Note: only the most recent reorder can be reverted; a new reorder overwrites the stored function.\n *\n * @example\n * ```ts\n * onReorder: ({ ids, setRevert }) => {\n * const prev = order;\n * setOrder(ids);\n * setRevert(() => setOrder(prev));\n * },\n * // later, on server error:\n * sortable.revert();\n * ```\n */\n revert(): void;\n /**\n * Re-reads the container's children and reapplies `draggable`, ARIA roles,\n * and handle attributes. Call this after programmatically adding, removing,\n * or replacing items — e.g. after a framework render that replaces DOM nodes.\n *\n * Not needed when items are only reordered via drag or keyboard.\n */\n sync(): void;\n}",
|
|
68
|
-
"createSortableScope": "export function createSortableScope(): SortableScope {\n const state: SortableScopeState = {
|
|
69
|
-
"createSortable": "export function createSortable(options: SortableOptions): Sortable {\n const {\n autoScroll = true,\n axis = 'vertical',\n element,\n getKey,\n handle,\n keyboard = true,\n placeholderClass = 'dnd-placeholder',\n scope = createSortableScope(),\n } = options;\n const autoScrollOptions = resolveAutoScrollOptions(autoScroll);\n const scopeState = getSortableScopeState(scope);\n\n if (handle !== undefined && handle.trim() === '') {\n warn(\n 'handle option is an empty string — no handle elements will be found. Provide a valid CSS selector or omit the option.',\n );\n }\n\n const getItems = (): HTMLElement[] =>\n Array.from(element.children).filter((c) => (c as HTMLElement).hasAttribute(ITEM_ATTR)) as HTMLElement[];\n\n const getOrderedIds = (): string[] => getItems().map((el) => getKey(el));\n\n const syncItems = (): void => {\n clearHandleAttributes(element);\n\n getItems().forEach((el) => {\n el.setAttribute('role', 'listitem');\n el.tabIndex = 0;\n\n if (handle) {\n el.removeAttribute('draggable');\n el.style.touchAction = '';\n el.querySelectorAll<HTMLElement>(handle).forEach((handleEl) => {\n handleEl.setAttribute(HANDLE_ATTR, '');\n handleEl.setAttribute('draggable', 'true');\n // See the matching comment above `cleanupItems()` for why this matters at all.\n handleEl.style.touchAction = 'none';\n });\n } else {\n el.setAttribute('draggable', 'true');\n // A native mouse drag has no competing gesture to arbitrate; touch does. Without this,\n // a mobile browser can decide the very first bit of finger movement is a page\n // scroll/pan — a decision it makes independently of, and before, this library's own\n // touch-shim threshold/`preventDefault()` logic ever runs — and hand the rest of the\n // gesture to native scrolling. Once that happens the item never receives the\n // `dragover` sequence needed to update the drop target, so the session ends up\n // committing back to wherever it started: indistinguishable from the drop \"reverting\".\n // `touch-action: none` opts the element out of every default touch gesture from\n // `touchstart` onward, leaving the whole interaction to this library's own JS.\n el.style.touchAction = 'none';\n }\n });\n };\n\n const markItems = (): void => {\n const seenKeys = new Set<string>();\n\n // Mark all children that have a key as sortable items\n Array.from(element.children).forEach((child) => {\n const el = child as HTMLElement;\n\n try {\n const key = getKey(el);\n\n if (key) {\n if (seenKeys.has(key)) {\n warn(\n `getKey returned the duplicate key \"${key}\" for two sibling items — onReorder's ids and applyReorder may become inconsistent. Ensure getKey returns a unique value per item.`,\n );\n } else {\n seenKeys.add(key);\n }\n\n el.setAttribute(ITEM_ATTR, '');\n }\n } catch (err) {\n warn(\n `getKey threw for a child element — the item will not be sortable. Check your getKey implementation. ${String(err)}`,\n );\n }\n });\n\n syncItems();\n };\n\n const cleanupItems = (): void => {\n clearHandleAttributes(element);\n\n element.querySelectorAll<HTMLElement>(`[${ITEM_ATTR}]`).forEach((item) => {\n item.removeAttribute(ITEM_ATTR);\n item.removeAttribute('draggable');\n item.removeAttribute('role');\n item.removeAttribute('tabindex');\n item.style.touchAction = '';\n });\n };\n\n const createPlaceholder = (source: HTMLElement): HTMLElement => {\n const p = document.createElement('div');\n\n p.className = placeholderClass;\n p.setAttribute('aria-hidden', 'true');\n\n if (axis === 'horizontal') {\n p.style.width = `${source.offsetWidth}px`;\n } else {\n p.style.height = `${source.offsetHeight}px`;\n }\n\n return p;\n };\n\n let lastRevert: (() => void) | null = null;\n\n const handle_: ContainerHandle = {\n commitReorder: (orderedIds) => {\n if (!options.onReorder) return;\n\n const event: ReorderEvent = {\n ids: orderedIds,\n setRevert(fn) {\n lastRevert = fn;\n },\n };\n\n options.onReorder(event);\n },\n getOrderedIds,\n isDisabled: () => resolveDisabled(options.disabled),\n notifyBeforeReorder: (from, to) => options.onBeforeReorder?.(from, to),\n notifyDragEnd: (id, event) => options.onDragEnd?.(id, event),\n notifyDragStart: (id, event) => options.onDragStart?.(id, event),\n };\n\n scopeState.handles.add(handle_);\n\n const handleDragStart = (e: DragEvent): void => {\n if (scopeState.active) return;\n\n if (handle_.isDisabled()) return;\n\n const target = e.target as HTMLElement;\n const item = target.closest<HTMLElement>(`[${ITEM_ATTR}]`);\n\n if (!item) return;\n\n if (handle && !target.closest(handle)) return;\n\n const originalParent = item.parentElement;\n\n if (!originalParent) return;\n\n const placeholder = createPlaceholder(item);\n const originalNextSibling = item.nextSibling;\n const activeId = getKey(item);\n\n // Snapshot only the source handle at drag start; targets are snapshotted lazily.\n const initialOrders = new Map<ContainerHandle, string[]>();\n\n initialOrders.set(handle_, handle_.getOrderedIds());\n item.setAttribute('data-dragging', '');\n originalParent.insertBefore(placeholder, originalNextSibling);\n\n const session: DragSession = {\n draggedEl: item,\n draggedId: activeId,\n hideFrame: null,\n initialOrders,\n originalDisplay: item.style.display,\n originalNextSibling,\n originalParent,\n placeholder,\n source: handle_,\n target: handle_,\n };\n\n scheduleHide(session);\n scopeState.active = session;\n\n if (e.dataTransfer) {\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', activeId);\n\n if (options.dragImage) {\n const preview =\n typeof options.dragImage === 'function' ? options.dragImage(activeId, item, e) : options.dragImage;\n const [offsetX, offsetY] = options.dragImageOffset ?? [0, 0];\n\n if (preview) e.dataTransfer.setDragImage(preview, offsetX, offsetY);\n }\n }\n\n handle_.notifyDragStart(session.draggedId, e);\n };\n\n const handleDragOver = (e: DragEvent): void => {\n const session = scopeState.active;\n\n if (!session) return;\n\n if (session.source.isDisabled() || handle_.isDisabled()) return;\n\n e.preventDefault();\n maybeAutoScroll(e, element, axis, autoScrollOptions);\n\n // Lazily snapshot this handle's order the first time it becomes a target.\n snapshotOrder(session, handle_);\n\n const { draggedEl, placeholder } = session;\n const target = (e.target as HTMLElement).closest<HTMLElement>(`[${ITEM_ATTR}]`);\n\n if (!target) {\n // Only append placeholder when it isn't already inside this container.\n // Moving it to the end on every over-empty-space event causes the\n // placeholder to oscillate between positions as the cursor moves.\n if (placeholder.parentElement !== element) {\n element.appendChild(placeholder);\n }\n\n session.target = handle_;\n\n return;\n }\n\n if (target === draggedEl || target === placeholder) return;\n\n const rect = target.getBoundingClientRect();\n const insertAfter =\n axis === 'vertical' ? e.clientY >= rect.top + rect.height / 2 : e.clientX >= rect.left + rect.width / 2;\n\n element.insertBefore(placeholder, insertAfter ? target.nextSibling : target);\n session.target = handle_;\n };\n\n const handleDrop = (e: DragEvent): void => {\n const session = scopeState.active;\n\n if (!session) return;\n\n if (session.source.isDisabled() || handle_.isDisabled()) return;\n\n e.preventDefault();\n // Record the drop target; the actual commit happens in handleDragEnd where\n // dataTransfer.dropEffect tells us whether the browser accepted the operation.\n session.target = handle_;\n };\n\n const handleDragEnd = (e: DragEvent): void => {\n if (scopeState.active?.source !== handle_) return;\n\n finishSession(scopeState, e, false);\n };\n\n const handleKeydown = (e: KeyboardEvent): void => {\n if (!keyboard || handle_.isDisabled()) return;\n\n const tagName = (e.target as HTMLElement | null)?.tagName;\n\n if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') return;\n\n const item = (e.target as HTMLElement).closest<HTMLElement>(`[${ITEM_ATTR}]`);\n\n if (!item || !element.contains(item)) return;\n\n const prevOrder = getOrderedIds();\n const newOrder = applyKeyboardReorder(item, element, getItems, getOrderedIds, e.key, axis);\n\n // null means unrecognized key or boundary — let the browser handle it (e.g. page scroll)\n if (newOrder === null) return;\n\n e.preventDefault();\n handle_.notifyBeforeReorder(prevOrder, newOrder);\n handle_.commitReorder(newOrder);\n };\n\n markItems();\n\n const disposable = createDisposable(() => {\n scopeState.disposables.delete(disposable.dispose);\n\n if (scopeState.active && (scopeState.active.source === handle_ || scopeState.active.target === handle_)) {\n finishSession(scopeState, new Event('dragend') as DragEvent, true);\n }\n\n scopeState.handles.delete(handle_);\n element.removeAttribute('role');\n cleanupItems();\n });\n\n element.setAttribute('role', 'list');\n element.addEventListener('dragstart', handleDragStart, { signal: disposable.disposalSignal });\n element.addEventListener('dragover', handleDragOver, { signal: disposable.disposalSignal });\n element.addEventListener('drop', handleDrop, { signal: disposable.disposalSignal });\n element.addEventListener('dragend', handleDragEnd, { signal: disposable.disposalSignal });\n element.addEventListener('keydown', handleKeydown, { signal: disposable.disposalSignal });\n\n // Register with scope so scope.dispose() can tear this down\n scopeState.disposables.add(disposable.dispose);\n\n return {\n get disposalSignal() {\n return disposable.disposalSignal;\n },\n dispose: disposable.dispose,\n get disposed() {\n return disposable.disposed;\n },\n get isDragging() {\n return scopeState.active?.source === handle_;\n },\n revert: () => {\n lastRevert?.();\n lastRevert = null;\n },\n [Symbol.dispose]: disposable[Symbol.dispose],\n sync: () => {\n markItems();\n },\n };\n}",
|
|
72
|
+
"createSortableScope": "export function createSortableScope(options: SortableScopeOptions = {}): SortableScope {\n const state: SortableScopeState = {\n active: null,\n commitMove(event): void {\n options.onMove?.({\n ...event,\n setRevert(fn): void {\n state.lastRevert = fn;\n },\n });\n },\n disposables: new Set(),\n handles: new Set(),\n lastRevert: null,\n touch: null,\n };\n const disposable = createDisposable(() => {\n state.touch?.dispose();\n\n // Dispose all registered sortables (each dispose() call is idempotent)\n for (const disposeFn of state.disposables) {\n disposeFn();\n }\n });\n\n const scope = {\n get disposalSignal() {\n return disposable.disposalSignal;\n },\n dispose: disposable.dispose,\n get disposed() {\n return disposable.disposed;\n },\n get isDragging() {\n return state.active !== null;\n },\n revert() {\n state.lastRevert?.();\n state.lastRevert = null;\n },\n [SCOPE_BRAND]: true as const,\n [Symbol.dispose]: disposable[Symbol.dispose],\n } as SortableScope;\n\n sortableScopeStates.set(scope, state);\n\n if (options.touch) {\n state.touch = createScopeTouchController(options.touch === true ? {} : options.touch, (target) => {\n for (const handle of state.handles) {\n const dragTarget = handle.resolveTouchTarget(target);\n\n if (dragTarget) return dragTarget;\n }\n\n return null;\n });\n }\n\n return scope;\n}",
|
|
73
|
+
"createSortable": "export function createSortable(options: SortableOptions): Sortable {\n const {\n autoScroll = true,\n axis = 'vertical',\n element,\n getKey,\n handle,\n keyboard = true,\n placeholderClass = 'dnd-placeholder',\n scope = createSortableScope(),\n } = options;\n const autoScrollOptions = resolveAutoScrollOptions(autoScroll);\n const scopeState = getSortableScopeState(scope);\n\n if (handle !== undefined && handle.trim() === '') {\n warn(\n 'handle option is an empty string — no handle elements will be found. Provide a valid CSS selector or omit the option.',\n );\n }\n\n const getItems = (): HTMLElement[] =>\n Array.from(element.children).filter((c) => (c as HTMLElement).hasAttribute(ITEM_ATTR)) as HTMLElement[];\n\n const getOrderedIds = (): string[] => getItems().map((el) => getKey(el));\n const managedElements = new Map<HTMLElement, ManagedElementState>();\n const originalContainerRole = element.getAttribute('role');\n\n const rememberElement = (managedElement: HTMLElement): ManagedElementState => {\n const existing = managedElements.get(managedElement);\n\n if (existing) return existing;\n\n const state: ManagedElementState = {\n dataDndHandle: managedElement.getAttribute(HANDLE_ATTR),\n dataDndItem: managedElement.getAttribute(ITEM_ATTR),\n draggable: managedElement.getAttribute('draggable'),\n role: managedElement.getAttribute('role'),\n tabIndex: managedElement.getAttribute('tabindex'),\n touchAction: managedElement.style.touchAction,\n };\n\n managedElements.set(managedElement, state);\n\n return state;\n };\n\n const restoreAttribute = (managedElement: HTMLElement, name: string, value: string | null): void => {\n if (value === null) {\n managedElement.removeAttribute(name);\n } else {\n managedElement.setAttribute(name, value);\n }\n };\n\n const syncItems = (): void => {\n getItems().forEach((el) => {\n const itemState = rememberElement(el);\n\n if (itemState.role === null) el.setAttribute('role', 'listitem');\n\n if (itemState.tabIndex === null) el.tabIndex = 0;\n\n if (handle) {\n el.querySelectorAll<HTMLElement>(handle).forEach((handleEl) => {\n rememberElement(handleEl);\n handleEl.setAttribute(HANDLE_ATTR, '');\n handleEl.setAttribute('draggable', 'true');\n handleEl.style.touchAction = 'none';\n });\n } else {\n el.setAttribute('draggable', 'true');\n // A native mouse drag has no competing gesture to arbitrate; touch does. Without this,\n // a mobile browser can decide the very first bit of finger movement is a page\n // scroll/pan — a decision it makes independently of, and before, this library's own\n // touch-shim threshold/`preventDefault()` logic ever runs — and hand the rest of the\n // gesture to native scrolling. Once that happens the item never receives the\n // `dragover` sequence needed to update the drop target, so the session ends up\n // committing back to wherever it started: indistinguishable from the drop \"reverting\".\n // `touch-action: none` opts the element out of every default touch gesture from\n // `touchstart` onward, leaving the whole interaction to this library's own JS.\n el.style.touchAction = 'none';\n }\n });\n };\n\n const markItems = (): void => {\n const seenKeys = new Set<string>();\n\n // Mark all children that have a key as sortable items\n Array.from(element.children).forEach((child) => {\n const el = child as HTMLElement;\n\n try {\n const key = getKey(el);\n\n if (key) {\n rememberElement(el);\n\n if (seenKeys.has(key)) {\n warn(\n `getKey returned the duplicate key \"${key}\" for two sibling items — onReorder's ids and applyReorder may become inconsistent. Ensure getKey returns a unique value per item.`,\n );\n } else {\n seenKeys.add(key);\n }\n\n el.setAttribute(ITEM_ATTR, '');\n }\n } catch (err) {\n warn(\n `getKey threw for a child element — the item will not be sortable. Check your getKey implementation. ${String(err)}`,\n );\n }\n });\n\n syncItems();\n };\n\n const cleanupItems = (): void => {\n for (const [managedElement, state] of managedElements) {\n restoreAttribute(managedElement, HANDLE_ATTR, state.dataDndHandle);\n restoreAttribute(managedElement, ITEM_ATTR, state.dataDndItem);\n restoreAttribute(managedElement, 'draggable', state.draggable);\n restoreAttribute(managedElement, 'role', state.role);\n restoreAttribute(managedElement, 'tabindex', state.tabIndex);\n managedElement.style.touchAction = state.touchAction;\n }\n\n managedElements.clear();\n };\n\n const createPlaceholder = (source: HTMLElement): HTMLElement => {\n const p = document.createElement('div');\n\n p.className = placeholderClass;\n p.setAttribute('aria-hidden', 'true');\n\n if (axis === 'horizontal') {\n p.style.width = `${source.offsetWidth}px`;\n } else {\n p.style.height = `${source.offsetHeight}px`;\n }\n\n return p;\n };\n\n let lastRevert: (() => void) | null = null;\n\n const handle_: ContainerHandle = {\n commitReorder: (orderedIds) => {\n if (!options.onReorder) return;\n\n const event: ReorderEvent = {\n ids: orderedIds,\n setRevert(fn) {\n lastRevert = fn;\n },\n };\n\n options.onReorder(event);\n },\n element,\n getOrderedIds,\n isDisabled: () => resolveDisabled(options.disabled),\n notifyBeforeReorder: (from, to) => options.onBeforeReorder?.(from, to),\n notifyDragEnd: (id, event) => options.onDragEnd?.(id, event),\n notifyDragStart: (id, event) => options.onDragStart?.(id, event),\n resolveTouchTarget: (target) => {\n if (resolveDisabled(options.disabled) || !element.contains(target)) return null;\n\n const item = target.closest<HTMLElement>(`[${ITEM_ATTR}]`);\n\n if (!item || !element.contains(item)) return null;\n\n if (!handle) return item;\n\n const handleTarget = target.closest<HTMLElement>(handle);\n\n return handleTarget && item.contains(handleTarget) ? handleTarget : null;\n },\n };\n\n scopeState.handles.add(handle_);\n\n const handleDragStart = (e: DragEvent): void => {\n if (scopeState.active) return;\n\n if (handle_.isDisabled()) return;\n\n const target = e.target as HTMLElement;\n const item = target.closest<HTMLElement>(`[${ITEM_ATTR}]`);\n\n if (!item) return;\n\n if (handle && !target.closest(handle)) return;\n\n const originalParent = item.parentElement;\n\n if (!originalParent) return;\n\n const placeholder = createPlaceholder(item);\n const originalNextSibling = item.nextSibling;\n const activeId = getKey(item);\n\n // Snapshot only the source handle at drag start; targets are snapshotted lazily.\n const initialOrders = new Map<ContainerHandle, string[]>();\n\n initialOrders.set(handle_, handle_.getOrderedIds());\n item.setAttribute('data-dragging', '');\n originalParent.insertBefore(placeholder, originalNextSibling);\n\n const session: DragSession = {\n draggedEl: item,\n draggedId: activeId,\n hideFrame: null,\n initialOrders,\n originalDisplay: item.style.display,\n originalNextSibling,\n originalParent,\n placeholder,\n source: handle_,\n target: handle_,\n };\n\n if (!isTouchDragEvent(e) || e.__dndTouchPreview) scheduleHide(session);\n\n scopeState.active = session;\n\n if (e.dataTransfer) {\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', activeId);\n\n if (options.dragImage) {\n const preview =\n typeof options.dragImage === 'function' ? options.dragImage(activeId, item, e) : options.dragImage;\n const [offsetX, offsetY] = options.dragImageOffset ?? [0, 0];\n\n if (preview) e.dataTransfer.setDragImage(preview, offsetX, offsetY);\n }\n }\n\n handle_.notifyDragStart(session.draggedId, e);\n };\n\n const handleDragOver = (e: DragEvent): void => {\n const session = scopeState.active;\n\n if (!session) return;\n\n if (session.source.isDisabled() || handle_.isDisabled()) return;\n\n e.preventDefault();\n maybeAutoScroll(e, element, axis, autoScrollOptions);\n\n // Lazily snapshot this handle's order the first time it becomes a target.\n snapshotOrder(session, handle_);\n\n const { draggedEl, placeholder } = session;\n const target = (e.target as HTMLElement).closest<HTMLElement>(`[${ITEM_ATTR}]`);\n\n if (!target) {\n // Only append placeholder when it isn't already inside this container.\n // Moving it to the end on every over-empty-space event causes the\n // placeholder to oscillate between positions as the cursor moves.\n if (placeholder.parentElement !== element) {\n element.appendChild(placeholder);\n }\n\n session.target = handle_;\n\n return;\n }\n\n if (target === draggedEl || target === placeholder) return;\n\n const rect = target.getBoundingClientRect();\n const insertAfter =\n axis === 'vertical' ? e.clientY >= rect.top + rect.height / 2 : e.clientX >= rect.left + rect.width / 2;\n\n element.insertBefore(placeholder, insertAfter ? target.nextSibling : target);\n session.target = handle_;\n };\n\n const handleDrop = (e: DragEvent): void => {\n const session = scopeState.active;\n\n if (!session) return;\n\n if (session.source.isDisabled() || handle_.isDisabled()) return;\n\n e.preventDefault();\n // Record the drop target; the actual commit happens in handleDragEnd where\n // dataTransfer.dropEffect tells us whether the browser accepted the operation.\n session.target = handle_;\n };\n\n const handleDragEnd = (e: DragEvent): void => {\n if (scopeState.active?.source !== handle_) return;\n\n finishSession(scopeState, e, false);\n };\n\n const handleKeydown = (e: KeyboardEvent): void => {\n if (!keyboard || handle_.isDisabled()) return;\n\n const tagName = (e.target as HTMLElement | null)?.tagName;\n\n if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') return;\n\n const item = (e.target as HTMLElement).closest<HTMLElement>(`[${ITEM_ATTR}]`);\n\n if (!item || !element.contains(item)) return;\n\n const prevOrder = getOrderedIds();\n const newOrder = applyKeyboardReorder(item, element, getItems, getOrderedIds, e.key, axis);\n\n // null means unrecognized key or boundary — let the browser handle it (e.g. page scroll)\n if (newOrder === null) return;\n\n e.preventDefault();\n handle_.notifyBeforeReorder(prevOrder, newOrder);\n handle_.commitReorder(newOrder);\n };\n\n markItems();\n\n const disposable = createDisposable(() => {\n scopeState.disposables.delete(disposable.dispose);\n\n if (scopeState.active && (scopeState.active.source === handle_ || scopeState.active.target === handle_)) {\n finishSession(scopeState, new Event('dragend') as DragEvent, true);\n }\n\n scopeState.handles.delete(handle_);\n restoreAttribute(element, 'role', originalContainerRole);\n cleanupItems();\n });\n\n if (originalContainerRole === null) element.setAttribute('role', 'list');\n\n element.addEventListener('dragstart', handleDragStart, { signal: disposable.disposalSignal });\n element.addEventListener('dragover', handleDragOver, { signal: disposable.disposalSignal });\n element.addEventListener('drop', handleDrop, { signal: disposable.disposalSignal });\n element.addEventListener('dragend', handleDragEnd, { signal: disposable.disposalSignal });\n element.addEventListener('keydown', handleKeydown, { signal: disposable.disposalSignal });\n\n // Register with scope so scope.dispose() can tear this down\n scopeState.disposables.add(disposable.dispose);\n\n return {\n get disposalSignal() {\n return disposable.disposalSignal;\n },\n dispose: disposable.dispose,\n get disposed() {\n return disposable.disposed;\n },\n get isDragging() {\n return scopeState.active?.source === handle_;\n },\n revert: () => {\n lastRevert?.();\n lastRevert = null;\n },\n [Symbol.dispose]: disposable[Symbol.dispose],\n sync: () => {\n markItems();\n },\n };\n}",
|
|
70
74
|
"applyReorder": "export function applyReorder<T>(items: T[], ids: string[], getKey: (item: T) => string): T[] {\n const byId = new Map(items.map((item) => [getKey(item), item] as const));\n const ordered: T[] = [];\n\n for (const id of ids) {\n if (!byId.has(id)) continue;\n\n const item = byId.get(id) as T;\n\n ordered.push(item);\n byId.delete(id);\n }\n\n for (const item of byId.values()) ordered.push(item);\n\n return ordered;\n}",
|
|
71
|
-
"TouchDragOptions": "export interface TouchDragOptions {\n /**\n * Set to `true` (or mutate this same options object's field later) to pause the shim without\n * disposing it — matches `createSortable`'s/`createDropZone`'s own `disabled` option.\n */\n disabled?: boolean;\n /**\n * CSS selector identifying draggable elements. Defaults to `[draggable=\"true\"]` — the exact\n * attribute `createSortable`/`createDropZone`-managed elements already carry, so the default\n * needs no configuration for the common case of bridging touch to an existing sortable/drop zone.\n */\n draggableSelector?: string;\n /**\n * Renders a floating clone of the dragged element that follows the touch point for the\n * duration of the drag. @default true\n *\n * A native mouse-driven HTML5 drag gets this for free: the browser snapshots the dragged\n * element into its own drag image the moment `dragstart` fires, then keeps that image under\n * the cursor itself for the whole gesture — `createSortable`'s `scheduleHide()` hides the real\n * element a frame later assuming that snapshot already exists. This shim's `dragstart` is a\n * synthetic `Event`, not a real drag, so the browser never creates that image; without this\n * preview, `scheduleHide()` still hides the real element on schedule and touch users are left\n * with *no* visual feedback for the whole gesture — only an empty placeholder box moving\n * between positions — making any drop feel arbitrary regardless of where it actually lands.\n * Set to `false` to render fully custom feedback instead (e.g. toggling a class from your own\n * `dragstart`/`dragend` listeners).\n */\n showDragPreview?: boolean;\n}",
|
|
72
|
-
"createTouchDragShim": "export function createTouchDragShim(options: TouchDragOptions = {}): Disposable {\n const dt = makeDataTransfer();\n const DRAG_START_DISTANCE_PX = 6;\n\n let pendingDraggable: HTMLElement | null = null;\n let pendingStartPoint: { clientX: number; clientY: number } | null = null;\n let dragging: HTMLElement | null = null;\n let lastTarget: Element | null = null;\n\n // The preview follows the touch point via a `translate3d` delta from wherever the touch was\n // when the preview was created (`previewOrigin`), not from the element's own rect — so it never\n // \"jumps\" at drag start (the delta starts at zero regardless of exactly where inside the\n // element the user first touched).\n let previewEl: HTMLElement | null = null;\n let previewOrigin: { clientX: number; clientY: number } | null = null;\n\n function removePreview(): void {\n previewEl?.remove();\n previewEl = null;\n previewOrigin = null;\n }\n\n // Hides both the real dragged element AND the floating preview for the duration of the\n // hit-test, then restores them. Hiding `dragging` alone isn't enough: the preview sits on top\n // of everything (by design, to follow the touch point) and, since `createPreviewElement()`\n // clones the dragged element's subtree, it can carry cloned custom elements (e.g. shadow-DOM\n // components) that re-run their own setup on the clone and may set `pointer-events` on their\n // *internal* shadow content — which a `pointer-events: none` set only on the preview's light-DOM\n // root does not reliably override. Hiding it outright (rather than relying on `pointer-events`)\n // sidesteps that entirely: a hidden element is never returned by `elementFromPoint`, regardless\n // of what any of its descendants — shadow DOM included — set.\n function elementBelow(clientX: number, clientY: number): Element | null {\n const prevDraggingDisplay = dragging?.style.display ?? '';\n const prevPreviewDisplay = previewEl?.style.display ?? '';\n\n if (dragging) dragging.style.display = 'none';\n\n if (previewEl) previewEl.style.display = 'none';\n\n const below = document.elementFromPoint(clientX, clientY);\n\n if (dragging) dragging.style.display = prevDraggingDisplay;\n\n if (previewEl) previewEl.style.display = prevPreviewDisplay;\n\n return below;\n }\n\n const disposable = createDisposable(() => {\n pendingDraggable = null;\n pendingStartPoint = null;\n dragging = null;\n lastTarget = null;\n removePreview();\n });\n\n // Built on a plain `Event` rather than `new DragEvent(...)`: jsdom (and potentially other\n // non-browser DOM implementations) doesn't expose a `DragEvent` constructor at all, and\n // `createSortable`/`createDropZone`'s own handlers only ever read `type`/`clientX`/`clientY`/\n // `dataTransfer` off the event object — they never check `instanceof DragEvent` — so a patched\n // plain `Event` is indistinguishable to them and works everywhere a real `DragEvent` would.\n function dispatch(el: Element, type: string, clientX: number, clientY: number): void {\n const event = new Event(type, { bubbles: true, cancelable: true });\n\n Object.defineProperty(event, 'clientX', { configurable: true, value: clientX });\n Object.defineProperty(event, 'clientY', { configurable: true, value: clientY });\n Object.defineProperty(event, 'dataTransfer', { configurable: true, value: dt });\n el.dispatchEvent(event);\n }\n\n document.addEventListener(\n 'touchstart',\n (e: TouchEvent) => {\n if (resolveDisabled(options.disabled)) return;\n\n const touch = e.touches[0];\n\n if (!touch) return;\n\n const target = document.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement | null;\n const draggable = target?.closest<HTMLElement>(options.draggableSelector ?? '[draggable=\"true\"]');\n\n if (!draggable) return;\n\n pendingDraggable = draggable;\n pendingStartPoint = { clientX: touch.clientX, clientY: touch.clientY };\n },\n { passive: false, signal: disposable.disposalSignal },\n );\n\n document.addEventListener(\n 'touchmove',\n (e: TouchEvent) => {\n const touch = e.touches[0];\n\n if (!touch) return;\n\n if (!dragging) {\n if (!pendingDraggable || !pendingStartPoint) return;\n\n const dx = touch.clientX - pendingStartPoint.clientX;\n const dy = touch.clientY - pendingStartPoint.clientY;\n const distance = Math.hypot(dx, dy);\n\n if (distance < DRAG_START_DISTANCE_PX) return;\n\n dragging = pendingDraggable;\n lastTarget = pendingDraggable;\n\n // Captured before dispatching 'dragstart': createSortable's own scheduleHide() hides\n // `dragging` a frame after that event fires, and by then its layout position may already\n // reflect the placeholder having been inserted. Reading the rect now, while it's still\n // exactly where the user picked it up, is what createPreviewElement() sizes/positions the\n // clone from.\n //\n // Wrapped in try/catch: `createPreviewElement()` clones the dragged element's whole\n // subtree, which — for a draggable item backed by custom elements — re-instantiates live\n // copies that re-run their own `connectedCallback`/setup once appended to `document.body`.\n // A best-effort *visual* feature must never be able to take down the *functional* one:\n // if any of that throws (a component assuming a page-unique id, a framework-internal\n // invariant, anything), the drag must still start normally with no preview, not silently\n // never start at all.\n if (options.showDragPreview !== false) {\n try {\n previewEl = createPreviewElement(dragging);\n previewOrigin = { clientX: touch.clientX, clientY: touch.clientY };\n } catch (err) {\n warn(`drag preview failed to render, continuing without one: ${String(err)}`);\n removePreview();\n }\n }\n\n dispatch(dragging, 'dragstart', touch.clientX, touch.clientY);\n }\n\n if (previewEl && previewOrigin) {\n const dx = touch.clientX - previewOrigin.clientX;\n const dy = touch.clientY - previewOrigin.clientY;\n\n previewEl.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;\n }\n\n const below = elementBelow(touch.clientX, touch.clientY);\n\n if (below && below !== lastTarget) {\n if (lastTarget) dispatch(lastTarget, 'dragleave', touch.clientX, touch.clientY);\n\n lastTarget = below;\n }\n\n if (below) dispatch(below, 'dragover', touch.clientX, touch.clientY);\n\n pendingDraggable = null;\n pendingStartPoint = null;\n e.preventDefault();\n },\n { passive: false, signal: disposable.disposalSignal },\n );\n\n document.addEventListener(\n 'touchend',\n (e: TouchEvent) => {\n if (!dragging) {\n pendingDraggable = null;\n pendingStartPoint = null;\n\n return;\n }\n\n const touch = e.changedTouches[0];\n\n if (!touch) return;\n\n const below = elementBelow(touch.clientX, touch.clientY);\n\n if (below) dispatch(below, 'drop', touch.clientX, touch.clientY);\n\n dispatch(dragging, 'dragend', touch.clientX, touch.clientY);\n\n pendingDraggable = null;\n pendingStartPoint = null;\n dragging = null;\n lastTarget = null;\n removePreview();\n },\n { passive: true, signal: disposable.disposalSignal },\n );\n\n document.addEventListener(\n 'touchcancel',\n (e: TouchEvent) => {\n if (!dragging) {\n pendingDraggable = null;\n pendingStartPoint = null;\n\n return;\n }\n\n const touch = e.changedTouches[0];\n\n if (!touch) return;\n\n dispatch(dragging, 'dragend', touch.clientX, touch.clientY);\n pendingDraggable = null;\n pendingStartPoint = null;\n dragging = null;\n lastTarget = null;\n removePreview();\n },\n { passive: true, signal: disposable.disposalSignal },\n );\n\n return disposable;\n}",
|
|
73
75
|
"Disposable": "export interface Disposable {\n [Symbol.dispose](): void;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n}"
|
|
74
76
|
}
|
|
75
77
|
}
|