+
+
+
+
+
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..a82526f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,209 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [v1.0.0] - 2026-05-11
+
+First stable release.
+
+### Breaking Changes
+
+- **`scroll` config replaced by `behaviors[]`**. Auto-scroll and scroll-sync are now `Behavior` plugins; built-in factories `autoScroll()` and `scrollSync()`, both included by default. Per-droppable override via `sortable({ behaviors })` / `target({ behaviors })`. `controller.setScrollConfig` removed — use `controller.setBehaviors`. `ScrollConfig` type replaced by `AutoScrollConfig`.
+- **Simulator API consolidated**. `simulateReturn`, `simulateDrop`, `simulateSwap`, `simulateBatchSwap` are replaced by `controller.animateItem` and `controller.animateLayout`. `SimulateOptions` removed; new types `AnimateItemOptions`, `AnimateLayoutOptions`, `ContainerPosition`.
+- **Animation config unified under `animation`**. Top-level `preview` option on `DndController` removed. Renames: `dropDuration` → `drop`, `returnDuration` → `return`, `slotCollapseDuration` → `slotCollapse`, `swapDuration` → `layout`. Preview delays moved into `animation.preview.show` / `animation.preview.hide` as `DelayedTransition` (`{ delay, duration, easing }`). `siblingShift` and `ghostResize` take a `Transition`; `drop`, `return`, `slotCollapse`, `layout`, `keyboardFlight` take a number or `Transition`.
+- **`controller.previewConfig` and `setPreviewConfig` removed**. Read from `controller.animation`, patch via `controller.setAnimation`.
+- **Ghost rotation no longer applied by the library**. Removed CSS vars `--dnd-ghost-rotation`, `--dnd-ghost-rotation-duration` and the `.dnd-ghost--returning` reset rule. Implement rotation in custom CSS if needed.
+- **`PreviewConfig` and `ScrollConfig` types are no longer exported**. Use `AnimationConfig` / `AutoScrollConfig` instead.
+
+### Features
+
+- **Sortable virtualization** via `sortable({ virtual })`. New `VirtualSource` and `SortableSource` types let a virtualizer drive zone geometry from live slot rects. Tested with virtua; other virtualizers should work via the same interface but are unverified. Grid layout falls back to DOM mode.
+- **Pluggable `Behavior` plugins**. Custom plugins implement `wrapDropAnimation(next, ctx)` and/or expose `autoScrollConfig`. New exports: `Behavior`, `BehaviorContext`, `AutoScrollConfig`, `autoScroll`, `scrollSync`, `ScrollSyncOptions`.
+- **`scrollSync({ threshold })`** — engagement gated by the destination slot's visible fraction.
+- **Ghost & preview auto-resize to the destination's item size**, mixing target-sibling and dragged-item dimensions per layout. Exposed reactively as `controller.dropPreviewSize`. CSS vars `--dnd-ghost-resize-duration` / `--dnd-ghost-resize-easing` driven by `animation.ghostResize`.
+- **Keyboard navigation expansions**. `Home` / `End`, cross-axis hops between sibling containers, same-row / same-column grid movement, per-keystroke ghost flight to the live slot rect, lockstep scroll-into-view for off-screen targets. Iterates the full position list, so virtualized slots stay reachable. Tunable via `animation.keyboardFlight`.
+- **Configurable easing for every animation**. rAF-driven steps accept a number or `{ duration, easing }`; CSS-driven steps (preview show/hide, `siblingShift`, `ghostResize`) gain matching easing fields. `scrollSync` inherits the wrapped step's easing.
+- **`parseEasing(str)` helper** exported for custom `AnimationStep` implementations.
+- **New CSS vars** `--dnd-preview-easing-in`, `--dnd-preview-easing-out`, `--dnd-ghost-resize-easing` written from `controller.animation`; user CSS still overrides.
+- **Runtime setters**: `setBehaviors`, `setAnimation` (deep-merge), `setSensors`, `setAnnouncements`, `setModifiers`, `setDebug`. No need to recreate the controller.
+- **Default flex direction for sortable `DndDroppable`** via `:where(.dnd-droppable[data-dnd-layout='vertical'|'horizontal'])`. Consumer classes (Tailwind, custom) win without `!important`. Grid sortables get no default.
+- **`Droppable.itemCount` and `Droppable.isVirtualized`** getters expose data length and virtualization mode.
+- **Per-call `behaviors` / `easing` override on `animateItem`**.
+- **`animateLayout({ morph: true })`** copies missing classes from pre- to post-state element so class-driven CSS transitions run in lockstep with the FLIP transform.
+- **New exports**: `GhostSnippet`, `GhostSnippetProps`, `ZonesInvalidatedCallback`.
+
+### Bug Fixes
+
+- Honour explicit `spacing={0}` on `DndDraggable`.
+- Clamp sortable drop zones to the container's scroll viewport.
+- Siblings no longer jump during slot collapse in scrollable sources.
+- Drop no longer fights the virtualizer's scroll-jump compensation.
+- Ghost tracks the tail preview correctly when the source slot collapses.
+- Repeated `preview.hide()` calls no longer leak collapse timers.
+- Overlapping `animateLayout` invocations reject with a clear error instead of corrupting state silently.
+- `scrollSync` now engages on tail previews in empty containers (degenerate-rect case).
+- Keyboard ghost flight reads the post-update tail spacing, so the first key after a drop lands at the correct rect.
+- `KeyboardSensor` no longer activates twice when `Enter` / `Space` bubbles from nested draggables.
+- Keyboard navigation no longer triggers auto-scroll (auto-scroll was tied to pointer updates).
+- Reactive prop updates on `DndDraggable` / `DndDroppable` / `DndPreview` propagate from the first read — entities are no longer pinned to the initial snapshot.
+- Post-drop rect reads in `animateLayout` happen against the updated DOM, fixing stale FLIP transforms.
+- `package.json` gains `exports.import` and `exports.default` conditions for correct ESM resolution.
+- Removed `aria-grabbed` from the dragged element (deprecated in ARIA 1.1).
+
+### Performance
+
+- Droppables mounting or unmounting mid-drag update zone derivations reactively instead of going stale until the next pointermove.
+- First-slot rect cached per drag session, avoiding a synchronous layout on every pointermove when computing `dropPreviewSize`.
+
+### Docs
+
+- New examples: virtualization, target-zones, behaviors live demo.
+- New pages: behaviors, `VirtualSource`, SSR FAQ. Examples updated to the consolidated simulator API and runtime setters.
+
+## [v1.0.0-rc.1] - 2026-04-19
+
+Release candidate for v1.0. Public API stabilized — please report regressions before stable.
+
+### Breaking Changes
+
+- **Strategy-instance API on `DndDroppable`**. The `mode` and `direction` props are replaced with a single `strategy` prop that takes a strategy instance. Factories exported: `sortable({ layout, flow })`, `target()`. Custom strategies implement the `ContainerStrategy` interface.
+- **`DndLayout` replaces `DndDirection`**. New values: `'vertical' | 'horizontal' | 'grid'`.
+- **Rich event objects replace primitive callback arguments**. `onDragStart`, `onDragEnd`, `onDrop`, `onDragOver`, `onDropCancelled` now receive structured `{ item, source, target, … }` objects (`DragStartEvent`, `DropEvent`, `DragEndEvent`, `DragOverEvent`, `DropCancelledEvent`).
+- **Per-item drag callbacks removed from `DndDraggable`**. Subscribe via `controller.onDragStart(…)` / `onDrop(…)` / `onDragEnd(…)` / `onDropCancelled(…)` / `onDragOver(…)` instead.
+- **Collision API replaces `overlap` prop**. `DndDroppable` now accepts a `collision` prop (or set globally via `DndController({ collision })`) conforming to the `CollisionAlgorithm` contract. Built-ins: `centerPoint` (default), `cursorOver`, `overlap`, `closestCenter`.
+- **`overlap()` takes a flat threshold**: `overlap(25)` or `overlap('25%')` instead of the previous object form.
+- **`centerPoint` semantics split**: it now tests the ghost's center; the previous cursor-based behaviour is now `cursorOver`.
+- **`DndSimulator` class no longer exported**. Use `controller.simulateReturn / simulateDrop / simulateSwap / simulateBatchSwap` instead.
+- **`DndState` class no longer exported** (type export remains for advanced use).
+- **`--dnd-slot-spacing` CSS variable removed**. Use the reactive `spacing` prop on `DndDroppable` instead.
+- **`DropPreview` shape simplified**: `.visible` flag removed (use nullable `DropPreview | null`); ghost-size fields unified into `ghostSize` on the controller.
+
+### Features
+
+- **Grid layout**. `sortable({ layout: 'grid', flow: 'row' | 'column' })` with dedicated grid zone geometry and cross-container slot animations.
+- **Sensor system + keyboard accessibility**. Configurable `sensors` on `DndController` (defaults to `[PointerSensor, KeyboardSensor]`) with per-item override on `DndDraggable`. `KeyboardSensor` enables Space/Enter to grab, arrow keys to navigate, Escape to cancel. ARIA live region built into `DndProvider`, plus customizable `announcements` (with `defaultAnnouncements` helper) on `DndController`. Exports: `PointerSensor`, `KeyboardSensor`, `Distance`, `Delay`, plus `SensorDescriptor` / `DistanceConfig` / `DelayConfig` types.
+- **Transform modifiers**. Pluggable `Modifier` pipeline on `DndController`. Built-ins: `restrictToVerticalAxis`, `restrictToHorizontalAxis`, `restrictToContainer`, `snapToGrid`.
+- **Pluggable collision algorithms** as described above.
+- **`onDragOver` event**. Fires when the drag-over target container or position changes.
+- **Simulator expansions**. New `simulateSwap` (two-item swap) and `simulateBatchSwap` (FLIP-based multi-item reorder). `simulateReturn` / `simulateDrop` accept `SimulateOptions` with `emitEvents` to fire real `onDrop` / `onDropCancelled`.
+- **`stopOnDrop` scroll option** on `ScrollConfig`.
+- **Reactive `spacing` prop** on `DndDroppable` (replaces the removed CSS var).
+- **Explicit runtime error** when `DndDraggable` / `DndDroppable` render outside a `DndProvider`.
+
+### Bug Fixes
+
+- Slot `position` now syncs reactively with the prop, preventing stale order after `{#each}` reorders.
+- Variable-height previews: correct alignment, slot sizing and ghost positioning in both vertical and horizontal lists.
+- Cross-container slot size uses the dragged element's own dimensions plus the target container's gap.
+- Drop animation no longer resets scroll position; auto-scroll stops cleanly before the ghost flight.
+- Drop-animation flags reset on each new drag session so rapid successive drags render correctly.
+- Reactive props on `DndDroppable` / `DndDraggable` (`disabled`, `data`, `type`, `accepts`, `collision`, `strategy`, `spacing`, `sensors`) propagate live to the underlying entities.
+- Container IDs with special characters work (querySelector escaping).
+- `disabled` on `DndDraggable` blocks drag activation in all sensors.
+- `KeyboardSensor` deferred listener is cleaned up on destroy.
+- Preview `hidePreviewTimeout` cleared on destroy; `clearAll` cleanup regression fixed.
+- `data-dnd-scroll` auto-scroll honours scope again; `scheduleRefresh` deduplicated during auto-scroll.
+
+### Internal
+
+- Entity-based architecture (`Draggable`, `Droppable`, `Preview`, `Slot`, `DragSession`) replaces the legacy handler/registrar/session-manager plumbing; strategies read entity state directly rather than traversing the DOM.
+- `DndController` split into `DragSessionManager` + `DropAnimationCoordinator` + `TranslationEngine` + `DropResolver`.
+- Layout snapshot captured at drag start (transform-free) so reactive transforms never feed back into zone calculations.
+- Internal-only surface marked with `@internal`.
+- Custom-strategy primitives exposed for advanced use: `AnimationStep`, `InstantStep`, `GhostToTargetStep`, `GhostReturnStep`, `StrategyBindContext`, `ContainerStrategy`, `Droppable`, `DragSession`, `DragSource`.
+
+### Docs
+
+- New pages: sensors, collision, modifiers, accessibility, custom strategies, `DndController` API, simulations.
+- Home page redesign.
+
+## [v1.0.0-beta.1] - 2025-04-06
+
+Complete rewrite with a new modular architecture. All previous APIs have changed — see the documentation.
+
+### Breaking Changes
+- `DragController` renamed to `DndController`
+- `position` is now a required prop on `DndDraggable`
+- Type filtering moved from `data={{ type, accepts }}` to dedicated `type` / `accepts` props
+- `DndPreview` is no longer used directly — remove all manual placements
+
+### Features
+- Drop previews and dragged item visibility handled automatically — no manual `DndPreview`, `hiddenId`, or `visibleItems` needed
+- `onDragStart` / `onDragEnd` callbacks no longer return an unsubscribe function — cleanup is automatic
+- `DndSimulator` for programmatic drag simulation
+- `mode="target"` on `DndDroppable` for non-sortable drop zones (trash, board columns)
+- `overlap` prop for intersection-based hit detection
+- `onDropCancelled` callback
+- `PreviewConfig` and `ScrollConfig` for fine-tuning animations and auto-scroll
+- `DndControllerConfig` constructor config on `DndController`
+- Touch support: long-press delay, momentum scroll, configurable scroll cancel threshold
+- SSR compatibility
+
+### Bug Fixes
+- Post-drop translation snap eliminated
+- Ghost return animation is scroll-aware
+- Drop preview correctly scoped in nested containers
+- Drag activation prevented in draggable padding area
+
+### Build
+- Migrated to Turborepo monorepo
+
+### Docs
+- New docs with live examples
+
+## [v0.3.0] - 2025-02-19
+
+### Features
+- Drag handle support via `data-dnd-handle` attribute
+- Cursor styling applied to handles only
+- Multiple handles per item supported
+
+### Bug Fixes
+- Drop zone visibility in debug overlay
+- Drag event propagation in nested draggables
+- Debug overlay state persistence
+
+### Internal
+- Data attributes now use the `data-dnd-` prefix to prevent conflicts with other libraries
+
+### Documentation
+- Updated attribute references
+- Added drag handle examples
+- Expanded FAQ
+
+## [v0.2.0] - 2025-02-17
+
+### Features
+- Ghost animation returns to origin with scrolling during cancelled drags
+- Auto-scroll functionality scoped to `data-dnd-scroll` attribute
+
+### Bug Fixes
+- Mobile support improvements
+- Drop zones clipped to visible container viewport boundaries
+
+### Refactoring
+- DOM operations and animation logic separated into dedicated modules
+
+## [v0.1.2] - 2025-02-14
+
+No notable changes.
+
+## [v0.1.1] - 2025-02-14
+
+### Bug Fixes
+- Fixed placeholder flicker during drag initiation
+
+## [v0.1.0] - 2025-02-14
+
+Initial release.
+
+[v1.0.0]: https://github.com/Horuse/svelte-dnd/compare/v1.0.0-rc.1...v1.0.0
+[v1.0.0-rc.1]: https://github.com/Horuse/svelte-dnd/compare/v1.0.0-beta.1...v1.0.0-rc.1
+[v1.0.0-beta.1]: https://github.com/Horuse/svelte-dnd/compare/v0.3.0...v1.0.0-beta.1
+[v0.3.0]: https://github.com/Horuse/svelte-dnd/compare/v0.2.0...v0.3.0
+[v0.2.0]: https://github.com/Horuse/svelte-dnd/compare/v0.1.2...v0.2.0
+[v0.1.2]: https://github.com/Horuse/svelte-dnd/compare/v0.1.1...v0.1.2
+[v0.1.1]: https://github.com/Horuse/svelte-dnd/compare/v0.1.0...v0.1.1
+[v0.1.0]: https://github.com/Horuse/svelte-dnd/releases/tag/v0.1.0
diff --git a/README.md b/README.md
deleted file mode 100644
index 121b55d..0000000
--- a/README.md
+++ /dev/null
@@ -1,115 +0,0 @@
-# @horuse/svelte-dnd
-
-[](https://www.npmjs.com/package/@horuse/svelte-dnd)
-[](https://github.com/Horuse/svelte-dnd/issues)
-
-A drag-and-drop library for Svelte 5 with animated drop previews, auto-scroll, and multi-container support.
-
-
-
-## Features
-
-- Vertical, horizontal layouts
-- Pointer & touch support - works seamlessly on mobile devices
-- Animated drop previews that follow the dragged item
-- Auto-scroll when dragging near container edges
-- Move items between multiple containers (kanban-style)
-- Custom ghost element via Svelte snippets
-- Zero dependencies beyond Svelte 5
-
-## Installation
-
-```bash
-npm install @horuse/svelte-dnd
-```
-
-## Basic Example
-
-A minimal working drag-and-drop setup requires four components: `DndProvider`, `DndDroppable`, `DndDraggable`, and `DndPreview`.
-
-```svelte
-
-
-
-
- {#each visibleItems as item, index (item.id)}
-
-
-
- {item.label}
-
- {/each}
-
-
-
-
-```
-
-## How It Works
-
-1. **DndProvider** wraps your app and creates a `DragController` context.
-2. **DndDroppable** defines a container where items can be dropped. Set `direction` to `"vertical"` or `"horizontal"`.
-3. **DndDraggable** wraps each draggable item. Each must have a unique `id`.
-4. **DndPreview** renders a placeholder at each potential drop position. Place one before each item and one after the last item.
-5. **Hide the dragged item** — subscribe to `onDragStart` / `onDragEnd` and filter out the dragged item from the rendered list. This removes the original element from the DOM flow so only the ghost follows the cursor.
-6. Use `controller.onDrop()` to handle reordering logic when an item is dropped.
-
-## Documentation
-
-Full docs and live examples are available at the [documentation site](https://svelte-dnd.vercel.app).
diff --git a/README.md b/README.md
new file mode 120000
index 0000000..94559f8
--- /dev/null
+++ b/README.md
@@ -0,0 +1 @@
+packages/svelte-dnd/README.md
\ No newline at end of file
diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore
new file mode 100644
index 0000000..3b462cb
--- /dev/null
+++ b/apps/docs/.gitignore
@@ -0,0 +1,23 @@
+node_modules
+
+# Output
+.output
+.vercel
+.netlify
+.wrangler
+/.svelte-kit
+/build
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Env
+.env
+.env.*
+!.env.example
+!.env.test
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
diff --git a/.npmrc b/apps/docs/.npmrc
similarity index 100%
rename from .npmrc
rename to apps/docs/.npmrc
diff --git a/apps/docs/.prettierignore b/apps/docs/.prettierignore
new file mode 100644
index 0000000..7d74fe2
--- /dev/null
+++ b/apps/docs/.prettierignore
@@ -0,0 +1,9 @@
+# Package Managers
+package-lock.json
+pnpm-lock.yaml
+yarn.lock
+bun.lock
+bun.lockb
+
+# Miscellaneous
+/static/
diff --git a/apps/docs/.prettierrc b/apps/docs/.prettierrc
new file mode 100644
index 0000000..3d918d4
--- /dev/null
+++ b/apps/docs/.prettierrc
@@ -0,0 +1,17 @@
+{
+ "useTabs": true,
+ "tabWidth": 4,
+ "singleQuote": true,
+ "trailingComma": "none",
+ "printWidth": 100,
+ "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
+ "overrides": [
+ {
+ "files": "*.svelte",
+ "options": {
+ "parser": "svelte"
+ }
+ }
+ ],
+ "tailwindStylesheet": "./src/routes/app.css"
+}
diff --git a/apps/docs/README.md b/apps/docs/README.md
new file mode 100644
index 0000000..42b69dd
--- /dev/null
+++ b/apps/docs/README.md
@@ -0,0 +1,42 @@
+# sv
+
+Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
+
+## Creating a project
+
+If you're seeing this, you've probably already done this step. Congrats!
+
+```sh
+# create a new project
+npx sv create my-app
+```
+
+To recreate this project with the same configuration:
+
+```sh
+# recreate this project
+bun x sv@0.12.8 create --template minimal --types ts --add prettier eslint tailwindcss="plugins:none" mdsvex --install bun ./
+```
+
+## Developing
+
+Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
+
+```sh
+npm run dev
+
+# or start the server and open the app in a new browser tab
+npm run dev -- --open
+```
+
+## Building
+
+To create a production version of your app:
+
+```sh
+npm run build
+```
+
+You can preview the production build with `npm run preview`.
+
+> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
diff --git a/apps/docs/bun.lock b/apps/docs/bun.lock
new file mode 100644
index 0000000..46b5347
--- /dev/null
+++ b/apps/docs/bun.lock
@@ -0,0 +1,665 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "docs",
+ "devDependencies": {
+ "@eslint/compat": "^2.0.2",
+ "@eslint/js": "^9.39.2",
+ "@sveltejs/adapter-auto": "^7.0.0",
+ "@sveltejs/kit": "^2.55.0",
+ "@sveltejs/package": "^2.5.7",
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
+ "@tailwindcss/typography": "^0.5.19",
+ "@tailwindcss/vite": "^4.1.18",
+ "@types/node": "^22",
+ "eslint": "^10.0.0",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-svelte": "^3.14.0",
+ "globals": "^17.3.0",
+ "mdsvex": "^0.12.6",
+ "prettier": "^3.8.1",
+ "prettier-plugin-svelte": "^3.4.1",
+ "prettier-plugin-tailwindcss": "^0.7.2",
+ "publint": "^0.3.17",
+ "shiki": "^3.22.0",
+ "svelte": "^5.53.13",
+ "svelte-check": "^4.4.0",
+ "tailwindcss": "^4.1.18",
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.54.0",
+ "vite": "^7.3.1",
+ },
+ },
+ },
+ "packages": {
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
+
+ "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
+
+ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
+
+ "@eslint/compat": ["@eslint/compat@2.0.3", "", { "dependencies": { "@eslint/core": "^1.1.1" }, "peerDependencies": { "eslint": "^8.40 || 9 || 10" }, "optionalPeers": ["eslint"] }, "sha512-SjIJhGigp8hmd1YGIBwh7Ovri7Kisl42GYFjrOyHhtfYGGoLW6teYi/5p8W50KSsawUPpuLOSmsq1bD0NGQLBw=="],
+
+ "@eslint/config-array": ["@eslint/config-array@0.23.3", "", { "dependencies": { "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw=="],
+
+ "@eslint/config-helpers": ["@eslint/config-helpers@0.5.3", "", { "dependencies": { "@eslint/core": "^1.1.1" } }, "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw=="],
+
+ "@eslint/core": ["@eslint/core@1.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ=="],
+
+ "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="],
+
+ "@eslint/object-schema": ["@eslint/object-schema@3.0.3", "", {}, "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ=="],
+
+ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.1", "", { "dependencies": { "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ=="],
+
+ "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
+
+ "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
+
+ "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
+
+ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
+
+ "@publint/pack": ["@publint/pack@0.1.4", "", {}, "sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ=="],
+
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
+
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
+
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
+
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
+
+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
+
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
+
+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
+
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
+
+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
+
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
+
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
+
+ "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
+
+ "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="],
+
+ "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="],
+
+ "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],
+
+ "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
+
+ "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
+
+ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
+
+ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
+ "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
+
+ "@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ=="],
+
+ "@sveltejs/kit": ["@sveltejs/kit@2.55.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA=="],
+
+ "@sveltejs/package": ["@sveltejs/package@2.5.7", "", { "dependencies": { "chokidar": "^5.0.0", "kleur": "^4.1.5", "sade": "^1.8.1", "semver": "^7.5.4", "svelte2tsx": "~0.7.33" }, "peerDependencies": { "svelte": "^3.44.0 || ^4.0.0 || ^5.0.0-next.1" }, "bin": { "svelte-package": "svelte-package.js" } }, "sha512-qqD9xa9H7TDiGFrF6rz7AirOR8k15qDK/9i4MIE8te4vWsv5GEogPks61rrZcLy+yWph+aI6pIj2MdoK3YI8AQ=="],
+
+ "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
+
+ "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
+
+ "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
+
+ "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
+
+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="],
+
+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="],
+
+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="],
+
+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="],
+
+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="],
+
+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="],
+
+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="],
+
+ "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="],
+
+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="],
+
+ "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="],
+
+ "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="],
+
+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="],
+
+ "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
+
+ "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="],
+
+ "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
+
+ "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
+
+ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+
+ "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
+
+ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
+
+ "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
+
+ "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
+
+ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
+
+ "@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+
+ "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/type-utils": "8.57.1", "@typescript-eslint/utils": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ=="],
+
+ "@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", "@typescript-eslint/typescript-estree": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw=="],
+
+ "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.1", "@typescript-eslint/types": "^8.57.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg=="],
+
+ "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.1", "", { "dependencies": { "@typescript-eslint/types": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1" } }, "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg=="],
+
+ "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg=="],
+
+ "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.1", "", { "dependencies": { "@typescript-eslint/types": "8.57.1", "@typescript-eslint/typescript-estree": "8.57.1", "@typescript-eslint/utils": "8.57.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA=="],
+
+ "@typescript-eslint/types": ["@typescript-eslint/types@8.57.1", "", {}, "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ=="],
+
+ "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.1", "@typescript-eslint/tsconfig-utils": "8.57.1", "@typescript-eslint/types": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g=="],
+
+ "@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", "@typescript-eslint/typescript-estree": "8.57.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ=="],
+
+ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.1", "", { "dependencies": { "@typescript-eslint/types": "8.57.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A=="],
+
+ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
+
+ "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
+
+ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
+
+ "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
+
+ "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
+
+ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
+
+ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+
+ "brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
+
+ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
+
+ "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
+
+ "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
+
+ "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
+
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
+ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
+
+ "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
+
+ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+
+ "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
+
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "dedent-js": ["dedent-js@1.0.1", "", {}, "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ=="],
+
+ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
+
+ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
+
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="],
+
+ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
+
+ "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
+
+ "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
+
+ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
+
+ "eslint": ["eslint@10.0.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.1", "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ=="],
+
+ "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
+
+ "eslint-plugin-svelte": ["eslint-plugin-svelte@3.15.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.37.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.4.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-k4Nsjs3bHujeEnnckoTM4mFYR1e8Mb9l2rTwNdmYiamA+Tjzn8X+2F+fuSP2w4VbXYhn2bmySyACQYdmUDW2Cg=="],
+
+ "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
+
+ "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
+
+ "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
+
+ "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
+
+ "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
+
+ "esrap": ["esrap@2.2.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@typescript-eslint/types": "^8.2.0" } }, "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg=="],
+
+ "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
+
+ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
+
+ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
+
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+
+ "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
+
+ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
+
+ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
+
+ "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
+
+ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
+
+ "globals": ["globals@17.4.0", "", {}, "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw=="],
+
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
+ "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
+
+ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
+
+ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
+
+ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
+
+ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
+
+ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
+
+ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+
+ "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
+
+ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+
+ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
+
+ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
+
+ "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
+
+ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
+
+ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
+
+ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
+
+ "known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="],
+
+ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
+
+ "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
+
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
+
+ "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
+
+ "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
+
+ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
+
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
+ "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
+
+ "mdsvex": ["mdsvex@0.12.7", "", { "dependencies": { "@types/mdast": "^4.0.4", "@types/unist": "^2.0.3", "prism-svelte": "^0.4.7", "prismjs": "^1.17.1", "unist-util-visit": "^2.0.1", "vfile-message": "^2.0.4" }, "peerDependencies": { "svelte": "^3.56.0 || ^4.0.0 || ^5.0.0-next.120" } }, "sha512-gx4bReLCUvq+MPErHXYeyX+TEq1hsS2KfiZtEOMNTcbibSouFy8AHc5h04KbGCl+g5tLuo4/lbgRVYRnc7bJZw=="],
+
+ "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
+
+ "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
+
+ "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
+
+ "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
+
+ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
+
+ "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
+
+ "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
+
+ "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
+
+ "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
+
+ "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
+
+ "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="],
+
+ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
+
+ "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
+
+ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
+
+ "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
+
+ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
+
+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+
+ "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
+
+ "postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="],
+
+ "postcss-safe-parser": ["postcss-safe-parser@7.0.1", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A=="],
+
+ "postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="],
+
+ "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
+
+ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+
+ "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
+
+ "prettier-plugin-svelte": ["prettier-plugin-svelte@3.5.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg=="],
+
+ "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="],
+
+ "prism-svelte": ["prism-svelte@0.4.7", "", {}, "sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ=="],
+
+ "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
+
+ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
+
+ "publint": ["publint@0.3.18", "", { "dependencies": { "@publint/pack": "^0.1.4", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "sade": "^1.8.1" }, "bin": { "publint": "src/cli.js" } }, "sha512-JRJFeBTrfx4qLwEuGFPk+haJOJN97KnPuK01yj+4k/Wj5BgoOK5uNsivporiqBjk2JDaslg7qJOhGRnpltGeog=="],
+
+ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+
+ "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
+
+ "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
+
+ "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
+
+ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
+
+ "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
+
+ "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
+
+ "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
+
+ "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+
+ "set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
+
+ "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
+
+ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+
+ "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
+
+ "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
+
+ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
+
+ "svelte": ["svelte@5.54.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg=="],
+
+ "svelte-check": ["svelte-check@4.4.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw=="],
+
+ "svelte-eslint-parser": ["svelte-eslint-parser@1.6.0", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0", "semver": "^7.7.2" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-qoB1ehychT6OxEtQAqc/guSqLS20SlA53Uijl7x375s8nlUT0lb9ol/gzraEEatQwsyPTJo87s2CmKL9Xab+Uw=="],
+
+ "svelte2tsx": ["svelte2tsx@0.7.52", "", { "dependencies": { "dedent-js": "^1.0.1", "scule": "^1.3.0" }, "peerDependencies": { "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", "typescript": "^4.9.4 || ^5.0.0" } }, "sha512-svdT1FTrCLpvlU62evO5YdJt/kQ7nxgQxII/9BpQUvKr+GJRVdAXNVw8UWOt0fhoe5uWKyU0WsUTMRVAtRbMQg=="],
+
+ "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
+
+ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
+
+ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+
+ "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
+
+ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
+
+ "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
+
+ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "typescript-eslint": ["typescript-eslint@8.57.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.1", "@typescript-eslint/parser": "8.57.1", "@typescript-eslint/typescript-estree": "8.57.1", "@typescript-eslint/utils": "8.57.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA=="],
+
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+ "unist-util-is": ["unist-util-is@4.1.0", "", {}, "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg=="],
+
+ "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
+
+ "unist-util-stringify-position": ["unist-util-stringify-position@2.0.3", "", { "dependencies": { "@types/unist": "^2.0.2" } }, "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g=="],
+
+ "unist-util-visit": ["unist-util-visit@2.0.3", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^4.0.0", "unist-util-visit-parents": "^3.0.0" } }, "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q=="],
+
+ "unist-util-visit-parents": ["unist-util-visit-parents@3.1.1", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^4.0.0" } }, "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg=="],
+
+ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
+
+ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+
+ "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
+
+ "vfile-message": ["vfile-message@2.0.4", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-stringify-position": "^2.0.0" } }, "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ=="],
+
+ "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
+
+ "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
+
+ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
+ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
+
+ "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
+
+ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+
+ "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
+
+ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+
+ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
+
+ "eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
+
+ "hast-util-to-html/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+
+ "mdast-util-to-hast/unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
+
+ "svelte-check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+
+ "svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
+
+ "svelte-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
+
+ "svelte-eslint-parser/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
+
+ "svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
+
+ "unist-util-position/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+
+ "vfile/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+
+ "vfile/vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
+
+ "mdast-util-to-hast/unist-util-visit/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+
+ "mdast-util-to-hast/unist-util-visit/unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
+
+ "mdast-util-to-hast/unist-util-visit/unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
+
+ "svelte-check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
+
+ "vfile/vfile-message/unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
+ }
+}
diff --git a/eslint.config.js b/apps/docs/eslint.config.js
similarity index 92%
rename from eslint.config.js
rename to apps/docs/eslint.config.js
index 9690379..bf092d2 100644
--- a/eslint.config.js
+++ b/apps/docs/eslint.config.js
@@ -13,10 +13,10 @@ const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
- ...ts.configs.recommended,
- ...svelte.configs.recommended,
+ ts.configs.recommended,
+ svelte.configs.recommended,
prettier,
- ...svelte.configs.prettier,
+ svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
diff --git a/apps/docs/package.json b/apps/docs/package.json
new file mode 100644
index 0000000..fd48ac0
--- /dev/null
+++ b/apps/docs/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "docs",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "prepare": "svelte-kit sync || echo ''",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "lint": "prettier --check . && eslint .",
+ "format": "prettier --write ."
+ },
+ "devDependencies": {
+ "@eslint/compat": "^2.0.2",
+ "@eslint/js": "^9.39.2",
+ "@horuse/svelte-dnd": "workspace:*",
+ "@sveltejs/adapter-auto": "^7.0.0",
+ "@sveltejs/adapter-vercel": "^6.3.3",
+ "@sveltejs/kit": "^2.56.1",
+ "@sveltejs/package": "^2.5.7",
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
+ "@tailwindcss/typography": "^0.5.19",
+ "@tailwindcss/vite": "^4.1.18",
+ "@types/node": "^22",
+ "eslint": "^10.0.0",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-svelte": "^3.14.0",
+ "globals": "^17.3.0",
+ "mdsvex": "^0.12.6",
+ "prettier": "^3.8.1",
+ "prettier-plugin-svelte": "^3.4.1",
+ "prettier-plugin-tailwindcss": "^0.7.2",
+ "publint": "^0.3.17",
+ "shiki": "^3.22.0",
+ "svelte": "^5.55.1",
+ "svelte-check": "^4.4.0",
+ "tailwindcss": "^4.1.18",
+ "turbo": "^2.9.3",
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.54.0",
+ "vite": "^7.3.1"
+ },
+ "imports": {
+ "#app.css": "./src/app.css"
+ },
+ "dependencies": {
+ "virtua": "^0.49.1"
+ }
+}
diff --git a/src/app.css b/apps/docs/src/app.css
similarity index 70%
rename from src/app.css
rename to apps/docs/src/app.css
index dfb8288..4fdd703 100644
--- a/src/app.css
+++ b/apps/docs/src/app.css
@@ -1,6 +1,6 @@
@import 'tailwindcss';
-@plugin '@tailwindcss/typography';
+/*@plugin '@tailwindcss/typography';*/
@custom-variant dark (&:where(.dark, .dark *));
@@ -49,6 +49,9 @@
--opacity-2: 2%;
--opacity-1_5: 1.5%;
--opacity-1: 1%;
+
+ --font-sans: "Inter", sans-serif;
+ --font-mono: "Roboto Mono", monospace;
}
.dark {
@@ -103,11 +106,74 @@
@layer base {
body {
- @apply bg-background
+ @apply bg-background;
}
button {
- @apply cursor-pointer
+ @apply cursor-pointer;
+ }
+
+ *::-webkit-scrollbar {
+ @apply w-2 h-2;
+ }
+ *::-webkit-scrollbar-track,
+ *::-webkit-scrollbar-corner {
+ @apply bg-transparent;
+ }
+ *::-webkit-scrollbar-thumb {
+ @apply rounded-full border-2 border-transparent;
+ }
+
+ input[type="range"] {
+ @apply w-full h-5 bg-transparent cursor-pointer appearance-none;
+ -webkit-appearance: none;
+ }
+ input[type="range"]:focus { @apply outline-none; }
+ input[type="range"]:disabled { @apply cursor-not-allowed opacity-50; }
+
+ /* WebKit / Chromium */
+ input[type="range"]::-webkit-slider-runnable-track {
+ @apply h-1.5 rounded-full;
+ background: color-mix(in oklch, var(--color-theme) 15%, transparent);
+ }
+ input[type="range"]::-webkit-slider-thumb {
+ @apply w-4 h-4 rounded-full appearance-none border-0 transition-transform duration-150 ease-out;
+ -webkit-appearance: none;
+ background: var(--color-theme);
+ margin-top: -5px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
+ }
+ input[type="range"]::-webkit-slider-thumb:hover { @apply scale-110; }
+ input[type="range"]:focus-visible::-webkit-slider-thumb {
+ box-shadow: 0 0 0 4px color-mix(in oklch, var(--color-theme) 25%, transparent);
+ }
+
+ /* Firefox */
+ input[type="range"]::-moz-range-track {
+ @apply h-1.5 rounded-full border-0;
+ background: color-mix(in oklch, var(--color-theme) 15%, transparent);
+ }
+ input[type="range"]::-moz-range-thumb {
+ @apply w-4 h-4 rounded-full border-0 transition-transform duration-150 ease-out;
+ background: var(--color-theme);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
+ }
+ input[type="range"]::-moz-range-thumb:hover { @apply scale-110; }
+ input[type="range"]:focus-visible::-moz-range-thumb {
+ box-shadow: 0 0 0 4px color-mix(in oklch, var(--color-theme) 25%, transparent);
+ }
+
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in oklch, var(--color-theme) 25%, transparent) transparent;
+ }
+ *::-webkit-scrollbar-thumb {
+ background: color-mix(in oklch, var(--color-theme) 25%, transparent);
+ background-clip: padding-box;
+ }
+ *::-webkit-scrollbar-thumb:hover {
+ background: color-mix(in oklch, var(--color-theme) 40%, transparent);
+ background-clip: padding-box;
}
}
@@ -146,12 +212,29 @@
}
.drag-item {
- @apply p-4 flex whitespace-nowrap items-center text-lg justify-center bg-primary border-dashed border-2 text-neutral-500 rounded-xl border-primary-border shadow-sm transition-all;
+ @apply p-4 flex whitespace-nowrap items-center text-lg justify-center bg-primary border-dashed border-2 text-neutral-500 rounded-xl border-primary-border transition-all;
@variant hover {
@apply bg-primary-hover border-primary-hover-border
}
}
+
+
+ .warning-block {
+ @apply text-sm flex flex-col dark:border-amber-500/20 dark:text-amber-500 border-amber-500/50 border-dashed border gap-2 bg-amber-500/10 p-4 rounded-xl text-amber-500;
+ }
+
+ .info-block {
+ @apply text-sm flex flex-col dark:border-blue-500/20 border-blue-500/50 border-dashed border gap-2 bg-blue-500/10 p-4 rounded-xl text-blue-500;
+ }
+
+ .warning-block a {
+ @apply underline underline-offset-2 hover:text-amber-600;
+ }
+
+ .code-block {
+ @apply rounded-lg relative box-decoration-clone inline w-fit bg-theme/10 text-theme/60 px-2 mx-1 py-0.5
+ }
}
.prose {
diff --git a/src/app.d.ts b/apps/docs/src/app.d.ts
similarity index 100%
rename from src/app.d.ts
rename to apps/docs/src/app.d.ts
diff --git a/apps/docs/src/app.html b/apps/docs/src/app.html
new file mode 100644
index 0000000..51e8ade
--- /dev/null
+++ b/apps/docs/src/app.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/apps/docs/src/lib/components/ComponentPreview.svelte b/apps/docs/src/lib/components/ComponentPreview.svelte
new file mode 100644
index 0000000..48264c1
--- /dev/null
+++ b/apps/docs/src/lib/components/ComponentPreview.svelte
@@ -0,0 +1,177 @@
+
+
+
+ {#if showPreview}
+
+ {#key previewKey}
+ {@render children?.()}
+ {/key}
+
+ {/if}
+
+ {#if tabs.length}
+
+
+ {#each tabs as tab, index (tab.name)}
+ (activeTab = index)}
+ >
+ {tab.name}
+
+ {/each}
+
+
+ {#if activeSource}
+
+ {/if}
+
+
+ {/if}
+
+
+ {#if activeSource}
+ {#if highlightedSources[activeSource.name]}
+
+ {:else}
+
{activeSource.code}
+ {/if}
+ {:else}
+ {@render codeSlot?.()}
+ {/if}
+
+ {#if collapsible && !expanded}
+
+
+
(expanded = true)} class="black-button p-1 px-3 text-xs gap-2">
+
+
+
+ Expand
+
+
+ {/if}
+ {#if collapsible && expanded}
+
+
(expanded = false)} class="black-button p-1 px-3 text-xs gap-2">
+
+
+
+ Collapse
+
+
+ {/if}
+
+
+
\ No newline at end of file
diff --git a/apps/docs/src/lib/components/InstallTabs.svelte b/apps/docs/src/lib/components/InstallTabs.svelte
new file mode 100644
index 0000000..5bb18b9
--- /dev/null
+++ b/apps/docs/src/lib/components/InstallTabs.svelte
@@ -0,0 +1,80 @@
+
+
+
+
+
+ {#each packageManagers as pm (pm)}
+ (packageManagerStore.active = pm)}
+ class={[
+ "relative px-4 transition-all py-2 text-sm font-medium outline-none select-none",
+ packageManagerStore.active === pm ? 'text-theme/70 bg-primary rounded-xl' : 'text-neutral-500 hover:text-theme'
+ ]}
+ >
+ {pm}
+
+ {/each}
+
+
+
+
+ {#if highlightedCommands[packageManagerStore.active]}
+
+ {:else}
+
+ {activeCommand}
+
+ {/if}
+
+
\ No newline at end of file
diff --git a/apps/docs/src/lib/components/ShikiCodeBlock.svelte b/apps/docs/src/lib/components/ShikiCodeBlock.svelte
new file mode 100644
index 0000000..716343e
--- /dev/null
+++ b/apps/docs/src/lib/components/ShikiCodeBlock.svelte
@@ -0,0 +1,37 @@
+
+
+
+
+ {@html htmlLight}
+
+
+ {@html htmlDark ?? htmlLight}
+
+
+
+
\ No newline at end of file
diff --git a/apps/docs/src/lib/components/contentsList.svelte b/apps/docs/src/lib/components/contentsList.svelte
new file mode 100644
index 0000000..889a9aa
--- /dev/null
+++ b/apps/docs/src/lib/components/contentsList.svelte
@@ -0,0 +1,380 @@
+
+
+
+ On this page
+
+
+
+
+
+
+
+
+ {#if indicatorHeight > 0}
+
+ {/if}
+
+
+
+ {#each headings as heading (heading.id)}
+
+ scrollTo(heading.id)}
+ use:registerLink={heading.id}
+ >
+ {heading.text}
+
+
+ {/each}
+
+
+
\ No newline at end of file
diff --git a/src/docs/components/header.svelte b/apps/docs/src/lib/components/header.svelte
similarity index 98%
rename from src/docs/components/header.svelte
rename to apps/docs/src/lib/components/header.svelte
index f53c8c0..66fece0 100644
--- a/src/docs/components/header.svelte
+++ b/apps/docs/src/lib/components/header.svelte
@@ -1,5 +1,5 @@
diff --git a/apps/docs/src/lib/components/index.ts b/apps/docs/src/lib/components/index.ts
new file mode 100644
index 0000000..84e8617
--- /dev/null
+++ b/apps/docs/src/lib/components/index.ts
@@ -0,0 +1,6 @@
+export { default as ContentsList } from './contentsList.svelte';
+export { default as Header } from './header.svelte';
+export { default as InstallTabs } from './InstallTabs.svelte';
+export { default as ShikiCodeBlock } from './ShikiCodeBlock.svelte';
+export { default as Sidebar } from './sidebar.svelte';
+export { default as ComponentPreview } from './ComponentPreview.svelte';
\ No newline at end of file
diff --git a/apps/docs/src/lib/components/markdown/Blockquote.svelte b/apps/docs/src/lib/components/markdown/Blockquote.svelte
new file mode 100644
index 0000000..993e833
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Blockquote.svelte
@@ -0,0 +1,22 @@
+
+
+
+ {@render children?.()}
+
\ No newline at end of file
diff --git a/apps/docs/src/lib/components/markdown/Code.svelte b/apps/docs/src/lib/components/markdown/Code.svelte
new file mode 100644
index 0000000..2ba29d4
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Code.svelte
@@ -0,0 +1,35 @@
+
+
+{#if isBlock(typeof className === 'string' ? className : undefined, restProps['data-theme'])}
+
+ {@render children?.()}
+
+{:else}
+
+ {@render children?.()}
+
+{/if}
\ No newline at end of file
diff --git a/apps/docs/src/lib/components/markdown/CopyCodeButton.svelte b/apps/docs/src/lib/components/markdown/CopyCodeButton.svelte
new file mode 100644
index 0000000..61b86df
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/CopyCodeButton.svelte
@@ -0,0 +1,81 @@
+
+
+ {
+ event.stopPropagation();
+ event.preventDefault();
+ handleCopy(code);
+ }}
+ aria-label={copied ? 'Copied code' : 'Copy code'}
+>
+ {copied ? 'Copied code' : 'Copy code'}
+
+
+
+
+
+
+
diff --git a/apps/docs/src/lib/components/markdown/Divider.svelte b/apps/docs/src/lib/components/markdown/Divider.svelte
new file mode 100644
index 0000000..abfa1cf
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Divider.svelte
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/apps/docs/src/lib/components/markdown/H1.svelte b/apps/docs/src/lib/components/markdown/H1.svelte
new file mode 100644
index 0000000..d2614df
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/H1.svelte
@@ -0,0 +1,22 @@
+
+
+
+ {@render children?.()}
+
\ No newline at end of file
diff --git a/apps/docs/src/lib/components/markdown/H2.svelte b/apps/docs/src/lib/components/markdown/H2.svelte
new file mode 100644
index 0000000..75b090e
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/H2.svelte
@@ -0,0 +1,22 @@
+
+
+
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/H3.svelte b/apps/docs/src/lib/components/markdown/H3.svelte
new file mode 100644
index 0000000..aa3e503
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/H3.svelte
@@ -0,0 +1,22 @@
+
+
+
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/H4.svelte b/apps/docs/src/lib/components/markdown/H4.svelte
new file mode 100644
index 0000000..82cd425
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/H4.svelte
@@ -0,0 +1,22 @@
+
+
+
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/Info.svelte b/apps/docs/src/lib/components/markdown/Info.svelte
new file mode 100644
index 0000000..558d659
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Info.svelte
@@ -0,0 +1,19 @@
+
+
+
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/Link.svelte b/apps/docs/src/lib/components/markdown/Link.svelte
new file mode 100644
index 0000000..eedc35e
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Link.svelte
@@ -0,0 +1,22 @@
+
+
+
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/ListItem.svelte b/apps/docs/src/lib/components/markdown/ListItem.svelte
new file mode 100644
index 0000000..076a5a5
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/ListItem.svelte
@@ -0,0 +1,22 @@
+
+
+
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/MarkdownPre.svelte b/apps/docs/src/lib/components/markdown/MarkdownPre.svelte
new file mode 100644
index 0000000..72d7582
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/MarkdownPre.svelte
@@ -0,0 +1,28 @@
+
+
+{#if code}
+
+ {@render code?.()}
+
+{:else}
+
+{/if}
\ No newline at end of file
diff --git a/apps/docs/src/lib/components/markdown/OrderedList.svelte b/apps/docs/src/lib/components/markdown/OrderedList.svelte
new file mode 100644
index 0000000..0f73eb3
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/OrderedList.svelte
@@ -0,0 +1,22 @@
+
+
+li]:pl-1',
+ className
+ )}
+>
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/Paragraph.svelte b/apps/docs/src/lib/components/markdown/Paragraph.svelte
new file mode 100644
index 0000000..e8ba5b7
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Paragraph.svelte
@@ -0,0 +1,22 @@
+
+
+
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/Pre.svelte b/apps/docs/src/lib/components/markdown/Pre.svelte
new file mode 100644
index 0000000..a1f154e
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Pre.svelte
@@ -0,0 +1,51 @@
+
+
+
+
+ {@render children?.()}
+
+ {#if code}
+
+
+
+ {/if}
+
+
+
\ No newline at end of file
diff --git a/apps/docs/src/lib/components/markdown/Step.svelte b/apps/docs/src/lib/components/markdown/Step.svelte
new file mode 100644
index 0000000..cf6374f
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Step.svelte
@@ -0,0 +1,46 @@
+
+
+
+ {#if title}
+
+ {:else}
+
+
+
+
+ {/if}
+
+ {@render children?.()}
+
+
diff --git a/apps/docs/src/lib/components/markdown/Steps.svelte b/apps/docs/src/lib/components/markdown/Steps.svelte
new file mode 100644
index 0000000..9b96a73
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Steps.svelte
@@ -0,0 +1,18 @@
+
+
+
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/Strong.svelte b/apps/docs/src/lib/components/markdown/Strong.svelte
new file mode 100644
index 0000000..18908d2
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Strong.svelte
@@ -0,0 +1,19 @@
+
+
+
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/Table.svelte b/apps/docs/src/lib/components/markdown/Table.svelte
new file mode 100644
index 0000000..0de0f4d
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Table.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+
+ {@render children?.()}
+
+
+
diff --git a/apps/docs/src/lib/components/markdown/Tbody.svelte b/apps/docs/src/lib/components/markdown/Tbody.svelte
new file mode 100644
index 0000000..9428360
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Tbody.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/Td.svelte b/apps/docs/src/lib/components/markdown/Td.svelte
new file mode 100644
index 0000000..9a1e15f
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Td.svelte
@@ -0,0 +1,19 @@
+
+
+
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/Th.svelte b/apps/docs/src/lib/components/markdown/Th.svelte
new file mode 100644
index 0000000..0660a3a
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Th.svelte
@@ -0,0 +1,19 @@
+
+
+
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/Thead.svelte b/apps/docs/src/lib/components/markdown/Thead.svelte
new file mode 100644
index 0000000..db2f52e
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Thead.svelte
@@ -0,0 +1,19 @@
+
+
+
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/Tr.svelte b/apps/docs/src/lib/components/markdown/Tr.svelte
new file mode 100644
index 0000000..de15182
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Tr.svelte
@@ -0,0 +1,22 @@
+
+
+
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/UnorderedList.svelte b/apps/docs/src/lib/components/markdown/UnorderedList.svelte
new file mode 100644
index 0000000..21279fa
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/UnorderedList.svelte
@@ -0,0 +1,22 @@
+
+
+li]:pl-1',
+ className
+ )}
+>
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/Warning.svelte b/apps/docs/src/lib/components/markdown/Warning.svelte
new file mode 100644
index 0000000..558d659
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/Warning.svelte
@@ -0,0 +1,19 @@
+
+
+
+ {@render children?.()}
+
diff --git a/apps/docs/src/lib/components/markdown/index.ts b/apps/docs/src/lib/components/markdown/index.ts
new file mode 100644
index 0000000..d9eefec
--- /dev/null
+++ b/apps/docs/src/lib/components/markdown/index.ts
@@ -0,0 +1,27 @@
+export { default as Blockquote } from './Blockquote.svelte';
+export { default as Code } from './Code.svelte';
+export { default as CopyCodeButton } from './CopyCodeButton.svelte';
+export { default as Divider } from './Divider.svelte';
+export { default as H1 } from './H1.svelte';
+export { default as H2 } from './H2.svelte';
+export { default as H3 } from './H3.svelte';
+export { default as H4 } from './H4.svelte';
+export { default as Link } from './Link.svelte';
+export { default as ListItem } from './ListItem.svelte';
+export { default as MarkdownPre } from './MarkdownPre.svelte';
+export { default as OrderedList } from './OrderedList.svelte';
+export { default as Paragraph } from './Paragraph.svelte';
+export { default as Pre } from './Pre.svelte';
+export { default as Step } from './Step.svelte';
+export { default as Steps } from './Steps.svelte';
+export { default as Strong } from './Strong.svelte';
+export { default as Table } from './Table.svelte';
+export { default as Tbody } from './Tbody.svelte';
+export { default as Td } from './Td.svelte';
+export { default as Th } from './Th.svelte';
+export { default as Thead } from './Thead.svelte';
+export { default as Tr } from './Tr.svelte';
+export { default as UnorderedList } from './UnorderedList.svelte';
+
+export { default as Warning } from './Warning.svelte';
+export { default as Info } from './Info.svelte';
diff --git a/apps/docs/src/lib/components/mdsvex.svelte b/apps/docs/src/lib/components/mdsvex.svelte
new file mode 100644
index 0000000..0ae9e69
--- /dev/null
+++ b/apps/docs/src/lib/components/mdsvex.svelte
@@ -0,0 +1,40 @@
+
+
+
+
+
+ {@render children?.()}
+
\ No newline at end of file
diff --git a/apps/docs/src/lib/components/sidebar.svelte b/apps/docs/src/lib/components/sidebar.svelte
new file mode 100644
index 0000000..8ce0783
--- /dev/null
+++ b/apps/docs/src/lib/components/sidebar.svelte
@@ -0,0 +1,186 @@
+
+
+{#if $sidebarOpen}
+
+
+
+
@horuse/svelte-dnd
+
($sidebarOpen = false)}
+ class="black-button size-9"
+ aria-label="Close sidebar"
+ >
+
+
+
+
+
+
+ {
+ if(e.currentTarget.value) window.location.href = e.currentTarget.value
+ }} class="w-full focus:outline-none">
+ v1.0.0
+ v0.3.0
+
+
+
+
+
+
+
Docs
+
+ {#each docsLinks as link}
+
+ {#if link.children}
+ toggleSection(link.href)}
+ class="sidebar-item w-full flex items-center justify-between"
+ class:active={isParentActive(link)}
+ >
+ {link.label}
+
+
+
+
+ {#if openSections[link.href]}
+
+ {#each link.children as child}
+
+
+
+ {/each}
+
+ {/if}
+ {:else}
+
+ {/if}
+
+ {/each}
+
+
+
+
Examples
+
+ {#each exampleLinks as link}
+
+
+
+ {/each}
+
+
+
+
+{/if}
\ No newline at end of file
diff --git a/apps/docs/src/lib/examples/AccessibilityExample.svelte b/apps/docs/src/lib/examples/AccessibilityExample.svelte
new file mode 100644
index 0000000..2ef5da9
--- /dev/null
+++ b/apps/docs/src/lib/examples/AccessibilityExample.svelte
@@ -0,0 +1,170 @@
+
+
+
+
+ Tab to focus a task · Space /Enter to pick up ·
+ ↑ ↓ ← → to move ·
+ Home /End to jump · Esc to cancel.
+
+
+
+
+ {#each Object.entries(columnLabels) as [id, label] (id)}
+
+
{label}
+
+ {#each board[id] as item, index (item.id)}
+
+ {item.label}
+
+ {/each}
+
+
+ {/each}
+
+
+
+
+ Screen reader log
+ {#if announcements.length === 0}
+ Announcements will appear here…
+ {:else}
+ {#each announcements as entry, idx (entry.id)}
+
+ {entry.text}
+
+ {/each}
+ {/if}
+
+
+
+
diff --git a/apps/docs/src/lib/examples/CollisionExample.svelte b/apps/docs/src/lib/examples/CollisionExample.svelte
new file mode 100644
index 0000000..a1b3a18
--- /dev/null
+++ b/apps/docs/src/lib/examples/CollisionExample.svelte
@@ -0,0 +1,73 @@
+
+
+
+
Right column requires 40% overlap — drag slowly across the border to feel the difference.
+
+
+
+
+
centerPoint default
+
+ {#each left as item, index (item.id)}
+
+ {item.label}
+
+ {/each}
+
+
+
+
+
overlap 40%
+
+ {#each right as item, index (item.id)}
+
+ {item.label}
+
+ {/each}
+
+
+
+
+
+
+
diff --git a/apps/docs/src/lib/examples/CustomBehaviorExample.svelte b/apps/docs/src/lib/examples/CustomBehaviorExample.svelte
new file mode 100644
index 0000000..1e8b28b
--- /dev/null
+++ b/apps/docs/src/lib/examples/CustomBehaviorExample.svelte
@@ -0,0 +1,155 @@
+
+
+
+
+ {#each items as item, index (item.id)}
+
+
+ {item.label}
+
+
+ {/each}
+
+
+
+
diff --git a/apps/docs/src/lib/examples/CustomGhostExample.svelte b/apps/docs/src/lib/examples/CustomGhostExample.svelte
new file mode 100644
index 0000000..8153fdb
--- /dev/null
+++ b/apps/docs/src/lib/examples/CustomGhostExample.svelte
@@ -0,0 +1,110 @@
+
+
+
+ {#snippet ghost({ data, itemId })}
+
+ {data?.label ?? itemId}
+
+ {/snippet}
+
+
+ {#each Object.entries(columns) as [columnId, columnItems] (columnId)}
+
+
{columnMeta[columnId]}
+
+ {#each columnItems as item, index (item.id)}
+
+
+ {item.label}
+
+
+ {/each}
+
+
+ {/each}
+
+
+
+
diff --git a/apps/docs/src/lib/examples/GridExample.svelte b/apps/docs/src/lib/examples/GridExample.svelte
new file mode 100644
index 0000000..fcabd32
--- /dev/null
+++ b/apps/docs/src/lib/examples/GridExample.svelte
@@ -0,0 +1,48 @@
+
+
+
+
+ {#each items as item, index (item.id)}
+
+
+ {item.id}
+
+
+ {/each}
+
+
+
+
diff --git a/apps/docs/src/lib/examples/HorizontalExample.svelte b/apps/docs/src/lib/examples/HorizontalExample.svelte
new file mode 100644
index 0000000..3787532
--- /dev/null
+++ b/apps/docs/src/lib/examples/HorizontalExample.svelte
@@ -0,0 +1,43 @@
+
+
+
+
+ {#each items as item, index (item.id)}
+
+
+ {item.id}
+
+
+ {/each}
+
+
+
+
diff --git a/apps/docs/src/lib/examples/ModifiersExample.svelte b/apps/docs/src/lib/examples/ModifiersExample.svelte
new file mode 100644
index 0000000..a59d15c
--- /dev/null
+++ b/apps/docs/src/lib/examples/ModifiersExample.svelte
@@ -0,0 +1,77 @@
+
+
+
+
+ {#each options as opt, i}
+ (activeIndex = i)}
+ >
+ {opt.label}
+
+ {/each}
+
+
+
+
+ {#each items as item, index (item.id)}
+
+
+ {item.label}
+
+
+ {/each}
+
+
+
+
+
diff --git a/apps/docs/src/lib/examples/MultiContainerExample.svelte b/apps/docs/src/lib/examples/MultiContainerExample.svelte
new file mode 100644
index 0000000..6bee577
--- /dev/null
+++ b/apps/docs/src/lib/examples/MultiContainerExample.svelte
@@ -0,0 +1,80 @@
+
+
+
+
+ {#each Object.entries(columns) as [columnId, columnItems] (columnId)}
+
+
{columnMeta[columnId]}
+
+ {#each columnItems as item, index (item.id)}
+
+
+ {item.label}
+
+
+ {/each}
+
+
+ {/each}
+
+
+
+
diff --git a/apps/docs/src/lib/examples/SensorsExample.svelte b/apps/docs/src/lib/examples/SensorsExample.svelte
new file mode 100644
index 0000000..cc46b9d
--- /dev/null
+++ b/apps/docs/src/lib/examples/SensorsExample.svelte
@@ -0,0 +1,111 @@
+
+
+
+
+
diff --git a/apps/docs/src/lib/examples/SimulationsBatchSwapExample.svelte b/apps/docs/src/lib/examples/SimulationsBatchSwapExample.svelte
new file mode 100644
index 0000000..f11e2fd
--- /dev/null
+++ b/apps/docs/src/lib/examples/SimulationsBatchSwapExample.svelte
@@ -0,0 +1,111 @@
+
+
+
+
+ Swap Teams
+
+
+
+
+
+
Team A
+
+ {#each teamA as item, index (item.id)}
+
+
+ {item.label}
+
+
+ {/each}
+
+
+
+
+
Team B
+
+ {#each teamB as item, index (item.id)}
+
+
+ {item.label}
+
+
+ {/each}
+
+
+
+
+
+
+
diff --git a/apps/docs/src/lib/examples/SimulationsExample.svelte b/apps/docs/src/lib/examples/SimulationsExample.svelte
new file mode 100644
index 0000000..8c5b384
--- /dev/null
+++ b/apps/docs/src/lib/examples/SimulationsExample.svelte
@@ -0,0 +1,79 @@
+
+
+
+
+ Sort A→Z
+
+ Undo ({history.length})
+
+
+
+
+
+ {#each items as item, index (item.id)}
+
+
+ {item.label}
+
+
+ {/each}
+
+
+
+
+
diff --git a/apps/docs/src/lib/examples/SimulationsGroupByColorExample.svelte b/apps/docs/src/lib/examples/SimulationsGroupByColorExample.svelte
new file mode 100644
index 0000000..94ccec4
--- /dev/null
+++ b/apps/docs/src/lib/examples/SimulationsGroupByColorExample.svelte
@@ -0,0 +1,150 @@
+
+
+
+
+
+ Group by color
+
+
+ Shuffle
+
+
+
+
+
+ {#each containerColors as color (color)}
+
+
+
+ {labelMap[color]}
+
+
+ {#each containers[color] as task, index (task.id)}
+
+
+
+ {task.label}
+
+
+ {/each}
+
+
+ {/each}
+
+
+
+
+
diff --git a/apps/docs/src/lib/examples/SortableContainersExample/TrashZone.svelte b/apps/docs/src/lib/examples/SortableContainersExample/TrashZone.svelte
new file mode 100644
index 0000000..b74b637
--- /dev/null
+++ b/apps/docs/src/lib/examples/SortableContainersExample/TrashZone.svelte
@@ -0,0 +1,175 @@
+
+
+
+{#if items.length > 0}
+
+
+
+
+
+ {#each items as item (item.task.id)}
+
+
+
+
{item.task.label}
+
cancelDelete(item)}
+ >
+ Undo
+
+
+ {/each}
+
+
Items will be permanently deleted when the bar empties.
+
+{/if}
+
+
+
+
+ {#each items as item (item.task.id)}
+
+
+ {item.task.label}
+ Alert
+
+
+ {/each}
+
+
+
+
diff --git a/apps/docs/src/lib/examples/SortableContainersExample/index.svelte b/apps/docs/src/lib/examples/SortableContainersExample/index.svelte
new file mode 100644
index 0000000..dcb2b88
--- /dev/null
+++ b/apps/docs/src/lib/examples/SortableContainersExample/index.svelte
@@ -0,0 +1,178 @@
+
+
+
+
+
Control panel
+
+
+
controller.toggleDebugZones()}>Show debug zones
+
- To see, start dragging
+
+
+
Move backlog[0] → In Progress
+
+
+
+
+
+
+ {#each columns as column, colIndex (column.id)}
+
+
+
+ ☰
+ {column.title}
+
+
+
+ {#each column.tasks as task, taskIndex (task.id)}
+
+
+ {task.label}
+ alert('Test')}>Alert
+
+
+ {/each}
+
+
+
+ {/each}
+
+
+
+
+
+
+
diff --git a/apps/docs/src/lib/examples/StrategiesExample.svelte b/apps/docs/src/lib/examples/StrategiesExample.svelte
new file mode 100644
index 0000000..171bd81
--- /dev/null
+++ b/apps/docs/src/lib/examples/StrategiesExample.svelte
@@ -0,0 +1,107 @@
+
+
+
+
Drag from Inbox to Triage — top half marks as high priority, bottom half as low.
+
+
+
+
+
Inbox
+
+ {#each inbox as item, index (item.id)}
+
+ {item.label}
+
+ {/each}
+
+
+
+
+
Triage priority mode
+
+ ⬆ High
+ {#each triage as item, index (item.id)}
+
+ {item.label}
+
+ {/each}
+ ⬇ Low
+
+
+
+
+
+
+
diff --git a/apps/docs/src/lib/examples/TargetZonesExample.svelte b/apps/docs/src/lib/examples/TargetZonesExample.svelte
new file mode 100644
index 0000000..054fb3f
--- /dev/null
+++ b/apps/docs/src/lib/examples/TargetZonesExample.svelte
@@ -0,0 +1,132 @@
+
+
+
+ {#snippet ghost({ data, itemId })}
+ {@const overSlot = !!controller.dropPreview && controller.dropPreview.containerId !== 'pool'}
+
+ {data?.label ?? itemId}
+
+ {/snippet}
+
+
+
+
Pool
+
+ {#each zones.pool as item, index (item.id)}
+
+ {item.label}
+
+ {/each}
+
+
+
+
+ {#each targetZones as zone (zone.id)}
+
+
{zone.label}
+
+ {#each zones[zone.id] as item, index (item.id)}
+
+ {item.label}
+
+ {/each}
+
+
+ {/each}
+
+
+
+
+
diff --git a/apps/docs/src/lib/examples/VerticalExample.svelte b/apps/docs/src/lib/examples/VerticalExample.svelte
new file mode 100644
index 0000000..ad81dba
--- /dev/null
+++ b/apps/docs/src/lib/examples/VerticalExample.svelte
@@ -0,0 +1,43 @@
+
+
+
+
+ {#each items as item, index (item.id)}
+
+
+ {item.id}
+
+
+ {/each}
+
+
+
+
diff --git a/apps/docs/src/lib/examples/VerticalSortableContainersExample/index.svelte b/apps/docs/src/lib/examples/VerticalSortableContainersExample/index.svelte
new file mode 100644
index 0000000..e43b967
--- /dev/null
+++ b/apps/docs/src/lib/examples/VerticalSortableContainersExample/index.svelte
@@ -0,0 +1,90 @@
+
+
+
+
+ {#each containers as container, containerIndex (container.id)}
+
+
+ {container.title}
+ ({container.items.length})
+
+ {#each container.items as item, itemIndex (item.id)}
+
+
+ {item.label}
+
+
+ {/each}
+
+ {/each}
+
+
+
+
diff --git a/apps/docs/src/lib/examples/VirtualizationExample.svelte b/apps/docs/src/lib/examples/VirtualizationExample.svelte
new file mode 100644
index 0000000..7e67a84
--- /dev/null
+++ b/apps/docs/src/lib/examples/VirtualizationExample.svelte
@@ -0,0 +1,105 @@
+
+
+
+
+ it.id} {keepMounted} style="padding: 12px;" data-dnd-scroll>
+ {#snippet children(item, index)}
+
+
+ {item.id}
+
+
+ {/snippet}
+
+
+
+
+
diff --git a/apps/docs/src/lib/site-config.ts b/apps/docs/src/lib/site-config.ts
new file mode 100644
index 0000000..004184e
--- /dev/null
+++ b/apps/docs/src/lib/site-config.ts
@@ -0,0 +1,22 @@
+export const siteConfig = {
+ name: '@horuse/svelte-dnd',
+ shortName: '@horuse/svelte-dnd',
+ description:
+ 'A lightweight drag-and-drop library for Svelte 5. Vertical, horizontal, and grid layouts, virtualized lists, animated drop previews, pointer & keyboard sensors, full TypeScript types.',
+ tagline:
+ 'A drag-and-drop library for Svelte 5 with animated drop previews, auto-scroll, pointer & touch support, keyboard navigation, and multi-container layouts.',
+ url: 'https://svelte-dnd.vercel.app',
+ ogImage: '/preview.gif',
+ keywords: [
+ 'svelte',
+ 'svelte 5',
+ 'drag and drop',
+ 'dnd',
+ 'sortable',
+ 'runes',
+ 'typescript',
+ 'accessibility'
+ ],
+ author: 'Horuse',
+ pkg: '@horuse/svelte-dnd'
+} as const;
diff --git a/src/docs/stores.ts b/apps/docs/src/lib/stores.ts
similarity index 100%
rename from src/docs/stores.ts
rename to apps/docs/src/lib/stores.ts
diff --git a/apps/docs/src/lib/stores/package-manager.svelte.ts b/apps/docs/src/lib/stores/package-manager.svelte.ts
new file mode 100644
index 0000000..3077f36
--- /dev/null
+++ b/apps/docs/src/lib/stores/package-manager.svelte.ts
@@ -0,0 +1,24 @@
+import { browser } from '$app/environment';
+
+export type PackageManagerSvelte = 'npm' | 'pnpm' | 'bun' | 'yarn';
+
+export const packageManagers: PackageManagerSvelte[] = ['npm', 'pnpm', 'bun', 'yarn'];
+
+function createPackageManagerStore() {
+ const storageKey = 'package-manager';
+ const stored = browser ? localStorage.getItem(storageKey) as PackageManagerSvelte : null;
+
+ let active = $state(
+ stored && packageManagers.includes(stored) ? stored : 'npm'
+ );
+
+ return {
+ get active() { return active; },
+ set active(v: PackageManagerSvelte) {
+ active = v;
+ if (browser) localStorage.setItem(storageKey, v);
+}
+};
+}
+
+export const packageManagerStore = createPackageManagerStore();
\ No newline at end of file
diff --git a/apps/docs/src/lib/utils/cn.ts b/apps/docs/src/lib/utils/cn.ts
new file mode 100644
index 0000000..eb30078
--- /dev/null
+++ b/apps/docs/src/lib/utils/cn.ts
@@ -0,0 +1,3 @@
+export function cn(...classes: Array) {
+ return classes.filter(Boolean).join(' ');
+}
\ No newline at end of file
diff --git a/apps/docs/src/lib/utils/docs-manifest.ts b/apps/docs/src/lib/utils/docs-manifest.ts
new file mode 100644
index 0000000..911a178
--- /dev/null
+++ b/apps/docs/src/lib/utils/docs-manifest.ts
@@ -0,0 +1,96 @@
+/**
+ * Builds a lightweight manifest of every `+page.svx` file under
+ * `src/routes/docs/**` and `src/routes/examples/**` from their YAML
+ * frontmatter, without pulling the compiled mdsvex modules into the bundle.
+ *
+ * Used by `/llms.txt`, `/sitemap.xml`, and per-page SEO in docs/examples
+ * layouts.
+ */
+
+const docsRaw = import.meta.glob('../../routes/docs/**/+page.svx', {
+ query: '?raw',
+ import: 'default',
+ eager: true
+}) as Record
+
+const examplesRaw = import.meta.glob('../../routes/examples/**/+page.svx', {
+ query: '?raw',
+ import: 'default',
+ eager: true
+}) as Record
+
+export type ManifestSection = 'Docs' | 'Components' | 'Migrations' | 'Examples'
+
+export type ManifestEntry = {
+ href: string
+ title: string
+ description: string
+ section: ManifestSection
+ source: string
+ modified?: string
+}
+
+function parseFrontmatter(raw: string): Record {
+ const m = raw.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/)
+ if (!m) return {}
+ const out: Record = {}
+ for (const line of m[1].split(/\r?\n/)) {
+ const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.+?)\s*$/)
+ if (kv) out[kv[1]] = kv[2].replace(/^['"]|['"]$/g, '')
+ }
+ return out
+}
+
+function pathToHref(filePath: string): string {
+ return filePath.replace(/^.*\/routes/, '').replace(/\/\+page\.svx$/, '')
+}
+
+function classify(href: string, override?: string): ManifestSection {
+ if (override === 'Docs' || override === 'Components' || override === 'Migrations' || override === 'Examples') {
+ return override
+ }
+ if (href.startsWith('/examples/')) return 'Examples'
+ if (href.startsWith('/docs/components-api/')) return 'Components'
+ if (href.startsWith('/docs/migrations/')) return 'Migrations'
+ return 'Docs'
+}
+
+function buildEntry(filePath: string, raw: string): ManifestEntry {
+ const fm = parseFrontmatter(raw)
+ const href = pathToHref(filePath)
+ const fallbackTitle = href.split('/').filter(Boolean).pop() ?? href
+ return {
+ href,
+ title: fm.title || fallbackTitle,
+ description: fm.description || '',
+ section: classify(href, fm.category),
+ source: filePath,
+ modified: fm.modified || undefined
+ }
+}
+
+function buildManifest(files: Record): ManifestEntry[] {
+ return Object.entries(files)
+ .map(([p, raw]) => buildEntry(p, raw))
+ .sort((a, b) => a.href.localeCompare(b.href))
+}
+
+export const docsManifest: ManifestEntry[] = buildManifest(docsRaw)
+export const examplesManifest: ManifestEntry[] = buildManifest(examplesRaw)
+export const allManifest: ManifestEntry[] = [...docsManifest, ...examplesManifest]
+
+export function findManifestEntry(pathname: string | undefined): ManifestEntry | undefined {
+ if (!pathname) return undefined
+ const normalized = pathname.replace(/\/+$/, '') || '/'
+ return allManifest.find((e) => e.href === normalized)
+}
+
+export function groupBySection(entries: ManifestEntry[]): Map {
+ const groups = new Map()
+ for (const e of entries) {
+ const arr = groups.get(e.section) ?? []
+ arr.push(e)
+ groups.set(e.section, arr)
+ }
+ return groups
+}
diff --git a/apps/docs/src/lib/utils/highlighter.ts b/apps/docs/src/lib/utils/highlighter.ts
new file mode 100644
index 0000000..571e9bb
--- /dev/null
+++ b/apps/docs/src/lib/utils/highlighter.ts
@@ -0,0 +1,25 @@
+import { createHighlighterCore } from 'shiki/core';
+import { createOnigurumaEngine } from 'shiki/engine/oniguruma';
+import getWasm from 'shiki/wasm';
+import githubLight from 'shiki/themes/github-light.mjs';
+import githubDark from 'shiki/themes/github-dark.mjs';
+
+import typescript from 'shiki/langs/typescript.mjs';
+import svelte from 'shiki/langs/svelte.mjs';
+import xml from 'shiki/langs/xml.mjs';
+import bash from 'shiki/langs/bash.mjs';
+import json from 'shiki/langs/json.mjs';
+import wgsl from 'shiki/langs/wgsl.mjs';
+
+let highlighter: Awaited> | null = null;
+
+export async function getHighlighter() {
+ if (!highlighter) {
+ highlighter = await createHighlighterCore({
+ themes: [githubLight, githubDark],
+ langs: [typescript, svelte, xml, bash, json, wgsl],
+ engine: createOnigurumaEngine(getWasm)
+ });
+ }
+ return highlighter;
+}
\ No newline at end of file
diff --git a/apps/docs/src/lib/utils/svx-to-markdown.ts b/apps/docs/src/lib/utils/svx-to-markdown.ts
new file mode 100644
index 0000000..ed5fbbe
--- /dev/null
+++ b/apps/docs/src/lib/utils/svx-to-markdown.ts
@@ -0,0 +1,63 @@
+/**
+ * Transforms raw .svx content into clean markdown for LLM consumption.
+ * Replaces blocks with fenced code blocks,
+ * and removes Svelte
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @horuse/svelte-dnd
+
+
+
+
+ A lightweight drag-and-drop library for Svelte 5 .
+ Vertical, horizontal, and grid layouts with smooth animations out of the box.
+
+
+
+ {#each [
+ 'Svelte 5 Runes — no legacy adapter',
+ 'Cross-container drag with slot collapse animation',
+ 'TypeScript + full event types',
+ 'Pointer & keyboard sensors built-in',
+ ] as feat}
+
+ -
+ {feat}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Installation
+
+
+
+
+
+
Quick start
+
+
+
+
+
diff --git a/apps/docs/src/routes/docs/+layout.svelte b/apps/docs/src/routes/docs/+layout.svelte
new file mode 100644
index 0000000..80f94c9
--- /dev/null
+++ b/apps/docs/src/routes/docs/+layout.svelte
@@ -0,0 +1,56 @@
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@render props.children?.()}
+ {#if modifiedLabel}
+
+ Updated {modifiedLabel}
+
+ {/if}
+
+
+
+
diff --git a/apps/docs/src/routes/docs/[...path].md/+server.ts b/apps/docs/src/routes/docs/[...path].md/+server.ts
new file mode 100644
index 0000000..c860e41
--- /dev/null
+++ b/apps/docs/src/routes/docs/[...path].md/+server.ts
@@ -0,0 +1,13 @@
+import type { RequestHandler } from './$types';
+import { svxToMarkdown } from '$lib/utils/svx-to-markdown';
+
+const rawFiles = import.meta.glob('../../../lib/**/*', { query: '?raw', import: 'default', eager: true }) as Record;
+
+export const GET: RequestHandler = async ({ params }) => {
+ const file = await import(`../${params.path}/+page.svx?raw`);
+ const content = svxToMarkdown(file.default, rawFiles, '../../../lib/');
+
+ return new Response(content, {
+ headers: { 'Content-Type': 'text/markdown' }
+ });
+};
diff --git a/apps/docs/src/routes/docs/accessibility/+page.svx b/apps/docs/src/routes/docs/accessibility/+page.svx
new file mode 100644
index 0000000..e7c1f2b
--- /dev/null
+++ b/apps/docs/src/routes/docs/accessibility/+page.svx
@@ -0,0 +1,101 @@
+---
+title: Accessibility
+description: Keyboard navigation, ARIA attributes, and screen-reader announcements.
+modified: 2026-05-11
+---
+
+# Accessibility
+
+## Keyboard navigation
+
+Keyboard drag-and-drop is powered by `KeyboardSensor` and is active by default. Focusable items have `role="button"` and `tabindex="0"` automatically set.
+
+| Key | Action |
+|-----|--------|
+| `Tab` | Move focus between draggable items |
+| `Enter` / `Space` | Pick up the focused item and start drag |
+| Main-axis arrows | Move within the current container by one position |
+| Cross-axis arrows | Switch to the visually adjacent container (any time, not just at the edge) |
+| `Home` / `End` | Jump to the first / last position of the current container |
+| `Enter` / `Space` | Drop at the current preview position |
+| `Escape` | Cancel drag, ghost returns to origin |
+
+Main and cross axes are derived from the source droppable's `layout`:
+
+- `vertical`: `↑` / `↓` move within, `←` / `→` switch container.
+- `horizontal`: `←` / `→` move within, `↑` / `↓` switch container.
+- `grid`: all four arrows move within (next column / next row).
+
+The adjacent container is picked by visual position (sorted by `getBoundingClientRect()`), and inside it the landing position is the one whose slot center is closest to the slot you came from on the cross axis — so a Kanban move from "To Do · pos 2" to the column on the right lands near the same vertical level rather than at the top.
+
+The ghost smoothly flies to the live slot rect on each keypress (`animation.keyboardFlight`, default 150 ms), and the target slot is auto-scrolled into view when it sits past the visible edge of any scrollable ancestor.
+
+## Screen-reader announcements
+
+`DndProvider` renders a visually-hidden ARIA live region (`aria-live="assertive"`) that announces drag events to screen readers. The default announcements are:
+
+| Event | Default message |
+|-------|----------------|
+| Drag start | `Started dragging item {item.id}.` |
+| Drag over new position | `Item {item.id} is over {current.id} at position {current.position}.` |
+| Drop | `Dropped item {item.id} into {target.id} at position {target.position}.` |
+| Cancel | `Dragging {item.id} was cancelled.` |
+
+### Custom announcements
+
+Pass `announcements` in the `DndController` constructor to customise the messages:
+
+```ts
+import { DndController, defaultAnnouncements } from '@horuse/svelte-dnd';
+
+const controller = new DndController({
+ announcements: {
+ ...defaultAnnouncements,
+ onDragStart: ({ item }) => `Picked up ${item.id}. Use arrow keys to move it.`,
+ onDragOver: ({ item, current }) =>
+ `Moving ${item.id} to column ${current.id}, position ${current.position + 1}.`,
+ onDrop: ({ item, target }) => `Dropped ${item.id} into ${target.id}.`,
+ onCancel: ({ item }) => `Cancelled moving ${item.id}.`,
+ },
+});
+```
+
+Each callback receives a rich event object. All event types are exported from `@horuse/svelte-dnd`.
+
+### Announcements type
+
+```ts
+import type { DragStartEvent, DragOverEvent, DropEvent, DropCancelledEvent } from '@horuse/svelte-dnd';
+
+interface Announcements {
+ onDragStart?: (event: DragStartEvent) => string;
+ onDragOver?: (event: DragOverEvent) => string;
+ onDrop?: (event: DropEvent) => string;
+ onCancel?: (event: DropCancelledEvent) => string;
+}
+```
+
+## ARIA attributes
+
+The library sets these attributes automatically:
+
+| Element | Attributes |
+|---------|-----------|
+| `DndDraggable` inner div | `role="button"`, `tabindex="0"`, `aria-roledescription="draggable item"` |
+| `DndDroppable` | `aria-dropeffect="move"` (or `"none"` when `disabled`) |
+
+`aria-grabbed` is intentionally not set — it was deprecated in WAI-ARIA 1.1 and has poor screen-reader support. Pickup state is communicated through the live-region announcer instead.
+
+---
+
+## Example
+
+
+
+
+
+
diff --git a/apps/docs/src/routes/docs/behaviors/+page.svx b/apps/docs/src/routes/docs/behaviors/+page.svx
new file mode 100644
index 0000000..c4c9413
--- /dev/null
+++ b/apps/docs/src/routes/docs/behaviors/+page.svx
@@ -0,0 +1,314 @@
+---
+title: Behaviors
+description: Pluggable drop-side hooks — auto-scroll under drag, scroll-sync during drop animations, and any future plugins. Composable per controller and per strategy.
+modified: 2026-05-11
+---
+
+
+
+# Behaviors
+
+Behaviors are the plugin layer for drop-side concerns. They mirror the patterns the rest of the library already uses — `modifiers[]`, `sensors[]`, `collision` factories — so you compose, swap, or replace them the same way.
+
+```ts
+import { autoScroll, scrollSync } from '@horuse/svelte-dnd'
+
+new DndController({
+ behaviors: [autoScroll(), scrollSync()] // default if omitted
+})
+```
+
+## Where they plug in
+
+
+
+ Set the behaviors field on new DndController. Applied to every droppable that doesn't override, plus non-droppable data-dnd-scroll wrappers.
+
+
+ Set the behaviors option on sortable or target. Replaces the controller defaults for that droppable.
+
+
+ Set the behaviors option on animateItem. Overrides everything for that animation only.
+
+
+
+Pass `behaviors: []` to opt out of all defaults.
+
+---
+
+## `autoScroll()`
+
+Edge-triggered auto-scroll while a drag is in progress. When the pointer enters the trigger zone near a scrollable container's edge, the container scrolls automatically — speed scales with proximity to the edge.
+
+
+
+### Options
+
+| Option | Default | Effect |
+|--------|---------|--------|
+| `zoneRatio` | `0.3` | Trigger zone size as a fraction of the container's height/width. `0.3` = innermost 30% from each edge engages scroll. |
+| `maxSpeed` | `30` | Maximum scroll speed in px/frame at 60fps. |
+| `stopOnDrop` | `false` | Halt scroll the moment the user releases the pointer. Controller-level only — per-strategy override is ignored. |
+
+### Examples
+
+```ts
+// Global default — also applies to data-dnd-scroll wrappers
+new DndController({
+ behaviors: [autoScroll({ zoneRatio: 0.2, maxSpeed: 50 })]
+})
+
+// Override on one droppable
+sortable({
+ layout: 'vertical',
+ behaviors: [autoScroll({ maxSpeed: 60 })]
+})
+
+// Disable auto-scroll for a specific list (keep scroll-sync)
+sortable({
+ layout: 'horizontal',
+ behaviors: [scrollSync()]
+})
+```
+
+---
+
+## `scrollSync()`
+
+Wraps the drop / return animation: when the destination slot is hidden enough inside its scrollable container, the container scrolls together with the ghost flight so the ghost stays inside the viewport.
+
+
+
+### Options
+
+| Option | Default | Effect |
+|--------|---------|--------|
+| `threshold` | `1` | Visibility ratio (0..1) below which scroll-sync engages. `1` = scroll if any pixel of the target slot is hidden. `0.5` = scroll only when less than half visible. `0` = never scroll-sync (pass-through to the standard ghost flight). |
+
+### Examples
+
+```ts
+// Tighter threshold — only scroll when most of the slot is off-screen
+sortable({
+ layout: 'vertical',
+ behaviors: [autoScroll(), scrollSync({ threshold: 0.5 })]
+})
+
+// Disable scroll-sync for a specific list
+sortable({
+ layout: 'horizontal',
+ behaviors: [autoScroll()]
+})
+
+// Per-call override — disable for a single programmatic animation
+await controller.animateItem('task-3', {
+ to: { containerId: 'in-progress', position: 0 },
+ behaviors: [autoScroll()]
+})
+```
+
+---
+
+## Composing behaviors
+
+Behaviors stack — pass as many as you want. **The first listed wraps outer-most** for any `wrapDropAnimation` hooks. Order matters when a behavior fully *replaces* the inner step: anything wrapped inside it doesn't run on that branch. `scrollSync()` is the typical case — when its threshold engages, it substitutes a scroll-synchronised flight for the base step, so put any side-effect behaviors that should fire on every drop earlier in the list (i.e. outer).
+
+```ts
+new DndController({
+ behaviors: [
+ autoScroll({ maxSpeed: 60 }),
+ dropSound, // side effect — fires regardless of scrollSync branch
+ scrollSync({ threshold: 0.5 })
+ // future: clipGhost(), edgeIndicator(), …
+ ]
+})
+```
+
+---
+
+## Replacing at runtime
+
+```ts
+controller.setBehaviors([
+ autoScroll({ maxSpeed: 60 }),
+ scrollSync({ threshold: 0.5 })
+])
+```
+
+Updates only the controller-level defaults. Per-strategy `behaviors` set on `sortable()` / `target()` are unaffected.
+
+---
+
+## Custom behaviors
+
+A `Behavior` is duck-typed — implement only the hooks you care about. Both library built-ins are written this way.
+
+```ts
+import type { Behavior } from '@horuse/svelte-dnd'
+
+// Compose four lightweight drop reactions:
+// 1. dropSound — short Web Audio chirp on every drop
+// 2. slotRipple — destination slot flashes indigo, fades out
+// 3. confettiBurst — six 🎉 spans burst from the drop position
+// 4. arrivalBounce — destination slot pops (scale 1 → 1.08 → 1)
+//
+// Each is its own Behavior so they can be toggled / reused independently.
+
+let audioCtx: AudioContext | null = null
+const dropSound: Behavior = {
+ name: 'dropSound',
+ wrapDropAnimation(next) {
+ return {
+ execute() {
+ audioCtx ??= new AudioContext()
+ if (audioCtx.state === 'suspended') audioCtx.resume()
+ const osc = audioCtx.createOscillator()
+ const gain = audioCtx.createGain()
+ osc.connect(gain).connect(audioCtx.destination)
+ osc.type = 'sine'
+ osc.frequency.value = 660
+ gain.gain.setValueAtTime(0.15, audioCtx.currentTime)
+ gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.15)
+ osc.start()
+ osc.stop(audioCtx.currentTime + 0.15)
+ return next.execute()
+ },
+ cancel: () => next.cancel?.()
+ }
+ }
+}
+
+const slotRipple: Behavior = {
+ name: 'slotRipple',
+ wrapDropAnimation(next, ctx) {
+ return {
+ execute() {
+ ctx.targetEl?.animate(
+ [
+ { backgroundColor: 'rgba(99, 102, 241, 0.35)', borderRadius: '12px' },
+ { backgroundColor: 'transparent', borderRadius: '12px' }
+ ],
+ { duration: 500, easing: 'ease-out' }
+ )
+ return next.execute()
+ },
+ cancel: () => next.cancel?.()
+ }
+ }
+}
+
+const confettiBurst: Behavior = {
+ name: 'confettiBurst',
+ wrapDropAnimation(next, ctx) {
+ return {
+ execute() {
+ if (ctx.targetEl) {
+ const rect = ctx.targetEl.getBoundingClientRect()
+ const cx = rect.left + rect.width / 2
+ const cy = rect.top + rect.height / 2
+ for (let i = 0; i < 6; i++) {
+ const span = document.createElement('span')
+ span.textContent = '🎉'
+ span.style.cssText = `position:fixed;left:${cx}px;top:${cy}px;font-size:22px;pointer-events:none;z-index:99999;transform:translate(-50%,-50%)`
+ document.body.appendChild(span)
+ const angle = (Math.PI * 2 * i) / 6 + (Math.random() - 0.5) * 0.6
+ const dist = 60 + Math.random() * 50
+ const tx = Math.cos(angle) * dist
+ const ty = Math.sin(angle) * dist - 20
+ const anim = span.animate(
+ [
+ { transform: 'translate(-50%, -50%) scale(0.5)', opacity: 1 },
+ { transform: `translate(calc(-50% + ${tx}px), calc(-50% + ${ty}px)) scale(1.2)`, opacity: 0 }
+ ],
+ { duration: 700, easing: 'ease-out', fill: 'forwards' }
+ )
+ anim.onfinish = () => span.remove()
+ }
+ }
+ return next.execute()
+ },
+ cancel: () => next.cancel?.()
+ }
+ }
+}
+
+const arrivalBounce: Behavior = {
+ name: 'arrivalBounce',
+ wrapDropAnimation(next, ctx) {
+ return {
+ execute() {
+ ctx.targetEl?.animate(
+ [
+ { transform: 'scale(1)' },
+ { transform: 'scale(1.08)', offset: 0.5 },
+ { transform: 'scale(1)' }
+ ],
+ { duration: 400, easing: 'ease-out' }
+ )
+ return next.execute()
+ },
+ cancel: () => next.cancel?.()
+ }
+ }
+}
+
+new DndController({
+ // Side-effect behaviors go BEFORE scrollSync — when scrollSync engages
+ // (off-screen drop) it replaces the inner flight, so anything wrapped
+ // INSIDE it would never run. Earlier-listed behaviors wrap outer-most,
+ // so they fire first regardless of scrollSync's branch.
+ behaviors: [autoScroll(), dropSound, slotRipple, confettiBurst, arrivalBounce, scrollSync()]
+})
+```
+
+
+
+
+
+### Hook reference
+
+```ts
+interface Behavior {
+ /** Optional debug-friendly identifier. */
+ name?: string
+
+ /** Read by ScrollController to drive auto-scroll under drag. */
+ autoScrollConfig?: AutoScrollConfig
+
+ /** Wraps the drop / return animation step (middleware-style). */
+ wrapDropAnimation?(next: AnimationStep, ctx: BehaviorContext): AnimationStep
+}
+
+interface BehaviorContext {
+ state: DndState
+ /** Layout axis of the destination container. */
+ direction: 'vertical' | 'horizontal'
+ /** Slot wrapper element of the destination position, when known. */
+ targetEl: HTMLElement | null
+ /** Destination droppable's root element, when known. */
+ container: HTMLElement | null
+ /** Configured duration of the inner animation step. */
+ duration: number
+ /** Spacing between sibling items, used as edge padding by scroll-sync. */
+ padding: number
+}
+```
+
+The first behavior in the list wraps outer-most: `[clip, scrollSync]` means `clip` sees the result of `scrollSync` wrapping the base step.
+
+---
+
+## When to put a behavior where
+
+| You want to … | Place it here |
+|---------------|---------------|
+| Apply to every droppable + `data-dnd-scroll` wrappers | Controller-level — `new DndController({ behaviors })` |
+| One container needs different auto-scroll speed / disabled scroll-sync | Per-strategy — `sortable({ behaviors })` |
+| One programmatic animation (undo, demo, onboarding) skips a default | Per-call — `animateItem({ behaviors })` |
+| Replace defaults at runtime (settings panel, accessibility toggle) | `controller.setBehaviors([...])` |
diff --git a/apps/docs/src/routes/docs/collision/+page.svx b/apps/docs/src/routes/docs/collision/+page.svx
new file mode 100644
index 0000000..8ebd1af
--- /dev/null
+++ b/apps/docs/src/routes/docs/collision/+page.svx
@@ -0,0 +1,105 @@
+---
+title: Collision Detection
+description: Pluggable collision algorithms — centerPoint, cursorOver, overlap, closestCenter, or custom.
+modified: 2026-05-11
+---
+
+# Collision Detection
+
+Collision detection determines which drop zone is the active target while an item is being dragged. The library ships with four algorithms and exposes the `CollisionAlgorithm` interface for custom implementations.
+
+## Built-in algorithms
+
+| Algorithm | Description |
+|-----------|-------------|
+| `centerPoint` | Active zone is the one whose rect contains the ghost's center. **Default.** |
+| `cursorOver` | Active zone is the one whose rect contains the pointer (cursor/touch) position |
+| `overlap` | Active zone is the one with the most overlap with the ghost rect. Accepts a threshold option |
+| `closestCenter` | Active zone is the one whose center is closest to the ghost's center |
+
+All four are exported from `@horuse/svelte-dnd`.
+
+## Global algorithm
+
+Set a global algorithm on `DndController`:
+
+```ts
+import { DndController, overlap } from '@horuse/svelte-dnd';
+
+const controller = new DndController({
+ collision: overlap('25%'),
+});
+```
+
+## Per-container override
+
+Use the `collision` prop on `DndDroppable` to override the algorithm for a specific container:
+
+```svelte
+
+
+
+ ...
+
+```
+
+**Priority:** per-container `collision` prop → global `collision` on controller → `centerPoint` default.
+
+## overlap options
+
+```ts
+import { overlap } from '@horuse/svelte-dnd';
+
+// pixels of intersection required (0 = any overlap)
+collision: overlap(0)
+
+// percentage of ghost's smaller dimension
+collision: overlap('25%')
+```
+
+## Custom algorithm
+
+Implement `CollisionAlgorithm` to write your own:
+
+```ts
+import type { CollisionAlgorithm, CollisionContext } from '@horuse/svelte-dnd';
+
+const myAlgorithm: CollisionAlgorithm = ({ zones, pointer, ghost }) => {
+ // zones: DropZone[] — all candidate zones
+ // pointer: { x, y } — current mouse/touch position
+ // ghost: { x, y, width, height } — ghost bounding box
+
+ // Return the winning zone or null
+ return zones.find(z => z.rect.x < pointer.x && pointer.x < z.rect.x + z.rect.width) ?? null;
+};
+
+const controller = new DndController({ collision: myAlgorithm });
+```
+
+## Types
+
+```ts
+type CollisionAlgorithm = (context: CollisionContext) => DropZone | null;
+
+interface CollisionContext {
+ zones: DropZone[];
+ pointer: { x: number; y: number };
+ ghost: { x: number; y: number; width: number; height: number };
+}
+```
+
+---
+
+## Example
+
+
+
+
+
+
diff --git a/apps/docs/src/routes/docs/components-api/DndDraggable/+page.svx b/apps/docs/src/routes/docs/components-api/DndDraggable/+page.svx
new file mode 100644
index 0000000..0a66a03
--- /dev/null
+++ b/apps/docs/src/routes/docs/components-api/DndDraggable/+page.svx
@@ -0,0 +1,59 @@
+---
+title: DndDraggable
+description: API reference for the DndDraggable component — props, events, CSS variables, and drag handle attributes.
+modified: 2026-05-11
+---
+
+# DndDraggable
+
+Wraps a single draggable element. Must be inside a `DndDroppable` which must be inside a `DndProvider`.
+
+Automatically renders a `DndPreview` placeholder at its position - you do not need to place `DndPreview` manually.
+
+### Props
+
+| Prop | Type | Default | Description |
+|------|------|---------|-------------|
+| `id` | `string` | **required** | Unique identifier for this draggable item |
+| `position` | `number` | `undefined` | Index of this item within its container. Required for `DndPreview` placeholder rendering — omit only when using fully custom container layouts |
+| `type` | `string` | `undefined` | Item type used for filtering — matched against `accepts` on `DndDroppable` |
+| `data` | `Record` | `{}` | Arbitrary data passed to drop callbacks |
+| `disabled` | `boolean` | `false` | Disables dragging when `true` |
+| `sensors` | `SensorDescriptor[]` | `undefined` | Override the active sensors for this item. Falls back to the controller-level sensors if omitted. See [Sensors](/docs/sensors) |
+| `class` | `string` | — | Additional CSS class names |
+
+### Accessibility
+
+The component sets `role="button"`, `aria-grabbed`, and `aria-roledescription="draggable item"` automatically. `tabindex` switches between `0` and `-1` based on the `disabled` prop, so disabled items leave the focus order until they are re-enabled.
+
+### CSS Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `--dnd-draggable-cursor` | `grab` | Cursor on the draggable element |
+| `--dnd-draggable-cursor-active` | `grabbing` | Cursor while dragging |
+| `--dnd-draggable-cursor-disabled` | `default` | Cursor when disabled |
+| `--dnd-draggable-opacity-dragging` | `0` | Opacity of the original element while its ghost is active |
+| `--dnd-draggable-opacity-disabled` | `0.5` | Opacity when disabled |
+
+### No drag
+
+Add `data-dnd-no-drag` to any child element to prevent drag initiation on it. Useful for buttons, inputs, and links.
+
+```svelte
+
+ alert('clicked')}>Click me
+ {task.label}
+
+```
+
+### Drag handle
+
+Add `data-dnd-handle` to restrict dragging to specific elements. When at least one handle is present, `data-dnd-no-drag` is ignored — only handle elements can initiate a drag.
+
+```svelte
+
+ ☰ {column.title}
+
+
+```
diff --git a/apps/docs/src/routes/docs/components-api/DndDroppable/+page.svx b/apps/docs/src/routes/docs/components-api/DndDroppable/+page.svx
new file mode 100644
index 0000000..9faf824
--- /dev/null
+++ b/apps/docs/src/routes/docs/components-api/DndDroppable/+page.svx
@@ -0,0 +1,128 @@
+---
+title: DndDroppable
+description: API reference for the DndDroppable and DndPreview components — props, strategy, and type filtering.
+modified: 2026-05-11
+---
+
+# DndDroppable
+
+Defines a container that accepts draggable items. Automatically registers drop zones and renders a tail preview (drop after the last item) when using the `sortable()` strategy.
+
+### Props
+
+| Prop | Type | Default | Description |
+|------|------|---------|-------------|
+| `id` | `string` | **required** | Unique container identifier |
+| `strategy` | `ContainerStrategy` | **required** | Strategy instance that owns zone calculation, translations, and drop/return animations. Use the built-in factories `sortable()` / `target()` or your own class. See [Strategies](#strategies) |
+| `data` | `Record` | `{}` | Arbitrary container metadata passed to drop callbacks |
+| `accepts` | `string \| string[]` | `undefined` | Item type(s) this container accepts. If omitted, accepts everything. See [Type filtering](#type-filtering) |
+| `disabled` | `boolean` | `false` | Disables dropping when `true`. Also sets `opacity: 0.5; pointer-events: none` |
+| `collision` | `CollisionAlgorithm` | `undefined` | Per-container collision algorithm. Overrides the global algorithm set on `DndController`. `undefined` falls back to the global, or `centerPoint` if none. See [Collision Detection](/docs/collision) |
+| `spacing` | `number` | `undefined` | Gap between items in pixels. Applied as margin between adjacent slots (not after the last one). Useful as a drop-in for `space-y-*` / `space-x-*` |
+| `class` | `string` | — | Additional CSS class names |
+
+### Strategies
+
+The `strategy` prop decides how a container behaves. Two built-in factories cover the common cases:
+
+```svelte
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+`sortable()` renders position-based drop zones with insert previews between items. `target()` covers the whole container with a single zone and emits no previews — useful for trash zones or anywhere a target isn't a list.
+
+For writing your own strategy see [Custom Strategies](/docs/custom-strategies).
+
+#### sortable options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `layout` | `'vertical' \| 'horizontal' \| 'grid'` | `'vertical'` | Container layout |
+| `flow` | `'row' \| 'column'` | `'row'` | For `layout: 'grid'`, which axis items fill first. `'row'` = left-to-right wrapping; `'column'` = top-to-bottom wrapping |
+| `behaviors` | `Behavior[]` | inherited | Per-container behavior list (e.g. `[autoScroll(), scrollSync()]`). Replaces the controller-level defaults for this droppable only. See [Behaviors](/docs/behaviors) |
+| `virtual` | `VirtualSource` | `undefined` | Virtualization adapter — set when items are rendered through a virtualizer. The bundled integration is tested with [`virtua`](https://github.com/inokawa/virtua); see [Virtualization example](/examples/virtualization) |
+
+### Snippets
+
+| Snippet | Description |
+|---------|-------------|
+| `children` | **Required.** Content of the droppable container |
+
+### CSS Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `--dnd-droppable-min-height` | `20px` | Minimum height of the container |
+
+### Auto-scroll
+
+`DndDroppable` automatically receives a `data-dnd-scroll` attribute. Auto-scroll only activates on elements with this attribute, so only droppable containers (not arbitrary scrollable ancestors) will be scrolled during a drag.
+
+### Type filtering
+
+Use the `accepts` prop to control which item types can be dropped into this container. Item types come from the `type` prop of `DndDraggable`.
+
+```svelte
+
+
+
+
+
+```
+
+On the draggable side, set the `type` prop:
+
+```svelte
+
+ {item.label}
+
+```
+
+---
+
+## DndPreview
+
+Renders a placeholder at a specific position within a container to indicate where a dragged item will be dropped.
+
+> **Note:** `DndPreview` is rendered automatically by `DndDraggable` (per-slot preview) and `DndDroppable` (tail preview). You should not place it manually — it requires internal `Slot`/`Droppable` entity references and is not intended for direct consumer use. Style it via the CSS variables below.
+
+### CSS Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `--dnd-preview-bg` | `rgba(99, 102, 241, 0.15)` | Background color of the preview |
+| `--dnd-preview-border` | `2px dashed rgba(99, 102, 241, 0.4)` | Border of the preview |
+| `--dnd-preview-border-radius` | `1rem` | Border radius of the preview |
+| `--dnd-preview-duration-in` | `200ms` | Transition duration when preview appears. Also tunable via `controller.animation.preview.show.duration`. |
+| `--dnd-preview-duration-out` | `200ms` | Transition duration when preview disappears. Also tunable via `controller.animation.preview.hide.duration`. |
+| `--dnd-preview-easing-in` | `ease` | Easing for the reveal transition. Also tunable via `controller.animation.preview.show.easing`. |
+| `--dnd-preview-easing-out` | `ease` | Easing for the collapse transition. Also tunable via `controller.animation.preview.hide.easing`. |
+
+---
+
+## Types
+#### DndLayout
+```ts
+export type DndLayout = 'vertical' | 'horizontal' | 'grid'
+```
+#### DndMode
+```ts
+export type DndMode = 'sortable' | 'target'
+```
diff --git a/apps/docs/src/routes/docs/components-api/DndProvider/+page.svx b/apps/docs/src/routes/docs/components-api/DndProvider/+page.svx
new file mode 100644
index 0000000..16137cf
--- /dev/null
+++ b/apps/docs/src/routes/docs/components-api/DndProvider/+page.svx
@@ -0,0 +1,55 @@
+---
+title: DndProvider
+description: API reference for the DndProvider component — props, ghost snippet, and CSS variables.
+modified: 2026-05-11
+---
+
+# DndProvider
+
+Wraps your drag-and-drop area and provides the `DndController` context to all child components.
+
+### Props
+
+| Prop | Type | Default | Description |
+|------|------|---------|-------------|
+| `controller` | `DndController` | auto-created | Optional pre-created controller instance. Configure it via its constructor — see [DndController API](/docs/dnd-controller-api) |
+
+### Snippets
+
+| Snippet | Props | Description |
+|---------|-------|-------------|
+| `ghost` | `GhostSnippetProps` | Custom ghost element renderer during drag. If omitted, the dragged element's `innerHTML` is cloned as the ghost |
+
+### GhostSnippetProps
+
+```ts
+interface GhostSnippetProps {
+ element: HTMLElement;
+ data?: Record;
+ itemId: string;
+}
+```
+
+### Drag cursor
+
+While a drag is in progress the provider injects a `
diff --git a/packages/svelte-dnd/src/lib/components/DndDroppable.svelte b/packages/svelte-dnd/src/lib/components/DndDroppable.svelte
new file mode 100644
index 0000000..0bd570b
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/components/DndDroppable.svelte
@@ -0,0 +1,195 @@
+
+
+
+ {@render children?.()}
+ {#if isSortable}
+
+ = 0 ? tailPosition : lastValidTailPosition}
+ />
+
+ {/if}
+
+
+
+
+
diff --git a/packages/svelte-dnd/src/lib/components/DndPreview.svelte b/packages/svelte-dnd/src/lib/components/DndPreview.svelte
new file mode 100644
index 0000000..4f27c95
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/components/DndPreview.svelte
@@ -0,0 +1,114 @@
+
+
+
+
+
diff --git a/packages/svelte-dnd/src/lib/components/DndProvider.svelte b/packages/svelte-dnd/src/lib/components/DndProvider.svelte
new file mode 100644
index 0000000..ef8468e
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/components/DndProvider.svelte
@@ -0,0 +1,191 @@
+
+
+{@render children()}
+
+{announcement}
+
+{#if (dragController.dragging || dragController.animatingReturn) && dragController.element && dragController.transform && dragController.ghostSize}
+ {@const previewSize = dragController.dropPreviewSize}
+ {@const ghostW = previewSize?.width ?? dragController.ghostSize.width}
+ {@const ghostH = previewSize?.height ?? dragController.ghostSize.height}
+ {@const resize = dragController.animation.ghostResize}
+
+ {#if ghost && dragController.draggedItem}
+ {@render ghost({
+ element: dragController.element,
+ data: dragController.draggedItemData,
+ itemId: dragController.draggedItem
+ })}
+ {:else}
+
+ {/if}
+
+{/if}
+
+{#if dragController.debugZones && dragController.dropZones}
+ {#each dragController.dragging ? dragController.filteredDropZones : dragController.dropZones as zone (zone.containerId + ':' + zone.position)}
+
+
+ {zone.containerId} pos:{zone.position}
+
+
+ {/each}
+{/if}
+
+
diff --git a/packages/svelte-dnd/src/lib/core/animation/animation-config.ts b/packages/svelte-dnd/src/lib/core/animation/animation-config.ts
new file mode 100644
index 0000000..f4bc279
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/animation/animation-config.ts
@@ -0,0 +1,197 @@
+/**
+ * CSS-driven animation transition with configurable duration and easing.
+ */
+export type Transition = {
+ /** Duration in milliseconds. */
+ duration: number
+ /**
+ * CSS easing string. Accepts any valid CSS timing-function value:
+ * `'ease'`, `'ease-out'`, `'linear'`, `'cubic-bezier(0.25, 0.46, 0.45, 0.94)'`, etc.
+ */
+ easing?: string
+}
+
+type ResolvedTransition = Required
+
+/**
+ * Transition with an optional debounce `delay` before it starts. Used for
+ * preview show/hide where the library waits a moment after the pointer
+ * enters/leaves a target before triggering the CSS reveal/collapse.
+ */
+export type DelayedTransition = Transition & {
+ /** Milliseconds to wait before the transition begins. Default: 0. */
+ delay?: number
+}
+
+type ResolvedDelayedTransition = Required
+
+/**
+ * Shorthand: a bare number is treated as `{ duration: }` keeping the
+ * default easing. Pass a full `Transition` object to also set easing.
+ */
+export type DurationOrTransition = number | Transition
+
+/**
+ * Tuning knobs for built-in drag animations.
+ *
+ * Every field accepts either a plain millisecond `number` (shorthand —
+ * easing stays at the library default) or a {@link Transition} object
+ * `{ duration, easing }` to override both.
+ */
+export interface AnimationConfig {
+ /**
+ * Drop preview — the placeholder that appears in the destination slot
+ * showing where the dragged item will land on release.
+ */
+ preview?: {
+ /**
+ * Reveal animation for the preview slot (opacity 0→1, transform scale
+ * 0.5→1). The optional `delay` debounces against pointer fly-bys —
+ * higher values prevent flicker, lower values feel more responsive.
+ * Default: `{ delay: 300, duration: 200, easing: 'ease' }`.
+ */
+ show?: DelayedTransition
+ /**
+ * Collapse animation for the preview slot (opacity 1→0, transform
+ * scale 1→0.5). The optional `delay` gives the user a beat to "come
+ * back" before the slot shrinks. Default:
+ * `{ delay: 200, duration: 200, easing: 'ease' }`.
+ */
+ hide?: DelayedTransition
+ }
+
+ /**
+ * Items repositioning to make room for the ghost. Covers BOTH:
+ * - intra-container — siblings translate aside via `transform`.
+ * - inter-container — target container's spacer grows in `height`/`width`
+ * to accommodate the incoming ghost.
+ *
+ * One field for both because they form a single visual effect: "items
+ * rearranging during drag". Default:
+ * `{ duration: 200, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }`.
+ */
+ siblingShift?: Transition
+
+ /**
+ * Ghost auto-resize transition when crossing containers with different
+ * item dimensions — the dragged ghost morphs in size to preview the
+ * destination layout. Default: `{ duration: 150, easing: 'ease' }`.
+ */
+ ghostResize?: Transition
+
+ /**
+ * Ghost flight to a destination on a successful drop (rAF-driven).
+ * Default: `{ duration: 250, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }`.
+ */
+ drop?: DurationOrTransition
+
+ /**
+ * Ghost return flight to origin on a cancelled drop (rAF-driven,
+ * scroll-aware). Default:
+ * `{ duration: 300, easing: 'cubic-bezier(0.33, 1, 0.68, 1)' }` (out-cubic).
+ */
+ return?: DurationOrTransition
+
+ /**
+ * Source slot collapse animation on a cross-container drop — the empty
+ * space the moved item used to occupy shrinks shut so siblings flow back.
+ * Default:
+ * `{ duration: 250, easing: 'cubic-bezier(0.45, 0, 0.55, 1)' }` (in-out-quad).
+ */
+ slotCollapse?: DurationOrTransition
+
+ /**
+ * Default for `controller.animateLayout()` FLIP transitions. Overridable
+ * per-call via the method's `duration` / `easing` options. Default:
+ * `{ duration: 300, easing: 'ease' }`.
+ */
+ layout?: DurationOrTransition
+
+ /**
+ * Ghost flight per keyboard navigation keystroke (cancelled on each new key).
+ * Default: `{ duration: 150, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }`.
+ */
+ keyboardFlight?: DurationOrTransition
+}
+
+export interface ResolvedAnimationConfig {
+ preview: {
+ show: ResolvedDelayedTransition
+ hide: ResolvedDelayedTransition
+ }
+ siblingShift: ResolvedTransition
+ ghostResize: ResolvedTransition
+ drop: ResolvedTransition
+ return: ResolvedTransition
+ slotCollapse: ResolvedTransition
+ layout: ResolvedTransition
+ keyboardFlight: ResolvedTransition
+}
+
+export const DEFAULT_ANIMATION_CONFIG: ResolvedAnimationConfig = {
+ preview: {
+ show: { delay: 300, duration: 200, easing: 'ease' },
+ hide: { delay: 200, duration: 200, easing: 'ease' }
+ },
+ siblingShift: { duration: 200, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
+ ghostResize: { duration: 150, easing: 'ease' },
+ drop: { duration: 250, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
+ return: { duration: 300, easing: 'cubic-bezier(0.33, 1, 0.68, 1)' },
+ slotCollapse: { duration: 250, easing: 'cubic-bezier(0.45, 0, 0.55, 1)' },
+ layout: { duration: 300, easing: 'ease' },
+ keyboardFlight: { duration: 150, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }
+}
+
+function resolveTransition(
+ partial: Transition | undefined,
+ fallback: ResolvedTransition
+): ResolvedTransition {
+ return {
+ duration: partial?.duration ?? fallback.duration,
+ easing: partial?.easing ?? fallback.easing
+ }
+}
+
+function resolveDelayedTransition(
+ partial: DelayedTransition | undefined,
+ fallback: ResolvedDelayedTransition
+): ResolvedDelayedTransition {
+ return {
+ delay: partial?.delay ?? fallback.delay,
+ duration: partial?.duration ?? fallback.duration,
+ easing: partial?.easing ?? fallback.easing
+ }
+}
+
+function resolveDurationOrTransition(
+ val: DurationOrTransition | undefined,
+ fallback: ResolvedTransition
+): ResolvedTransition {
+ if (val == null) return fallback
+ if (typeof val === 'number') return { duration: val, easing: fallback.easing }
+ return resolveTransition(val, fallback)
+}
+
+/**
+ * Resolve a partial AnimationConfig against a base. Defaults to library
+ * defaults when no base is supplied. Use a different base (e.g. the
+ * controller's current resolved config) to apply a partial patch.
+ */
+export function resolveAnimationConfig(
+ config?: AnimationConfig,
+ base: ResolvedAnimationConfig = DEFAULT_ANIMATION_CONFIG
+): ResolvedAnimationConfig {
+ return {
+ preview: {
+ show: resolveDelayedTransition(config?.preview?.show, base.preview.show),
+ hide: resolveDelayedTransition(config?.preview?.hide, base.preview.hide)
+ },
+ siblingShift: resolveTransition(config?.siblingShift, base.siblingShift),
+ ghostResize: resolveTransition(config?.ghostResize, base.ghostResize),
+ drop: resolveDurationOrTransition(config?.drop, base.drop),
+ return: resolveDurationOrTransition(config?.return, base.return),
+ slotCollapse: resolveDurationOrTransition(config?.slotCollapse, base.slotCollapse),
+ layout: resolveDurationOrTransition(config?.layout, base.layout),
+ keyboardFlight: resolveDurationOrTransition(config?.keyboardFlight, base.keyboardFlight)
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/animation/apply-behaviors.ts b/packages/svelte-dnd/src/lib/core/animation/apply-behaviors.ts
new file mode 100644
index 0000000..5bca06e
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/animation/apply-behaviors.ts
@@ -0,0 +1,50 @@
+import type { AnimationStep } from './steps/animation-step.js'
+import type { Behavior, BehaviorContext } from './behavior.js'
+import type { Droppable } from '../entities/droppable.svelte.js'
+
+/**
+ * Resolves the effective behavior list for a droppable: per-strategy behaviors
+ * if non-empty, otherwise the controller-level defaults. The first listed
+ * behavior wraps outer-most.
+ */
+export function resolveBehaviors(droppable: Droppable | null, defaults: Behavior[]): Behavior[] {
+ const strategyBehaviors = droppable?.strategy.behaviors
+ if (strategyBehaviors && strategyBehaviors.length > 0) return strategyBehaviors
+ return defaults
+}
+
+/**
+ * Wraps a base animation step with every behavior that provides
+ * `wrapDropAnimation`. Order matches the resolved behavior list — the first
+ * listed becomes the outer-most wrapper.
+ */
+export function wrapWithBehaviors(
+ step: AnimationStep,
+ behaviors: Behavior[],
+ ctx: BehaviorContext
+): AnimationStep {
+ let wrapped = step
+ for (let i = behaviors.length - 1; i >= 0; i--) {
+ const b = behaviors[i]
+ if (b.wrapDropAnimation) {
+ wrapped = b.wrapDropAnimation(wrapped, ctx)
+ }
+ }
+ return wrapped
+}
+
+/**
+ * Finds the slot wrapper element of the destination position inside a
+ * droppable. Returns `null` for `target()` containers (no per-slot preview)
+ * or when no preview entity is registered for that position yet.
+ */
+export function findTargetSlotWrapper(
+ droppable: Droppable | null,
+ position: number
+): HTMLElement | null {
+ if (!droppable || droppable.mode === 'target') return null
+ const previewEntity = droppable.getSlotAt(position)?.preview ?? droppable.tailPreview
+ const previewEl = previewEntity?.element
+ if (!previewEl) return null
+ return (previewEl.parentElement ?? previewEl) as HTMLElement
+}
diff --git a/packages/svelte-dnd/src/lib/core/animation/behavior.ts b/packages/svelte-dnd/src/lib/core/animation/behavior.ts
new file mode 100644
index 0000000..56ac4f3
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/animation/behavior.ts
@@ -0,0 +1,57 @@
+import type { DndState } from '../dnd/dnd-state.svelte.js'
+import type { AnimationStep } from './steps/animation-step.js'
+
+/**
+ * Per-droppable / per-controller pluggable hook for drop-side concerns.
+ *
+ * Mirrors the `modifiers` / `sensors` / `collision` plugin patterns:
+ * built-in factories such as `autoScroll(...)` and `scrollSync(...)` produce
+ * `Behavior` instances; custom behaviors can be authored by anyone.
+ *
+ * A behavior is duck-typed — implement only the hooks you care about:
+ *
+ * - `autoScrollConfig` — data-only hook read by `ScrollController` to drive
+ * edge-triggered auto-scroll while a drag is in progress.
+ * - `wrapDropAnimation` — middleware-style hook that wraps the drop / return
+ * animation step. Behaviors are applied outer-first: the first behavior in
+ * the list becomes the outer-most wrapper.
+ */
+export interface Behavior {
+ /** Optional debug-friendly identifier (e.g. `'autoScroll'`). */
+ name?: string
+
+ /** Auto-scroll tuning consumed by `ScrollController` while dragging. */
+ autoScrollConfig?: AutoScrollConfig
+
+ /** Wrap the inner animation step with extra behavior. Order: first listed wraps outer-most. */
+ wrapDropAnimation?(next: AnimationStep, ctx: BehaviorContext): AnimationStep
+}
+
+export interface BehaviorContext {
+ state: DndState
+ /** Layout axis of the destination container. */
+ direction: 'vertical' | 'horizontal'
+ /** Slot wrapper element of the destination position, when known. */
+ targetEl: HTMLElement | null
+ /** Destination droppable's root element, when known. */
+ container: HTMLElement | null
+ /** Configured duration of the inner animation step. */
+ duration: number
+ /**
+ * CSS easing string of the inner animation step. Behaviors that replace
+ * the inner step (e.g. `scrollSync`) should use this to keep visual
+ * continuity. Pass through `parseEasing()` to get an interpolation fn.
+ */
+ easing: string
+ /** Spacing between sibling items, used as edge padding by scroll-sync. */
+ padding: number
+}
+
+export interface AutoScrollConfig {
+ /** Fraction of container size that triggers scroll. Default: 0.3 */
+ zoneRatio?: number
+ /** Max scroll speed in px/frame at 60fps. Default: 30 */
+ maxSpeed?: number
+ /** Stop auto-scroll the moment the user releases. Default: false */
+ stopOnDrop?: boolean
+}
diff --git a/packages/svelte-dnd/src/lib/core/animation/behaviors/auto-scroll.ts b/packages/svelte-dnd/src/lib/core/animation/behaviors/auto-scroll.ts
new file mode 100644
index 0000000..aa4892c
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/animation/behaviors/auto-scroll.ts
@@ -0,0 +1,28 @@
+import type { Behavior, AutoScrollConfig } from '../behavior.js'
+
+/**
+ * Edge-triggered auto-scroll while a drag is in progress.
+ *
+ * Built-in default — present in the controller's behavior list out of the box,
+ * so opting out means passing an explicit `behaviors` array without this entry.
+ *
+ * @example
+ * ```ts
+ * // Controller-level (also applies to non-droppable `data-dnd-scroll` wrappers)
+ * new DndController({
+ * behaviors: [autoScroll({ zoneRatio: 0.2, maxSpeed: 20 })]
+ * })
+ *
+ * // Per-strategy override
+ * sortable({
+ * layout: 'vertical',
+ * behaviors: [autoScroll({ maxSpeed: 60 })]
+ * })
+ * ```
+ */
+export function autoScroll(opts: AutoScrollConfig = {}): Behavior {
+ return {
+ name: 'autoScroll',
+ autoScrollConfig: opts
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/animation/behaviors/scroll-sync.ts b/packages/svelte-dnd/src/lib/core/animation/behaviors/scroll-sync.ts
new file mode 100644
index 0000000..cae5786
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/animation/behaviors/scroll-sync.ts
@@ -0,0 +1,98 @@
+import type { Behavior } from '../behavior.js'
+import type { AnimationStep } from '../steps/animation-step.js'
+import { DOMHelper } from '../../utils/dom-helper.js'
+import { planScrollSync } from '../scroll-sync-runner.js'
+import { parseEasing } from '../easing.js'
+
+export interface ScrollSyncOptions {
+ /**
+ * Visibility ratio (0..1) below which the destination container scrolls
+ * in lockstep with the ghost flight, so the ghost stays inside the
+ * container's viewport.
+ *
+ * - `1.0` (default) — engage scroll-sync as soon as any pixel of the target slot is hidden
+ * - `0.5` — only when less than half is visible
+ * - `0` — disable (delegate fully to the wrapped step)
+ */
+ threshold?: number
+}
+
+/**
+ * Wraps the drop / return animation: when the destination slot is hidden
+ * enough inside its scrollable container, the container scrolls together
+ * with the ghost flight instead of letting the ghost fly off-screen.
+ *
+ * Built-in default — present in the controller's behavior list out of the box.
+ *
+ * @example
+ * ```ts
+ * sortable({
+ * layout: 'vertical',
+ * behaviors: [scrollSync({ threshold: 0.5 })]
+ * })
+ * ```
+ */
+export function scrollSync(opts: ScrollSyncOptions = {}): Behavior {
+ const threshold = opts.threshold ?? 1
+
+ return {
+ name: 'scrollSync',
+ wrapDropAnimation(next, ctx) {
+ let cancelled = false
+
+ return {
+ execute(): Promise {
+ return new Promise((resolve) => {
+ // No target rect / disabled / fully visible → fall through to inner step.
+ if (!ctx.targetEl || !ctx.container || threshold <= 0) {
+ next.execute().then(resolve)
+ return
+ }
+ const visible = DOMHelper.computeVisibleFraction(
+ ctx.targetEl,
+ ctx.container
+ )
+ if (visible >= threshold) {
+ next.execute().then(resolve)
+ return
+ }
+
+ // Replace inner flight with a scroll-synchronised one.
+ ctx.state.setAnimating(true)
+ const plan = planScrollSync({
+ state: ctx.state,
+ container: ctx.container,
+ targetEl: ctx.targetEl,
+ direction: ctx.direction,
+ padding: ctx.padding
+ })
+
+ const startTime = Date.now()
+ const easeFn = parseEasing(ctx.easing)
+ const animate = () => {
+ if (cancelled) {
+ ctx.state.setAnimating(false)
+ resolve()
+ return
+ }
+ const progress = Math.min((Date.now() - startTime) / plan.duration, 1)
+ plan.update(easeFn(progress))
+ if (progress < 1) {
+ requestAnimationFrame(animate)
+ } else {
+ plan.finalize()
+ ctx.state.setAnimating(false)
+ resolve()
+ }
+ }
+ requestAnimationFrame(animate)
+ })
+ },
+ cancel(): void {
+ cancelled = true
+ next.cancel?.()
+ }
+ } satisfies AnimationStep
+ }
+ }
+}
diff --git a/src/lib/core/animation/direction-adapter.ts b/packages/svelte-dnd/src/lib/core/animation/direction-adapter.ts
similarity index 100%
rename from src/lib/core/animation/direction-adapter.ts
rename to packages/svelte-dnd/src/lib/core/animation/direction-adapter.ts
diff --git a/packages/svelte-dnd/src/lib/core/animation/drop-animation-coordinator.ts b/packages/svelte-dnd/src/lib/core/animation/drop-animation-coordinator.ts
new file mode 100644
index 0000000..7e112b5
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/animation/drop-animation-coordinator.ts
@@ -0,0 +1,447 @@
+import type { DndState } from '../dnd/dnd-state.svelte.js'
+import type { DndEventEmitter } from '../dnd/dnd-event-emitter.js'
+import type { ScrollController } from '../scroll/scroll-controller.js'
+import type { DropResolver } from '../zones/drop-resolver.js'
+import type { AnimationStep } from './steps/animation-step.js'
+import type {
+ DropPreview,
+ DndItemInfo,
+ DndContainerInfo,
+ DropEvent,
+ DragEndEvent,
+ DragOverEvent,
+ DropCancelledEvent
+} from '../../types.js'
+import type { Droppable } from '../entities/droppable.svelte.js'
+import type { ResolvedAnimationConfig } from './animation-config.js'
+import type { Behavior, BehaviorContext } from './behavior.js'
+import { AnimationPipeline } from './steps/animation-pipeline.js'
+import { GhostToTargetStep } from './steps/ghost-to-target-step.js'
+import { GhostReturnStep } from './steps/ghost-return-step.js'
+import { DEFAULT_ANIMATION_CONFIG } from './animation-config.js'
+import { parseEasing } from './easing.js'
+import { resolveBehaviors, wrapWithBehaviors, findTargetSlotWrapper } from './apply-behaviors.js'
+
+export class DropAnimationCoordinator {
+ private currentAnimation: AnimationPipeline | null = null
+ private lastPreviewKey: string | null = null
+
+ constructor(
+ private state: DndState,
+ private eventEmitter: DndEventEmitter,
+ private scrollController: ScrollController,
+ private dropResolver: DropResolver,
+ private droppablesById: Map = new Map(),
+ private animation: ResolvedAnimationConfig = DEFAULT_ANIMATION_CONFIG,
+ private defaultBehaviors: Behavior[] = []
+ ) {}
+
+ private buildContext(
+ droppable: Droppable | null,
+ position: number,
+ duration: number,
+ easing: string
+ ): BehaviorContext {
+ const layout = droppable?.layout
+ return {
+ state: this.state,
+ direction: layout === 'horizontal' ? 'horizontal' : 'vertical',
+ targetEl: findTargetSlotWrapper(droppable, position),
+ container: droppable?.element ?? null,
+ duration,
+ easing,
+ padding: droppable?.spacing ?? 0
+ }
+ }
+
+ private wrap(
+ step: AnimationStep,
+ droppable: Droppable | null,
+ position: number,
+ duration: number,
+ easing: string
+ ): AnimationStep {
+ const behaviors = resolveBehaviors(droppable, this.defaultBehaviors)
+ const ctx = this.buildContext(droppable, position, duration, easing)
+ return wrapWithBehaviors(step, behaviors, ctx)
+ }
+
+ setDefaultBehaviors(behaviors: Behavior[]) {
+ this.defaultBehaviors = behaviors
+ }
+
+ setAnimationConfig(animation: ResolvedAnimationConfig) {
+ this.animation = animation
+ }
+
+ updateDropPreview(pointer: { x: number; y: number }) {
+ if (!this.state.dragging) {
+ this.state.setDropPreview(null)
+ return
+ }
+
+ const targetZone = this.dropResolver.findZoneAt(pointer)
+
+ if (targetZone) {
+ this.notifyPreviewChange({
+ containerId: targetZone.containerId,
+ position: targetZone.position
+ })
+
+ if (this.state.skipDropPreviewAnimation) {
+ requestAnimationFrame(() => {
+ this.state.setSkipDropPreviewAnimation(false)
+ })
+ }
+ } else if (this.state.dropPreview) {
+ // Pointer left all zones — drop the preview immediately. The Preview entity
+ // plays its own collapse animation via collapseTimer, so visual fade-out keeps working.
+ this.state.setDropPreview(null)
+ this.lastPreviewKey = null
+ }
+ }
+
+ /** Set the active drop preview from outside the pointer path (e.g. keyboard navigation). Fires `onDragOver` like `updateDropPreview`. */
+ setActivePreview(preview: DropPreview) {
+ if (!this.state.dragging) return
+ this.notifyPreviewChange(preview)
+ if (this.state.skipDropPreviewAnimation) {
+ requestAnimationFrame(() => {
+ this.state.setSkipDropPreviewAnimation(false)
+ })
+ }
+ }
+
+ private notifyPreviewChange(preview: DropPreview) {
+ this.state.setDropPreview(preview)
+
+ const previewKey = `${preview.containerId}:${preview.position}`
+ if (previewKey === this.lastPreviewKey) return
+
+ const prevKey = this.lastPreviewKey
+ this.lastPreviewKey = previewKey
+
+ const sourceId = this.state.draggedItem
+ const element = this.state.element
+ if (!sourceId || !element) return
+
+ const originContainerId = this.state.originContainerId
+ const sourceDroppable = originContainerId
+ ? this.droppablesById.get(originContainerId)
+ : null
+ const targetDroppable = this.droppablesById.get(preview.containerId)
+ if (!sourceDroppable || !targetDroppable) return
+
+ const itemInfo: DndItemInfo = {
+ id: sourceId,
+ data: this.state.draggedItemData,
+ type: this.state.draggedType ?? undefined,
+ element
+ }
+ const sourceInfo: DndContainerInfo = sourceDroppable.toContainerInfo(
+ this.state.originPosition
+ )
+ const currentInfo: DndContainerInfo = targetDroppable.toContainerInfo(preview.position)
+
+ let previousInfo: DndContainerInfo | null = null
+ if (prevKey) {
+ const sepIdx = prevKey.lastIndexOf(':')
+ const prevContainerId = prevKey.slice(0, sepIdx)
+ const prevPosition = parseInt(prevKey.slice(sepIdx + 1))
+ const prevDroppable = this.droppablesById.get(prevContainerId)
+ if (prevDroppable) {
+ previousInfo = prevDroppable.toContainerInfo(prevPosition)
+ }
+ }
+
+ const event: DragOverEvent = {
+ item: itemInfo,
+ source: sourceInfo,
+ current: currentInfo,
+ previous: previousInfo
+ }
+ this.eventEmitter.notifyDragOver(event)
+ }
+
+ performDrop(
+ sourceId: string,
+ sourceData: Record | undefined,
+ targetContainerId: string,
+ position: number
+ ) {
+ const element = this.state.element
+ const type = this.state.draggedType ?? undefined
+ const originContainerId = this.state.originContainerId
+ const originPosition = this.state.originPosition
+ const sourceDroppable = originContainerId
+ ? this.droppablesById.get(originContainerId)
+ : null
+ const targetDroppable = this.droppablesById.get(targetContainerId)
+
+ const targetZone = this.state.zones.find(
+ (zone) => zone.containerId === targetContainerId && zone.position === position
+ )
+
+ this.state.setPerformingDrop(true)
+ if (this.scrollController.stopOnDrop) this.scrollController.clearAll()
+
+ const isCrossContainer = targetContainerId !== originContainerId
+
+ // Smooth source-slot collapse runs in parallel with the ghost flight so
+ // items below the dragged source move up smoothly while the ghost flies.
+ const collapsePromise =
+ isCrossContainer && element && sourceDroppable
+ ? this.startSlotCollapse(element, sourceDroppable, sourceId, originPosition)
+ : Promise.resolve()
+
+ const onDropComplete = async () => {
+ await collapsePromise
+
+ if (element && sourceDroppable && targetDroppable) {
+ const itemInfo: DndItemInfo = { id: sourceId, data: sourceData, type, element }
+ const sourceInfo: DndContainerInfo = sourceDroppable.toContainerInfo(originPosition)
+ const targetInfo: DndContainerInfo = targetDroppable.toContainerInfo(position)
+ const dropEvent: DropEvent = {
+ item: itemInfo,
+ source: sourceInfo,
+ target: targetInfo
+ }
+ const dragEndEvent: DragEndEvent = {
+ item: itemInfo,
+ source: sourceInfo,
+ target: targetInfo,
+ cancelled: false
+ }
+
+ // Save scroll positions before DOM reorder — browser scroll anchoring
+ // can shift scrollTop when content height changes after items update.
+ // Skip for virtualized containers entirely: they manage their own
+ // scroll state and any extra write fights their reconciliation.
+ const srcScrollTarget = !sourceDroppable.isVirtualized
+ ? sourceDroppable.element
+ : undefined
+ const tgtScrollTarget =
+ sourceDroppable !== targetDroppable && !targetDroppable.isVirtualized
+ ? targetDroppable.element
+ : undefined
+ const srcScroll = srcScrollTarget?.scrollTop
+ const tgtScroll = tgtScrollTarget?.scrollTop
+
+ this.eventEmitter.notifyDrop(dropEvent)
+ this.finalizeDragEnd(dragEndEvent)
+
+ queueMicrotask(() =>
+ queueMicrotask(() => {
+ if (srcScroll !== undefined && srcScrollTarget)
+ srcScrollTarget.scrollTop = srcScroll
+ if (tgtScroll !== undefined && tgtScrollTarget)
+ tgtScrollTarget.scrollTop = tgtScroll
+ })
+ )
+ } else {
+ this.finalizeDragEnd(null)
+ }
+ }
+
+ if (targetZone && this.state.element && this.state.transform) {
+ const baseStep = new GhostToTargetStep(
+ this.state,
+ targetZone,
+ this.droppablesById,
+ this.animation.drop.duration,
+ this.animation.drop.easing
+ )
+ const wrapped = this.wrap(
+ baseStep,
+ targetDroppable ?? null,
+ position,
+ this.animation.drop.duration,
+ this.animation.drop.easing
+ )
+ this.animate(wrapped, onDropComplete)
+ } else {
+ onDropComplete()
+ }
+ }
+
+ endDrag(shouldAnimate = true) {
+ const itemId = this.state.draggedItem
+ const session = this.state.session
+
+ // Capture session data before any async animation
+ const element = this.state.element
+ const type = this.state.draggedType ?? undefined
+ const originContainerId = this.state.originContainerId
+ const originPosition = this.state.originPosition
+ const sourceDroppable = originContainerId
+ ? this.droppablesById.get(originContainerId)
+ : null
+
+ let cancelledEvent: DropCancelledEvent | null = null
+ let dragEndEvent: DragEndEvent | null = null
+
+ if (itemId && element && sourceDroppable) {
+ const itemInfo: DndItemInfo = {
+ id: itemId,
+ data: this.state.draggedItemData,
+ type,
+ element
+ }
+ const sourceInfo: DndContainerInfo = sourceDroppable.toContainerInfo(originPosition)
+ cancelledEvent = { item: itemInfo, source: sourceInfo }
+ dragEndEvent = { item: itemInfo, source: sourceInfo, target: null, cancelled: true }
+ }
+
+ if (cancelledEvent) this.eventEmitter.notifyDropCancelled(cancelledEvent)
+
+ if (shouldAnimate && session?.originContainerId) {
+ this.state.setDropPreview({
+ containerId: session.originContainerId,
+ position: session.originPosition
+ })
+ }
+
+ if (shouldAnimate && session) {
+ requestAnimationFrame(() => {
+ const originId = this.state.originContainerId
+ const originPos = this.state.originPosition
+ const originDroppable = originId
+ ? (this.droppablesById.get(originId) ?? null)
+ : null
+ const baseStep = new GhostReturnStep(
+ this.state,
+ originId,
+ originPos,
+ this.droppablesById,
+ this.animation.return.duration,
+ this.animation.return.easing
+ )
+ const wrapped = this.wrap(
+ baseStep,
+ originDroppable,
+ originPos,
+ this.animation.return.duration,
+ this.animation.return.easing
+ )
+ this.animate(wrapped, () => this.finalizeDragEnd(dragEndEvent))
+ })
+ } else {
+ this.finalizeDragEnd(dragEndEvent)
+ }
+ }
+
+ cancelCurrentAnimation() {
+ this.currentAnimation?.cancel()
+ }
+
+ destroy() {
+ this.currentAnimation?.cancel()
+ }
+
+ // --- Private ---
+
+ private startSlotCollapse(
+ element: HTMLElement,
+ sourceDroppable: Droppable,
+ sourceId: string,
+ originPosition: number
+ ): Promise {
+ const slotEl = element.parentElement
+ if (!slotEl?.hasAttribute('data-dnd-slot')) return Promise.resolve()
+
+ const isHorizontal = sourceDroppable.layout === 'horizontal'
+ const slotSize = this.state.dragSlotSize
+ const fullSize = isHorizontal ? (slotSize?.width ?? 0) : (slotSize?.height ?? 0)
+ const startDim = isHorizontal ? slotEl.offsetWidth : slotEl.offsetHeight
+ const computed = getComputedStyle(slotEl)
+ const startMargin = isHorizontal
+ ? parseFloat(computed.marginRight) || 0
+ : parseFloat(computed.marginBottom) || 0
+
+ // Items below the dragged item need their transforms adjusted per-frame
+ // so they appear to stay still as the DOM layout shrinks beneath them.
+ const affectedDraggables = sourceDroppable
+ .getSortedSlots()
+ .filter((s) => s.draggable.id !== sourceId && s.position > originPosition)
+ .map((s) => s.draggable.element)
+
+ // `flex-shrink: 0` blocks the flex container from collapsing the slot itself
+ // when `overflow: hidden` (next line) drops min-height/width to 0. Without it,
+ // in an overflowing scrollable parent the slot snaps to 0 on the very first
+ // frame and siblings jump up by `fullSize` before our per-frame transform can
+ // compensate.
+ slotEl.style.flexShrink = '0'
+ slotEl.style.overflow = 'hidden'
+
+ // Matches the ghost flight duration so both animations finish together.
+ const duration = this.animation.slotCollapse.duration
+ const easeFn = parseEasing(this.animation.slotCollapse.easing)
+ return new Promise((resolve) => {
+ const startTime = performance.now()
+
+ const tick = (now: number) => {
+ const elapsed = now - startTime
+ const t = Math.min(elapsed / duration, 1)
+ const eased = easeFn(t)
+ const remaining = 1 - eased
+ const collapseAmount = (startDim + startMargin) * eased
+
+ if (isHorizontal) {
+ slotEl.style.width = startDim * remaining + 'px'
+ slotEl.style.marginRight = startMargin * remaining + 'px'
+ } else {
+ slotEl.style.height = startDim * remaining + 'px'
+ slotEl.style.marginBottom = startMargin * remaining + 'px'
+ }
+
+ if (fullSize > 0 && affectedDraggables.length > 0) {
+ const adj = -(fullSize - collapseAmount)
+ for (const el of affectedDraggables) {
+ el.style.transform = isHorizontal
+ ? `translate3d(${adj}px, 0, 0)`
+ : `translate3d(0, ${adj}px, 0)`
+ }
+ }
+
+ if (t < 1) {
+ requestAnimationFrame(tick)
+ } else {
+ resolve()
+ }
+ }
+
+ requestAnimationFrame(tick)
+ })
+ }
+
+ private animate(step: AnimationStep, onComplete: () => void | Promise): void {
+ this.currentAnimation?.cancel()
+ const pipeline = AnimationPipeline.chain(step)
+ this.currentAnimation = pipeline
+ pipeline.execute().then(() => {
+ this.currentAnimation = null
+ return onComplete()
+ })
+ }
+
+ private finalizeDragEnd(dragEndEvent: DragEndEvent | null) {
+ this.lastPreviewKey = null
+ // Ordering matters: set skip=true BEFORE reset() so Preview sees "skip" when session
+ // clears; keep performingDrop=true across reset() so Preview.hide() reads it as true and
+ // collapses instantly (otherwise it uses the delayed path). Clear on next frame.
+ this.state.setSkipDropPreviewAnimation(true)
+ this.scrollController.clearAll()
+ this.state.reset()
+ requestAnimationFrame(() => {
+ this.state.setPerformingDrop(false)
+ })
+
+ if (dragEndEvent) {
+ this.eventEmitter.notifyDragEnd(dragEndEvent)
+ }
+
+ setTimeout(() => {
+ this.state.setSkipDropPreviewAnimation(false)
+ }, 100)
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/animation/easing.ts b/packages/svelte-dnd/src/lib/core/animation/easing.ts
new file mode 100644
index 0000000..76af8f3
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/animation/easing.ts
@@ -0,0 +1,78 @@
+/**
+ * Parses a CSS timing-function string into a JS interpolation function.
+ *
+ * Supports:
+ * - Keywords: `linear`, `ease`, `ease-in`, `ease-out`, `ease-in-out`
+ * - `cubic-bezier(x1, y1, x2, y2)` with arbitrary control points
+ *
+ * Falls back to `ease-out` and emits a console warning on unknown input.
+ *
+ * @example
+ * const ease = parseEasing('cubic-bezier(0.25, 0.46, 0.45, 0.94)')
+ * ease(0) // => 0
+ * ease(0.5) // => ~0.62
+ * ease(1) // => 1
+ */
+export function parseEasing(easing: string): (t: number) => number {
+ const trimmed = easing.trim().toLowerCase()
+ if (trimmed === 'linear') return (t) => t
+ if (trimmed === 'ease') return cubicBezierFn(0.25, 0.1, 0.25, 1)
+ if (trimmed === 'ease-in') return cubicBezierFn(0.42, 0, 1, 1)
+ if (trimmed === 'ease-out') return cubicBezierFn(0, 0, 0.58, 1)
+ if (trimmed === 'ease-in-out') return cubicBezierFn(0.42, 0, 0.58, 1)
+
+ const m = trimmed.match(
+ /^cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)$/
+ )
+ if (m) {
+ return cubicBezierFn(parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3]), parseFloat(m[4]))
+ }
+
+ if (typeof console !== 'undefined') {
+ console.warn(`[svelte-dnd] Unsupported easing "${easing}", falling back to ease-out`)
+ }
+ return cubicBezierFn(0, 0, 0.58, 1)
+}
+
+/**
+ * Cubic-bezier solver. Builds a sample table once, then for each t finds the
+ * matching point on the curve via Newton-Raphson. Same algorithm browsers use.
+ */
+function cubicBezierFn(x1: number, y1: number, x2: number, y2: number): (t: number) => number {
+ const SAMPLE_COUNT = 11
+ const SAMPLE_STEP = 1 / (SAMPLE_COUNT - 1)
+ const samples = new Float32Array(SAMPLE_COUNT)
+
+ const a = (a1: number, a2: number) => 1 - 3 * a2 + 3 * a1
+ const b = (a1: number, a2: number) => 3 * a2 - 6 * a1
+ const c = (a1: number) => 3 * a1
+ const calc = (t: number, a1: number, a2: number) =>
+ ((a(a1, a2) * t + b(a1, a2)) * t + c(a1)) * t
+ const slope = (t: number, a1: number, a2: number) =>
+ 3 * a(a1, a2) * t * t + 2 * b(a1, a2) * t + c(a1)
+
+ for (let i = 0; i < SAMPLE_COUNT; i++) samples[i] = calc(i * SAMPLE_STEP, x1, x2)
+
+ const tForX = (x: number): number => {
+ let intervalStart = 0
+ let i = 1
+ for (; i !== SAMPLE_COUNT - 1 && samples[i] <= x; i++) intervalStart += SAMPLE_STEP
+ i--
+
+ const dist = (x - samples[i]) / (samples[i + 1] - samples[i])
+ let guess = intervalStart + dist * SAMPLE_STEP
+
+ for (let j = 0; j < 4; j++) {
+ const s = slope(guess, x1, x2)
+ if (s === 0) return guess
+ guess -= (calc(guess, x1, x2) - x) / s
+ }
+ return guess
+ }
+
+ return (x: number) => {
+ if (x <= 0) return 0
+ if (x >= 1) return 1
+ return calc(tForX(x), y1, y2)
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/animation/keyboard-flight.ts b/packages/svelte-dnd/src/lib/core/animation/keyboard-flight.ts
new file mode 100644
index 0000000..46d3ef9
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/animation/keyboard-flight.ts
@@ -0,0 +1,64 @@
+import type { DndState } from '../dnd/dnd-state.svelte.js'
+import { parseEasing } from './easing.js'
+
+export interface KeyboardFlightPlan {
+ duration: number
+ update(eased: number): void
+ finalize?(): void
+}
+
+/** Cancellable rAF runner for keyboard-driven ghost flights. A new `run`/`animateTo` replaces any in-flight. */
+export class KeyboardFlight {
+ private rafId: number | null = null
+
+ constructor(private state: DndState) {}
+
+ run(plan: KeyboardFlightPlan, easing: string) {
+ this.cancel()
+ const startTime = performance.now()
+ const easeFn = parseEasing(easing)
+
+ const tick = (now: number) => {
+ const progress = Math.min((now - startTime) / plan.duration, 1)
+ plan.update(easeFn(progress))
+ if (progress < 1) {
+ this.rafId = requestAnimationFrame(tick)
+ } else {
+ plan.finalize?.()
+ this.rafId = null
+ }
+ }
+
+ this.rafId = requestAnimationFrame(tick)
+ }
+
+ animateTo(target: { x: number; y: number }, options: { duration: number; easing: string }) {
+ const start = this.state.transform
+ if (!start) {
+ this.cancel()
+ this.state.setTransform(target)
+ return
+ }
+ const startPos = { x: start.x, y: start.y }
+ const state = this.state
+ this.run(
+ {
+ duration: options.duration,
+ update(eased) {
+ state.setTransform({
+ x: startPos.x + (target.x - startPos.x) * eased,
+ y: startPos.y + (target.y - startPos.y) * eased
+ })
+ }
+ },
+ options.easing
+ )
+ }
+
+ cancel() {
+ if (this.rafId !== null) {
+ cancelAnimationFrame(this.rafId)
+ this.rafId = null
+ }
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/animation/preview-slot-rect.ts b/packages/svelte-dnd/src/lib/core/animation/preview-slot-rect.ts
new file mode 100644
index 0000000..dcd7f9c
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/animation/preview-slot-rect.ts
@@ -0,0 +1,27 @@
+import type { Droppable } from '../entities/droppable.svelte.js'
+
+/**
+ * Absolute viewport position the ghost should sit at for the preview slot at
+ * `(droppable, position)`. Returns `null` for `target` mode, an unmounted
+ * preview entity, or missing ghost size.
+ */
+export function computePreviewSlotTarget(
+ droppable: Droppable,
+ position: number,
+ ghostSize: { width: number; height: number } | null
+): { x: number; y: number } | null {
+ if (droppable.mode === 'target') return null
+
+ const previewEntity = droppable.getSlotAt(position)?.preview ?? droppable.tailPreview
+ const previewEl = previewEntity?.element
+ if (!previewEl || !previewEntity) return null
+
+ const slotWrapper = (previewEl.parentElement ?? previewEl) as HTMLElement
+ const wrapperRect = slotWrapper.getBoundingClientRect()
+ const isHorizontal = previewEntity.isHorizontal
+ const alignEndY = previewEntity.align === 'end' && !isHorizontal
+ const alignEndX = previewEntity.align === 'end' && isHorizontal
+ const y = alignEndY ? wrapperRect.bottom - (ghostSize?.height ?? 0) : wrapperRect.top
+ const x = alignEndX ? wrapperRect.right - (ghostSize?.width ?? 0) : wrapperRect.left
+ return { x, y }
+}
diff --git a/src/lib/core/animation/scroll-sync-calculator.ts b/packages/svelte-dnd/src/lib/core/animation/scroll-sync-calculator.ts
similarity index 55%
rename from src/lib/core/animation/scroll-sync-calculator.ts
rename to packages/svelte-dnd/src/lib/core/animation/scroll-sync-calculator.ts
index 7411a0a..b309389 100644
--- a/src/lib/core/animation/scroll-sync-calculator.ts
+++ b/packages/svelte-dnd/src/lib/core/animation/scroll-sync-calculator.ts
@@ -1,4 +1,4 @@
-import { getDirectionAdapter, type DirectionAdapter } from './direction-adapter.js'
+import { getDirectionAdapter } from './direction-adapter.js'
// Animation timing constants
const ANIMATION_DURATION = {
@@ -9,10 +9,12 @@ const ANIMATION_DURATION = {
const SCROLL_SPEED_PX_PER_SEC = 1800
interface ScrollTargetParams {
- placeholder: HTMLElement
+ preview: HTMLElement
container: HTMLElement
expectedSize: number
direction: 'vertical' | 'horizontal'
+ /** Extra space (px) to keep between the preview and the container edge after scrolling. Defaults to 0. */
+ padding?: number
}
interface ScrollTargetResult {
@@ -21,17 +23,17 @@ interface ScrollTargetResult {
}
interface FinalGhostPositionParams {
- placeholderRect: DOMRect
+ previewRect: DOMRect
scrollDelta: number
direction: 'vertical' | 'horizontal'
}
export class ScrollSyncCalculator {
calculateScrollTarget(params: ScrollTargetParams): ScrollTargetResult {
- const { placeholder, container, expectedSize, direction } = params
+ const { preview, container, expectedSize, direction, padding = 0 } = params
const adapter = getDirectionAdapter(direction)
- const placeholderRect = placeholder.getBoundingClientRect()
+ const previewRect = preview.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
const startScroll = adapter.getScroll(container)
@@ -39,19 +41,24 @@ export class ScrollSyncCalculator {
const containerStart = adapter.getPosition(containerRect)
const containerEnd = containerStart + containerSize
- const placeholderSize = adapter.getSize(placeholderRect) || expectedSize
- const placeholderStart = adapter.getPosition(placeholderRect)
- const placeholderEnd = adapter.getEndPosition(placeholderRect, placeholderSize)
+ const previewSize = adapter.getSize(previewRect) || expectedSize
+ const previewStart = adapter.getPosition(previewRect)
+ const previewEnd = adapter.getEndPosition(previewRect, previewSize)
+
+ // Inset the visible band by `padding` so the preview lands with a gap
+ // from the container edge — same value droppable.spacing leaves between items.
+ const visibleStart = containerStart + padding
+ const visibleEnd = containerEnd - padding
let targetScroll = startScroll
- if (placeholderStart < containerStart) {
- // Placeholder вище/лівіше видимої області
- const overflow = containerStart - placeholderStart
+ if (previewStart < visibleStart) {
+ // Preview is above/left of the visible band
+ const overflow = visibleStart - previewStart
targetScroll = startScroll - overflow
- } else if (placeholderEnd > containerEnd) {
- // Placeholder нижче/правіше видимої області
- const overflow = placeholderEnd - containerEnd
+ } else if (previewEnd > visibleEnd) {
+ // Preview is below/right of the visible band
+ const overflow = previewEnd - visibleEnd
targetScroll = startScroll + overflow
}
@@ -72,17 +79,17 @@ export class ScrollSyncCalculator {
}
calculateFinalGhostPosition(params: FinalGhostPositionParams): { x: number; y: number } {
- const { placeholderRect, scrollDelta, direction } = params
+ const { previewRect, scrollDelta, direction } = params
if (direction === 'horizontal') {
return {
- x: placeholderRect.left - scrollDelta,
- y: placeholderRect.top
+ x: previewRect.left - scrollDelta,
+ y: previewRect.top
}
} else {
return {
- x: placeholderRect.left,
- y: placeholderRect.top - scrollDelta
+ x: previewRect.left,
+ y: previewRect.top - scrollDelta
}
}
}
diff --git a/packages/svelte-dnd/src/lib/core/animation/scroll-sync-runner.ts b/packages/svelte-dnd/src/lib/core/animation/scroll-sync-runner.ts
new file mode 100644
index 0000000..1434855
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/animation/scroll-sync-runner.ts
@@ -0,0 +1,85 @@
+import { ScrollSyncCalculator } from './scroll-sync-calculator.js'
+import { getDirectionAdapter } from './direction-adapter.js'
+import type { DndState } from '../dnd/dnd-state.svelte.js'
+
+const calc = new ScrollSyncCalculator()
+
+export interface ScrollSyncPlan {
+ /** Adaptive duration in ms, based on scroll distance. */
+ duration: number
+ /** Update ghost transform + container scroll for an eased progress in [0, 1]. */
+ update(eased: number): void
+ /** Snap to final exact scroll + transform — reads fresh target rect. */
+ finalize(): void
+}
+
+/**
+ * Builds an adaptive scroll-sync plan that ferries a ghost to a target element
+ * inside a scrollable container. Used when the target element is off-screen:
+ * the container scrolls in lockstep with the ghost flight so the ghost never
+ * disappears past the container's viewport.
+ *
+ * Caller drives the rAF loop and supplies its own cancellation check; this
+ * helper stays loop-agnostic so different animation steps can share the same
+ * scroll-aware behaviour.
+ */
+export function planScrollSync(args: {
+ state: DndState
+ container: HTMLElement
+ targetEl: HTMLElement
+ direction: 'vertical' | 'horizontal'
+ /** Extra space to keep between the target and the container edge after scrolling. Defaults to 0. */
+ padding?: number
+ /** When `true` (default), `finalize()` also re-reads the target rect and snaps the ghost there. */
+ snapToTargetOnFinalize?: boolean
+}): ScrollSyncPlan {
+ const {
+ state,
+ container,
+ targetEl,
+ direction,
+ padding = 0,
+ snapToTargetOnFinalize = true
+ } = args
+ const adapter = getDirectionAdapter(direction)
+ const startScroll = adapter.getScroll(container)
+ const startGhostPos = { ...(state.transform ?? { x: 0, y: 0 }) }
+ const previewRect = targetEl.getBoundingClientRect()
+
+ const expectedSize =
+ direction === 'horizontal' ? (state.ghostSize?.width ?? 0) : (state.ghostSize?.height ?? 0)
+
+ const { targetScroll, scrollDelta } = calc.calculateScrollTarget({
+ preview: targetEl,
+ container,
+ expectedSize,
+ direction,
+ padding
+ })
+
+ const duration = calc.calculateAdaptiveDuration(Math.abs(scrollDelta))
+
+ const finalGhostPos = calc.calculateFinalGhostPosition({
+ previewRect,
+ scrollDelta,
+ direction
+ })
+
+ return {
+ duration,
+ update(eased: number) {
+ adapter.setScroll(container, startScroll + scrollDelta * eased)
+ state.setTransform({
+ x: startGhostPos.x + (finalGhostPos.x - startGhostPos.x) * eased,
+ y: startGhostPos.y + (finalGhostPos.y - startGhostPos.y) * eased
+ })
+ },
+ finalize() {
+ adapter.setScroll(container, targetScroll)
+ if (snapToTargetOnFinalize) {
+ const finalRect = targetEl.getBoundingClientRect()
+ state.setTransform({ x: finalRect.left, y: finalRect.top })
+ }
+ }
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/animation/steps/animation-pipeline.ts b/packages/svelte-dnd/src/lib/core/animation/steps/animation-pipeline.ts
new file mode 100644
index 0000000..d233e61
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/animation/steps/animation-pipeline.ts
@@ -0,0 +1,27 @@
+import type { AnimationStep } from './animation-step.js'
+
+export class AnimationPipeline implements AnimationStep {
+ private steps: AnimationStep[] = []
+ private currentIndex = 0
+ private cancelled = false
+
+ static chain(...steps: AnimationStep[]): AnimationPipeline {
+ const pipeline = new AnimationPipeline()
+ pipeline.steps = steps
+ return pipeline
+ }
+
+ async execute(): Promise {
+ for (this.currentIndex = 0; this.currentIndex < this.steps.length; this.currentIndex++) {
+ if (this.cancelled) return
+ await this.steps[this.currentIndex].execute()
+ }
+ }
+
+ cancel(): void {
+ this.cancelled = true
+ if (this.steps[this.currentIndex]) {
+ this.steps[this.currentIndex].cancel()
+ }
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/animation/steps/animation-step.ts b/packages/svelte-dnd/src/lib/core/animation/steps/animation-step.ts
new file mode 100644
index 0000000..30a10bc
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/animation/steps/animation-step.ts
@@ -0,0 +1,11 @@
+export interface AnimationStep {
+ execute(onProgress?: (progress: number) => void): Promise
+ cancel(): void
+}
+
+export class InstantStep implements AnimationStep {
+ execute(): Promise {
+ return Promise.resolve()
+ }
+ cancel(): void {}
+}
diff --git a/packages/svelte-dnd/src/lib/core/animation/steps/ghost-return-step.ts b/packages/svelte-dnd/src/lib/core/animation/steps/ghost-return-step.ts
new file mode 100644
index 0000000..16fc27c
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/animation/steps/ghost-return-step.ts
@@ -0,0 +1,102 @@
+import type { AnimationStep } from './animation-step.js'
+import type { DndState } from '../../dnd/dnd-state.svelte.js'
+import type { Droppable } from '../../entities/droppable.svelte.js'
+import { DOMHelper } from '../../utils/dom-helper.js'
+import { DEFAULT_ANIMATION_CONFIG } from '../animation-config.js'
+import { parseEasing } from '../easing.js'
+
+/**
+ * Animates the ghost back to the dragged item's origin position.
+ *
+ * The step itself is layout-agnostic — it interpolates from the current ghost
+ * transform to the live origin slot rect each frame. Scroll-aware behaviour
+ * (scroll the container in lockstep when the origin slot is off-screen) is
+ * provided by the `scrollSync()` behavior wrapping this step.
+ */
+export class GhostReturnStep implements AnimationStep {
+ private cancelled = false
+
+ constructor(
+ private state: DndState,
+ private containerId: string | null,
+ private position: number,
+ private droppablesById: Map,
+ private duration: number = DEFAULT_ANIMATION_CONFIG.return.duration,
+ private easing: string = DEFAULT_ANIMATION_CONFIG.return.easing
+ ) {}
+
+ execute(): Promise {
+ return new Promise((resolve, reject) => {
+ if (!this.state.element || !this.state.transform || !this.state.originalPosition) {
+ resolve()
+ return
+ }
+
+ const fallbackPos = { ...this.state.originalPosition }
+ const startPos = { ...this.state.transform }
+ this.state.setAnimating(true)
+ const startTime = Date.now()
+ const easeFn = parseEasing(this.easing)
+ // Single exit point — every path through animate() either calls finish()
+ // or rethrows after clearing setAnimating, so the flag never leaks.
+ const finish = () => {
+ this.state.setAnimating(false)
+ resolve()
+ }
+
+ const animate = () => {
+ try {
+ if (this.cancelled) {
+ finish()
+ return
+ }
+
+ const progress = Math.min((Date.now() - startTime) / this.duration, 1)
+ const eased = easeFn(progress)
+ const target = this.getCurrentSlotPosition(fallbackPos)
+
+ this.state.setTransform({
+ x: startPos.x + (target.x - startPos.x) * eased,
+ y: startPos.y + (target.y - startPos.y) * eased
+ })
+
+ if (progress < 1) {
+ requestAnimationFrame(animate)
+ } else {
+ finish()
+ }
+ } catch (err) {
+ this.state.setAnimating(false)
+ reject(err)
+ }
+ }
+
+ requestAnimationFrame(animate)
+ })
+ }
+
+ cancel(): void {
+ this.cancelled = true
+ }
+
+ private getCurrentSlotPosition(fallback: { x: number; y: number }): { x: number; y: number } {
+ if (!this.containerId) return fallback
+ const slotEl = this.getSlotWrapper()
+ if (!slotEl) return fallback
+ const rect = slotEl.getBoundingClientRect()
+ return { x: rect.left, y: rect.top }
+ }
+
+ /** Returns the slot wrapper element for the origin position, using entity lookup. */
+ private getSlotWrapper(): HTMLElement | null {
+ if (!this.containerId) return null
+ const droppable = this.droppablesById.get(this.containerId)
+ // Entity lookup (works for all regular slot positions)
+ const slotEl = droppable?.getSlotAt(this.position)?.element
+ if (slotEl) return slotEl
+ // DOM fallback for tail preview or missing entity
+ const container = droppable?.element
+ if (!container) return null
+ return DOMHelper.findPreviewSlot(container, this.position)
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/animation/steps/ghost-to-target-step.ts b/packages/svelte-dnd/src/lib/core/animation/steps/ghost-to-target-step.ts
new file mode 100644
index 0000000..a055946
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/animation/steps/ghost-to-target-step.ts
@@ -0,0 +1,110 @@
+import type { AnimationStep } from './animation-step.js'
+import type { DndState } from '../../dnd/dnd-state.svelte.js'
+import type { DropZone } from '../../../types.js'
+import type { Droppable } from '../../entities/droppable.svelte.js'
+import { DEFAULT_ANIMATION_CONFIG } from '../animation-config.js'
+import { parseEasing } from '../easing.js'
+import { computePreviewSlotTarget } from '../preview-slot-rect.js'
+
+export class GhostToTargetStep implements AnimationStep {
+ private cancelled = false
+
+ constructor(
+ private state: DndState,
+ private targetZone: DropZone,
+ private droppablesById: Map,
+ private duration: number = DEFAULT_ANIMATION_CONFIG.drop.duration,
+ private easing: string = DEFAULT_ANIMATION_CONFIG.drop.easing
+ ) {}
+
+ execute(): Promise {
+ return new Promise((resolve, reject) => {
+ if (!this.state.element || !this.state.transform) {
+ resolve()
+ return
+ }
+
+ this.state.setAnimating(true)
+ const startPos = { ...this.state.transform }
+ const startTime = Date.now()
+ const easeFn = parseEasing(this.easing)
+ // Single exit point — every path through animate() either calls finish()
+ // or rethrows after clearing setAnimating, so the flag never leaks.
+ const finish = () => {
+ this.state.setAnimating(false)
+ resolve()
+ }
+
+ const animate = () => {
+ try {
+ if (this.cancelled) {
+ finish()
+ return
+ }
+
+ const elapsed = Date.now() - startTime
+ const progress = Math.min(elapsed / this.duration, 1)
+ const easedProgress = easeFn(progress)
+ const targetPos = this.calculateTargetPosition()
+
+ this.state.setTransform({
+ x: startPos.x + (targetPos.x - startPos.x) * easedProgress,
+ y: startPos.y + (targetPos.y - startPos.y) * easedProgress
+ })
+
+ if (progress < 1) {
+ requestAnimationFrame(animate)
+ } else {
+ finish()
+ }
+ } catch (err) {
+ this.state.setAnimating(false)
+ reject(err)
+ }
+ }
+
+ requestAnimationFrame(animate)
+ })
+ }
+
+ cancel(): void {
+ this.cancelled = true
+ }
+
+ private calculateTargetPosition(): { x: number; y: number } {
+ const droppable = this.droppablesById.get(this.targetZone.containerId)
+ const container = droppable?.element ?? null
+ if (!container || !droppable) return this.fallbackPosition()
+
+ if (droppable.mode === 'target') {
+ const rect = container.getBoundingClientRect()
+ const width = this.state.ghostSize?.width ?? 0
+ const height = this.state.ghostSize?.height ?? 0
+ return {
+ x: rect.left + rect.width / 2 - width / 2,
+ y: rect.top + rect.height / 2 - height / 2
+ }
+ }
+
+ const slotTarget = computePreviewSlotTarget(
+ droppable,
+ this.targetZone.position,
+ this.state.ghostSize
+ )
+ if (slotTarget) return slotTarget
+
+ const containerRect = container.getBoundingClientRect()
+ return { x: containerRect.left, y: this.targetZone.rect.y }
+ }
+
+ private fallbackPosition(): { x: number; y: number } {
+ return {
+ x:
+ this.targetZone.rect.x +
+ (this.targetZone.rect.width - (this.state.ghostSize?.width ?? 0)) / 2,
+ y:
+ this.targetZone.rect.y +
+ (this.targetZone.rect.height - (this.state.ghostSize?.height ?? 0)) / 2
+ }
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/collision/center-point.ts b/packages/svelte-dnd/src/lib/core/collision/center-point.ts
new file mode 100644
index 0000000..43e4757
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/collision/center-point.ts
@@ -0,0 +1,15 @@
+import type { CollisionAlgorithm } from './collision-algorithm.js'
+
+export const centerPoint: CollisionAlgorithm = ({ zones, ghost }) => {
+ const cx = ghost.x + ghost.width / 2
+ const cy = ghost.y + ghost.height / 2
+ return (
+ zones.find(
+ (zone) =>
+ cx >= zone.rect.x &&
+ cx <= zone.rect.x + zone.rect.width &&
+ cy >= zone.rect.y &&
+ cy <= zone.rect.y + zone.rect.height
+ ) ?? null
+ )
+}
diff --git a/packages/svelte-dnd/src/lib/core/collision/closest-center.ts b/packages/svelte-dnd/src/lib/core/collision/closest-center.ts
new file mode 100644
index 0000000..a73b5af
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/collision/closest-center.ts
@@ -0,0 +1,20 @@
+import type { CollisionAlgorithm } from './collision-algorithm.js'
+
+export const closestCenter: CollisionAlgorithm = ({ zones, ghost }) => {
+ const ghostCenterX = ghost.x + ghost.width / 2
+ const ghostCenterY = ghost.y + ghost.height / 2
+ let closest = null
+ let minDist = Infinity
+
+ for (const zone of zones) {
+ const zoneCenterX = zone.rect.x + zone.rect.width / 2
+ const zoneCenterY = zone.rect.y + zone.rect.height / 2
+ const dist = Math.hypot(ghostCenterX - zoneCenterX, ghostCenterY - zoneCenterY)
+ if (dist < minDist) {
+ minDist = dist
+ closest = zone
+ }
+ }
+
+ return closest
+}
diff --git a/packages/svelte-dnd/src/lib/core/collision/collision-algorithm.ts b/packages/svelte-dnd/src/lib/core/collision/collision-algorithm.ts
new file mode 100644
index 0000000..7e62481
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/collision/collision-algorithm.ts
@@ -0,0 +1,9 @@
+import type { DropZone } from '../../types.js'
+
+export interface CollisionContext {
+ zones: DropZone[]
+ pointer: { x: number; y: number }
+ ghost: { x: number; y: number; width: number; height: number }
+}
+
+export type CollisionAlgorithm = (context: CollisionContext) => DropZone | null
diff --git a/packages/svelte-dnd/src/lib/core/collision/cursor-over.ts b/packages/svelte-dnd/src/lib/core/collision/cursor-over.ts
new file mode 100644
index 0000000..6e839a5
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/collision/cursor-over.ts
@@ -0,0 +1,13 @@
+import type { CollisionAlgorithm } from './collision-algorithm.js'
+
+export const cursorOver: CollisionAlgorithm = ({ zones, pointer }) => {
+ return (
+ zones.find(
+ (zone) =>
+ pointer.x >= zone.rect.x &&
+ pointer.x <= zone.rect.x + zone.rect.width &&
+ pointer.y >= zone.rect.y &&
+ pointer.y <= zone.rect.y + zone.rect.height
+ ) ?? null
+ )
+}
diff --git a/packages/svelte-dnd/src/lib/core/collision/overlap.ts b/packages/svelte-dnd/src/lib/core/collision/overlap.ts
new file mode 100644
index 0000000..dfbc1b8
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/collision/overlap.ts
@@ -0,0 +1,32 @@
+import type { CollisionAlgorithm } from './collision-algorithm.js'
+
+function parseThreshold(value: number | string, ghost: { width: number; height: number }): number {
+ if (typeof value === 'string' && value.endsWith('%')) {
+ const pct = parseFloat(value) / 100
+ return pct * Math.min(ghost.width, ghost.height)
+ }
+ return typeof value === 'number' ? value : parseFloat(value) || 0
+}
+
+/**
+ * Minimum overlap required on both axes before a zone is considered hit.
+ * Number = pixels. String ending in `%` = fraction of `min(ghost.width, ghost.height)`.
+ * Defaults to 0 (any overlap).
+ */
+export const overlap = (threshold: number | string = 0): CollisionAlgorithm => {
+ return ({ zones, ghost }) => {
+ for (const zone of zones) {
+ const px = parseThreshold(threshold, ghost)
+
+ const intersectW =
+ Math.min(ghost.x + ghost.width, zone.rect.x + zone.rect.width) -
+ Math.max(ghost.x, zone.rect.x)
+ const intersectH =
+ Math.min(ghost.y + ghost.height, zone.rect.y + zone.rect.height) -
+ Math.max(ghost.y, zone.rect.y)
+
+ if (intersectW > px && intersectH > px) return zone
+ }
+ return null
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/containers/strategies/container-strategy.ts b/packages/svelte-dnd/src/lib/core/containers/strategies/container-strategy.ts
new file mode 100644
index 0000000..08db241
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/containers/strategies/container-strategy.ts
@@ -0,0 +1,44 @@
+import type { DropZone, DndMode, DndLayout } from '../../../types.js'
+import type { DragSession } from '../../dnd/drag-session.svelte.js'
+import type { DndState } from '../../dnd/dnd-state.svelte.js'
+import type { AnimationStep } from '../../animation/steps/animation-step.js'
+import type { Droppable } from '../../entities/droppable.svelte.js'
+import type { Behavior } from '../../animation/behavior.js'
+
+export interface StrategyBindContext {
+ state: DndState
+ droppablesById: Map
+}
+
+export interface ContainerStrategy {
+ readonly mode: DndMode
+ /**
+ * Layout hint for strategies that have a layout concept (sortable).
+ * Target-like strategies can omit it.
+ */
+ readonly layout?: DndLayout
+ /**
+ * Per-strategy behaviors (auto-scroll, scroll-sync, future plugins).
+ * When `undefined` or empty, the controller's default behaviors apply.
+ * When non-empty, replaces the controller defaults for this droppable.
+ */
+ readonly behaviors?: Behavior[]
+ calculateDropZones(droppable: Droppable, session: DragSession | null): DropZone[]
+ getTranslations(
+ droppable: Droppable,
+ session: DragSession
+ ): Map
+ getDropAnimation(session: DragSession, targetZone: DropZone): AnimationStep
+ getReturnAnimation(session: DragSession): AnimationStep
+ /**
+ * Called once per strategy instance when the owning droppable is first attached
+ * to a controller. Binds controller-level state (animation coordination, etc).
+ */
+ bindContext?(ctx: StrategyBindContext): void
+ /**
+ * Optional hook invoked on every droppable using this strategy at drag start,
+ * before any reactive cycle runs. Use it to capture layout snapshots or any
+ * other transform-free state the strategy needs during the drag.
+ */
+ onSessionStart?(droppable: Droppable, session: DragSession): void
+}
diff --git a/packages/svelte-dnd/src/lib/core/containers/strategies/sortable-container-strategy.ts b/packages/svelte-dnd/src/lib/core/containers/strategies/sortable-container-strategy.ts
new file mode 100644
index 0000000..967ae81
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/containers/strategies/sortable-container-strategy.ts
@@ -0,0 +1,302 @@
+import type { ContainerStrategy, StrategyBindContext } from './container-strategy.js'
+import type { DropZone, DndMode, DndLayout } from '../../../types.js'
+import type { DragSession } from '../../dnd/drag-session.svelte.js'
+import type { DndState } from '../../dnd/dnd-state.svelte.js'
+import type { AnimationStep } from '../../animation/steps/animation-step.js'
+import type { Droppable } from '../../entities/droppable.svelte.js'
+import type { Behavior } from '../../animation/behavior.js'
+import type { SlotLayoutRect } from '../../zones/layout-snapshot.js'
+import { captureLayoutSnapshot } from '../../zones/layout-snapshot.js'
+import type { VirtualSource } from '../../zones/sortable-source.js'
+import { DomSortableSource, VirtualSortableSource } from '../../zones/sortable-source.js'
+import { GhostToTargetStep } from '../../animation/steps/ghost-to-target-step.js'
+import { GhostReturnStep } from '../../animation/steps/ghost-return-step.js'
+import { pickGeometry } from '../../zones/geometry-registry.js'
+
+export interface SortableOptions {
+ /** Container layout for drop-zone geometry. Defaults to `'vertical'`. */
+ layout?: DndLayout
+ /**
+ * For `layout: 'grid'`, which axis items fill first.
+ * `'row'` (default): items fill left-to-right, wrapping to next row.
+ * `'column'`: items fill top-to-bottom, wrapping to next column.
+ */
+ flow?: 'row' | 'column'
+ /**
+ * Per-strategy behaviors (auto-scroll, scroll-sync, future plugins).
+ * Pass an array to replace the controller's default behaviors for this droppable.
+ * Omit (or pass an empty array) to inherit the controller defaults.
+ *
+ * @example
+ * ```ts
+ * sortable({
+ * layout: 'vertical',
+ * behaviors: [autoScroll({ maxSpeed: 60 }), scrollSync({ threshold: 0.5 })]
+ * })
+ * ```
+ */
+ behaviors?: Behavior[]
+ /**
+ * Opt into virtualized layout. When set, the strategy will not capture a
+ * one-shot DOM layout snapshot at drag start; it asks `virtual.getOffset(i)`
+ * and `virtual.getSize(i)` for any slot it needs to position.
+ *
+ * Only supported for `layout: 'vertical' | 'horizontal'` — `grid` falls back
+ * to DOM mode and ignores this option.
+ *
+ * Typical setup with [virtua](https://github.com/inokawa/virtua) (Svelte):
+ * ```svelte
+ * items.length,
+ * getOffset: (i) => vlist.getItemOffset(i),
+ * getSize: (i) => vlist.getItemSize(i)
+ * }
+ * })}>
+ *
+ * {#snippet children(item, index)}
+ * ...
+ * {/snippet}
+ *
+ *
+ * ```
+ *
+ * Keep the dragged item mounted while dragging (e.g. virtua's `keepMounted`)
+ * so its slot stays in the DOM and translations stay coherent.
+ */
+ virtual?: VirtualSource
+}
+
+/**
+ * Sortable container strategy — position-based drop zones with insert previews.
+ *
+ * Layout-agnostic. Reads all geometry from a LayoutSnapshot captured at drag start
+ * (transform-free), so reactive transforms during drag never feed back into
+ * zone or translation calculations. Direction-specific logic lives exclusively
+ * in ZoneGeometry implementations.
+ */
+export class SortableContainerStrategy implements ContainerStrategy {
+ readonly mode: DndMode = 'sortable'
+ readonly layout: DndLayout
+ readonly flow: 'row' | 'column'
+ readonly behaviors: Behavior[]
+ readonly virtual?: VirtualSource
+
+ private state!: DndState
+ private droppablesById!: Map
+
+ constructor(options: SortableOptions = {}) {
+ this.layout = options.layout ?? 'vertical'
+ this.flow = options.flow ?? 'row'
+ this.behaviors = options.behaviors ?? []
+ this.virtual = options.virtual
+ }
+
+ bindContext(ctx: StrategyBindContext): void {
+ this.state = ctx.state
+ this.droppablesById = ctx.droppablesById
+ }
+
+ onSessionStart(droppable: Droppable, session: DragSession): void {
+ if (this.virtual && (this.layout === 'vertical' || this.layout === 'horizontal')) {
+ const isOrigin = droppable.id === session.originContainerId
+ const draggedIndex = isOrigin ? session.originPosition : -1
+ session.setSource(
+ droppable.id,
+ new VirtualSortableSource(
+ droppable.id,
+ draggedIndex,
+ droppable,
+ this.virtual,
+ session.itemId
+ )
+ )
+ return
+ }
+ const snapshot = captureLayoutSnapshot(droppable, session.itemId)
+ session.setSource(droppable.id, new DomSortableSource(snapshot, session.itemId))
+ }
+
+ calculateDropZones(droppable: Droppable, session: DragSession | null): DropZone[] {
+ const containerRect = droppable.element.getBoundingClientRect()
+ const scrollLeft = droppable.element.scrollLeft
+ const scrollTop = droppable.element.scrollTop
+ const geometry = pickGeometry(this.layout, this.flow)
+
+ const source = session?.getSource(droppable.id)
+ const ctx = {
+ containerId: droppable.id,
+ containerRect,
+ scrollLeft,
+ scrollTop,
+ draggedIndex: source?.draggedIndex ?? -1
+ }
+
+ if (!source) return [geometry.buildEmptyZone(ctx)]
+
+ const visible = source.visibleRects(ctx)
+ if (visible.length === 0) return [geometry.buildEmptyZone(ctx)]
+ return geometry.buildZones(visible, ctx)
+ }
+
+ getTranslations(
+ droppable: Droppable,
+ session: DragSession
+ ): Map {
+ const map = new Map()
+ const source = session.getSource(droppable.id)
+ if (!source) return map
+
+ const containerId = droppable.id
+ const slotSize = session.slotSize
+ const layout = this.layout
+
+ // Grid keeps the adjacency-based shift: each item slides to the next/prev rect's
+ // absolute position. Requires the full set of rects, so it only runs against a
+ // DOM-backed source.
+ if (layout === 'grid') {
+ if (!(source instanceof DomSortableSource)) return map
+ const snapshot = source.snapshot
+ return this.getGridTranslations(
+ snapshot.rects,
+ snapshot.draggedIndex,
+ session.dropPreview,
+ containerId,
+ session
+ )
+ }
+
+ // Vertical/horizontal: every displaced item shifts by the dragged slot's own size
+ // (width/height including spacing). Each mounted slot whose `position` falls in
+ // the shift range moves up/down one "slot worth" — works for both DOM and virtual
+ // sources, where only a subset of slots is mounted at any moment.
+ const axis: 'x' | 'y' = layout === 'vertical' ? 'y' : 'x'
+ const step = !slotSize ? 0 : axis === 'y' ? slotSize.height : slotSize.width
+ if (step === 0) return map
+
+ const applyShift = (slotId: string, delta: number) => {
+ if (delta === 0) return
+ map.set(slotId, axis === 'y' ? { x: 0, y: delta } : { x: delta, y: 0 })
+ }
+
+ const D = source.draggedIndex
+ const preview = session.dropPreview
+ const mounted = source.mountedSlots()
+
+ if (!preview) {
+ // No hover target: collapse the gap left by the dragged item in its origin container.
+ if (containerId !== session.originContainerId || D === -1) return map
+ for (const s of mounted) if (s.position > D) applyShift(s.id, -step)
+ return map
+ }
+
+ if (preview.containerId === containerId) {
+ const P = preview.position
+
+ if (D === -1) {
+ // Cross-container target: items at position >= P shift forward by one slot.
+ for (const s of mounted) if (s.position >= P) applyShift(s.id, step)
+ } else {
+ // Same-container reorder. targetIdx is the full-array drop index that accounts
+ // for the dragged slot still occupying its origin position.
+ const targetIdx = P <= D ? P : P + 1
+ if (targetIdx < D) {
+ // Drag moves earlier — items in [targetIdx..D-1] make room by shifting forward.
+ for (const s of mounted)
+ if (s.position >= targetIdx && s.position < D) applyShift(s.id, step)
+ } else if (targetIdx > D) {
+ // Drag moves later — items in (D..targetIdx) fill the gap by shifting back.
+ for (const s of mounted)
+ if (s.position > D && s.position < targetIdx) applyShift(s.id, -step)
+ }
+ }
+ } else if (containerId === session.originContainerId && D !== -1) {
+ // Origin container when the item is hovering over a different container: collapse the gap.
+ for (const s of mounted) if (s.position > D) applyShift(s.id, -step)
+ }
+
+ return map
+ }
+
+ private getGridTranslations(
+ rects: SlotLayoutRect[],
+ D: number,
+ preview: { containerId: string; position: number } | null,
+ containerId: string,
+ session: DragSession
+ ): Map {
+ const map = new Map()
+ const slotSize = session.slotSize
+
+ const extrapolateNext = (rect: SlotLayoutRect): SlotLayoutRect | null => {
+ if (!slotSize) return null
+ return {
+ ...rect,
+ offsetLeft: rect.offsetLeft + slotSize.width,
+ offsetTop: rect.offsetTop
+ }
+ }
+
+ const shift = (rect: SlotLayoutRect, target: SlotLayoutRect | null) => {
+ if (!target) return
+ const dx = target.offsetLeft - rect.offsetLeft
+ const dy = target.offsetTop - rect.offsetTop
+ if (dx === 0 && dy === 0) return
+ map.set(rect.slotId, { x: dx, y: dy })
+ }
+
+ if (!preview) {
+ if (containerId !== session.originContainerId || D === -1) return map
+ for (let i = D + 1; i < rects.length; i++) shift(rects[i], rects[i - 1])
+ return map
+ }
+
+ if (preview.containerId === containerId) {
+ const P = preview.position
+ if (D === -1) {
+ for (let i = P; i < rects.length; i++) {
+ const target = rects[i + 1] ?? extrapolateNext(rects[i])
+ shift(rects[i], target)
+ }
+ } else {
+ const targetIdx = P <= D ? P : P + 1
+ for (let i = 0; i < rects.length; i++) {
+ if (i === D) continue
+ if (i < D && i >= targetIdx) shift(rects[i], rects[i + 1])
+ else if (i > D && i < targetIdx) shift(rects[i], rects[i - 1])
+ }
+ }
+ } else if (containerId === session.originContainerId && D !== -1) {
+ for (let i = D + 1; i < rects.length; i++) shift(rects[i], rects[i - 1])
+ }
+
+ return map
+ }
+
+ getDropAnimation(session: DragSession, targetZone: DropZone): AnimationStep {
+ return new GhostToTargetStep(this.state, targetZone, this.droppablesById)
+ }
+
+ getReturnAnimation(session: DragSession): AnimationStep {
+ return new GhostReturnStep(
+ this.state,
+ session.originContainerId,
+ session.originPosition,
+ this.droppablesById
+ )
+ }
+}
+
+/**
+ * Factory for `SortableContainerStrategy`. Pass options to configure layout
+ * and (for grid) flow axis.
+ *
+ * @example
+ * ```svelte
+ *
+ * ```
+ */
+export function sortable(options?: SortableOptions): SortableContainerStrategy {
+ return new SortableContainerStrategy(options)
+}
diff --git a/packages/svelte-dnd/src/lib/core/containers/strategies/target-container-strategy.ts b/packages/svelte-dnd/src/lib/core/containers/strategies/target-container-strategy.ts
new file mode 100644
index 0000000..5f5e85c
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/containers/strategies/target-container-strategy.ts
@@ -0,0 +1,96 @@
+import type { ContainerStrategy, StrategyBindContext } from './container-strategy.js'
+import type { DropZone, DndMode } from '../../../types.js'
+import type { DragSession } from '../../dnd/drag-session.svelte.js'
+import type { DndState } from '../../dnd/dnd-state.svelte.js'
+import type { AnimationStep } from '../../animation/steps/animation-step.js'
+import type { Droppable } from '../../entities/droppable.svelte.js'
+import type { Behavior } from '../../animation/behavior.js'
+import { DOMHelper } from '../../utils/dom-helper.js'
+import { GhostToTargetStep } from '../../animation/steps/ghost-to-target-step.js'
+import { GhostReturnStep } from '../../animation/steps/ghost-return-step.js'
+
+export interface TargetOptions {
+ /**
+ * Per-strategy behaviors (auto-scroll, scroll-sync, future plugins).
+ * Pass an array to replace the controller's default behaviors for this droppable.
+ *
+ * @example
+ * ```ts
+ * target({ behaviors: [autoScroll({ maxSpeed: 60 })] })
+ * ```
+ */
+ behaviors?: Behavior[]
+}
+
+/**
+ * Target container strategy — single drop zone covering the whole container, no insert previews.
+ * Useful for trash zones, boards, or any container that isn't a sorted list.
+ */
+export class TargetContainerStrategy implements ContainerStrategy {
+ readonly mode: DndMode = 'target'
+ readonly behaviors: Behavior[]
+
+ private state!: DndState
+ private droppablesById!: Map
+
+ constructor(options: TargetOptions = {}) {
+ this.behaviors = options.behaviors ?? []
+ }
+
+ bindContext(ctx: StrategyBindContext): void {
+ this.state = ctx.state
+ this.droppablesById = ctx.droppablesById
+ }
+
+ calculateDropZones(droppable: Droppable, _session: DragSession | null): DropZone[] {
+ const rect = DOMHelper.getRect(droppable.element)
+ return [
+ {
+ containerId: droppable.id,
+ position: 0,
+ layout: 'vertical',
+ rect: {
+ x: rect.left,
+ y: rect.top,
+ width: rect.width,
+ height: Math.max(rect.height, 20)
+ }
+ }
+ ]
+ }
+
+ // Target containers don't have sortable items — no translations needed.
+ // The origin container's strategy handles gap collapse.
+ getTranslations(
+ _droppable: Droppable,
+ _session: DragSession
+ ): Map {
+ return new Map()
+ }
+
+ getDropAnimation(session: DragSession, targetZone: DropZone): AnimationStep {
+ return new GhostToTargetStep(this.state, targetZone, this.droppablesById)
+ }
+
+ getReturnAnimation(session: DragSession): AnimationStep {
+ return new GhostReturnStep(
+ this.state,
+ session.originContainerId,
+ session.originPosition,
+ this.droppablesById
+ )
+ }
+}
+
+/**
+ * Factory for `TargetContainerStrategy`.
+ *
+ * @example
+ * ```svelte
+ *
+ *
+ * ```
+ */
+export function target(options?: TargetOptions): TargetContainerStrategy {
+ return new TargetContainerStrategy(options)
+}
diff --git a/packages/svelte-dnd/src/lib/core/dnd/dnd-controller.svelte.ts b/packages/svelte-dnd/src/lib/core/dnd/dnd-controller.svelte.ts
new file mode 100644
index 0000000..541abde
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/dnd/dnd-controller.svelte.ts
@@ -0,0 +1,977 @@
+import { SvelteMap } from 'svelte/reactivity'
+import { DndState } from './dnd-state.svelte.js'
+import { ScrollController } from '../scroll/scroll-controller.js'
+import { DndEventEmitter } from './dnd-event-emitter.js'
+import { TranslationEngine } from '../zones/translation-engine.svelte.js'
+import type { SensorDescriptor, NavigationDirection } from '../sensors/sensor.js'
+import { PointerSensor } from '../sensors/pointer-sensor.js'
+import { KeyboardSensor } from '../sensors/keyboard-sensor.js'
+import type { CollisionAlgorithm } from '../collision/collision-algorithm.js'
+import { DropResolver } from '../zones/drop-resolver.js'
+import { DropAnimationCoordinator } from '../animation/drop-animation-coordinator.js'
+import type { Modifier } from '../modifiers/modifier.js'
+import { DndSimulator } from './dnd-simulator.js'
+import type { AnimateItemOptions, AnimateLayoutOptions } from './dnd-simulator.js'
+import type {
+ DragStartCallback,
+ DragEndCallback,
+ DropCallback,
+ DragOverCallback,
+ DropCancelledCallback,
+ ZonesInvalidatedCallback,
+ DndItemInfo,
+ DndContainerInfo,
+ DragStartEvent,
+ Announcements,
+ DndLayout
+} from '../../types.js'
+import type { AnimationConfig, ResolvedAnimationConfig } from '../animation/animation-config.js'
+import { resolveAnimationConfig, DEFAULT_ANIMATION_CONFIG } from '../animation/animation-config.js'
+import type { Behavior, AutoScrollConfig } from '../animation/behavior.js'
+import { autoScroll } from '../animation/behaviors/auto-scroll.js'
+import { scrollSync } from '../animation/behaviors/scroll-sync.js'
+import { resolveBehaviors, findTargetSlotWrapper } from '../animation/apply-behaviors.js'
+import { computePreviewSlotTarget } from '../animation/preview-slot-rect.js'
+import { KeyboardFlight } from '../animation/keyboard-flight.js'
+import { getDirectionAdapter } from '../animation/direction-adapter.js'
+import { findScrollTarget } from '../scroll/scroll-into-view.js'
+import { DragSession } from './drag-session.svelte.js'
+import type { Draggable } from '../entities/draggable.svelte.js'
+import type { Droppable } from '../entities/droppable.svelte.js'
+import type { Slot } from '../entities/slot.js'
+
+export interface DndControllerConfig {
+ animation?: AnimationConfig
+ /**
+ * Drop-side plugins. Default — `[autoScroll(), scrollSync()]`. Pass an
+ * explicit list (including empty `[]` to opt out of all defaults) to
+ * customise. Strategies can override per-droppable via their own
+ * `behaviors` option.
+ */
+ behaviors?: Behavior[]
+ debug?: boolean
+ sensors?: SensorDescriptor[]
+ collision?: CollisionAlgorithm
+ modifiers?: Modifier[]
+ announcements?: Announcements
+}
+
+export type {
+ DragStartCallback,
+ DragEndCallback,
+ DropCallback,
+ DragOverCallback,
+ DropCancelledCallback,
+ ZonesInvalidatedCallback
+} from '../../types.js'
+
+/**
+ * Central controller for drag-and-drop. Create one instance and pass it to
+ * `DndProvider` — all child `DndDroppable` and `DndDraggable` components will
+ * share the same state.
+ *
+ * @example
+ * ```ts
+ * const controller = new DndController()
+ *
+ * controller.onDrop(({ item, source, target }) => {
+ * // reorder / move items in your data model
+ * })
+ * ```
+ */
+export class DndController {
+ private state = new DndState()
+ private eventEmitter = new DndEventEmitter()
+ private translationEngine: TranslationEngine
+ private dropResolver: DropResolver
+ private scrollController: ScrollController
+ private animationCoordinator: DropAnimationCoordinator
+ private simulator: DndSimulator
+ private modifiers: Modifier[]
+ private keyboardFlight: KeyboardFlight | null = null
+
+ // --- Entity maps ---
+ // Reactive maps so $derived computations (TranslationEngine, dropPreviewSize)
+ // re-run when droppables mount/unmount mid-drag (lazy routes, virtualized parents).
+ private droppables = new SvelteMap()
+ private droppablesById = new SvelteMap()
+ /**
+ * Global element→Slot lookup used by sensors and attachSlot.
+ * @internal
+ */
+ slots = new Map()
+ /** Current drag session. Read-only — stored in DndState. */
+ get session(): DragSession | null {
+ return this.state.session
+ }
+
+ debug = $state(false)
+ sensors = $state(undefined)
+ announcements = $state(undefined)
+ private defaultBehaviors: Behavior[] = []
+ /**
+ * Reactive resolved animation config. DndPreview, DndProvider, DndDraggable
+ * and DndDroppable read live values from here for delays, durations and
+ * easings.
+ */
+ animation = $state(DEFAULT_ANIMATION_CONFIG)
+
+ constructor({
+ animation,
+ behaviors,
+ debug = false,
+ sensors,
+ collision,
+ modifiers = [],
+ announcements
+ }: DndControllerConfig = {}) {
+ this.debug = debug
+ this.sensors = sensors ?? [new PointerSensor(), new KeyboardSensor()]
+ this.announcements = announcements
+ this.modifiers = modifiers
+ this.animation = resolveAnimationConfig(animation)
+ this.defaultBehaviors = behaviors ?? [autoScroll(), scrollSync()]
+
+ this.translationEngine = new TranslationEngine(this.state, this.droppables)
+ this.dropResolver = new DropResolver(this.state, this.droppablesById, collision)
+
+ this.scrollController = new ScrollController(this.state, {
+ onZoneRefresh: () => this.eventEmitter.notifyZonesInvalidated(),
+ onMouseUpdate: (x, y) => this.updateMousePosition(x, y),
+ resolveAutoScrollConfig: (container) => this.resolveAutoScrollConfig(container),
+ stopOnDrop: this.controllerAutoScrollConfig()?.stopOnDrop ?? false
+ })
+
+ this.animationCoordinator = new DropAnimationCoordinator(
+ this.state,
+ this.eventEmitter,
+ this.scrollController,
+ this.dropResolver,
+ this.droppablesById,
+ this.animation,
+ this.defaultBehaviors
+ )
+
+ this.simulator = new DndSimulator(
+ this.state,
+ this.droppablesById,
+ this.slots,
+ this.eventEmitter,
+ this.animation,
+ this.defaultBehaviors
+ )
+ }
+
+ /** First `autoScroll(...)` config among the controller's default behaviors. */
+ private controllerAutoScrollConfig(): AutoScrollConfig | null {
+ return this.defaultBehaviors.find((b) => b.autoScrollConfig)?.autoScrollConfig ?? null
+ }
+
+ /** Looks up the active auto-scroll config for any scrollable element ScrollController encounters. */
+ private resolveAutoScrollConfig(container: HTMLElement): AutoScrollConfig | null {
+ const dropId = container.getAttribute('data-dnd-drop-id')
+ if (dropId) {
+ const droppable = this.droppablesById.get(dropId)
+ const behaviors = resolveBehaviors(droppable ?? null, this.defaultBehaviors)
+ return behaviors.find((b) => b.autoScrollConfig)?.autoScrollConfig ?? null
+ }
+ // `data-dnd-scroll` (non-droppable wrappers) — controller defaults only.
+ return this.controllerAutoScrollConfig()
+ }
+
+ // --- Reactive state (read-only) ---
+
+ /**
+ * CSS translate offsets for each draggable item during an active drag. Keyed by item id.
+ * @internal
+ */
+ get translations() {
+ return this.translationEngine.translations
+ }
+
+ /**
+ * Extra margin for the cross-container drop target so it grows in layout flow,
+ * preventing translated items from visually overflowing into siblings.
+ * null when not in a cross-container drag.
+ * @internal
+ */
+ get dropTargetPadding() {
+ return this.translationEngine.dropTargetPadding
+ }
+
+ /** `true` while the user is dragging an item. */
+ get dragging() {
+ return this.state.dragging
+ }
+
+ /** The DOM element currently being dragged. */
+ get element() {
+ return this.state.element
+ }
+
+ /** @internal Alias of `element` used by `DroppableControllerRef` to disambiguate the dragged element from other DOM elements a consumer might track. */
+ get draggedElement() {
+ return this.state.element
+ }
+
+ /** Current `{ x, y }` transform of the ghost element. */
+ get transform() {
+ return this.state.transform
+ }
+
+ /** Id of the item being dragged. */
+ get draggedItem() {
+ return this.state.draggedItem
+ }
+
+ /** `type` field from the dragged item's data, used for accept filtering. */
+ get draggedType() {
+ return this.state.draggedType
+ }
+
+ /** Full data object of the dragged item. */
+ get draggedItemData() {
+ return this.state.draggedItemData
+ }
+
+ /** Width/height of the dragged element. */
+ get ghostSize() {
+ return this.state.ghostSize
+ }
+
+ /** `true` while the ghost is animating back to its origin. */
+ get animatingReturn() {
+ return this.state.animating
+ }
+
+ /** Current drop preview (target container + insertion position). */
+ get dropPreview() {
+ return this.state.dropPreview
+ }
+
+ // Cache for first-slot rects keyed by containerId. Avoids `getBoundingClientRect`
+ // on every pointermove (the $derived below otherwise re-runs at 60fps and forces a
+ // synchronous layout each tick). The slotId is stored alongside so we re-measure if
+ // the first non-dragged slot identity changes (e.g. virtualizer remounts).
+ // Cleared at session start so a stale rect from the previous drag never leaks in.
+ private firstSlotRectCache = new Map<
+ string,
+ { slotId: string; width: number; height: number }
+ >()
+
+ /**
+ * Reactive size for sizing ghost/preview to match the destination layout.
+ * Returns `null` when no drop preview is active, the target is empty, no
+ * first slot is mounted yet, or the target uses `target()` strategy (which
+ * doesn't constrain item size at all).
+ *
+ * Mixes target-sibling and dragged-item dimensions based on the target's
+ * layout — only the cross-axis is borrowed, because that's where target-side
+ * layout shrinkage shows up (e.g. a scrollbar narrowing items):
+ *
+ * - `vertical` sortable: width from target sibling, height from dragged.
+ * - `horizontal` sortable: height from target sibling, width from dragged.
+ * - `grid` sortable: both from target sibling (cells constrain both axes).
+ *
+ * For dimensions taken from the dragged item, falls back to the target
+ * sibling when `ghostSize` is missing (defensive — should always be set
+ * during an active preview).
+ */
+ dropPreviewSize = $derived.by((): { width: number; height: number } | null => {
+ const preview = this.state.dropPreview
+ if (!preview) return null
+ const droppable = this.droppablesById.get(preview.containerId)
+ if (!droppable || droppable.mode !== 'sortable') return null
+
+ const draggedId = this.state.draggedItem
+ const firstSlot = droppable.getSortedSlots().find((s) => s.draggable?.id !== draggedId)
+ if (!firstSlot?.draggable?.element) return null
+
+ const slotId = firstSlot.draggable.id
+ let cached = this.firstSlotRectCache.get(preview.containerId)
+ if (!cached || cached.slotId !== slotId) {
+ const r = firstSlot.draggable.element.getBoundingClientRect()
+ cached = { slotId, width: r.width, height: r.height }
+ this.firstSlotRectCache.set(preview.containerId, cached)
+ }
+
+ const ghost = this.state.ghostSize
+ const ghostW = ghost?.width ?? cached.width
+ const ghostH = ghost?.height ?? cached.height
+
+ const layout = droppable.layout
+ if (layout === 'horizontal') return { width: ghostW, height: cached.height }
+ if (layout === 'grid') return { width: cached.width, height: cached.height }
+ // vertical (default)
+ return { width: cached.width, height: ghostH }
+ })
+
+ /** All registered drop zones across every `DndDroppable`. */
+ get dropZones() {
+ return this.state.zones
+ }
+
+ /** @internal */
+ get debugZones() {
+ return this.state.debugZones
+ }
+
+ /**
+ * Drop zones filtered to only those that accept the currently dragged item type.
+ * @internal
+ */
+ get filteredDropZones() {
+ return this.dropResolver.filteredZones
+ }
+
+ /** `true` while the drop animation is in progress. */
+ get performingDrop() {
+ return this.state.performingDrop
+ }
+
+ /** @internal */
+ get skipDropPreviewAnimation() {
+ return this.state.skipDropPreviewAnimation
+ }
+
+ /** `'user'` during real drag, `'programmatic'` during simulation. */
+ get dragSource() {
+ return this.state.dragSource
+ }
+
+ // --- Event subscriptions ---
+
+ /** Fired when a drag begins. */
+ onDragStart(cb: DragStartCallback) {
+ return this.eventEmitter.onDragStart(cb)
+ }
+
+ /** Fired when a drag ends (drop or cancel). */
+ onDragEnd(cb: DragEndCallback) {
+ return this.eventEmitter.onDragEnd(cb)
+ }
+
+ /** Fired when a drag is cancelled (ghost returns to origin, no drop occurred). */
+ onDropCancelled(cb: DropCancelledCallback) {
+ return this.eventEmitter.onDropCancelled(cb)
+ }
+
+ /** Fired when an item is successfully dropped into a container. */
+ onDrop(cb: DropCallback) {
+ return this.eventEmitter.onDrop(cb)
+ }
+
+ /** Fired each time the drag-over target (container + position) changes. */
+ onDragOver(cb: DragOverCallback) {
+ return this.eventEmitter.onDragOver(cb)
+ }
+
+ /**
+ * Fired after auto-scroll moves a container, invalidating existing drop zone
+ * coordinates. Subscribe to recalculate zones if you manage them manually.
+ */
+ onZonesInvalidated(cb: ZonesInvalidatedCallback) {
+ return this.eventEmitter.onZonesInvalidated(cb)
+ }
+
+ // --- Lifecycle ---
+
+ /** @internal */
+ setSkipDropPreviewAnimation(value: boolean) {
+ this.state.setSkipDropPreviewAnimation(value)
+ }
+
+ updateTransform(rawTransform: { x: number; y: number }) {
+ if (this.modifiers.length === 0) {
+ this.state.setTransform(rawTransform)
+ return
+ }
+
+ const initialTransform = this.state.session?.ghostTransform ?? rawTransform
+ const ghostSize = this.state.ghostSize ?? { width: 0, height: 0 }
+ const originContainerId = this.state.session?.originContainerId ?? ''
+
+ let transform = rawTransform
+ for (const modifier of this.modifiers) {
+ transform = modifier({ transform, initialTransform, ghostSize, originContainerId })
+ }
+ this.state.setTransform(transform)
+ }
+
+ updateMousePosition(mouseX: number, mouseY: number) {
+ if (this.state.dragging && !this.state.performingDrop) {
+ this.animationCoordinator.updateDropPreview({ x: mouseX, y: mouseY })
+ }
+ }
+
+ handleAutoScroll(mouseX: number, mouseY: number) {
+ if (this.state.dragging && !this.state.performingDrop) {
+ this.scrollController.handleAutoScroll(mouseX, mouseY)
+ }
+ }
+
+ /** @internal */
+ navigate(direction: NavigationDirection) {
+ if (!this.state.dragging) return
+ const session = this.state.session
+ if (!session) return
+
+ const current = this.state.dropPreview ?? {
+ containerId: session.originContainerId,
+ position: session.originPosition
+ }
+ const currentDroppable = this.droppablesById.get(current.containerId)
+ if (!currentDroppable) return
+
+ const target = this.isMainAxisDirection(currentDroppable.layout, direction)
+ ? this.navigateWithinContainer(currentDroppable, current.position, direction)
+ : this.navigateAcrossContainers(currentDroppable, current.position, direction)
+
+ if (target) this.applyKeyboardTarget(target)
+ }
+
+ private isMainAxisDirection(layout: DndLayout, direction: NavigationDirection): boolean {
+ if (direction === 'home' || direction === 'end') return true
+ if (layout === 'grid') return true
+ if (layout === 'horizontal') return direction === 'left' || direction === 'right'
+ return direction === 'up' || direction === 'down'
+ }
+
+ // Full position list for keyboard navigation — independent of viewport
+ // (unlike `dropResolver.filteredZones`, which filters by viewport visibility
+ // for pointer-collision purposes). Same-container: 0..itemCount-1, tail not
+ // reachable. Cross-container: 0..itemCount inclusive (tail allowed).
+ private validPositions(droppable: Droppable): number[] {
+ const itemCount = droppable.itemCount
+ const isSource = this.state.session?.originContainerId === droppable.id
+ const max = isSource ? itemCount - 1 : itemCount
+ const out: number[] = []
+ for (let i = 0; i <= max; i++) out.push(i)
+ return out
+ }
+
+ private slotOrTailRect(droppable: Droppable, position: number): DOMRect | null {
+ const slotEl = droppable.getSlotAt(position)?.element
+ if (slotEl) return slotEl.getBoundingClientRect()
+ if (position === droppable.itemCount) {
+ const tailEl = droppable.tailPreview?.element?.parentElement
+ if (tailEl) return tailEl.getBoundingClientRect()
+ }
+ return null
+ }
+
+ private navigateWithinContainer(
+ droppable: Droppable,
+ currentPosition: number,
+ direction: NavigationDirection
+ ): { containerId: string; position: number } | null {
+ const positions = this.validPositions(droppable)
+ if (positions.length === 0) return null
+
+ if (direction === 'home') return { containerId: droppable.id, position: positions[0] }
+ if (direction === 'end')
+ return { containerId: droppable.id, position: positions[positions.length - 1] }
+
+ const currentIdx = positions.indexOf(currentPosition)
+ const safeIdx = currentIdx === -1 ? 0 : currentIdx
+
+ if (droppable.layout === 'grid') {
+ return this.navigateWithinGrid(droppable, positions, safeIdx, direction)
+ }
+
+ const isForward = direction === 'down' || direction === 'right'
+ const next = positions[isForward ? safeIdx + 1 : safeIdx - 1]
+ if (next === undefined) return null
+ return { containerId: droppable.id, position: next }
+ }
+
+ private navigateWithinGrid(
+ droppable: Droppable,
+ positions: number[],
+ currentIdx: number,
+ direction: NavigationDirection
+ ): { containerId: string; position: number } | null {
+ const currentPos = positions[currentIdx]
+ const currentRect = this.slotOrTailRect(droppable, currentPos)
+
+ if (!currentRect) {
+ const isForward = direction === 'down' || direction === 'right'
+ const next = positions[isForward ? currentIdx + 1 : currentIdx - 1]
+ if (next === undefined) return null
+ return { containerId: droppable.id, position: next }
+ }
+
+ const isHorizontal = direction === 'left' || direction === 'right'
+ const isForward = direction === 'down' || direction === 'right'
+ const bandHalf = isHorizontal ? currentRect.height / 2 : currentRect.width / 2
+ const currentMain = isHorizontal
+ ? currentRect.left + currentRect.width / 2
+ : currentRect.top + currentRect.height / 2
+ const currentCross = isHorizontal
+ ? currentRect.top + currentRect.height / 2
+ : currentRect.left + currentRect.width / 2
+
+ let best: number | null = null
+ let bestScore = Infinity
+ for (const position of positions) {
+ if (position === currentPos) continue
+ const rect = this.slotOrTailRect(droppable, position)
+ if (!rect) continue
+ const main = isHorizontal ? rect.left + rect.width / 2 : rect.top + rect.height / 2
+ const cross = isHorizontal ? rect.top + rect.height / 2 : rect.left + rect.width / 2
+ const dMain = main - currentMain
+ if (isForward ? dMain <= 0 : dMain >= 0) continue
+
+ if (isHorizontal) {
+ const sameLine = Math.abs(cross - currentCross) < bandHalf
+ if (!sameLine) continue
+ const score = Math.abs(dMain)
+ if (score < bestScore) {
+ bestScore = score
+ best = position
+ }
+ } else {
+ const score = Math.abs(dMain) * 2 + Math.abs(cross - currentCross)
+ if (score < bestScore) {
+ bestScore = score
+ best = position
+ }
+ }
+ }
+
+ if (best === null) return null
+ return { containerId: droppable.id, position: best }
+ }
+
+ private navigateAcrossContainers(
+ currentDroppable: Droppable,
+ currentPosition: number,
+ direction: NavigationDirection
+ ): { containerId: string; position: number } | null {
+ const isHorizontal = direction === 'left' || direction === 'right'
+ const draggedType = this.state.draggedType ?? undefined
+
+ const candidates: Array<{ droppable: Droppable; rect: DOMRect }> = []
+ for (const droppable of this.droppablesById.values()) {
+ if (droppable.disabled) continue
+ if (droppable.mode !== 'sortable') continue
+ if (!droppable.acceptsType(draggedType)) continue
+ candidates.push({ droppable, rect: droppable.element.getBoundingClientRect() })
+ }
+ candidates.sort((a, b) =>
+ isHorizontal ? a.rect.left - b.rect.left : a.rect.top - b.rect.top
+ )
+
+ const currentIdx = candidates.findIndex((c) => c.droppable.id === currentDroppable.id)
+ if (currentIdx === -1) return null
+
+ const isForward = direction === 'down' || direction === 'right'
+ const next = candidates[isForward ? currentIdx + 1 : currentIdx - 1]
+ if (!next) return null
+
+ const nextPositions = this.validPositions(next.droppable)
+ if (nextPositions.length === 0) return null
+
+ const refRect = this.slotOrTailRect(currentDroppable, currentPosition)
+ if (!refRect) return { containerId: next.droppable.id, position: nextPositions[0] }
+
+ const refCoord = isHorizontal
+ ? refRect.top + refRect.height / 2
+ : refRect.left + refRect.width / 2
+
+ let best: number = nextPositions[0]
+ let bestDist = Infinity
+ for (const position of nextPositions) {
+ const slotRect = this.slotOrTailRect(next.droppable, position)
+ if (!slotRect) continue
+ const center = isHorizontal
+ ? slotRect.top + slotRect.height / 2
+ : slotRect.left + slotRect.width / 2
+ const dist = Math.abs(center - refCoord)
+ if (dist < bestDist) {
+ bestDist = dist
+ best = position
+ }
+ }
+
+ return { containerId: next.droppable.id, position: best }
+ }
+
+ private applyKeyboardTarget(target: { containerId: string; position: number }) {
+ const targetDroppable = this.droppablesById.get(target.containerId)
+ if (!targetDroppable) return
+
+ this.animationCoordinator.setActivePreview({
+ containerId: target.containerId,
+ position: target.position
+ })
+
+ // Defer one frame so reactive style updates (last-slot margin toggle on
+ // tail activation, sibling translations) flush before we measure. Rapid
+ // keypresses coalesce via the dropPreview match check.
+ requestAnimationFrame(() => {
+ if (!this.state.dragging) return
+ const preview = this.state.dropPreview
+ if (preview?.containerId !== target.containerId) return
+ if (preview?.position !== target.position) return
+
+ const slotEl = findTargetSlotWrapper(targetDroppable, target.position)
+ const padding = targetDroppable.spacing ?? 0
+ const flightCfg = this.animation.keyboardFlight
+ const flight = this.getKeyboardFlight()
+ const ghostSize = this.state.ghostSize
+
+ // Target off-screen — animate scroll + ghost transform in lockstep.
+ const scrollTarget = slotEl ? findScrollTarget(slotEl, padding) : null
+ if (slotEl && scrollTarget) {
+ this.runScrollSyncFlight(
+ flight,
+ flightCfg,
+ targetDroppable,
+ target.position,
+ slotEl,
+ scrollTarget.container,
+ scrollTarget.direction,
+ padding,
+ ghostSize
+ )
+ return
+ }
+
+ // Target already visible — fly straight to its live slot rect.
+ const ghostTarget = computePreviewSlotTarget(
+ targetDroppable,
+ target.position,
+ ghostSize
+ )
+ if (ghostTarget) {
+ flight.animateTo(ghostTarget, flightCfg)
+ return
+ }
+
+ // Slot not mounted (e.g. virtualized) — fall back to zone rect.
+ const fallback = this.state.zones.find(
+ (z) => z.containerId === target.containerId && z.position === target.position
+ )
+ if (fallback) {
+ flight.animateTo(
+ {
+ x: fallback.rect.x + (fallback.rect.width - (ghostSize?.width ?? 0)) / 2,
+ y: fallback.rect.y
+ },
+ flightCfg
+ )
+ }
+ })
+ }
+
+ // Scroll + ghost flight in lockstep. Anchors the GHOST rect (not the slot
+ // wrapper) to the visible band — so ghost.top sits at visibleStart on Up
+ // and ghost.bottom sits at visibleEnd on Down, independent of slot height.
+ private runScrollSyncFlight(
+ flight: KeyboardFlight,
+ flightCfg: { duration: number; easing: string },
+ droppable: Droppable,
+ position: number,
+ slotEl: HTMLElement,
+ container: HTMLElement,
+ direction: 'vertical' | 'horizontal',
+ padding: number,
+ ghostSize: { width: number; height: number } | null
+ ) {
+ const adapter = getDirectionAdapter(direction)
+ const ghostW = ghostSize?.width ?? 0
+ const ghostH = ghostSize?.height ?? 0
+
+ // Live wrapper rect → align-aware ghost rect in current viewport space.
+ const previewRect = slotEl.getBoundingClientRect()
+ const previewEntity = droppable.getSlotAt(position)?.preview ?? droppable.tailPreview
+ const previewHorizontal = previewEntity?.isHorizontal ?? false
+ const alignEnd = previewEntity?.align === 'end'
+ const currentGhostX =
+ alignEnd && previewHorizontal ? previewRect.right - ghostW : previewRect.left
+ const currentGhostY =
+ alignEnd && !previewHorizontal ? previewRect.bottom - ghostH : previewRect.top
+
+ const containerRect = container.getBoundingClientRect()
+ const startScroll = adapter.getScroll(container)
+
+ let scrollDelta = 0
+ let ghostFinalX = currentGhostX
+ let ghostFinalY = currentGhostY
+
+ if (direction === 'vertical') {
+ const visibleTop = containerRect.top + padding
+ const visibleBottom = containerRect.bottom - padding
+ if (currentGhostY < visibleTop) {
+ ghostFinalY = visibleTop
+ scrollDelta = currentGhostY - visibleTop
+ } else if (currentGhostY + ghostH > visibleBottom) {
+ ghostFinalY = visibleBottom - ghostH
+ scrollDelta = currentGhostY + ghostH - visibleBottom
+ }
+ } else {
+ const visibleLeft = containerRect.left + padding
+ const visibleRight = containerRect.right - padding
+ if (currentGhostX < visibleLeft) {
+ ghostFinalX = visibleLeft
+ scrollDelta = currentGhostX - visibleLeft
+ } else if (currentGhostX + ghostW > visibleRight) {
+ ghostFinalX = visibleRight - ghostW
+ scrollDelta = currentGhostX + ghostW - visibleRight
+ }
+ }
+
+ if (scrollDelta === 0) {
+ flight.animateTo({ x: currentGhostX, y: currentGhostY }, flightCfg)
+ return
+ }
+
+ const targetScroll = startScroll + scrollDelta
+ const startGhost = { ...(this.state.transform ?? { x: 0, y: 0 }) }
+ const state = this.state
+
+ flight.run(
+ {
+ duration: flightCfg.duration,
+ update(eased) {
+ adapter.setScroll(container, startScroll + scrollDelta * eased)
+ state.setTransform({
+ x: startGhost.x + (ghostFinalX - startGhost.x) * eased,
+ y: startGhost.y + (ghostFinalY - startGhost.y) * eased
+ })
+ },
+ finalize: () => {
+ adapter.setScroll(container, targetScroll)
+ // Snap to live align-aware target — corrects for any drift
+ // from translation updates that landed during the flight.
+ const snap = computePreviewSlotTarget(droppable, position, ghostSize)
+ if (snap) state.setTransform(snap)
+ }
+ },
+ flightCfg.easing
+ )
+ }
+
+ private getKeyboardFlight(): KeyboardFlight {
+ if (!this.keyboardFlight) this.keyboardFlight = new KeyboardFlight(this.state)
+ return this.keyboardFlight
+ }
+
+ performDrop(
+ sourceId: string,
+ sourceData: Record | undefined,
+ targetContainerId: string,
+ position: number
+ ) {
+ this.animationCoordinator.performDrop(sourceId, sourceData, targetContainerId, position)
+ }
+
+ /** Cancel the current drag and animate the ghost back to its origin. */
+ endDrag(shouldAnimate = true) {
+ this.keyboardFlight?.cancel()
+ this.animationCoordinator.endDrag(shouldAnimate)
+ }
+
+ /**
+ * Recalculate drop zones for a Droppable entity. Called by Droppable.invalidateZones().
+ * @internal
+ */
+ refreshDroppableZones(droppable: Droppable) {
+ const newZones = droppable.strategy.calculateDropZones(droppable, this.state.session)
+ const otherZones = this.state.zones.filter((z) => z.containerId !== droppable.id)
+ this.state.setDropZones([...otherZones, ...newZones])
+ }
+
+ /**
+ * Replace the controller-level default `behaviors` at runtime. Per-strategy
+ * `behaviors` set on a `sortable()` / `target()` instance are unaffected.
+ * Changes take effect on the next scroll tick / next drop animation.
+ */
+ setBehaviors(behaviors: Behavior[]) {
+ this.defaultBehaviors = behaviors
+ this.scrollController.updateOptions({
+ stopOnDrop: this.controllerAutoScrollConfig()?.stopOnDrop ?? false
+ })
+ this.animationCoordinator.setDefaultBehaviors(behaviors)
+ this.simulator.setDefaultBehaviors(behaviors)
+ }
+
+ /**
+ * Update animation config at runtime. Accepts a partial — provided fields
+ * override, omitted fields keep their current value. Changes propagate
+ * reactively to every live DndPreview, DndDraggable, DndDroppable, and to
+ * the next drop / return / FLIP animation.
+ */
+ setAnimation(config: AnimationConfig = {}) {
+ this.animation = resolveAnimationConfig(config, this.animation)
+ this.animationCoordinator.setAnimationConfig(this.animation)
+ this.simulator.setAnimationConfig(this.animation)
+ }
+
+ /** Toggle dev warnings (duplicate positions, etc.) at runtime. */
+ setDebug(value: boolean) {
+ this.debug = value
+ }
+
+ /**
+ * Replace the active sensors at runtime. Pass `undefined` to fall back to
+ * the default `[PointerSensor, KeyboardSensor]` set.
+ */
+ setSensors(sensors: SensorDescriptor[] | undefined) {
+ this.sensors = sensors ?? [new PointerSensor(), new KeyboardSensor()]
+ }
+
+ /**
+ * Replace the screen-reader announcement strings at runtime. Pass
+ * `undefined` to restore the defaults.
+ */
+ setAnnouncements(announcements: Announcements | undefined) {
+ this.announcements = announcements
+ }
+
+ /**
+ * Replace the active transform modifier pipeline at runtime. Pass an empty
+ * array to disable all modifiers.
+ */
+ setModifiers(modifiers: Modifier[]) {
+ this.modifiers = modifiers
+ }
+
+ /** Toggle visual overlay of drop zones — useful for debugging layout. */
+ toggleDebugZones() {
+ this.state.toggleDebugZones()
+ }
+
+ /** Delegates to {@link DndSimulator.animateItem}. */
+ animateItem(itemId: string, options: AnimateItemOptions): Promise {
+ return this.simulator.animateItem(itemId, options)
+ }
+
+ /** Delegates to {@link DndSimulator.animateLayout}. */
+ animateLayout(
+ applyState: () => void | Promise,
+ options?: AnimateLayoutOptions
+ ): Promise {
+ return this.simulator.animateLayout(applyState, options)
+ }
+
+ // --- Entity-based API ---
+
+ /**
+ * @attach handler for DndDroppable — registers a Droppable in the entity maps
+ * and binds its strategy to controller state on first attach.
+ * @internal
+ */
+ attachDroppable(droppable: Droppable) {
+ return (element: HTMLElement) => {
+ droppable.element = element
+ droppable.strategy.bindContext?.({
+ state: this.state,
+ droppablesById: this.droppablesById
+ })
+ this.droppables.set(element, droppable)
+ this.droppablesById.set(droppable.id, droppable)
+ droppable.setupEventListeners()
+
+ return () => {
+ this.droppables.delete(element)
+ this.droppablesById.delete(droppable.id)
+ droppable.destroy()
+ this.state.setDropZones(
+ this.state.zones.filter((z) => z.containerId !== droppable.id)
+ )
+ }
+ }
+ }
+
+ /**
+ * Start a drag session from an entity-based Draggable.
+ * The same DragSession instance is shared with DndState.
+ * @internal
+ */
+ startSession(draggable: Draggable, initialTransform: { x: number; y: number }): DragSession {
+ const slot = draggable.slot
+ const sourceContainer = slot.droppable
+ const rect = draggable.element.getBoundingClientRect()
+
+ const newSession = new DragSession(
+ draggable,
+ sourceContainer,
+ rect,
+ initialTransform,
+ 'user'
+ )
+
+ // Give every strategy a chance to capture transform-free state before the
+ // reactive cycle runs. Built-in sortable uses this to snapshot layout rects;
+ // custom strategies can hook in the same way.
+ for (const droppable of this.droppablesById.values()) {
+ droppable.strategy.onSessionStart?.(droppable, newSession)
+ }
+
+ // Drop the per-session geometry cache so a fresh first-slot rect is taken on
+ // the next dropPreviewSize read. Without this, a previous drag's measurement
+ // would size the new session's preview/ghost.
+ this.firstSlotRectCache.clear()
+
+ this.state.startSession(newSession)
+ this.state.setSkipDropPreviewAnimation(true)
+
+ const itemInfo: DndItemInfo = {
+ id: draggable.id,
+ data: draggable.data,
+ type: draggable.type,
+ element: draggable.element
+ }
+ const sourceInfo: DndContainerInfo = sourceContainer.toContainerInfo(slot.position)
+ const startEvent: DragStartEvent = { item: itemInfo, source: sourceInfo }
+ this.eventEmitter.notifyDragStart(startEvent)
+
+ return newSession
+ }
+
+ /**
+ * Cancel the current drag session (no drop, no return animation).
+ * @internal
+ */
+ cancelSession() {
+ this.endDrag(false)
+ }
+
+ /**
+ * Commit the current drag session — perform drop if a valid target exists,
+ * otherwise animate the ghost back to origin.
+ * @internal
+ */
+ commitSession() {
+ this.keyboardFlight?.cancel()
+ const dropPreview = this.state.dropPreview
+ const session = this.state.session
+ const srcId = session?.source.id ?? this.state.draggedItem
+ const srcData = session?.source.data ?? this.state.draggedItemData
+
+ if (dropPreview && srcId) {
+ this.state.setSkipDropPreviewAnimation(true)
+ this.animationCoordinator.performDrop(
+ srcId,
+ srcData,
+ dropPreview.containerId,
+ dropPreview.position
+ )
+ } else {
+ this.endDrag(true)
+ }
+ }
+
+ /** Release all resources. Call when the `DndProvider` is destroyed. */
+ destroy() {
+ this.keyboardFlight?.cancel()
+ this.animationCoordinator.destroy()
+ this.scrollController.destroy()
+ this.eventEmitter.destroy()
+ // Drop any active session so a mid-drag teardown (e.g. route change) doesn't leave
+ // the ghost element, translations, or drop preview lingering in the DOM.
+ this.state.reset()
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/dnd/dnd-event-emitter.ts b/packages/svelte-dnd/src/lib/core/dnd/dnd-event-emitter.ts
new file mode 100644
index 0000000..f07be55
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/dnd/dnd-event-emitter.ts
@@ -0,0 +1,97 @@
+import type {
+ DragStartCallback,
+ DragEndCallback,
+ DropCallback,
+ DragOverCallback,
+ DropCancelledCallback,
+ ZonesInvalidatedCallback,
+ DragStartEvent,
+ DragEndEvent,
+ DropEvent,
+ DragOverEvent,
+ DropCancelledEvent
+} from '../../types.js'
+
+export class DndEventEmitter {
+ private dragStartCallbacks = new Set()
+ private dragEndCallbacks = new Set()
+ private dropCallbacks = new Set()
+ private dragOverCallbacks = new Set()
+ private dropCancelledCallbacks = new Set()
+ private zonesInvalidatedCallbacks = new Set()
+
+ onDragStart(cb: DragStartCallback): () => void {
+ this.dragStartCallbacks.add(cb)
+ return () => {
+ this.dragStartCallbacks.delete(cb)
+ }
+ }
+
+ onDragEnd(cb: DragEndCallback): () => void {
+ this.dragEndCallbacks.add(cb)
+ return () => {
+ this.dragEndCallbacks.delete(cb)
+ }
+ }
+
+ onDrop(cb: DropCallback): () => void {
+ this.dropCallbacks.add(cb)
+ return () => {
+ this.dropCallbacks.delete(cb)
+ }
+ }
+
+ onDragOver(cb: DragOverCallback): () => void {
+ this.dragOverCallbacks.add(cb)
+ return () => {
+ this.dragOverCallbacks.delete(cb)
+ }
+ }
+
+ onDropCancelled(cb: DropCancelledCallback): () => void {
+ this.dropCancelledCallbacks.add(cb)
+ return () => {
+ this.dropCancelledCallbacks.delete(cb)
+ }
+ }
+
+ notifyDragStart(event: DragStartEvent) {
+ this.dragStartCallbacks.forEach((cb) => cb(event))
+ }
+
+ notifyDragEnd(event: DragEndEvent) {
+ this.dragEndCallbacks.forEach((cb) => cb(event))
+ }
+
+ notifyDrop(event: DropEvent) {
+ this.dropCallbacks.forEach((cb) => cb(event))
+ }
+
+ notifyDragOver(event: DragOverEvent) {
+ this.dragOverCallbacks.forEach((cb) => cb(event))
+ }
+
+ notifyDropCancelled(event: DropCancelledEvent) {
+ this.dropCancelledCallbacks.forEach((cb) => cb(event))
+ }
+
+ onZonesInvalidated(cb: ZonesInvalidatedCallback): () => void {
+ this.zonesInvalidatedCallbacks.add(cb)
+ return () => {
+ this.zonesInvalidatedCallbacks.delete(cb)
+ }
+ }
+
+ notifyZonesInvalidated() {
+ this.zonesInvalidatedCallbacks.forEach((cb) => cb())
+ }
+
+ destroy() {
+ this.dragStartCallbacks.clear()
+ this.dragEndCallbacks.clear()
+ this.dropCallbacks.clear()
+ this.dragOverCallbacks.clear()
+ this.dropCancelledCallbacks.clear()
+ this.zonesInvalidatedCallbacks.clear()
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/dnd/dnd-simulator.ts b/packages/svelte-dnd/src/lib/core/dnd/dnd-simulator.ts
new file mode 100644
index 0000000..0e8fc04
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/dnd/dnd-simulator.ts
@@ -0,0 +1,472 @@
+import type { DndState } from './dnd-state.svelte.js'
+import type { DndEventEmitter } from './dnd-event-emitter.js'
+import { DragSession } from './drag-session.svelte.js'
+import type {
+ DropZone,
+ DropEvent,
+ DropCancelledEvent,
+ DndItemInfo,
+ DndContainerInfo
+} from '../../types.js'
+import type { Droppable } from '../entities/droppable.svelte.js'
+import type { Slot } from '../entities/slot.js'
+import type { ResolvedAnimationConfig } from '../animation/animation-config.js'
+import type { Behavior, BehaviorContext } from '../animation/behavior.js'
+import type { AnimationStep } from '../animation/steps/animation-step.js'
+import { flushSync } from 'svelte'
+import { AnimationPipeline } from '../animation/steps/animation-pipeline.js'
+import { GhostToTargetStep } from '../animation/steps/ghost-to-target-step.js'
+import { GhostReturnStep } from '../animation/steps/ghost-return-step.js'
+import { DEFAULT_ANIMATION_CONFIG } from '../animation/animation-config.js'
+import {
+ resolveBehaviors,
+ wrapWithBehaviors,
+ findTargetSlotWrapper
+} from '../animation/apply-behaviors.js'
+
+export interface ContainerPosition {
+ containerId: string
+ /** Default 0 for `target` containers; required for sortable destinations. */
+ position?: number
+}
+
+export interface AnimateItemOptions {
+ /** Where the item should fly to. */
+ to: ContainerPosition
+ /** Where the item starts. Defaults to its current location in the slot map. */
+ from?: ContainerPosition
+ /**
+ * Animation style:
+ * - `'drop'` (default) — uses `GhostToTargetStep` for both same- and cross-container moves.
+ * - `'return'` — uses `GhostReturnStep` when the destination is the same container,
+ * falls back to `GhostToTargetStep` for cross-container moves.
+ */
+ style?: 'return' | 'drop'
+ /** Fire `onDrop` (style 'drop') or `onDropCancelled` (style 'return') after the animation. */
+ emitEvents?: boolean
+ /** Override the configured drop/return duration. */
+ duration?: number
+ /** Override the configured drop/return easing for this call. */
+ easing?: string
+ /**
+ * Per-call override for the destination droppable's behaviors. Replaces both
+ * the strategy-level and controller-level defaults for this animation.
+ */
+ behaviors?: Behavior[]
+}
+
+export interface AnimateLayoutOptions {
+ /**
+ * Items to FLIP. Defaults to every registered draggable except `state.draggedItem`
+ * (whose ghost flight handles its own animation during a real drag).
+ */
+ items?: string[]
+ /**
+ * When `true`, copies the missing classes from each item's pre-state classList
+ * onto the post-state element so class-driven CSS properties (border-radius,
+ * scale, background, color, …) transition together with the FLIP transform.
+ */
+ morph?: boolean
+ /** Override the configured layout duration. */
+ duration?: number
+ /** Override the configured layout easing (any valid CSS timing-function). */
+ easing?: string
+}
+
+type EmitKind = 'drop' | 'cancel'
+
+export class DndSimulator {
+ // Guards against concurrent animateLayout invocations. Two overlapping calls would
+ // race on captured rects and DOM transforms, producing visible jumps.
+ private layoutAnimating = false
+
+ constructor(
+ private state: DndState,
+ private droppablesById: Map,
+ private slots: Map,
+ private eventEmitter?: DndEventEmitter,
+ private animation: ResolvedAnimationConfig = DEFAULT_ANIMATION_CONFIG,
+ private defaultBehaviors: Behavior[] = []
+ ) {}
+
+ setDefaultBehaviors(behaviors: Behavior[]) {
+ this.defaultBehaviors = behaviors
+ }
+
+ setAnimationConfig(animation: ResolvedAnimationConfig) {
+ this.animation = animation
+ }
+
+ /**
+ * Animate a single item flying to a destination through the ghost system.
+ *
+ * @example
+ * // Animate task `t1` from backlog to position 0 in in-progress.
+ * await controller.animateItem('t1', {
+ * to: { containerId: 'in-progress', position: 0 }
+ * })
+ *
+ * @example
+ * // Undo a move — fly back to where it came from with scroll-aware return.
+ * await controller.animateItem('t1', {
+ * to: { containerId: 'backlog', position: 4 },
+ * style: 'return'
+ * })
+ */
+ animateItem(itemId: string, options: AnimateItemOptions): Promise {
+ const {
+ to,
+ from,
+ style = 'drop',
+ emitEvents = false,
+ duration,
+ easing,
+ behaviors
+ } = options
+
+ const sourceContainerId = from?.containerId ?? this.findItemContainer(itemId)
+ if (!sourceContainerId) {
+ return Promise.reject(
+ new Error(`DndSimulator.animateItem: item "${itemId}" not found in any container`)
+ )
+ }
+ const toPosition = to.position ?? 0
+ const useReturnStep = style === 'return' && to.containerId === sourceContainerId
+ const baseTransition = useReturnStep ? this.animation.return : this.animation.drop
+ const stepDuration = duration ?? baseTransition.duration
+ const stepEasing = easing ?? baseTransition.easing
+
+ const makeStep = (): AnimationStep => {
+ const baseStep = useReturnStep
+ ? new GhostReturnStep(
+ this.state,
+ to.containerId,
+ toPosition,
+ this.droppablesById,
+ stepDuration,
+ stepEasing
+ )
+ : new GhostToTargetStep(
+ this.state,
+ this.syntheticZone(to.containerId, toPosition),
+ this.droppablesById,
+ stepDuration,
+ stepEasing
+ )
+ const targetDroppable = this.droppablesById.get(to.containerId) ?? null
+ const resolved = behaviors ?? resolveBehaviors(targetDroppable, this.defaultBehaviors)
+ const ctx: BehaviorContext = {
+ state: this.state,
+ direction: targetDroppable?.layout === 'horizontal' ? 'horizontal' : 'vertical',
+ targetEl: findTargetSlotWrapper(targetDroppable, toPosition),
+ container: targetDroppable?.element ?? null,
+ duration: stepDuration,
+ easing: stepEasing,
+ padding: targetDroppable?.spacing ?? 0
+ }
+ return wrapWithBehaviors(baseStep, resolved, ctx)
+ }
+
+ const emitKind: EmitKind = style === 'return' ? 'cancel' : 'drop'
+ return this.run(
+ itemId,
+ sourceContainerId,
+ to.containerId,
+ toPosition,
+ makeStep,
+ emitEvents,
+ emitKind
+ )
+ }
+
+ /**
+ * Animate a layout/state change. Captures item rects before `applyState`,
+ * mutates state via `applyState`, then FLIPs items to their new positions.
+ *
+ * @example
+ * // Sort the list and animate every item to its new spot.
+ * await controller.animateLayout(() => {
+ * items = [...items].sort((a, b) => a.label.localeCompare(b.label))
+ * })
+ *
+ * @example
+ * // Swap two items by id and morph their class-driven shapes.
+ * await controller.animateLayout(() => swap(a, b), {
+ * items: [a.id, b.id],
+ * morph: true
+ * })
+ */
+ async animateLayout(
+ applyState: () => void | Promise,
+ options: AnimateLayoutOptions = {}
+ ): Promise {
+ if (this.layoutAnimating) {
+ throw new Error(
+ 'DndSimulator.animateLayout: another layout animation is already running'
+ )
+ }
+ this.layoutAnimating = true
+
+ const {
+ items,
+ morph = false,
+ duration = this.animation.layout.duration,
+ easing = this.animation.layout.easing
+ } = options
+ const ids = items ?? this.collectAllItemIds().filter((id) => id !== this.state.draggedItem)
+
+ // Capture old rects (visual rects — include any active translates) and class lists.
+ const oldRects = new Map()
+ const oldClasses = morph ? new Map() : null
+ for (const id of ids) {
+ const el = this.findElementById(id)
+ if (!el) continue
+ oldRects.set(id, el.getBoundingClientRect())
+ if (oldClasses) oldClasses.set(id, [...el.classList])
+ }
+
+ // Tracked outside the try block so the finally clause can always undo morph
+ // classes / inline styles even if applyState or a rAF rejects mid-flight.
+ interface Patched {
+ el: HTMLElement
+ addedClasses: string[]
+ }
+ const elements: Patched[] = []
+
+ try {
+ // Apply state change (sync or async).
+ const result = applyState()
+ if (result && typeof (result as Promise).then === 'function') await result
+
+ // Synchronously flush pending Svelte updates so the new DOM layout is readable
+ // in the same sync block as the inverse transform below. `await tick()` would
+ // leave a microtask gap during which the browser could paint the post-state
+ // layout — visible as a 1-frame jump to the new position.
+ flushSync()
+
+ // Invert — apply inverse transforms (and restore the old class diff when morphing)
+ // so items visually stay at their pre-state-change positions.
+ for (const id of ids) {
+ const el = this.findElementById(id)
+ const oldRect = oldRects.get(id)
+ if (!el || !oldRect) continue
+
+ const newRect = el.getBoundingClientRect()
+ const dx = oldRect.left - newRect.left
+ const dy = oldRect.top - newRect.top
+
+ let addedClasses: string[] = []
+ if (oldClasses) {
+ const old = oldClasses.get(id) ?? []
+ const newSet = new Set(el.classList)
+ addedClasses = old.filter((c) => !newSet.has(c))
+ for (const c of addedClasses) el.classList.add(c)
+ }
+
+ if (dx === 0 && dy === 0 && addedClasses.length === 0) continue
+
+ el.style.transition = 'none'
+ if (dx !== 0 || dy !== 0) el.style.transform = `translate(${dx}px, ${dy}px)`
+ elements.push({ el, addedClasses })
+ }
+
+ if (elements.length === 0) return
+
+ // Double rAF to force reflow before enabling transitions.
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
+ )
+
+ // Play — animate to final positions/styles. `all` lets class-driven properties
+ // (border-radius, scale, color, …) ride along with the FLIP transform.
+ const transitionProp = morph ? 'all' : 'transform'
+ for (const { el, addedClasses } of elements) {
+ el.style.transition = `${transitionProp} ${duration}ms ${easing}`
+ el.style.transform = ''
+ for (const c of addedClasses) el.classList.remove(c)
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, duration + 50))
+ } finally {
+ // Always strip transition + leftover morph classes — without this an exception
+ // during applyState or a rAF leaves stray classes on the element forever.
+ for (const { el, addedClasses } of elements) {
+ el.style.transition = ''
+ for (const c of addedClasses) el.classList.remove(c)
+ }
+ this.layoutAnimating = false
+ }
+ }
+
+ // --- Private ---
+
+ private run(
+ itemId: string,
+ fromContainerId: string,
+ toContainerId: string,
+ toPosition: number,
+ makeStep: () => AnimationStep,
+ emitEvents: boolean,
+ emitKind: EmitKind
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ if (this.state.dragging) {
+ reject(new Error('DndSimulator: cannot animate while a drag is in progress'))
+ return
+ }
+
+ const fromDroppable = this.droppablesById.get(fromContainerId)
+ if (!fromDroppable) {
+ reject(new Error(`DndSimulator: container "${fromContainerId}" not found`))
+ return
+ }
+
+ const fromSlot = fromDroppable.getSortedSlots().find((s) => s.draggable.id === itemId)
+ if (!fromSlot) {
+ reject(
+ new Error(
+ `DndSimulator: item "${itemId}" not found in container "${fromContainerId}"`
+ )
+ )
+ return
+ }
+
+ const element = fromSlot.draggable.element
+ const rect = element.getBoundingClientRect()
+ const positionInFrom = fromSlot.position
+
+ let slotSize = fromSlot.getSize()
+ if (toContainerId !== fromContainerId) {
+ const toDroppable = this.droppablesById.get(toContainerId)
+ if (toDroppable) {
+ const toSlots = toDroppable.getSortedSlots()
+ if (toSlots.length > 0) {
+ const toSlotSize = toSlots[0].getSize()
+ const gapH = toSlotSize.height - toSlots[0].draggable.element.offsetHeight
+ const gapW = toSlotSize.width - toSlots[0].draggable.element.offsetWidth
+ slotSize = {
+ height: element.offsetHeight + Math.max(0, gapH),
+ width: element.offsetWidth + Math.max(0, gapW)
+ }
+ }
+ }
+ }
+
+ const session = new DragSession(
+ fromSlot.draggable,
+ fromDroppable,
+ rect,
+ { x: rect.left, y: rect.top },
+ 'programmatic'
+ )
+ session.slotSize = slotSize
+ session.ghostSize = { width: element.offsetWidth, height: element.offsetHeight }
+ session.dropPreview = {
+ containerId: toContainerId,
+ position: toPosition
+ }
+
+ // Let each strategy capture transform-free state before reactive cycle runs.
+ for (const droppable of this.droppablesById.values()) {
+ droppable.strategy.onSessionStart?.(droppable, session)
+ }
+
+ this.state.startSession(session)
+ // setAnimating(true) makes the dragged element opacity:0 immediately via
+ // the animatingReturn path, without disabling CSS transitions on siblings.
+ this.state.setAnimating(true)
+
+ const step = makeStep()
+
+ // Wait one frame for DndPreview to render at toContainerId/toPosition.
+ requestAnimationFrame(() => {
+ AnimationPipeline.chain(step)
+ .execute()
+ .then(() => {
+ // setPerformingDrop(true) so Preview.hide() collapses instantly.
+ this.state.setPerformingDrop(true)
+ if (emitEvents && this.eventEmitter) {
+ const draggable = fromSlot.draggable
+ const itemInfo: DndItemInfo = {
+ id: itemId,
+ data: draggable.data,
+ type: draggable.type,
+ element
+ }
+ const sourceInfo: DndContainerInfo = fromDroppable.toContainerInfo(
+ positionInFrom >= 0 ? positionInFrom : 0
+ )
+
+ if (emitKind === 'drop') {
+ const toDroppable = this.droppablesById.get(toContainerId)
+ if (toDroppable) {
+ const targetInfo: DndContainerInfo =
+ toDroppable.toContainerInfo(toPosition)
+ const dropEvent: DropEvent = {
+ item: itemInfo,
+ source: sourceInfo,
+ target: targetInfo
+ }
+ this.eventEmitter.notifyDrop(dropEvent)
+ }
+ } else {
+ const cancelEvent: DropCancelledEvent = {
+ item: itemInfo,
+ source: sourceInfo
+ }
+ this.eventEmitter.notifyDropCancelled(cancelEvent)
+ }
+ }
+ this.cleanup()
+ resolve()
+ })
+ })
+ })
+ }
+
+ private syntheticZone(containerId: string, position: number): DropZone {
+ return {
+ containerId,
+ position,
+ layout: 'vertical',
+ rect: { x: 0, y: 0, width: 0, height: 0 }
+ }
+ }
+
+ private collectAllItemIds(): string[] {
+ const ids: string[] = []
+ for (const slot of this.slots.values()) {
+ ids.push(slot.draggable.id)
+ }
+ return ids
+ }
+
+ private findItemContainer(itemId: string): string | null {
+ for (const droppable of this.droppablesById.values()) {
+ if (droppable.getSortedSlots().some((s) => s.draggable.id === itemId)) {
+ return droppable.id
+ }
+ }
+ return null
+ }
+
+ private findElementById(id: string): HTMLElement | null {
+ for (const slot of this.slots.values()) {
+ if (slot.draggable.id === id) return slot.draggable.element
+ }
+ return null
+ }
+
+ private cleanup(): void {
+ // Mirrors finalizeDragEnd ordering — see DropAnimationCoordinator.finalizeDragEnd
+ // for the rationale behind the skip/performingDrop sequencing.
+ this.state.setSkipDropPreviewAnimation(true)
+ this.state.reset()
+ requestAnimationFrame(() => {
+ this.state.setPerformingDrop(false)
+ })
+ setTimeout(() => {
+ this.state.setSkipDropPreviewAnimation(false)
+ }, 100)
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/dnd/dnd-state.svelte.ts b/packages/svelte-dnd/src/lib/core/dnd/dnd-state.svelte.ts
new file mode 100644
index 0000000..8a32a35
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/dnd/dnd-state.svelte.ts
@@ -0,0 +1,118 @@
+import type { DropZone, DropPreview } from '../../types.js'
+import type { DragSession } from './drag-session.svelte.js'
+
+export class DndState {
+ session = $state(null)
+ dropZones = $state([])
+ showDebugZones = $state(false)
+ isPerformingDrop = $state(false)
+ shouldSkipDropPreviewAnimation = $state(false)
+ isAnimating = $state(false)
+
+ // --- Getters (same public API as before) ---
+
+ get dragging() {
+ return this.session !== null
+ }
+ get element() {
+ return this.session?.element ?? null
+ }
+ get transform() {
+ return this.session?.ghostTransform ?? null
+ }
+ get draggedItem() {
+ return this.session?.itemId ?? null
+ }
+ get draggedType() {
+ return this.session?.draggedItemType ?? null
+ }
+ get draggedItemData() {
+ return this.session?.itemData
+ }
+ get ghostSize() {
+ return this.session?.ghostSize ?? null
+ }
+ get animating() {
+ return this.isAnimating
+ }
+ get dropPreview() {
+ return this.session?.dropPreview ?? null
+ }
+ get zones() {
+ return this.dropZones
+ }
+ get debugZones() {
+ return this.showDebugZones
+ }
+ get performingDrop() {
+ return this.isPerformingDrop
+ }
+ get skipDropPreviewAnimation() {
+ return this.shouldSkipDropPreviewAnimation
+ }
+ get originContainerId() {
+ return this.session?.originContainerId ?? null
+ }
+ get originPosition() {
+ return this.session?.originPosition ?? 0
+ }
+ get dragSlotSize() {
+ return this.session?.slotSize ?? null
+ }
+ get dragSource() {
+ return this.session?.dragSource ?? null
+ }
+ get originalPosition() {
+ const r = this.session?.startRect
+ return r ? { x: r.left, y: r.top } : null
+ }
+
+ // --- Session management ---
+
+ startSession(session: DragSession): void {
+ this.session = session
+ this.isPerformingDrop = false
+ this.shouldSkipDropPreviewAnimation = false
+ }
+
+ endSession(): void {
+ this.session = null
+ }
+
+ // --- Setters that mutate current session or direct fields ---
+
+ setTransform(transform: { x: number; y: number } | null): void {
+ if (this.session && transform) this.session.ghostTransform = transform
+ }
+
+ setDropPreview(preview: DropPreview | null): void {
+ if (this.session) this.session.dropPreview = preview
+ }
+
+ setAnimating(value: boolean): void {
+ this.isAnimating = value
+ }
+ setDropZones(zones: DropZone[]): void {
+ this.dropZones = zones
+ }
+ setPerformingDrop(value: boolean): void {
+ this.isPerformingDrop = value
+ }
+ setSkipDropPreviewAnimation(value: boolean): void {
+ this.shouldSkipDropPreviewAnimation = value
+ }
+ toggleDebugZones(): void {
+ this.showDebugZones = !this.showDebugZones
+ }
+
+ // NOTE: intentionally does NOT reset `isPerformingDrop` or `shouldSkipDropPreviewAnimation`.
+ // Callers (finalizeDragEnd / DndSimulator.cleanup) manage those flags around reset() on their
+ // own schedule — `isPerformingDrop` is cleared next frame so Preview.hide() sees it as `true`
+ // and collapses instantly; `shouldSkipDropPreviewAnimation` is cleared ~100ms later.
+ // startSession() clears both defensively, so leftover values can't leak into a new drag.
+ reset(): void {
+ this.session = null
+ this.isAnimating = false
+ this.dropZones = []
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/dnd/drag-session.svelte.ts b/packages/svelte-dnd/src/lib/core/dnd/drag-session.svelte.ts
new file mode 100644
index 0000000..0283ac2
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/dnd/drag-session.svelte.ts
@@ -0,0 +1,73 @@
+import type { DropPreview } from '../../types.js'
+import type { Draggable } from '../entities/draggable.svelte.js'
+import type { Droppable } from '../entities/droppable.svelte.js'
+import type { SortableSource } from '../zones/sortable-source.js'
+
+export type DragSource = 'user' | 'programmatic'
+
+export class DragSession {
+ source: Draggable
+ sourceContainer: Droppable
+ startRect: DOMRect
+ dragSource: DragSource
+
+ ghostTransform = $state({ x: 0, y: 0 })
+ dropPreview = $state(null)
+ ghostSize = $state<{ width: number; height: number }>({ width: 0, height: 0 })
+ slotSize = $state<{ width: number; height: number } | null>(null)
+
+ private sources = new Map()
+
+ constructor(
+ source: Draggable,
+ sourceContainer: Droppable,
+ startRect: DOMRect,
+ initialTransform: { x: number; y: number },
+ dragSource: DragSource = 'user'
+ ) {
+ this.source = source
+ this.sourceContainer = sourceContainer
+ this.startRect = startRect
+ this.ghostTransform = initialTransform
+ this.dragSource = dragSource
+ this.ghostSize = { width: source.element.offsetWidth, height: source.element.offsetHeight }
+ this.slotSize = source.slot ? source.slot.getSize() : null
+ }
+
+ /**
+ * Register a strategy-owned geometry source for a container. Called from
+ * `ContainerStrategy.onSessionStart`, before any reactive translations are
+ * applied to slot elements.
+ */
+ setSource(containerId: string, source: SortableSource): void {
+ this.sources.set(containerId, source)
+ }
+
+ getSource(containerId: string): SortableSource | undefined {
+ return this.sources.get(containerId)
+ }
+
+ // --- Derived accessors ---
+
+ get itemId() {
+ return this.source.id
+ }
+ get element() {
+ return this.source.element
+ }
+ get itemData() {
+ return this.source.data
+ }
+ get draggedItemType() {
+ return this.source.type ?? null
+ }
+ get originContainerId() {
+ return this.sourceContainer.id
+ }
+ get originPosition() {
+ return this.source.slot?.position ?? 0
+ }
+ get originalPosition() {
+ return { x: this.startRect.left, y: this.startRect.top }
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/entities/draggable.svelte.ts b/packages/svelte-dnd/src/lib/core/entities/draggable.svelte.ts
new file mode 100644
index 0000000..a2b8117
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/entities/draggable.svelte.ts
@@ -0,0 +1,214 @@
+import type { SensorDescriptor, SensorActivation, NavigationDirection } from '../sensors/sensor.js'
+import { PointerSensor } from '../sensors/pointer-sensor.js'
+import type { DropPreview } from '../../types.js'
+import type { Slot } from './slot.js'
+
+export type DraggableControllerRef = {
+ session: { source: Draggable } | null
+ sensors: SensorDescriptor[] | undefined
+ startSession(draggable: Draggable, initialTransform: { x: number; y: number }): void
+ updateTransform(transform: { x: number; y: number }): void
+ updateMousePosition?(mouseX: number, mouseY: number): void
+ handleAutoScroll?(mouseX: number, mouseY: number): void
+ navigate(direction: NavigationDirection): void
+ dropPreview: DropPreview | null
+ setSkipDropPreviewAnimation(value: boolean): void
+ performDrop(
+ sourceId: string,
+ sourceData: Record | undefined,
+ targetContainerId: string,
+ position: number
+ ): void
+ endDrag(shouldAnimate?: boolean): void
+}
+
+interface DraggableConfig {
+ id: string
+ data?: Record
+ type?: string
+ disabled?: boolean
+ sensors?: SensorDescriptor[]
+}
+
+const DEFAULT_SENSORS: SensorDescriptor[] = [new PointerSensor()]
+
+export class Draggable {
+ element!: HTMLElement
+ slot!: Slot
+
+ id: string
+ data: Record | undefined
+ type: string | undefined
+ disabled: boolean
+ sensors: SensorDescriptor[] | undefined
+
+ isDragging = $state(false)
+ dragOccurred = $state(false)
+ translate = $state({ x: 0, y: 0 })
+
+ private dragOffset = { x: 0, y: 0 }
+ private activeActivation: SensorActivation | null = null
+ private controller: DraggableControllerRef
+
+ constructor(config: DraggableConfig, controller: DraggableControllerRef) {
+ this.id = config.id
+ this.data = config.data
+ this.type = config.type
+ this.disabled = config.disabled ?? false
+ this.sensors = config.sensors
+ this.controller = controller
+ }
+
+ get isDraggingSession() {
+ return this.controller.session?.source === this
+ }
+
+ getCenter(): { x: number; y: number } {
+ const rect = this.element.getBoundingClientRect()
+ return {
+ x: rect.left + rect.width / 2,
+ y: rect.top + rect.height / 2
+ }
+ }
+
+ // --- Public event handlers (bound in template) ---
+
+ handlePointerDown = (e: PointerEvent) => {
+ if (this.disabled) return
+ if (!this.element) return
+
+ const sensors = this.getSensors()
+
+ for (const sensor of sensors) {
+ const activationRef = { current: null as SensorActivation | null }
+ const activation = sensor.activate(e, this.element, {
+ onStart: (transform) => {
+ this.dragOffset = activationRef.current?.offset ?? { x: 0, y: 0 }
+ this.startDragSession(transform)
+ },
+ onMove: (transform, mouseX, mouseY) => {
+ this.handleDragMove(transform, mouseX, mouseY)
+ },
+ onEnd: () => {
+ this.handleDragEnd()
+ },
+ onCancel: () => {
+ this.handleDragCancel()
+ },
+ onNavigate: (direction) => {
+ this.controller.navigate(direction)
+ }
+ })
+
+ if (activation) {
+ activationRef.current = activation
+ this.activeActivation = activation
+ this.dragOffset = activation.offset
+ break
+ }
+ }
+ }
+
+ handleKeyDown = (e: KeyboardEvent) => {
+ if (this.isDragging) return
+ if (this.disabled) return
+ if (!this.element) return
+
+ const sensors = this.getSensors()
+ let handled = false
+
+ for (const sensor of sensors) {
+ const activationRef = { current: null as SensorActivation | null }
+ const activation = sensor.activate(e, this.element, {
+ onStart: (transform) => {
+ this.dragOffset = activationRef.current?.offset ?? { x: 0, y: 0 }
+ this.startDragSession(transform)
+ },
+ onMove: (transform, mouseX, mouseY) => {
+ this.handleDragMove(transform, mouseX, mouseY)
+ },
+ onEnd: () => {
+ this.handleDragEnd()
+ },
+ onCancel: () => {
+ this.handleDragCancel()
+ },
+ onNavigate: (direction) => {
+ this.controller.navigate(direction)
+ }
+ })
+
+ if (activation) {
+ activationRef.current = activation
+ this.activeActivation = activation
+ this.dragOffset = activation.offset
+ handled = true
+ break
+ }
+ }
+
+ // Fallback: Enter/Space triggers click for non-DnD keyboard interactions
+ if (!handled && (e.key === 'Enter' || e.key === ' ')) {
+ this.element.click()
+ }
+ }
+
+ destroy() {
+ this.activeActivation?.destroy()
+ this.activeActivation = null
+ }
+
+ // --- Private ---
+
+ private getSensors(): SensorDescriptor[] {
+ if (this.sensors) return this.sensors
+ if (this.controller.sensors) return this.controller.sensors
+ return DEFAULT_SENSORS
+ }
+
+ private startDragSession(initialTransform: { x: number; y: number }) {
+ if (this.isDragging) return
+ this.isDragging = true
+ this.dragOccurred = true
+
+ this.controller.startSession(this, initialTransform)
+ this.controller.updateMousePosition?.(
+ initialTransform.x + this.dragOffset.x,
+ initialTransform.y + this.dragOffset.y
+ )
+ }
+
+ private handleDragMove(transform: { x: number; y: number }, mouseX: number, mouseY: number) {
+ if (!this.isDragging) return
+
+ this.controller.updateTransform(transform)
+ this.controller.updateMousePosition?.(mouseX, mouseY)
+ this.controller.handleAutoScroll?.(mouseX, mouseY)
+ }
+
+ private handleDragEnd() {
+ if (!this.isDragging) return
+ this.isDragging = false
+ this.activeActivation = null
+
+ const dropPreview = this.controller.dropPreview
+ if (dropPreview) {
+ this.controller.setSkipDropPreviewAnimation(true)
+ this.controller.performDrop(
+ this.id,
+ this.data,
+ dropPreview.containerId,
+ dropPreview.position
+ )
+ } else {
+ this.controller.endDrag(true)
+ }
+ }
+
+ private handleDragCancel() {
+ if (!this.isDragging) return
+ this.isDragging = false
+ this.activeActivation = null
+ this.controller.endDrag(false)
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/entities/droppable.svelte.ts b/packages/svelte-dnd/src/lib/core/entities/droppable.svelte.ts
new file mode 100644
index 0000000..80ce635
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/entities/droppable.svelte.ts
@@ -0,0 +1,238 @@
+import { SvelteMap } from 'svelte/reactivity'
+import { isBrowser } from '../utils/dom-helper.js'
+import type {
+ DndContainerInfo,
+ DndLayout,
+ DndMode,
+ DragStartCallback,
+ DragEndCallback
+} from '../../types.js'
+import type { CollisionAlgorithm } from '../collision/collision-algorithm.js'
+import type { ContainerStrategy } from '../containers/strategies/container-strategy.js'
+import type { Slot } from './slot.js'
+import type { Preview } from './preview.svelte.js'
+import type { DragSession } from '../dnd/drag-session.svelte.js'
+
+// Minimal controller interface needed by Droppable
+export type DroppableControllerRef = {
+ session: DragSession | null
+ /** DOM element of the item currently being dragged (not the ghost). */
+ draggedElement: HTMLElement | null
+ cancelSession(): void
+ slots: Map
+ onDragStart(cb: DragStartCallback): () => void
+ onZonesInvalidated(cb: () => void): () => void
+ onDragEnd(cb: DragEndCallback): () => void
+ refreshDroppableZones(droppable: Droppable): void
+ dragging: boolean
+}
+
+interface DroppableConfig {
+ id: string
+ data?: Record
+ disabled?: boolean
+ collision?: CollisionAlgorithm
+ accepts?: string | string[]
+ strategy: ContainerStrategy
+}
+
+export class Droppable {
+ element!: HTMLElement
+
+ id: string
+ data: Record | undefined
+ disabled: boolean
+ collision: CollisionAlgorithm | undefined
+ accepts: string | string[] | undefined
+ strategy: ContainerStrategy
+
+ spacing = $state(undefined)
+ slots = new SvelteMap()
+ tailPreview: Preview | undefined = undefined
+
+ private controller: DroppableControllerRef
+ private scrollListeners: HTMLElement[] = []
+ private scrollTimeout: ReturnType | null = null
+ private unsubscribeDragStart: (() => void) | undefined
+ private unsubscribeZonesInvalidated: (() => void) | undefined
+ private unsubscribeDragEnd: (() => void) | undefined
+
+ constructor(config: DroppableConfig, controller: DroppableControllerRef) {
+ this.id = config.id
+ this.data = config.data
+ this.disabled = config.disabled ?? false
+ this.collision = config.collision
+ this.accepts = config.accepts
+ this.strategy = config.strategy
+ this.controller = controller
+ }
+
+ get mode(): DndMode {
+ return this.strategy.mode
+ }
+
+ get layout(): DndLayout {
+ return this.strategy.layout ?? 'vertical'
+ }
+
+ get isHorizontal(): boolean {
+ return this.layout === 'horizontal'
+ }
+
+ /**
+ * Number of items the container holds in its public list (the array users
+ * splice into on drop). For DOM-rendered lists this is `slots.size`; for
+ * virtualized strategies it comes from the strategy's virtual source so
+ * "last slot" / "tail position" math reflects the full data length, not the
+ * mounted window.
+ */
+ get itemCount(): number {
+ const virt = (this.strategy as { virtual?: { itemCount?: () => number } }).virtual
+ const reported = virt?.itemCount?.()
+ if (typeof reported === 'number' && reported >= 0) return reported
+ return this.slots.size
+ }
+
+ /**
+ * Whether this container delegates its scroll viewport to a virtualizer
+ * (virtua, tanstack/virtual, …) rather than scrolling its own element.
+ * The drop coordinator uses this to skip its DOM scroll save/restore for
+ * virtualized droppables — those manage their own scroll-jump compensation
+ * and an extra write fights it.
+ */
+ get isVirtualized(): boolean {
+ return !!(this.strategy as { virtual?: unknown }).virtual
+ }
+
+ /** Build a public-facing snapshot for event callbacks. */
+ toContainerInfo(position: number): DndContainerInfo {
+ return {
+ id: this.id,
+ data: this.data,
+ layout: this.layout,
+ mode: this.mode,
+ disabled: this.disabled,
+ accepts: this.accepts,
+ position
+ }
+ }
+
+ get isOver(): boolean {
+ return this.controller.session?.dropPreview?.containerId === this.id
+ }
+
+ // --- Slot management ---
+
+ /**
+ * @attach handler — registers a Slot with this droppable and the global controller slots map.
+ * Called via {@attach droppable.attachSlot(slot)} on the wrapper element in DndDraggable.
+ */
+ attachSlot(slot: Slot) {
+ return (element: HTMLElement) => {
+ slot.element = element
+ slot.droppable = this
+ this.slots.set(element, slot)
+ this.controller.slots.set(element, slot)
+
+ return () => {
+ if (this.controller.session?.source.slot === slot) {
+ this.controller.cancelSession()
+ }
+ this.slots.delete(element)
+ this.controller.slots.delete(element)
+ }
+ }
+ }
+
+ getSlotAt(position: number): Slot | undefined {
+ for (const slot of this.slots.values()) {
+ if (slot.position === position) return slot
+ }
+ return undefined
+ }
+
+ getSortedSlots(): Slot[] {
+ return Array.from(this.slots.values()).sort((a, b) => a.position - b.position)
+ }
+
+ acceptsType(type: string | undefined): boolean {
+ if (!this.accepts) return true
+ if (!type) return true
+ if (Array.isArray(this.accepts)) return this.accepts.includes(type)
+ return this.accepts === type
+ }
+
+ getScrollOffset(): { x: number; y: number } {
+ if (!this.element) return { x: 0, y: 0 }
+ return { x: this.element.scrollLeft, y: this.element.scrollTop }
+ }
+
+ invalidateZones() {
+ if (!this.element || this.disabled) return
+ // Skip if this droppable is nested inside the currently dragged element —
+ // the zone moves with the ghost, so its stored rect is still correct relative to the cursor.
+ if (this.controller.draggedElement?.contains(this.element)) return
+ this.controller.refreshDroppableZones(this)
+ }
+
+ // --- Lifecycle (set up from attachDroppable in controller) ---
+
+ setupEventListeners() {
+ this.unsubscribeDragStart = this.controller.onDragStart(() => {
+ this.invalidateZones()
+ this.setupScrollListeners()
+ })
+
+ this.unsubscribeZonesInvalidated = this.controller.onZonesInvalidated(() => {
+ this.invalidateZones()
+ })
+
+ this.unsubscribeDragEnd = this.controller.onDragEnd(() => {
+ this.cleanupScrollListeners()
+ })
+ }
+
+ private handleScroll = () => {
+ if (this.controller.dragging) {
+ if (this.scrollTimeout) clearTimeout(this.scrollTimeout)
+ this.scrollTimeout = setTimeout(() => {
+ this.invalidateZones()
+ this.scrollTimeout = null
+ }, 10)
+ }
+ }
+
+ private setupScrollListeners() {
+ if (!isBrowser) return
+ let parent = this.element?.parentElement
+ while (parent) {
+ const style = window.getComputedStyle(parent)
+ if (
+ ['auto', 'scroll', 'overlay'].includes(style.overflowY) ||
+ ['auto', 'scroll', 'overlay'].includes(style.overflowX)
+ ) {
+ parent.addEventListener('scroll', this.handleScroll, { passive: true })
+ this.scrollListeners.push(parent)
+ }
+ parent = parent.parentElement
+ }
+ window.addEventListener('scroll', this.handleScroll, { passive: true })
+ }
+
+ private cleanupScrollListeners() {
+ this.scrollListeners.forEach((el) => el.removeEventListener('scroll', this.handleScroll))
+ if (isBrowser) window.removeEventListener('scroll', this.handleScroll)
+ this.scrollListeners = []
+ if (this.scrollTimeout) {
+ clearTimeout(this.scrollTimeout)
+ this.scrollTimeout = null
+ }
+ }
+
+ destroy() {
+ this.cleanupScrollListeners()
+ this.unsubscribeDragStart?.()
+ this.unsubscribeZonesInvalidated?.()
+ this.unsubscribeDragEnd?.()
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/entities/preview.svelte.ts b/packages/svelte-dnd/src/lib/core/entities/preview.svelte.ts
new file mode 100644
index 0000000..0ba6757
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/entities/preview.svelte.ts
@@ -0,0 +1,168 @@
+import { untrack } from 'svelte'
+import type { Slot } from './slot.js'
+import type { Droppable } from './droppable.svelte.js'
+import type { DropPreview } from '../../types.js'
+
+export interface PreviewConfig {
+ /** Debounce delay (ms) before revealing the preview slot. Default: 300 */
+ showDelay?: number
+ /** Delay (ms) before collapsing the slot. Default: 200 */
+ hideDelay?: number
+}
+
+// Minimal controller interface needed by Preview
+export type PreviewControllerRef = {
+ dropPreview: DropPreview | null
+ dropPreviewSize: { width: number; height: number } | null
+ ghostSize: { width: number; height: number } | null
+ skipDropPreviewAnimation: boolean
+ performingDrop: boolean
+ translations: Map
+}
+
+export interface PreviewInit {
+ slot?: Slot
+ droppable?: Droppable
+ position: number
+ config?: PreviewConfig
+}
+
+export class Preview {
+ element!: HTMLElement
+ slot = $state(undefined)
+ droppable = $state(undefined)
+ position = $state(0)
+
+ height = $state(0)
+ width = $state(0)
+ revealed = $state(false)
+ instant = $state(false)
+
+ showDelay: number
+ hideDelay: number
+
+ private controller: PreviewControllerRef
+ private showTimer: ReturnType | null = null
+ private collapseTimer: ReturnType | null = null
+
+ constructor(controller: PreviewControllerRef, init: PreviewInit) {
+ this.controller = controller
+ this.slot = init.slot
+ this.droppable = init.droppable
+ this.position = init.position
+ this.showDelay = init.config?.showDelay ?? 300
+ this.hideDelay = init.config?.hideDelay ?? 200
+ }
+
+ get containerId(): string {
+ return this.slot?.droppable?.id ?? this.droppable?.id ?? ''
+ }
+
+ get isHorizontal(): boolean {
+ return this.slot?.droppable?.isHorizontal ?? this.droppable?.isHorizontal ?? false
+ }
+
+ get isVisible(): boolean {
+ const dp = this.controller.dropPreview
+ const cid = this.containerId
+ if (!cid) return false
+ return !!dp && dp.containerId === cid && dp.position === this.position
+ }
+
+ /**
+ * Which edge of the slot wrapper the preview anchors to.
+ * Computed from this slot's translate — a negative translate means the slot
+ * has moved backward, so the ghost lands at the far edge of the wrapper.
+ * Tail previews (no slot) always anchor to 'start'.
+ */
+ get align(): 'start' | 'end' {
+ const id = this.slot?.draggable?.id
+ if (!id) return 'start'
+ const translate = this.controller.translations.get(id) ?? { x: 0, y: 0 }
+ const val = this.isHorizontal ? translate.x : translate.y
+ return val < 0 ? 'end' : 'start'
+ }
+
+ show() {
+ // Prefer the target's own item size so the preview matches what the
+ // dropped item will actually be rendered as (covers cases where the
+ // target shrinks items, e.g. a scrollbar appearing in the source/target).
+ // Falls back to the dragged element's size when the target is empty.
+ const previewSize = this.controller.dropPreviewSize
+ const ghostSize = this.controller.ghostSize
+ this.height = previewSize?.height ?? ghostSize?.height ?? 0
+ this.width = previewSize?.width ?? ghostSize?.width ?? 0
+
+ if (this.collapseTimer) {
+ clearTimeout(this.collapseTimer)
+ this.collapseTimer = null
+ }
+
+ const skip = untrack(() => this.controller.skipDropPreviewAnimation)
+ if (skip) {
+ if (this.showTimer) {
+ clearTimeout(this.showTimer)
+ this.showTimer = null
+ }
+ this.revealed = true
+ this.instant = true
+ requestAnimationFrame(() => {
+ this.instant = false
+ })
+ } else if (!this.showTimer) {
+ this.showTimer = setTimeout(() => {
+ this.revealed = true
+ this.showTimer = null
+ }, this.showDelay)
+ }
+ }
+
+ hide(instant = false) {
+ if (this.showTimer) {
+ clearTimeout(this.showTimer)
+ this.showTimer = null
+ }
+ this.revealed = false
+
+ // Repeated hide() during a drag would otherwise leak orphan timers that later zero out height after show().
+ if (this.collapseTimer) {
+ clearTimeout(this.collapseTimer)
+ this.collapseTimer = null
+ }
+
+ if (instant) {
+ this.instant = true
+ this.height = 0
+ this.width = 0
+ requestAnimationFrame(() => {
+ this.instant = false
+ })
+ } else {
+ this.instant = false
+ this.collapseTimer = setTimeout(() => {
+ this.height = 0
+ this.width = 0
+ this.collapseTimer = null
+ }, this.hideDelay)
+ }
+ }
+
+ destroy() {
+ if (this.showTimer) clearTimeout(this.showTimer)
+ if (this.collapseTimer) clearTimeout(this.collapseTimer)
+ }
+
+ /** @attach handler — sets element reference and links to slot or droppable (tail preview). */
+ attach() {
+ return (element: HTMLElement) => {
+ this.element = element
+ if (this.slot) this.slot.preview = this
+ else if (this.droppable) this.droppable.tailPreview = this
+ return () => {
+ this.destroy()
+ if (this.slot) this.slot.preview = undefined
+ else if (this.droppable) this.droppable.tailPreview = undefined
+ }
+ }
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/entities/slot.ts b/packages/svelte-dnd/src/lib/core/entities/slot.ts
new file mode 100644
index 0000000..510b335
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/entities/slot.ts
@@ -0,0 +1,70 @@
+import type { Draggable } from './draggable.svelte.js'
+import type { Preview } from './preview.svelte.js'
+import type { Droppable } from './droppable.svelte.js'
+
+export class Slot {
+ element!: HTMLElement
+ draggable!: Draggable
+ preview: Preview | undefined = undefined
+ droppable!: Droppable
+ position: number
+
+ constructor(position: number) {
+ this.position = position
+ }
+
+ /**
+ * Replaces DOMHelper.calculateSlotSize — finds neighbors via droppable.getSlotAt()
+ * instead of DOM traversal. Preserves all 4 edge cases of the original implementation.
+ */
+ getSize(): { width: number; height: number } {
+ const nextSlot = this.droppable.getSlotAt(this.position + 1)
+ const prevSlot = this.droppable.getSlotAt(this.position - 1)
+
+ if (nextSlot) {
+ const elementRect = this.element.getBoundingClientRect()
+ const nextRect = nextSlot.element.getBoundingClientRect()
+ return {
+ width: nextRect.left - elementRect.left,
+ height: nextRect.top - elementRect.top
+ }
+ } else if (prevSlot) {
+ const elementRect = this.element.getBoundingClientRect()
+ const prevRect = prevSlot.element.getBoundingClientRect()
+ const gapH = elementRect.top - (prevRect.top + prevSlot.draggable.element.offsetHeight)
+ const gapW = elementRect.left - (prevRect.left + prevSlot.draggable.element.offsetWidth)
+ return {
+ width: this.draggable.element.offsetWidth + Math.max(0, gapW),
+ height: this.draggable.element.offsetHeight + Math.max(0, gapH)
+ }
+ } else {
+ // No slot neighbors — use element size + droppable spacing.
+ // Cannot rely on DOM margin because suppressSpacing may have zeroed it on the last slot.
+ const spacing = this.droppable?.spacing ?? 0
+ const isHorizontal = this.droppable?.layout === 'horizontal'
+ return {
+ width: this.draggable.element.offsetWidth + (isHorizontal ? spacing : 0),
+ height: this.draggable.element.offsetHeight + (isHorizontal ? 0 : spacing)
+ }
+ }
+ }
+
+ getBoundingRect(): DOMRect {
+ return this.element.getBoundingClientRect()
+ }
+
+ /**
+ * @attach handler — sets element, links Draggable↔Slot, registers data-dnd-drag-id.
+ * Called via {@attach slot.attachDraggable(draggable)} on the inner draggable div.
+ */
+ attachDraggable(draggable: Draggable): (element: HTMLElement) => () => void {
+ return (element: HTMLElement) => {
+ draggable.element = element
+ draggable.slot = this
+ this.draggable = draggable
+ return () => {
+ draggable.destroy()
+ }
+ }
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/modifiers/modifier.ts b/packages/svelte-dnd/src/lib/core/modifiers/modifier.ts
new file mode 100644
index 0000000..bd2cace
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/modifiers/modifier.ts
@@ -0,0 +1,8 @@
+export interface ModifierContext {
+ transform: { x: number; y: number }
+ initialTransform: { x: number; y: number }
+ ghostSize: { width: number; height: number }
+ originContainerId: string
+}
+
+export type Modifier = (context: ModifierContext) => { x: number; y: number }
diff --git a/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-container.ts b/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-container.ts
new file mode 100644
index 0000000..5174fb9
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-container.ts
@@ -0,0 +1,11 @@
+import type { Modifier } from './modifier.js'
+import { DOMHelper } from '../utils/dom-helper.js'
+
+export const restrictToContainer: Modifier = ({ transform, ghostSize, originContainerId }) => {
+ const rect = DOMHelper.getContainerRect(originContainerId)
+ if (!rect) return transform
+ return {
+ x: Math.max(rect.left, Math.min(transform.x, rect.right - ghostSize.width)),
+ y: Math.max(rect.top, Math.min(transform.y, rect.bottom - ghostSize.height))
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-horizontal-axis.ts b/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-horizontal-axis.ts
new file mode 100644
index 0000000..ffaafbd
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-horizontal-axis.ts
@@ -0,0 +1,6 @@
+import type { Modifier } from './modifier.js'
+
+export const restrictToHorizontalAxis: Modifier = ({ transform, initialTransform }) => ({
+ x: transform.x,
+ y: initialTransform.y
+})
diff --git a/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-vertical-axis.ts b/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-vertical-axis.ts
new file mode 100644
index 0000000..e051b18
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-vertical-axis.ts
@@ -0,0 +1,6 @@
+import type { Modifier } from './modifier.js'
+
+export const restrictToVerticalAxis: Modifier = ({ transform, initialTransform }) => ({
+ x: initialTransform.x,
+ y: transform.y
+})
diff --git a/packages/svelte-dnd/src/lib/core/modifiers/snap-to-grid.ts b/packages/svelte-dnd/src/lib/core/modifiers/snap-to-grid.ts
new file mode 100644
index 0000000..a2875be
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/modifiers/snap-to-grid.ts
@@ -0,0 +1,12 @@
+import type { Modifier } from './modifier.js'
+
+export const snapToGrid =
+ (gridSize: number | { x: number; y: number }): Modifier =>
+ ({ transform }) => {
+ const gx = typeof gridSize === 'number' ? gridSize : gridSize.x
+ const gy = typeof gridSize === 'number' ? gridSize : gridSize.y
+ return {
+ x: Math.round(transform.x / gx) * gx,
+ y: Math.round(transform.y / gy) * gy
+ }
+ }
diff --git a/src/lib/core/scroll-controller.ts b/packages/svelte-dnd/src/lib/core/scroll/scroll-controller.ts
similarity index 54%
rename from src/lib/core/scroll-controller.ts
rename to packages/svelte-dnd/src/lib/core/scroll/scroll-controller.ts
index 0cde798..57941a2 100644
--- a/src/lib/core/scroll-controller.ts
+++ b/packages/svelte-dnd/src/lib/core/scroll/scroll-controller.ts
@@ -1,17 +1,32 @@
-import type { DragState } from './drag-state.svelte.js'
+import type { DndState } from '../dnd/dnd-state.svelte.js'
+import type { AutoScrollConfig } from '../animation/behavior.js'
-export interface ScrollOptions {
+export interface ScrollControllerOptions {
onZoneRefresh?: () => void
onMouseUpdate?: (x: number, y: number) => void
+ /**
+ * Returns the active auto-scroll config for a scrollable container.
+ * Source: the first `autoScroll(...)` behavior on that container's
+ * strategy (per-droppable), falling back to the controller-level
+ * defaults for `data-dnd-scroll` wrappers. Return `null` to opt this
+ * container out of auto-scroll entirely.
+ */
+ resolveAutoScrollConfig?: (container: HTMLElement) => AutoScrollConfig | null
+ /**
+ * Whether to halt auto-scroll the moment the user releases the pointer.
+ * Sourced from the controller-level `autoScroll(...)` behavior.
+ */
+ stopOnDrop?: boolean
}
export class ScrollController {
- private scrollIntervals = new Map()
+ private scrollFrames = new Map()
private lastMousePosition = { x: 0, y: 0 }
+ private refreshTimer: ReturnType | null = null
constructor(
- private state: DragState,
- private options: ScrollOptions = {}
+ private state: DndState,
+ private options: ScrollControllerOptions = {}
) {}
handleAutoScroll(mouseX: number, mouseY: number) {
@@ -24,10 +39,10 @@ export class ScrollController {
}
private clearInvalidIntervals(validContainers: HTMLElement[]) {
- for (const [container, intervalId] of this.scrollIntervals) {
+ for (const [container, frameId] of this.scrollFrames) {
if (!validContainers.includes(container)) {
- clearInterval(intervalId)
- this.scrollIntervals.delete(container)
+ cancelAnimationFrame(frameId)
+ this.scrollFrames.delete(container)
}
}
}
@@ -36,19 +51,35 @@ export class ScrollController {
containers.forEach((container) => {
const scrollConfig = this.calculateScrollConfig(container)
- if (scrollConfig.shouldScroll && !this.scrollIntervals.has(container)) {
+ if (scrollConfig.shouldScroll && !this.scrollFrames.has(container)) {
this.startScrolling(container)
- } else if (!scrollConfig.shouldScroll && this.scrollIntervals.has(container)) {
+ } else if (!scrollConfig.shouldScroll && this.scrollFrames.has(container)) {
this.stopScrolling(container)
}
})
}
+ private resolveConfig(container: HTMLElement): AutoScrollConfig | null {
+ return this.options.resolveAutoScrollConfig?.(container) ?? null
+ }
+
private calculateScrollConfig(container: HTMLElement) {
+ const cfg = this.resolveConfig(container)
+ if (!cfg)
+ return {
+ shouldScroll: false,
+ directionY: null,
+ speedY: 0,
+ directionX: null,
+ speedX: 0
+ } as const
+
const rect = container.getBoundingClientRect()
const { x: mouseX, y: mouseY } = this.lastMousePosition
- const scrollZoneY = rect.height * 0.3
- const scrollZoneX = rect.width * 0.3
+ const ratio = cfg.zoneRatio ?? 0.3
+ const maxSpeed = cfg.maxSpeed ?? 30
+ const scrollZoneY = rect.height * ratio
+ const scrollZoneX = rect.width * ratio
const distanceFromTop = mouseY - rect.top
const distanceFromBottom = rect.bottom - mouseY
@@ -60,10 +91,10 @@ export class ScrollController {
if (distanceFromTop < scrollZoneY && distanceFromTop > 0) {
directionY = 'up'
- speedY = this.calculateSpeed(1 - distanceFromTop / scrollZoneY)
+ speedY = this.calculateSpeed(1 - distanceFromTop / scrollZoneY, maxSpeed)
} else if (distanceFromBottom < scrollZoneY && distanceFromBottom > 0) {
directionY = 'down'
- speedY = this.calculateSpeed(1 - distanceFromBottom / scrollZoneY)
+ speedY = this.calculateSpeed(1 - distanceFromBottom / scrollZoneY, maxSpeed)
}
let speedX = 0
@@ -71,10 +102,10 @@ export class ScrollController {
if (distanceFromLeft < scrollZoneX && distanceFromLeft > 0) {
directionX = 'left'
- speedX = this.calculateSpeed(1 - distanceFromLeft / scrollZoneX)
+ speedX = this.calculateSpeed(1 - distanceFromLeft / scrollZoneX, maxSpeed)
} else if (distanceFromRight < scrollZoneX && distanceFromRight > 0) {
directionX = 'right'
- speedX = this.calculateSpeed(1 - distanceFromRight / scrollZoneX)
+ speedX = this.calculateSpeed(1 - distanceFromRight / scrollZoneX, maxSpeed)
}
return {
@@ -83,23 +114,37 @@ export class ScrollController {
speedY,
directionX,
speedX
- }
+ } as const
}
- private calculateSpeed(proximityRatio: number): number {
+ private calculateSpeed(proximityRatio: number, maxSpeed: number): number {
+ let base: number
if (proximityRatio < 0.33) {
- return 2 + proximityRatio * 3 * 6
+ base = 2 + proximityRatio * 3 * 6
} else if (proximityRatio < 0.66) {
- return 8 + (proximityRatio - 0.33) * 3 * 10
+ base = 8 + (proximityRatio - 0.33) * 3 * 10
} else {
- return 18 + (proximityRatio - 0.66) * 3 * 12
+ base = 18 + (proximityRatio - 0.66) * 3 * 12
}
+ return base * (maxSpeed / 30)
}
private startScrolling(container: HTMLElement) {
- const intervalId = setInterval(() => {
+ let lastTime = 0
+
+ const tick = (time: number) => {
+ if (!this.scrollFrames.has(container)) return
+
+ const delta = lastTime ? time - lastTime : 16
+ lastTime = time
+
const config = this.calculateScrollConfig(container)
+ if (!config.shouldScroll) {
+ this.stopScrolling(container)
+ return
+ }
+
let scrolledX = false
let scrolledY = false
@@ -122,36 +167,43 @@ export class ScrollController {
}
if (scrolledX || scrolledY) {
+ const scale = delta / 16
container.scrollBy({
top: scrolledY
? config.directionY === 'up'
- ? -config.speedY
- : config.speedY
+ ? -config.speedY * scale
+ : config.speedY * scale
: 0,
left: scrolledX
? config.directionX === 'left'
- ? -config.speedX
- : config.speedX
+ ? -config.speedX * scale
+ : config.speedX * scale
: 0,
behavior: 'auto'
})
this.scheduleRefresh()
}
- }, 16)
- this.scrollIntervals.set(container, intervalId as unknown as number)
+ const frameId = requestAnimationFrame(tick)
+ this.scrollFrames.set(container, frameId)
+ }
+
+ const frameId = requestAnimationFrame(tick)
+ this.scrollFrames.set(container, frameId)
}
private stopScrolling(container: HTMLElement) {
- const intervalId = this.scrollIntervals.get(container)
- if (intervalId) {
- clearInterval(intervalId)
- this.scrollIntervals.delete(container)
+ const frameId = this.scrollFrames.get(container)
+ if (frameId) {
+ cancelAnimationFrame(frameId)
+ this.scrollFrames.delete(container)
}
}
private scheduleRefresh() {
- setTimeout(() => {
+ if (this.refreshTimer) return
+ this.refreshTimer = setTimeout(() => {
+ this.refreshTimer = null
this.options.onZoneRefresh?.()
this.options.onMouseUpdate?.(this.lastMousePosition.x, this.lastMousePosition.y)
}, 10)
@@ -162,7 +214,11 @@ export class ScrollController {
const elementsAtPoint = document.elementsFromPoint(mouseX, mouseY)
for (const element of elementsAtPoint) {
- if (element instanceof HTMLElement && element.hasAttribute('data-dnd-scroll')) {
+ if (
+ element instanceof HTMLElement &&
+ (element.hasAttribute('data-dnd-droppable') ||
+ element.hasAttribute('data-dnd-scroll'))
+ ) {
const computedStyle = window.getComputedStyle(element)
const overflowY = computedStyle.overflowY
const overflowX = computedStyle.overflowX
@@ -184,14 +240,24 @@ export class ScrollController {
return containers
}
+ get stopOnDrop() {
+ return this.options.stopOnDrop ?? false
+ }
+
+ /** Replace runtime callbacks/options. Used by `setBehaviors`. */
+ updateOptions(options: Partial) {
+ this.options = { ...this.options, ...options }
+ }
+
clearAll() {
- for (const [, intervalId] of this.scrollIntervals) {
- clearInterval(intervalId)
+ for (const [, frameId] of this.scrollFrames) {
+ cancelAnimationFrame(frameId)
}
- this.scrollIntervals.clear()
+ this.scrollFrames.clear()
}
destroy() {
this.clearAll()
+ if (this.refreshTimer) clearTimeout(this.refreshTimer)
}
}
diff --git a/packages/svelte-dnd/src/lib/core/scroll/scroll-into-view.ts b/packages/svelte-dnd/src/lib/core/scroll/scroll-into-view.ts
new file mode 100644
index 0000000..302e5dc
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/scroll/scroll-into-view.ts
@@ -0,0 +1,63 @@
+import { isBrowser } from '../utils/dom-helper.js'
+
+export interface ScrollTarget {
+ container: HTMLElement
+ direction: 'vertical' | 'horizontal'
+}
+
+const SCROLL_OVERFLOW_VALUES = new Set(['auto', 'scroll', 'overlay'])
+
+function isScrollableY(el: HTMLElement, style: CSSStyleDeclaration): boolean {
+ return SCROLL_OVERFLOW_VALUES.has(style.overflowY) && el.scrollHeight > el.clientHeight
+}
+
+function isScrollableX(el: HTMLElement, style: CSSStyleDeclaration): boolean {
+ return SCROLL_OVERFLOW_VALUES.has(style.overflowX) && el.scrollWidth > el.clientWidth
+}
+
+function* scrollableAncestors(start: HTMLElement): Generator {
+ let parent = start.parentElement
+ while (parent) {
+ const style = window.getComputedStyle(parent)
+ if (isScrollableY(parent, style) || isScrollableX(parent, style)) {
+ yield parent
+ }
+ parent = parent.parentElement
+ }
+}
+
+/**
+ * Find the nearest scrollable ancestor of `slotEl` where the slot sits outside
+ * the visible band (container rect minus `padding` on each edge), and return
+ * the axis that needs to scroll. Returns `null` when the slot is already fully
+ * inside every scrollable ancestor.
+ */
+export function findScrollTarget(slotEl: HTMLElement, padding = 0): ScrollTarget | null {
+ if (!isBrowser) return null
+
+ for (const container of scrollableAncestors(slotEl)) {
+ const slotRect = slotEl.getBoundingClientRect()
+ const containerRect = container.getBoundingClientRect()
+ const style = window.getComputedStyle(container)
+
+ if (isScrollableY(container, style)) {
+ if (
+ slotRect.top < containerRect.top + padding ||
+ slotRect.bottom > containerRect.bottom - padding
+ ) {
+ return { container, direction: 'vertical' }
+ }
+ }
+
+ if (isScrollableX(container, style)) {
+ if (
+ slotRect.left < containerRect.left + padding ||
+ slotRect.right > containerRect.right - padding
+ ) {
+ return { container, direction: 'horizontal' }
+ }
+ }
+ }
+
+ return null
+}
diff --git a/packages/svelte-dnd/src/lib/core/scroll/touch-scroll.ts b/packages/svelte-dnd/src/lib/core/scroll/touch-scroll.ts
new file mode 100644
index 0000000..c3c089a
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/scroll/touch-scroll.ts
@@ -0,0 +1,91 @@
+export class TouchScroll {
+ private scrollTarget: Element | null = null
+ private lastPos = { x: 0, y: 0 }
+ private lastTime = 0
+ private velocity = { x: 0, y: 0 }
+ private raf: number | null = null
+
+ private static readonly DECELERATION = 0.92
+ private static readonly VELOCITY_STOP = 0.05
+ private static readonly VELOCITY_ALPHA = 0.7
+
+ get isScrolling() {
+ return this.scrollTarget !== null
+ }
+
+ start(fromElement: HTMLElement, clientX: number, clientY: number) {
+ this.stopMomentum()
+ this.scrollTarget = this.findScrollableParent(fromElement)
+ this.lastPos = { x: clientX, y: clientY }
+ this.lastTime = Date.now()
+ this.velocity = { x: 0, y: 0 }
+ }
+
+ update(clientX: number, clientY: number) {
+ if (!this.scrollTarget) return
+
+ const dx = this.lastPos.x - clientX
+ const dy = this.lastPos.y - clientY
+ const now = Date.now()
+ const dt = now - this.lastTime
+
+ this.scrollTarget.scrollBy(dx, dy)
+
+ if (dt > 0) {
+ const a = TouchScroll.VELOCITY_ALPHA
+ this.velocity.x = a * (dx / dt) + (1 - a) * this.velocity.x
+ this.velocity.y = a * (dy / dt) + (1 - a) * this.velocity.y
+ }
+
+ this.lastPos = { x: clientX, y: clientY }
+ this.lastTime = now
+ }
+
+ end() {
+ const target = this.scrollTarget
+ this.scrollTarget = null
+ if (target) this.applyMomentum(target)
+ }
+
+ stopMomentum() {
+ if (this.raf !== null) {
+ cancelAnimationFrame(this.raf)
+ this.raf = null
+ }
+ }
+
+ private applyMomentum(target: Element) {
+ const tick = () => {
+ this.velocity.x *= TouchScroll.DECELERATION
+ this.velocity.y *= TouchScroll.DECELERATION
+
+ if (
+ Math.abs(this.velocity.x) < TouchScroll.VELOCITY_STOP &&
+ Math.abs(this.velocity.y) < TouchScroll.VELOCITY_STOP
+ ) {
+ this.raf = null
+ return
+ }
+
+ target.scrollBy(this.velocity.x * 16, this.velocity.y * 16)
+ this.raf = requestAnimationFrame(tick)
+ }
+
+ this.raf = requestAnimationFrame(tick)
+ }
+
+ private findScrollableParent(el: HTMLElement): Element | null {
+ let parent = el.parentElement
+ while (parent && parent !== document.body) {
+ const style = getComputedStyle(parent)
+ if (
+ ['auto', 'scroll', 'overlay'].includes(style.overflowY) ||
+ ['auto', 'scroll', 'overlay'].includes(style.overflowX)
+ ) {
+ return parent
+ }
+ parent = parent.parentElement
+ }
+ return document.scrollingElement
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/sensors/activation-constraints.ts b/packages/svelte-dnd/src/lib/core/sensors/activation-constraints.ts
new file mode 100644
index 0000000..11637ca
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/sensors/activation-constraints.ts
@@ -0,0 +1,59 @@
+import type { StartCondition, ActivationState, ConditionResult } from './sensor.js'
+
+export interface DistanceConfig {
+ value: number
+ tolerance?: number
+}
+
+export class Distance implements StartCondition {
+ constructor(private config: DistanceConfig) {}
+
+ evaluate(state: ActivationState): ConditionResult {
+ const dx = Math.abs(state.currentX - state.startX)
+ const dy = Math.abs(state.currentY - state.startY)
+ const distance = Math.sqrt(dx * dx + dy * dy)
+
+ if (this.config.tolerance !== undefined && distance > this.config.tolerance) {
+ return 'aborted'
+ }
+
+ if (distance >= this.config.value) {
+ return 'satisfied'
+ }
+
+ return 'pending'
+ }
+
+ getRequiredDuration(): number | null {
+ return null
+ }
+}
+
+export interface DelayConfig {
+ value: number
+ tolerance?: number
+}
+
+export class Delay implements StartCondition {
+ constructor(private config: DelayConfig) {}
+
+ evaluate(state: ActivationState): ConditionResult {
+ const dx = Math.abs(state.currentX - state.startX)
+ const dy = Math.abs(state.currentY - state.startY)
+ const distance = Math.sqrt(dx * dx + dy * dy)
+
+ if (this.config.tolerance !== undefined && distance > this.config.tolerance) {
+ return 'aborted'
+ }
+
+ if (state.elapsedMs >= this.config.value) {
+ return 'satisfied'
+ }
+
+ return 'pending'
+ }
+
+ getRequiredDuration(): number | null {
+ return this.config.value
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/sensors/keyboard-sensor.ts b/packages/svelte-dnd/src/lib/core/sensors/keyboard-sensor.ts
new file mode 100644
index 0000000..4d2beed
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/sensors/keyboard-sensor.ts
@@ -0,0 +1,78 @@
+import type {
+ SensorDescriptor,
+ SensorActivation,
+ SensorCallbacks,
+ NavigationDirection
+} from './sensor.js'
+
+export class KeyboardSensor implements SensorDescriptor {
+ activate(
+ event: Event,
+ element: HTMLElement,
+ callbacks: SensorCallbacks
+ ): SensorActivation | null {
+ if (!(event instanceof KeyboardEvent)) return null
+ if (event.key !== 'Enter' && event.key !== ' ') return null
+ // Skip bubbled events from nested draggables.
+ if (event.target && event.target !== element) return null
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ const rect = element.getBoundingClientRect()
+ const offset = { x: rect.width / 2, y: rect.height / 2 }
+ const initialTransform = { x: rect.left, y: rect.top }
+
+ let started = false
+
+ const keyToDirection: Record = {
+ ArrowUp: 'up',
+ ArrowDown: 'down',
+ ArrowLeft: 'left',
+ ArrowRight: 'right',
+ Home: 'home',
+ End: 'end'
+ }
+
+ const onWindowKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault()
+ cleanup()
+ callbacks.onCancel()
+ return
+ }
+
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ cleanup()
+ callbacks.onEnd()
+ return
+ }
+
+ const direction = keyToDirection[e.key]
+ if (direction) {
+ e.preventDefault()
+ if (!started) {
+ callbacks.onStart(initialTransform)
+ started = true
+ }
+ callbacks.onNavigate?.(direction)
+ }
+ }
+
+ // Defer listener so the current Enter keydown event doesn't immediately trigger onEnd.
+ // Track the timer so cleanup() can cancel it if destroy fires before the listener attaches.
+ const timerId = setTimeout(() => window.addEventListener('keydown', onWindowKeyDown), 0)
+
+ // Start drag immediately on Enter/Space
+ callbacks.onStart(initialTransform)
+ started = true
+
+ function cleanup() {
+ clearTimeout(timerId)
+ window.removeEventListener('keydown', onWindowKeyDown)
+ }
+
+ return { initialTransform, offset, destroy: cleanup }
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/sensors/pointer-sensor.ts b/packages/svelte-dnd/src/lib/core/sensors/pointer-sensor.ts
new file mode 100644
index 0000000..6d937c8
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/sensors/pointer-sensor.ts
@@ -0,0 +1,252 @@
+import { TouchScroll } from '../scroll/touch-scroll.js'
+import { isBrowser } from '../utils/dom-helper.js'
+import type {
+ SensorDescriptor,
+ SensorActivation,
+ SensorCallbacks,
+ ActivationState,
+ StartCondition,
+ StartConditionInput
+} from './sensor.js'
+import { Distance, Delay } from './activation-constraints.js'
+
+export interface PointerSensorOptions {
+ startConditions?: StartConditionInput
+}
+
+const DEFAULT_MOUSE_CONDITIONS: StartCondition[] = [new Distance({ value: 5 })]
+const DEFAULT_TOUCH_CONDITIONS: StartCondition[] = [new Delay({ value: 300, tolerance: 8 })]
+
+function getDefaultStartConditions(pointerType: string): StartCondition[] {
+ if (pointerType === 'mouse') return DEFAULT_MOUSE_CONDITIONS
+ return DEFAULT_TOUCH_CONDITIONS
+}
+
+function resolveStartConditions(
+ event: PointerEvent,
+ options: PointerSensorOptions | undefined
+): StartCondition[] {
+ const input = options?.startConditions
+ if (!input) return getDefaultStartConditions(event.pointerType)
+ if (typeof input === 'function') return input(event)
+ return input
+}
+
+export class PointerSensor implements SensorDescriptor {
+ constructor(private options?: PointerSensorOptions) {}
+
+ activate(
+ event: Event,
+ element: HTMLElement,
+ callbacks: SensorCallbacks
+ ): SensorActivation | null {
+ if (!(event instanceof PointerEvent)) return null
+ if (event.button !== 0) return null
+
+ const e = event
+
+ const target = e.target as HTMLElement
+ const hasHandle = !!element.querySelector('[data-dnd-handle]')
+ if (hasHandle) {
+ if (!target.closest('[data-dnd-handle]')) return null
+ } else {
+ if (target.closest('[data-dnd-no-drag]')) return null
+ }
+
+ const rect = element.getBoundingClientRect()
+ const styles = getComputedStyle(element)
+ const contentTop = rect.top + parseFloat(styles.paddingTop)
+ const contentBottom = rect.bottom - parseFloat(styles.paddingBottom)
+ const contentLeft = rect.left + parseFloat(styles.paddingLeft)
+ const contentRight = rect.right - parseFloat(styles.paddingRight)
+ if (
+ e.clientY < contentTop ||
+ e.clientY > contentBottom ||
+ e.clientX < contentLeft ||
+ e.clientX > contentRight
+ )
+ return null
+
+ e.stopPropagation()
+
+ const offset = { x: e.clientX - rect.left, y: e.clientY - rect.top }
+ const initialTransform = { x: e.clientX - offset.x, y: e.clientY - offset.y }
+
+ const conditions = resolveStartConditions(e, this.options)
+ const startTime = performance.now()
+
+ const state: ActivationState = {
+ startX: e.clientX,
+ startY: e.clientY,
+ currentX: e.clientX,
+ currentY: e.clientY,
+ elapsedMs: 0,
+ pointerType: e.pointerType as 'mouse' | 'touch' | 'pen'
+ }
+
+ let isDragging = false
+ let isPotentialDrag = true
+ const lastPointerId = e.pointerId
+ const lastPointerPos = { x: e.clientX, y: e.clientY }
+ let conditionTimer: ReturnType | null = null
+ const touchScroll = new TouchScroll()
+
+ touchScroll.stopMomentum()
+
+ const startActualDrag = (clientX: number, clientY: number, pointerId: number) => {
+ if (isDragging) return
+ isDragging = true
+ isPotentialDrag = false
+
+ if (element.hasPointerCapture(pointerId)) {
+ element.releasePointerCapture(pointerId)
+ }
+
+ if (isBrowser) {
+ window.addEventListener('pointermove', onWindowMove)
+ window.addEventListener('pointerup', onWindowUp)
+ window.addEventListener('pointercancel', onWindowCancel)
+ }
+
+ const transform = { x: clientX - offset.x, y: clientY - offset.y }
+ callbacks.onStart(transform)
+ }
+
+ const cancelTimer = () => {
+ if (conditionTimer) {
+ clearTimeout(conditionTimer)
+ conditionTimer = null
+ }
+ }
+
+ const evaluateConditions = (): 'satisfied' | 'pending' | 'all_aborted' => {
+ let allAborted = true
+
+ for (const condition of conditions) {
+ const result = condition.evaluate(state)
+ if (result === 'satisfied') return 'satisfied'
+ if (result !== 'aborted') allAborted = false
+ }
+
+ return allAborted ? 'all_aborted' : 'pending'
+ }
+
+ const onWindowMove = (e: PointerEvent) => {
+ if (!isDragging) return
+ const transform = { x: e.clientX - offset.x, y: e.clientY - offset.y }
+ callbacks.onMove(transform, e.clientX, e.clientY)
+ }
+
+ const onWindowUp = (_e: PointerEvent) => {
+ if (!isDragging) return
+ isDragging = false
+ removeWindowListeners()
+ callbacks.onEnd()
+ }
+
+ const onWindowCancel = () => {
+ if (!isDragging) return
+ isDragging = false
+ removeWindowListeners()
+ callbacks.onCancel()
+ }
+
+ const removeWindowListeners = () => {
+ if (!isBrowser) return
+ window.removeEventListener('pointermove', onWindowMove)
+ window.removeEventListener('pointerup', onWindowUp)
+ window.removeEventListener('pointercancel', onWindowCancel)
+ }
+
+ const onElementMove = (e: PointerEvent) => {
+ if (!isPotentialDrag && !touchScroll.isScrolling) return
+
+ lastPointerPos.x = e.clientX
+ lastPointerPos.y = e.clientY
+
+ if (!isPotentialDrag) {
+ touchScroll.update(e.clientX, e.clientY)
+ return
+ }
+
+ state.currentX = e.clientX
+ state.currentY = e.clientY
+ state.elapsedMs = performance.now() - startTime
+
+ const result = evaluateConditions()
+
+ if (result === 'satisfied') {
+ cancelTimer()
+ startActualDrag(e.clientX, e.clientY, e.pointerId)
+ } else if (result === 'all_aborted') {
+ cancelTimer()
+ isPotentialDrag = false
+ touchScroll.start(element, e.clientX, e.clientY)
+ }
+ }
+
+ const onElementUp = (e: PointerEvent) => {
+ if (element.hasPointerCapture(e.pointerId)) {
+ element.releasePointerCapture(e.pointerId)
+ }
+ if (touchScroll.isScrolling) touchScroll.end()
+ if (isPotentialDrag) {
+ cancelTimer()
+ isPotentialDrag = false
+ }
+ element.removeEventListener('pointermove', onElementMove)
+ element.removeEventListener('pointerup', onElementUp)
+ element.removeEventListener('pointercancel', onElementCancel)
+ }
+
+ const onElementCancel = () => {
+ if (touchScroll.isScrolling) touchScroll.end()
+ cancelTimer()
+ isPotentialDrag = false
+ element.removeEventListener('pointermove', onElementMove)
+ element.removeEventListener('pointerup', onElementUp)
+ element.removeEventListener('pointercancel', onElementCancel)
+ }
+
+ element.addEventListener('pointermove', onElementMove)
+ element.addEventListener('pointerup', onElementUp)
+ element.addEventListener('pointercancel', onElementCancel)
+
+ // Set timer for the minimum required duration among all conditions
+ const durations = conditions
+ .map((c) => c.getRequiredDuration?.())
+ .filter((d): d is number => d !== null && d !== undefined)
+ const minDuration = durations.length > 0 ? Math.min(...durations) : null
+
+ if (minDuration !== null && minDuration > 0) {
+ conditionTimer = setTimeout(() => {
+ conditionTimer = null
+ if (!isPotentialDrag || touchScroll.isScrolling) return
+
+ state.elapsedMs = performance.now() - startTime
+ const result = evaluateConditions()
+
+ if (result === 'satisfied') {
+ element.setPointerCapture(lastPointerId)
+ startActualDrag(lastPointerPos.x, lastPointerPos.y, lastPointerId)
+ } else if (result === 'all_aborted') {
+ isPotentialDrag = false
+ touchScroll.start(element, lastPointerPos.x, lastPointerPos.y)
+ }
+ }, minDuration)
+ }
+
+ return {
+ initialTransform,
+ offset,
+ destroy: () => {
+ cancelTimer()
+ touchScroll.stopMomentum()
+ removeWindowListeners()
+ element.removeEventListener('pointermove', onElementMove)
+ element.removeEventListener('pointerup', onElementUp)
+ element.removeEventListener('pointercancel', onElementCancel)
+ }
+ }
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/sensors/sensor.ts b/packages/svelte-dnd/src/lib/core/sensors/sensor.ts
new file mode 100644
index 0000000..8e9a787
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/sensors/sensor.ts
@@ -0,0 +1,44 @@
+export type NavigationDirection = 'up' | 'down' | 'left' | 'right' | 'home' | 'end'
+
+export interface SensorCallbacks {
+ onStart: (transform: { x: number; y: number }) => void
+ onMove: (transform: { x: number; y: number }, mouseX: number, mouseY: number) => void
+ onEnd: () => void
+ onCancel: () => void
+ onNavigate?: (direction: NavigationDirection) => void
+}
+
+export interface SensorActivation {
+ /** Initial position of the ghost element */
+ initialTransform: { x: number; y: number }
+ /** Offset from the element's top-left corner to the pointer */
+ offset: { x: number; y: number }
+ /** Cancel pending timers/listeners if drag never started */
+ destroy: () => void
+}
+
+export interface ActivationState {
+ startX: number
+ startY: number
+ currentX: number
+ currentY: number
+ elapsedMs: number
+ pointerType: 'mouse' | 'touch' | 'pen'
+}
+
+export type ConditionResult = 'satisfied' | 'pending' | 'aborted'
+
+export interface StartCondition {
+ evaluate(state: ActivationState): ConditionResult
+ getRequiredDuration?(): number | null
+}
+
+export type StartConditionInput = StartCondition[] | ((event: PointerEvent) => StartCondition[])
+
+export interface SensorDescriptor {
+ activate(
+ event: Event,
+ element: HTMLElement,
+ callbacks: SensorCallbacks
+ ): SensorActivation | null
+}
diff --git a/packages/svelte-dnd/src/lib/core/utils/dom-helper.ts b/packages/svelte-dnd/src/lib/core/utils/dom-helper.ts
new file mode 100644
index 0000000..d2fcb60
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/utils/dom-helper.ts
@@ -0,0 +1,71 @@
+/**
+ * Static DOM utilities for querying DnD-related elements by their data attributes.
+ * Used internally by the library; exposed for advanced custom implementations.
+ */
+
+export const isBrowser = typeof window !== 'undefined'
+
+// DOM selectors
+const SELECTORS = {
+ container: (id: string) => `[data-dnd-drop-id="${CSS.escape(id)}"]`,
+ preview: (position: number) => `[data-dnd-preview-position="${position}"]`
+} as const
+
+export class DOMHelper {
+ // Container queries
+ static findContainer(containerId: string): HTMLElement | null {
+ return document.querySelector(SELECTORS.container(containerId))
+ }
+
+ static getContainerRect(containerId: string): DOMRect | null {
+ const container = DOMHelper.findContainer(containerId)
+ return container ? container.getBoundingClientRect() : null
+ }
+
+ // Preview queries
+ static findPreview(container: HTMLElement, position: number): HTMLElement | null {
+ const all = container.querySelectorAll(SELECTORS.preview(position))
+ for (const el of all) {
+ if (el.closest('[data-dnd-drop-id]') === container) return el
+ }
+ return null
+ }
+
+ static findPreviewSlot(container: HTMLElement, position: number): HTMLElement | null {
+ const preview = DOMHelper.findPreview(container, position)
+ if (!preview) return null
+ return preview.parentElement ?? preview
+ }
+
+ // Visibility checks
+ static isElementVisibleInContainer(element: HTMLElement, container: HTMLElement): boolean {
+ const containerRect = container.getBoundingClientRect()
+ const elementRect = element.getBoundingClientRect()
+
+ return (
+ elementRect.top >= containerRect.top &&
+ elementRect.bottom <= containerRect.bottom &&
+ elementRect.left >= containerRect.left &&
+ elementRect.right <= containerRect.right
+ )
+ }
+
+ /**
+ * Fraction of the element's area that is currently inside the container's
+ * rect, in the range [0, 1]. Returns 0 when the element has zero area.
+ */
+ static computeVisibleFraction(element: HTMLElement, container: HTMLElement): number {
+ const e = element.getBoundingClientRect()
+ const c = container.getBoundingClientRect()
+ const ix = Math.max(0, Math.min(e.right, c.right) - Math.max(e.left, c.left))
+ const iy = Math.max(0, Math.min(e.bottom, c.bottom) - Math.max(e.top, c.top))
+ const intersection = ix * iy
+ const area = e.width * e.height
+ return area > 0 ? intersection / area : 0
+ }
+
+ // Rect helpers
+ static getRect(element: HTMLElement): DOMRect {
+ return element.getBoundingClientRect()
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/zones/drop-resolver.ts b/packages/svelte-dnd/src/lib/core/zones/drop-resolver.ts
new file mode 100644
index 0000000..b2ecf7c
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/zones/drop-resolver.ts
@@ -0,0 +1,77 @@
+import type { DropZone } from '../../types.js'
+import type { DndState } from '../dnd/dnd-state.svelte.js'
+import type { Droppable } from '../entities/droppable.svelte.js'
+import type { CollisionAlgorithm } from '../collision/collision-algorithm.js'
+import { centerPoint } from '../collision/center-point.js'
+
+/**
+ * Resolves which drop zone contains a given point during an active drag.
+ * Uses pluggable collision algorithms with the following priority:
+ * 1. Per-container algorithm (from droppable entity)
+ * 2. Global algorithm (passed to constructor)
+ * 3. centerPoint (default)
+ */
+export class DropResolver {
+ constructor(
+ private state: DndState,
+ private droppablesById: Map,
+ private globalAlgorithm?: CollisionAlgorithm
+ ) {}
+
+ /** All zones filtered to only those that accept the currently dragged item type. */
+ get filteredZones(): DropZone[] {
+ return this.filterZonesByType(this.state.zones)
+ }
+
+ findZoneAt(point: { x: number; y: number }): DropZone | null {
+ const draggedItemId = this.state.draggedItem
+ if (!draggedItemId) return null
+
+ const filteredZones = this.filterZonesByType(this.state.zones)
+ const ghost = this.getGhostRect()
+
+ // Group zones by container so each per-container algorithm sees all of its
+ // candidates at once. Calling algorithms with a single-zone array makes
+ // closestCenter/overlap degenerate (they can't compare anything).
+ const groups = new Map()
+ for (const zone of filteredZones) {
+ const list = groups.get(zone.containerId)
+ if (list) list.push(zone)
+ else groups.set(zone.containerId, [zone])
+ }
+
+ for (const [containerId, zones] of groups) {
+ const droppable = this.droppablesById.get(containerId)
+ const algorithm = droppable?.collision ?? this.globalAlgorithm ?? centerPoint
+
+ const hit = algorithm({ zones, pointer: point, ghost })
+ if (hit) return hit
+ }
+
+ return null
+ }
+
+ private filterZonesByType(zones: DropZone[]): DropZone[] {
+ const draggedType = this.state.draggedType
+ if (!draggedType) return zones
+
+ return zones.filter((zone) => {
+ const droppable = this.droppablesById.get(zone.containerId)
+ const accepts = droppable?.accepts
+ if (!accepts) return true
+ if (Array.isArray(accepts)) return accepts.includes(draggedType)
+ return accepts === draggedType
+ })
+ }
+
+ private getGhostRect(): { x: number; y: number; width: number; height: number } {
+ const transform = this.state.transform
+ const size = this.state.ghostSize
+ return {
+ x: transform?.x ?? 0,
+ y: transform?.y ?? 0,
+ width: size?.width ?? 0,
+ height: size?.height ?? 0
+ }
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/zones/geometries/axis-zone-geometry.ts b/packages/svelte-dnd/src/lib/core/zones/geometries/axis-zone-geometry.ts
new file mode 100644
index 0000000..7f4b7b3
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/zones/geometries/axis-zone-geometry.ts
@@ -0,0 +1,147 @@
+import type { DropZone, DndLayout } from '../../../types.js'
+import type { SlotLayoutRect } from '../layout-snapshot.js'
+import type { ZoneGeometry, ZoneGeometryContext } from '../zone-geometry.js'
+
+type Axis = 'vertical' | 'horizontal'
+
+/**
+ * Zone geometry for single-axis layouts (vertical / horizontal lists).
+ * Each item contributes two half-rect zones: one for "insert before me",
+ * one for "insert after me". Adjacent halves belong to neighbouring positions.
+ */
+export class AxisZoneGeometry implements ZoneGeometry {
+ constructor(private axis: Axis) {}
+
+ buildZones(visibleRects: SlotLayoutRect[], ctx: ZoneGeometryContext): DropZone[] {
+ if (visibleRects.length === 0) return [this.buildEmptyZone(ctx)]
+
+ const zones: DropZone[] = []
+ const layout: DndLayout = this.axis
+ const beforePos = (rect: SlotLayoutRect) =>
+ ctx.draggedIndex !== -1 && rect.position > ctx.draggedIndex
+ ? rect.position - 1
+ : rect.position
+
+ visibleRects.forEach((rect, index) => {
+ const viewport = this.toViewport(rect, ctx)
+ const before = beforePos(rect)
+
+ if (this.axis === 'vertical') {
+ const halfHeight = viewport.height / 2
+
+ if (index === 0) {
+ zones.push({
+ containerId: ctx.containerId,
+ position: before,
+ layout,
+ rect: {
+ x: ctx.containerRect.left,
+ y: ctx.containerRect.top,
+ width: ctx.containerRect.width,
+ height: Math.max(
+ halfHeight,
+ viewport.y - ctx.containerRect.top + halfHeight
+ )
+ }
+ })
+ }
+
+ const next = visibleRects[index + 1]
+ const zoneY = viewport.y + halfHeight
+ let zoneHeight = halfHeight
+
+ if (next) {
+ const nextViewport = this.toViewport(next, ctx)
+ zoneHeight =
+ halfHeight +
+ (nextViewport.y - (viewport.y + viewport.height)) +
+ nextViewport.height / 2
+ } else {
+ zoneHeight = Math.max(halfHeight, ctx.containerRect.bottom - zoneY)
+ }
+
+ zones.push({
+ containerId: ctx.containerId,
+ position: before + 1,
+ layout,
+ rect: {
+ x: ctx.containerRect.left,
+ y: zoneY,
+ width: ctx.containerRect.width,
+ height: zoneHeight
+ }
+ })
+ } else {
+ const halfWidth = viewport.width / 2
+
+ if (index === 0) {
+ zones.push({
+ containerId: ctx.containerId,
+ position: before,
+ layout,
+ rect: {
+ x: ctx.containerRect.left,
+ y: ctx.containerRect.top,
+ width: Math.max(
+ halfWidth,
+ viewport.x - ctx.containerRect.left + halfWidth
+ ),
+ height: ctx.containerRect.height
+ }
+ })
+ }
+
+ const next = visibleRects[index + 1]
+ const zoneX = viewport.x + halfWidth
+ let zoneWidth = halfWidth
+
+ if (next) {
+ const nextViewport = this.toViewport(next, ctx)
+ zoneWidth =
+ halfWidth +
+ (nextViewport.x - (viewport.x + viewport.width)) +
+ nextViewport.width / 2
+ } else {
+ zoneWidth = Math.max(halfWidth, ctx.containerRect.right - zoneX)
+ }
+
+ zones.push({
+ containerId: ctx.containerId,
+ position: before + 1,
+ layout,
+ rect: {
+ x: zoneX,
+ y: ctx.containerRect.top,
+ width: zoneWidth,
+ height: ctx.containerRect.height
+ }
+ })
+ }
+ })
+
+ return zones
+ }
+
+ buildEmptyZone(ctx: ZoneGeometryContext): DropZone {
+ return {
+ containerId: ctx.containerId,
+ position: 0,
+ layout: this.axis,
+ rect: {
+ x: ctx.containerRect.left,
+ y: ctx.containerRect.top,
+ width: ctx.containerRect.width,
+ height: Math.max(ctx.containerRect.height, 20)
+ }
+ }
+ }
+
+ private toViewport(rect: SlotLayoutRect, ctx: ZoneGeometryContext) {
+ return {
+ x: rect.offsetLeft + ctx.containerRect.left - ctx.scrollLeft,
+ y: rect.offsetTop + ctx.containerRect.top - ctx.scrollTop,
+ width: rect.width,
+ height: rect.height
+ }
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/zones/geometries/grid-zone-geometry.ts b/packages/svelte-dnd/src/lib/core/zones/geometries/grid-zone-geometry.ts
new file mode 100644
index 0000000..4a1635e
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/zones/geometries/grid-zone-geometry.ts
@@ -0,0 +1,187 @@
+import type { DropZone } from '../../../types.js'
+import type { SlotLayoutRect } from '../layout-snapshot.js'
+import type { ZoneGeometry, ZoneGeometryContext } from '../zone-geometry.js'
+
+export type GridFlow = 'row' | 'column'
+
+/**
+ * Zone geometry for 2D grid layouts.
+ *
+ * `flow: 'row'` (default): items fill left-to-right, wrapping to next row.
+ * `flow: 'column'`: items fill top-to-bottom, wrapping to next column.
+ *
+ * Items are grouped along the secondary (wrap) axis using the captured snapshot,
+ * then each item is split into two halves along the primary axis — one for
+ * "insert before me", one for "insert after me".
+ */
+export class GridZoneGeometry implements ZoneGeometry {
+ constructor(private flow: GridFlow = 'row') {}
+
+ buildZones(visibleRects: SlotLayoutRect[], ctx: ZoneGeometryContext): DropZone[] {
+ if (visibleRects.length === 0) return [this.buildEmptyZone(ctx)]
+
+ const ax = axisFor(this.flow)
+ const groups = groupByTrack(visibleRects, ax)
+ const zones: DropZone[] = []
+ const beforePos = (rect: SlotLayoutRect) =>
+ ctx.draggedIndex !== -1 && rect.position > ctx.draggedIndex
+ ? rect.position - 1
+ : rect.position
+
+ for (let gi = 0; gi < groups.length; gi++) {
+ const group = groups[gi]
+ const prevGroup = groups[gi - 1]
+ const nextGroup = groups[gi + 1]
+
+ const trackStart = Math.min(...group.map((r) => ax.secondaryOf(r)))
+ const trackEnd = Math.max(
+ ...group.map((r) => ax.secondaryOf(r) + ax.secondarySizeOf(r))
+ )
+
+ const secStart = prevGroup
+ ? (Math.max(...prevGroup.map((r) => ax.secondaryOf(r) + ax.secondarySizeOf(r))) +
+ trackStart) /
+ 2
+ : Math.min(0, trackStart)
+ const secEnd = nextGroup
+ ? (trackEnd + Math.min(...nextGroup.map((r) => ax.secondaryOf(r)))) / 2
+ : Math.max(ax.containerSecondaryLength(ctx) + ax.scrollSecondary(ctx), trackEnd)
+
+ for (let ii = 0; ii < group.length; ii++) {
+ const rect = group[ii]
+ const prev = group[ii - 1]
+ const next = group[ii + 1]
+ const before = beforePos(rect)
+
+ const primaryStart = ax.primaryOf(rect)
+ const primarySize = ax.primarySizeOf(rect)
+ const primaryMid = primaryStart + primarySize / 2
+
+ const beforeStart = prev
+ ? (ax.primaryOf(prev) + ax.primarySizeOf(prev) + primaryStart) / 2
+ : Math.min(0, primaryStart)
+ zones.push(ax.toZone(ctx, before, beforeStart, primaryMid, secStart, secEnd))
+
+ const afterEnd = next
+ ? (primaryStart + primarySize + ax.primaryOf(next)) / 2
+ : Math.max(
+ ax.containerPrimaryLength(ctx) + ax.scrollPrimary(ctx),
+ primaryStart + primarySize
+ )
+ zones.push(ax.toZone(ctx, before + 1, primaryMid, afterEnd, secStart, secEnd))
+ }
+ }
+
+ return zones
+ }
+
+ buildEmptyZone(ctx: ZoneGeometryContext): DropZone {
+ return {
+ containerId: ctx.containerId,
+ position: 0,
+ layout: 'grid',
+ rect: {
+ x: ctx.containerRect.left,
+ y: ctx.containerRect.top,
+ width: ctx.containerRect.width,
+ height: Math.max(ctx.containerRect.height, 20)
+ }
+ }
+ }
+}
+
+/**
+ * Axis abstraction lets the grouping + zone-splitting logic stay symmetric
+ * across `flow: 'row'` (primary = X, secondary = Y) and `flow: 'column'`
+ * (primary = Y, secondary = X).
+ */
+interface AxisMapping {
+ primaryOf(r: SlotLayoutRect): number
+ secondaryOf(r: SlotLayoutRect): number
+ primarySizeOf(r: SlotLayoutRect): number
+ secondarySizeOf(r: SlotLayoutRect): number
+ containerPrimaryLength(ctx: ZoneGeometryContext): number
+ containerSecondaryLength(ctx: ZoneGeometryContext): number
+ scrollPrimary(ctx: ZoneGeometryContext): number
+ scrollSecondary(ctx: ZoneGeometryContext): number
+ toZone(
+ ctx: ZoneGeometryContext,
+ position: number,
+ primaryStart: number,
+ primaryEnd: number,
+ secondaryStart: number,
+ secondaryEnd: number
+ ): DropZone
+}
+
+function axisFor(flow: GridFlow): AxisMapping {
+ if (flow === 'row') {
+ return {
+ primaryOf: (r) => r.offsetLeft,
+ secondaryOf: (r) => r.offsetTop,
+ primarySizeOf: (r) => r.width,
+ secondarySizeOf: (r) => r.height,
+ containerPrimaryLength: (ctx) => ctx.containerRect.width,
+ containerSecondaryLength: (ctx) => ctx.containerRect.height,
+ scrollPrimary: (ctx) => ctx.scrollLeft,
+ scrollSecondary: (ctx) => ctx.scrollTop,
+ toZone: (ctx, position, pStart, pEnd, sStart, sEnd) => ({
+ containerId: ctx.containerId,
+ position,
+ layout: 'grid',
+ rect: {
+ x: pStart + ctx.containerRect.left - ctx.scrollLeft,
+ y: sStart + ctx.containerRect.top - ctx.scrollTop,
+ width: pEnd - pStart,
+ height: sEnd - sStart
+ }
+ })
+ }
+ }
+ // column
+ return {
+ primaryOf: (r) => r.offsetTop,
+ secondaryOf: (r) => r.offsetLeft,
+ primarySizeOf: (r) => r.height,
+ secondarySizeOf: (r) => r.width,
+ containerPrimaryLength: (ctx) => ctx.containerRect.height,
+ containerSecondaryLength: (ctx) => ctx.containerRect.width,
+ scrollPrimary: (ctx) => ctx.scrollTop,
+ scrollSecondary: (ctx) => ctx.scrollLeft,
+ toZone: (ctx, position, pStart, pEnd, sStart, sEnd) => ({
+ containerId: ctx.containerId,
+ position,
+ layout: 'grid',
+ rect: {
+ x: sStart + ctx.containerRect.left - ctx.scrollLeft,
+ y: pStart + ctx.containerRect.top - ctx.scrollTop,
+ width: sEnd - sStart,
+ height: pEnd - pStart
+ }
+ })
+ }
+}
+
+/**
+ * Group rects into tracks along the secondary axis. A track is a set of rects
+ * whose secondary position overlaps (same row in flow=row, same column in flow=column).
+ */
+function groupByTrack(rects: SlotLayoutRect[], ax: AxisMapping): SlotLayoutRect[][] {
+ if (rects.length === 0) return []
+ const groups: SlotLayoutRect[][] = []
+ let current: SlotLayoutRect[] = [rects[0]]
+ let trackStart = ax.secondaryOf(rects[0])
+
+ for (let i = 1; i < rects.length; i++) {
+ const r = rects[i]
+ if (Math.abs(ax.secondaryOf(r) - trackStart) < ax.secondarySizeOf(r) * 0.5) {
+ current.push(r)
+ } else {
+ groups.push(current)
+ current = [r]
+ trackStart = ax.secondaryOf(r)
+ }
+ }
+ groups.push(current)
+ return groups
+}
diff --git a/packages/svelte-dnd/src/lib/core/zones/geometry-registry.ts b/packages/svelte-dnd/src/lib/core/zones/geometry-registry.ts
new file mode 100644
index 0000000..cc40ca5
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/zones/geometry-registry.ts
@@ -0,0 +1,15 @@
+import type { DndLayout } from '../../types.js'
+import type { ZoneGeometry } from './zone-geometry.js'
+import { AxisZoneGeometry } from './geometries/axis-zone-geometry.js'
+import { GridZoneGeometry, type GridFlow } from './geometries/grid-zone-geometry.js'
+
+const VERTICAL_GEOMETRY: ZoneGeometry = new AxisZoneGeometry('vertical')
+const HORIZONTAL_GEOMETRY: ZoneGeometry = new AxisZoneGeometry('horizontal')
+const GRID_ROW_GEOMETRY: ZoneGeometry = new GridZoneGeometry('row')
+const GRID_COLUMN_GEOMETRY: ZoneGeometry = new GridZoneGeometry('column')
+
+export function pickGeometry(layout: DndLayout, flow: GridFlow = 'row'): ZoneGeometry {
+ if (layout === 'grid') return flow === 'column' ? GRID_COLUMN_GEOMETRY : GRID_ROW_GEOMETRY
+ if (layout === 'horizontal') return HORIZONTAL_GEOMETRY
+ return VERTICAL_GEOMETRY
+}
diff --git a/packages/svelte-dnd/src/lib/core/zones/layout-snapshot.ts b/packages/svelte-dnd/src/lib/core/zones/layout-snapshot.ts
new file mode 100644
index 0000000..5bfbae1
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/zones/layout-snapshot.ts
@@ -0,0 +1,78 @@
+import type { Droppable } from '../entities/droppable.svelte.js'
+
+/**
+ * Snapshot of a slot's layout geometry in container content-space.
+ * Coordinates are relative to the container's scroll content, so they stay
+ * valid under container scroll. Captured at drag start before any CSS transforms
+ * exist, so transforms applied during drag do not feed back into calculations.
+ */
+export interface SlotLayoutRect {
+ slotId: string
+ position: number
+ offsetLeft: number
+ offsetTop: number
+ width: number
+ height: number
+}
+
+export interface LayoutSnapshot {
+ containerId: string
+ rects: SlotLayoutRect[]
+ draggedIndex: number
+}
+
+/**
+ * Capture the current layout of a droppable's slots. Must be called at drag start,
+ * before any translations are applied. Uses the slot wrapper element (not the inner
+ * draggable) because the wrapper never receives transforms — its rect is always
+ * the true layout rect.
+ */
+export function captureLayoutSnapshot(
+ droppable: Droppable,
+ draggedId: string | null
+): LayoutSnapshot {
+ const container = droppable.element
+ const containerRect = container.getBoundingClientRect()
+ const scrollLeft = container.scrollLeft
+ const scrollTop = container.scrollTop
+
+ const slots = droppable.getSortedSlots()
+ const rects: SlotLayoutRect[] = []
+ let draggedIndex = -1
+
+ for (const slot of slots) {
+ const r = slot.element.getBoundingClientRect()
+ rects.push({
+ slotId: slot.draggable.id,
+ position: slot.position,
+ offsetLeft: r.left - containerRect.left + scrollLeft,
+ offsetTop: r.top - containerRect.top + scrollTop,
+ width: r.width,
+ height: r.height
+ })
+ if (slot.draggable.id === draggedId) {
+ draggedIndex = rects.length - 1
+ }
+ }
+
+ return { containerId: droppable.id, rects, draggedIndex }
+}
+
+/**
+ * Project a content-space rect back to viewport coordinates using the current
+ * container rect and scroll offset. Used when building DropZones, which need
+ * viewport rects for pointer-collision.
+ */
+export function toViewportRect(
+ rect: SlotLayoutRect,
+ containerRect: DOMRect,
+ scrollLeft: number,
+ scrollTop: number
+): { x: number; y: number; width: number; height: number } {
+ return {
+ x: rect.offsetLeft + containerRect.left - scrollLeft,
+ y: rect.offsetTop + containerRect.top - scrollTop,
+ width: rect.width,
+ height: rect.height
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/zones/sortable-source.ts b/packages/svelte-dnd/src/lib/core/zones/sortable-source.ts
new file mode 100644
index 0000000..5ab6860
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/zones/sortable-source.ts
@@ -0,0 +1,133 @@
+import type { Droppable } from '../entities/droppable.svelte.js'
+import type { SlotLayoutRect, LayoutSnapshot } from './layout-snapshot.js'
+import type { ZoneGeometryContext } from './zone-geometry.js'
+
+/**
+ * Geometry source for a sortable container. Hides the difference between a
+ * DOM-driven layout (every slot mounted) and a virtualized layout (only a
+ * window of slots mounted at a time) behind a uniform contract.
+ *
+ * SortableContainerStrategy reads `visibleRects` to build drop zones and
+ * `mountedSlots` to drive translation shifts; both implementations decide what
+ * "visible" / "mounted" means in their world.
+ */
+export interface SortableSource {
+ readonly containerId: string
+ /** Index of the dragged item in the full source list, or -1 when the drag
+ * came from a different container. Drives the splice-position correction. */
+ readonly draggedIndex: number
+ /** Rects whose viewport projection intersects the container rect, sorted by
+ * position and excluding the dragged slot. The zone geometry consumes this. */
+ visibleRects(ctx: ZoneGeometryContext): SlotLayoutRect[]
+ /** Slot ids + positions for items currently animated as displaced neighbours.
+ * Translations only ever touch mounted DOM elements. */
+ mountedSlots(): { id: string; position: number }[]
+}
+
+/**
+ * Source backed by a one-shot DOM snapshot taken at drag start. All rects are
+ * known up front — `visibleRects` just clips the snapshot to the live viewport,
+ * `mountedSlots` returns every slot in the snapshot.
+ */
+export class DomSortableSource implements SortableSource {
+ constructor(
+ readonly snapshot: LayoutSnapshot,
+ private draggedId: string | null
+ ) {}
+
+ get containerId(): string {
+ return this.snapshot.containerId
+ }
+
+ get draggedIndex(): number {
+ return this.snapshot.draggedIndex
+ }
+
+ visibleRects(ctx: ZoneGeometryContext): SlotLayoutRect[] {
+ const { containerRect, scrollLeft, scrollTop } = ctx
+ return this.snapshot.rects.filter((r) => {
+ if (r.slotId === this.draggedId) return false
+ const vy = r.offsetTop + containerRect.top - scrollTop
+ const vx = r.offsetLeft + containerRect.left - scrollLeft
+ return (
+ vy + r.height > containerRect.top &&
+ vy < containerRect.bottom &&
+ vx + r.width > containerRect.left &&
+ vx < containerRect.right
+ )
+ })
+ }
+
+ mountedSlots(): { id: string; position: number }[] {
+ return this.snapshot.rects.map((r) => ({ id: r.slotId, position: r.position }))
+ }
+}
+
+/**
+ * User-supplied virtualization hooks. All fields are optional today — the MVP
+ * only uses `itemCount` for bounds checks. `getOffset` / `getSize` are part of
+ * the contract for future features (drop into not-yet-mounted gaps, virtual
+ * preview rendering) and can be wired up by callers without affecting current
+ * behavior.
+ */
+export interface VirtualSource {
+ itemCount?: () => number
+ getOffset?: (index: number) => number
+ getSize?: (index: number) => number
+}
+
+/**
+ * Source for virtualized sortable lists. Geometry is read live from each
+ * mounted slot's bounding rect — that lets us work alongside any virtualizer
+ * (virtua, tanstack/virtual, …) without knowing where its scroll container
+ * lives, since the rect is already in viewport coordinates. Slots not mounted
+ * by the virtualizer simply don't contribute zones or translations.
+ */
+export class VirtualSortableSource implements SortableSource {
+ constructor(
+ readonly containerId: string,
+ readonly draggedIndex: number,
+ private droppable: Droppable,
+ private virtual: VirtualSource,
+ private draggedId: string | null
+ ) {}
+
+ visibleRects(ctx: ZoneGeometryContext): SlotLayoutRect[] {
+ const { containerRect } = ctx
+ const slots = this.droppable.getSortedSlots()
+ const itemCount = this.virtual.itemCount?.()
+ const out: SlotLayoutRect[] = []
+ for (const slot of slots) {
+ if (slot.draggable.id === this.draggedId) continue
+ const position = slot.position
+ if (itemCount !== undefined && (position < 0 || position >= itemCount)) continue
+ if (!slot.element) continue
+ const r = slot.element.getBoundingClientRect()
+ if (
+ r.bottom <= containerRect.top ||
+ r.top >= containerRect.bottom ||
+ r.right <= containerRect.left ||
+ r.left >= containerRect.right
+ )
+ continue
+ out.push({
+ slotId: slot.draggable.id,
+ position,
+ offsetLeft: r.left - containerRect.left,
+ offsetTop: r.top - containerRect.top,
+ width: r.width,
+ height: r.height
+ })
+ }
+ out.sort((a, b) => a.position - b.position)
+ return out
+ }
+
+ mountedSlots(): { id: string; position: number }[] {
+ const out: { id: string; position: number }[] = []
+ for (const slot of this.droppable.getSortedSlots()) {
+ out.push({ id: slot.draggable.id, position: slot.position })
+ }
+ return out
+ }
+}
diff --git a/packages/svelte-dnd/src/lib/core/zones/translation-engine.svelte.ts b/packages/svelte-dnd/src/lib/core/zones/translation-engine.svelte.ts
new file mode 100644
index 0000000..a6263b5
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/zones/translation-engine.svelte.ts
@@ -0,0 +1,69 @@
+import type { DndState } from '../dnd/dnd-state.svelte.js'
+import type { Droppable } from '../entities/droppable.svelte.js'
+
+/**
+ * Computes CSS translate offsets for draggable items during an active drag.
+ * Item translations delegate to ContainerStrategy.getTranslations().
+ *
+ * Cross-container overflow is handled by dropTargetPadding: the target DndDroppable
+ * adds margin-bottom/margin-right equal to the effective slot size, so the container
+ * actually grows in layout flow and the browser naturally pushes subsequent siblings down.
+ */
+export class TranslationEngine {
+ constructor(
+ private state: DndState,
+ private droppables: Map
+ ) {}
+
+ translations = $derived.by((): Map => {
+ const map = new Map()
+
+ if (!this.state.dragging || !this.state.session) return map
+
+ const session = this.state.session
+
+ for (const droppable of this.droppables.values()) {
+ const containerTranslations = droppable.strategy.getTranslations(droppable, session)
+ for (const [itemId, offset] of containerTranslations) {
+ map.set(itemId, offset)
+ }
+ }
+
+ return map
+ })
+
+ /**
+ * Extra margin to add to the cross-container drop target so it grows in layout flow,
+ * preventing translated items from visually overflowing into siblings below.
+ * Only set for cross-container drags; null otherwise.
+ */
+ dropTargetPadding = $derived.by((): { containerId: string; x: number; y: number } | null => {
+ if (!this.state.dragging || !this.state.session) return null
+
+ const session = this.state.session
+ const preview = session.dropPreview
+ if (!preview) return null
+ if (session.originContainerId === preview.containerId) return null
+
+ let targetDroppable: Droppable | undefined
+ for (const d of this.droppables.values()) {
+ if (d.id === preview.containerId) {
+ targetDroppable = d
+ break
+ }
+ }
+ if (!targetDroppable) return null
+
+ const slotSize = session.slotSize
+ const layout = targetDroppable.layout
+ const size = layout === 'horizontal' ? (slotSize?.width ?? 0) : (slotSize?.height ?? 0)
+ const ghostSize = session.ghostSize
+ const elementSize = layout === 'horizontal' ? ghostSize.width : ghostSize.height
+ const effectiveSize = size || elementSize
+ if (effectiveSize === 0) return null
+
+ return layout === 'horizontal'
+ ? { containerId: preview.containerId, x: effectiveSize, y: 0 }
+ : { containerId: preview.containerId, x: 0, y: effectiveSize }
+ })
+}
diff --git a/packages/svelte-dnd/src/lib/core/zones/zone-geometry.ts b/packages/svelte-dnd/src/lib/core/zones/zone-geometry.ts
new file mode 100644
index 0000000..fcb89c0
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/core/zones/zone-geometry.ts
@@ -0,0 +1,30 @@
+import type { DropZone } from '../../types.js'
+import type { SlotLayoutRect } from './layout-snapshot.js'
+
+export interface ZoneGeometryContext {
+ containerId: string
+ containerRect: DOMRect
+ scrollLeft: number
+ scrollTop: number
+ /**
+ * Index of the dragged item inside the captured snapshot, or -1 when the
+ * drag originated in a different container. Geometries use it to translate
+ * a slot's full-array `position` into its `splice`-target position in the
+ * array-without-dragged that drop handlers consume.
+ */
+ draggedIndex: number
+}
+
+/**
+ * Builds drop zones from a layout snapshot. A zone geometry only decides
+ * the SHAPE of collision rects for each insertion position — drop logic
+ * (translations, splice math) is direction-agnostic and lives elsewhere.
+ *
+ * Adding a new layout (masonry, square, …) means adding a new implementation,
+ * not touching SortableContainerStrategy.
+ */
+export interface ZoneGeometry {
+ buildZones(visibleRects: SlotLayoutRect[], context: ZoneGeometryContext): DropZone[]
+
+ buildEmptyZone(context: ZoneGeometryContext): DropZone
+}
diff --git a/packages/svelte-dnd/src/lib/index.ts b/packages/svelte-dnd/src/lib/index.ts
new file mode 100644
index 0000000..3134c6d
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/index.ts
@@ -0,0 +1,92 @@
+export { default as DndProvider } from './components/DndProvider.svelte'
+export { default as DndDraggable } from './components/DndDraggable.svelte'
+export { default as DndDroppable } from './components/DndDroppable.svelte'
+export { default as DndPreview } from './components/DndPreview.svelte'
+export { DndController } from './core/dnd/dnd-controller.svelte.js'
+export type { DndControllerConfig } from './core/dnd/dnd-controller.svelte.js'
+export type {
+ AnimationConfig,
+ ResolvedAnimationConfig,
+ Transition,
+ DelayedTransition,
+ DurationOrTransition
+} from './core/animation/animation-config.js'
+export { parseEasing } from './core/animation/easing.js'
+export type { Behavior, BehaviorContext, AutoScrollConfig } from './core/animation/behavior.js'
+export { autoScroll } from './core/animation/behaviors/auto-scroll.js'
+export { scrollSync, type ScrollSyncOptions } from './core/animation/behaviors/scroll-sync.js'
+export type {
+ DropZone,
+ DropPreview,
+ DndLayout,
+ DndMode,
+ DndItemInfo,
+ DndContainerInfo,
+ DragStartEvent,
+ DropEvent,
+ DragEndEvent,
+ DragOverEvent,
+ DropCancelledEvent,
+ DragStartCallback,
+ DragEndCallback,
+ DropCallback,
+ DragOverCallback,
+ DropCancelledCallback,
+ ZonesInvalidatedCallback,
+ GhostSnippet,
+ GhostSnippetProps,
+ Announcements
+} from './types.js'
+export { defaultAnnouncements } from './types.js'
+export type {
+ AnimateItemOptions,
+ AnimateLayoutOptions,
+ ContainerPosition
+} from './core/dnd/dnd-simulator.js'
+export type { DragSource, DragSession } from './core/dnd/drag-session.svelte.js'
+export type { DndState } from './core/dnd/dnd-state.svelte.js'
+export type { Droppable } from './core/entities/droppable.svelte.js'
+export type {
+ ContainerStrategy,
+ StrategyBindContext
+} from './core/containers/strategies/container-strategy.js'
+export {
+ SortableContainerStrategy,
+ sortable,
+ type SortableOptions
+} from './core/containers/strategies/sortable-container-strategy.js'
+export type { VirtualSource, SortableSource } from './core/zones/sortable-source.js'
+export {
+ TargetContainerStrategy,
+ target,
+ type TargetOptions
+} from './core/containers/strategies/target-container-strategy.js'
+export type { GridFlow } from './core/zones/geometries/grid-zone-geometry.js'
+export type { AnimationStep } from './core/animation/steps/animation-step.js'
+export { InstantStep } from './core/animation/steps/animation-step.js'
+export { GhostToTargetStep } from './core/animation/steps/ghost-to-target-step.js'
+export { GhostReturnStep } from './core/animation/steps/ghost-return-step.js'
+export type {
+ SensorDescriptor,
+ SensorActivation,
+ SensorCallbacks,
+ ActivationState,
+ ConditionResult,
+ StartCondition,
+ StartConditionInput,
+ NavigationDirection
+} from './core/sensors/sensor.js'
+export { PointerSensor, type PointerSensorOptions } from './core/sensors/pointer-sensor.js'
+export { KeyboardSensor } from './core/sensors/keyboard-sensor.js'
+export { Distance, Delay } from './core/sensors/activation-constraints.js'
+export type { DistanceConfig, DelayConfig } from './core/sensors/activation-constraints.js'
+export type { CollisionAlgorithm, CollisionContext } from './core/collision/collision-algorithm.js'
+export type { Modifier, ModifierContext } from './core/modifiers/modifier.js'
+export { restrictToVerticalAxis } from './core/modifiers/restrict-to-vertical-axis.js'
+export { restrictToHorizontalAxis } from './core/modifiers/restrict-to-horizontal-axis.js'
+export { restrictToContainer } from './core/modifiers/restrict-to-container.js'
+export { snapToGrid } from './core/modifiers/snap-to-grid.js'
+export { centerPoint } from './core/collision/center-point.js'
+export { cursorOver } from './core/collision/cursor-over.js'
+export { overlap } from './core/collision/overlap.js'
+export { closestCenter } from './core/collision/closest-center.js'
diff --git a/packages/svelte-dnd/src/lib/types.ts b/packages/svelte-dnd/src/lib/types.ts
new file mode 100644
index 0000000..1800737
--- /dev/null
+++ b/packages/svelte-dnd/src/lib/types.ts
@@ -0,0 +1,113 @@
+import type { Snippet } from 'svelte'
+
+export type DndLayout = 'vertical' | 'horizontal' | 'grid'
+
+// --- Rich event types ---
+
+export interface DndItemInfo {
+ id: string
+ data: Record | undefined
+ type: string | undefined
+ element: HTMLElement
+}
+
+export interface DndContainerInfo {
+ id: string
+ data: Record | undefined
+ layout: DndLayout
+ mode: DndMode
+ disabled: boolean
+ accepts: string | string[] | undefined
+ /**
+ * Insertion index within the container.
+ * `0` = before all items, `items.length` = after all items.
+ * On `source`, it's the item's original index; on `target`/`current`, it's where the
+ * item would be inserted. Not an index into your data array — always the slot between items.
+ */
+ position: number
+}
+
+export interface DragStartEvent {
+ item: DndItemInfo
+ source: DndContainerInfo
+}
+
+export interface DropEvent {
+ item: DndItemInfo
+ source: DndContainerInfo
+ target: DndContainerInfo
+}
+
+export interface DragEndEvent {
+ item: DndItemInfo
+ source: DndContainerInfo
+ target: DndContainerInfo | null
+ cancelled: boolean
+}
+
+export interface DragOverEvent {
+ item: DndItemInfo
+ source: DndContainerInfo
+ current: DndContainerInfo
+ previous: DndContainerInfo | null
+}
+
+export interface DropCancelledEvent {
+ item: DndItemInfo
+ source: DndContainerInfo
+}
+
+// --- Callback types ---
+
+export type DragStartCallback = (event: DragStartEvent) => void
+export type DragEndCallback = (event: DragEndEvent) => void
+export type DropCallback = (event: DropEvent) => void
+export type DragOverCallback = (event: DragOverEvent) => void
+export type DropCancelledCallback = (event: DropCancelledEvent) => void
+export type ZonesInvalidatedCallback = () => void
+
+// --- Announcements ---
+
+export interface Announcements {
+ onDragStart?: (event: DragStartEvent) => string
+ onDragOver?: (event: DragOverEvent) => string
+ onDrop?: (event: DropEvent) => string
+ onCancel?: (event: DropCancelledEvent) => string
+}
+
+export const defaultAnnouncements: Announcements = {
+ onDragStart: ({ item }) => `Started dragging item ${item.id}.`,
+ onDragOver: ({ item, current }) =>
+ `Item ${item.id} is over ${current.id} at position ${current.position}.`,
+ onDrop: ({ item, target }) =>
+ `Dropped item ${item.id} into ${target.id} at position ${target.position}.`,
+ onCancel: ({ item }) => `Dragging ${item.id} was cancelled.`
+}
+
+export type DndMode = 'sortable' | 'target' | (string & {})
+
+export interface DropZone {
+ containerId: string
+ position: number
+ layout: DndLayout
+ itemId?: string
+ rect: {
+ x: number
+ y: number
+ width: number
+ height: number
+ }
+}
+
+export interface DropPreview {
+ containerId: string
+ position: number
+}
+
+export interface GhostSnippetProps {
+ element: HTMLElement
+ data?: Record
+ itemId: string
+}
+
+export type GhostSnippet = Snippet<[GhostSnippetProps]>
diff --git a/packages/svelte-dnd/svelte.config.js b/packages/svelte-dnd/svelte.config.js
new file mode 100644
index 0000000..68d13ed
--- /dev/null
+++ b/packages/svelte-dnd/svelte.config.js
@@ -0,0 +1,17 @@
+import adapter from '@sveltejs/adapter-auto'
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ kit: {
+ // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
+ // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
+ // See https://svelte.dev/docs/kit/adapters for more information about adapters.
+ adapter: adapter()
+ },
+ vitePlugin: {
+ dynamicCompileOptions: ({ filename }) =>
+ filename.includes('node_modules') ? undefined : { runes: true }
+ }
+}
+
+export default config
diff --git a/packages/svelte-dnd/tests/animation/animation-config.test.ts b/packages/svelte-dnd/tests/animation/animation-config.test.ts
new file mode 100644
index 0000000..5ff13a1
--- /dev/null
+++ b/packages/svelte-dnd/tests/animation/animation-config.test.ts
@@ -0,0 +1,92 @@
+import { describe, it, expect } from 'vitest'
+import {
+ resolveAnimationConfig,
+ DEFAULT_ANIMATION_CONFIG
+} from '../../src/lib/core/animation/animation-config.js'
+
+describe('resolveAnimationConfig', () => {
+ it('returns all defaults when no config is provided', () => {
+ expect(resolveAnimationConfig()).toEqual(DEFAULT_ANIMATION_CONFIG)
+ })
+
+ it('returns all defaults when an empty config is provided', () => {
+ expect(resolveAnimationConfig({})).toEqual(DEFAULT_ANIMATION_CONFIG)
+ })
+
+ it('shorthand number is normalised to a Transition with default easing', () => {
+ const resolved = resolveAnimationConfig({ drop: 100, layout: 500 })
+ expect(resolved.drop.duration).toBe(100)
+ expect(resolved.drop.easing).toBe(DEFAULT_ANIMATION_CONFIG.drop.easing)
+ expect(resolved.layout.duration).toBe(500)
+ expect(resolved.layout.easing).toBe(DEFAULT_ANIMATION_CONFIG.layout.easing)
+ expect(resolved.return).toEqual(DEFAULT_ANIMATION_CONFIG.return)
+ expect(resolved.slotCollapse).toEqual(DEFAULT_ANIMATION_CONFIG.slotCollapse)
+ })
+
+ it('object form lets you set both duration and easing', () => {
+ const resolved = resolveAnimationConfig({
+ drop: { duration: 400, easing: 'linear' }
+ })
+ expect(resolved.drop).toEqual({ duration: 400, easing: 'linear' })
+ })
+
+ it('object form keeps default easing when only duration is provided', () => {
+ const resolved = resolveAnimationConfig({
+ drop: { duration: 400 }
+ })
+ expect(resolved.drop.duration).toBe(400)
+ expect(resolved.drop.easing).toBe(DEFAULT_ANIMATION_CONFIG.drop.easing)
+ })
+
+ it('treats 0 as an explicit value, not a falsy fallback', () => {
+ const resolved = resolveAnimationConfig({ drop: 0 })
+ expect(resolved.drop.duration).toBe(0)
+ })
+
+ it('preview.show accepts delay alongside duration / easing', () => {
+ const resolved = resolveAnimationConfig({
+ preview: { show: { delay: 50, duration: 300, easing: 'linear' } }
+ })
+ expect(resolved.preview.show).toEqual({ delay: 50, duration: 300, easing: 'linear' })
+ })
+
+ it('preview.hide.delay overrides only delay; duration and easing keep defaults', () => {
+ const resolved = resolveAnimationConfig({
+ preview: {
+ hide: { delay: 500, duration: DEFAULT_ANIMATION_CONFIG.preview.hide.duration }
+ }
+ })
+ expect(resolved.preview.hide.delay).toBe(500)
+ expect(resolved.preview.hide.duration).toBe(DEFAULT_ANIMATION_CONFIG.preview.hide.duration)
+ expect(resolved.preview.hide.easing).toBe(DEFAULT_ANIMATION_CONFIG.preview.hide.easing)
+ })
+
+ it('preview show is independent of hide', () => {
+ const resolved = resolveAnimationConfig({
+ preview: {
+ show: { delay: 100, duration: DEFAULT_ANIMATION_CONFIG.preview.show.duration }
+ }
+ })
+ expect(resolved.preview.show.delay).toBe(100)
+ expect(resolved.preview.hide).toEqual(DEFAULT_ANIMATION_CONFIG.preview.hide)
+ })
+
+ it('deep-merges Transition fields preserving the unspecified half', () => {
+ const resolved = resolveAnimationConfig({
+ siblingShift: { duration: 350 }
+ })
+ expect(resolved.siblingShift.duration).toBe(350)
+ expect(resolved.siblingShift.easing).toBe(DEFAULT_ANIMATION_CONFIG.siblingShift.easing)
+ })
+
+ it('uses a custom base when provided (partial patch on top of current state)', () => {
+ const current = resolveAnimationConfig({
+ drop: { duration: 999, easing: 'linear' },
+ slotCollapse: 555
+ })
+ const patched = resolveAnimationConfig({ drop: 100 }, current)
+ expect(patched.drop.duration).toBe(100)
+ expect(patched.drop.easing).toBe('linear') // preserved from current base
+ expect(patched.slotCollapse.duration).toBe(555)
+ })
+})
diff --git a/packages/svelte-dnd/tests/animation/direction-adapter.test.ts b/packages/svelte-dnd/tests/animation/direction-adapter.test.ts
new file mode 100644
index 0000000..9d39520
--- /dev/null
+++ b/packages/svelte-dnd/tests/animation/direction-adapter.test.ts
@@ -0,0 +1,59 @@
+import { describe, it, expect } from 'vitest'
+import { getDirectionAdapter } from '../../src/lib/core/animation/direction-adapter.js'
+import { setRect, makeElement } from '../helpers/dom.js'
+
+describe('getDirectionAdapter', () => {
+ it('reads/writes scrollTop and uses rect.top/.height for vertical', () => {
+ const adapter = getDirectionAdapter('vertical')
+ const container = makeElement()
+ Object.defineProperty(container, 'scrollTop', {
+ configurable: true,
+ writable: true,
+ value: 30
+ })
+ Object.defineProperty(container, 'scrollLeft', {
+ configurable: true,
+ writable: true,
+ value: 0
+ })
+
+ expect(adapter.getScroll(container)).toBe(30)
+ adapter.setScroll(container, 100)
+ expect(container.scrollTop).toBe(100)
+ expect(container.scrollLeft).toBe(0)
+
+ const rect = makeElement()
+ setRect(rect, { x: 5, y: 50, width: 200, height: 100 })
+ const r = rect.getBoundingClientRect()
+ expect(adapter.getPosition(r)).toBe(50)
+ expect(adapter.getSize(r)).toBe(100)
+ expect(adapter.getEndPosition(r, 100)).toBe(150)
+ })
+
+ it('reads/writes scrollLeft and uses rect.left/.width for horizontal', () => {
+ const adapter = getDirectionAdapter('horizontal')
+ const container = makeElement()
+ Object.defineProperty(container, 'scrollTop', {
+ configurable: true,
+ writable: true,
+ value: 0
+ })
+ Object.defineProperty(container, 'scrollLeft', {
+ configurable: true,
+ writable: true,
+ value: 25
+ })
+
+ expect(adapter.getScroll(container)).toBe(25)
+ adapter.setScroll(container, 80)
+ expect(container.scrollLeft).toBe(80)
+ expect(container.scrollTop).toBe(0)
+
+ const rect = makeElement()
+ setRect(rect, { x: 30, y: 0, width: 150, height: 50 })
+ const r = rect.getBoundingClientRect()
+ expect(adapter.getPosition(r)).toBe(30)
+ expect(adapter.getSize(r)).toBe(150)
+ expect(adapter.getEndPosition(r, 150)).toBe(180)
+ })
+})
diff --git a/packages/svelte-dnd/tests/animation/easing.test.ts b/packages/svelte-dnd/tests/animation/easing.test.ts
new file mode 100644
index 0000000..fde102d
--- /dev/null
+++ b/packages/svelte-dnd/tests/animation/easing.test.ts
@@ -0,0 +1,59 @@
+import { describe, it, expect, vi } from 'vitest'
+import { parseEasing } from '../../src/lib/core/animation/easing.js'
+
+describe('parseEasing', () => {
+ it('linear is identity', () => {
+ const ease = parseEasing('linear')
+ expect(ease(0)).toBe(0)
+ expect(ease(0.5)).toBe(0.5)
+ expect(ease(1)).toBe(1)
+ })
+
+ it('endpoints are exact for any keyword', () => {
+ for (const k of ['ease', 'ease-in', 'ease-out', 'ease-in-out']) {
+ const ease = parseEasing(k)
+ expect(ease(0)).toBeCloseTo(0, 5)
+ expect(ease(1)).toBeCloseTo(1, 5)
+ }
+ })
+
+ it('ease-out decelerates (mid-point above linear)', () => {
+ const ease = parseEasing('ease-out')
+ expect(ease(0.5)).toBeGreaterThan(0.5)
+ })
+
+ it('ease-in accelerates (mid-point below linear)', () => {
+ const ease = parseEasing('ease-in')
+ expect(ease(0.5)).toBeLessThan(0.5)
+ })
+
+ it('cubic-bezier(...) parses arbitrary control points', () => {
+ const ease = parseEasing('cubic-bezier(0.25, 0.1, 0.25, 1)')
+ expect(ease(0)).toBeCloseTo(0, 5)
+ expect(ease(1)).toBeCloseTo(1, 5)
+ expect(ease(0.5)).toBeGreaterThan(0)
+ expect(ease(0.5)).toBeLessThan(1)
+ })
+
+ it('cubic-bezier handles whitespace and casing', () => {
+ const a = parseEasing('CUBIC-BEZIER( 0.25,0.1, 0.25, 1 )')
+ const b = parseEasing('cubic-bezier(0.25, 0.1, 0.25, 1)')
+ expect(a(0.4)).toBeCloseTo(b(0.4), 4)
+ })
+
+ it('clamps inputs outside [0,1]', () => {
+ const ease = parseEasing('ease')
+ expect(ease(-1)).toBe(0)
+ expect(ease(2)).toBe(1)
+ })
+
+ it('falls back to ease-out and warns on invalid input', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const ease = parseEasing('not-a-real-easing')
+ expect(warn).toHaveBeenCalled()
+ expect(ease(0)).toBe(0)
+ expect(ease(1)).toBe(1)
+ expect(ease(0.5)).toBeGreaterThan(0.5) // ease-out shape
+ warn.mockRestore()
+ })
+})
diff --git a/packages/svelte-dnd/tests/animation/scroll-sync-calculator.test.ts b/packages/svelte-dnd/tests/animation/scroll-sync-calculator.test.ts
new file mode 100644
index 0000000..a05c604
--- /dev/null
+++ b/packages/svelte-dnd/tests/animation/scroll-sync-calculator.test.ts
@@ -0,0 +1,128 @@
+import { describe, it, expect } from 'vitest'
+import { ScrollSyncCalculator } from '../../src/lib/core/animation/scroll-sync-calculator.js'
+import { setRect, makeElement, type FakeRect } from '../helpers/dom.js'
+import { scrollableEl } from '../helpers/fixtures.js'
+
+const buildContainer = (rect: FakeRect, scrollTop = 0, scrollLeft = 0) =>
+ scrollableEl(rect, { left: scrollLeft, top: scrollTop })
+
+describe('ScrollSyncCalculator.calculateAdaptiveDuration', () => {
+ const calc = new ScrollSyncCalculator()
+
+ it('clamps to the minimum duration for very short scrolls', () => {
+ expect(calc.calculateAdaptiveDuration(10)).toBe(400)
+ })
+
+ it('clamps to the maximum duration for very long scrolls', () => {
+ expect(calc.calculateAdaptiveDuration(100_000)).toBe(1500)
+ })
+
+ it('scales linearly with distance between the bounds', () => {
+ // 1800 px/sec → 900px takes 500ms → between min(400) and max(1500), so use 500.
+ expect(calc.calculateAdaptiveDuration(900)).toBe(500)
+ })
+})
+
+describe('ScrollSyncCalculator.calculateScrollTarget', () => {
+ const calc = new ScrollSyncCalculator()
+
+ it('returns no scroll change when the preview is already inside the viewport (vertical)', () => {
+ const container = buildContainer({ x: 0, y: 0, width: 200, height: 400 }, 50)
+ const preview = makeElement()
+ setRect(preview, { x: 0, y: 100, width: 100, height: 100 })
+
+ const result = calc.calculateScrollTarget({
+ preview,
+ container,
+ expectedSize: 100,
+ direction: 'vertical'
+ })
+ expect(result.scrollDelta).toBe(0)
+ })
+
+ it('scrolls up when the preview sits above the viewport (vertical)', () => {
+ const container = buildContainer({ x: 0, y: 100, width: 200, height: 400 }, 200)
+ const preview = makeElement()
+ // preview top = 50 < container top 100 → overflow = 50
+ setRect(preview, { x: 0, y: 50, width: 100, height: 100 })
+
+ const result = calc.calculateScrollTarget({
+ preview,
+ container,
+ expectedSize: 100,
+ direction: 'vertical'
+ })
+ expect(result.scrollDelta).toBe(-50)
+ expect(result.targetScroll).toBe(150)
+ })
+
+ it('scrolls down when the preview hangs below the viewport (vertical)', () => {
+ const container = buildContainer({ x: 0, y: 0, width: 200, height: 200 }, 0)
+ const preview = makeElement()
+ // preview bottom = 250 > container bottom 200 → overflow = 50
+ setRect(preview, { x: 0, y: 150, width: 100, height: 100 })
+
+ const result = calc.calculateScrollTarget({
+ preview,
+ container,
+ expectedSize: 100,
+ direction: 'vertical'
+ })
+ expect(result.scrollDelta).toBe(50)
+ expect(result.targetScroll).toBe(50)
+ })
+
+ it('respects the padding option, scrolling extra to keep a gap from the edge', () => {
+ const container = buildContainer({ x: 0, y: 0, width: 200, height: 200 }, 0)
+ const preview = makeElement()
+ // preview bottom = 200 sits exactly on the container edge — without
+ // padding scrollDelta would be 0; with padding 16 we should scroll 16 more.
+ setRect(preview, { x: 0, y: 100, width: 100, height: 100 })
+
+ const result = calc.calculateScrollTarget({
+ preview,
+ container,
+ expectedSize: 100,
+ direction: 'vertical',
+ padding: 16
+ })
+ expect(result.scrollDelta).toBe(16)
+ expect(result.targetScroll).toBe(16)
+ })
+
+ it('clamps the target scroll position to zero (cannot scroll past the start)', () => {
+ const container = buildContainer({ x: 0, y: 0, width: 200, height: 200 }, 30)
+ const preview = makeElement()
+ setRect(preview, { x: 0, y: -100, width: 100, height: 100 })
+
+ const result = calc.calculateScrollTarget({
+ preview,
+ container,
+ expectedSize: 100,
+ direction: 'vertical'
+ })
+ expect(result.targetScroll).toBe(0)
+ })
+})
+
+describe('ScrollSyncCalculator.calculateFinalGhostPosition', () => {
+ const calc = new ScrollSyncCalculator()
+
+ it('subtracts scrollDelta from y for vertical direction', () => {
+ const result = calc.calculateFinalGhostPosition({
+ previewRect: { left: 100, top: 200 } as DOMRect,
+ scrollDelta: 30,
+ direction: 'vertical'
+ })
+ expect(result).toEqual({ x: 100, y: 170 })
+ })
+
+ it('subtracts scrollDelta from x for horizontal direction', () => {
+ const result = calc.calculateFinalGhostPosition({
+ previewRect: { left: 100, top: 200 } as DOMRect,
+ scrollDelta: 30,
+ direction: 'horizontal'
+ })
+ expect(result).toEqual({ x: 70, y: 200 })
+ })
+})
diff --git a/packages/svelte-dnd/tests/collision/collision.test.ts b/packages/svelte-dnd/tests/collision/collision.test.ts
new file mode 100644
index 0000000..2b03a17
--- /dev/null
+++ b/packages/svelte-dnd/tests/collision/collision.test.ts
@@ -0,0 +1,161 @@
+import { describe, it, expect } from 'vitest'
+import { centerPoint } from '../../src/lib/core/collision/center-point.js'
+import { cursorOver } from '../../src/lib/core/collision/cursor-over.js'
+import { overlap } from '../../src/lib/core/collision/overlap.js'
+import { closestCenter } from '../../src/lib/core/collision/closest-center.js'
+import type { DropZone } from '../../src/lib/types.js'
+import type { CollisionContext } from '../../src/lib/core/collision/collision-algorithm.js'
+
+const zone = (
+ containerId: string,
+ position: number,
+ x: number,
+ y: number,
+ w: number,
+ h: number
+): DropZone => ({
+ containerId,
+ position,
+ layout: 'vertical',
+ rect: { x, y, width: w, height: h }
+})
+
+const ctx = (
+ zones: DropZone[],
+ pointer: { x: number; y: number },
+ ghost: { x: number; y: number; width: number; height: number }
+): CollisionContext => ({ zones, pointer, ghost })
+
+describe('centerPoint', () => {
+ const zones = [zone('a', 0, 0, 0, 100, 50), zone('a', 1, 0, 50, 100, 50)]
+
+ it('returns the zone whose rect contains the ghost center', () => {
+ // ghost (10,10) 20x20 → center (20,20) — inside first zone
+ const hit = centerPoint(ctx(zones, { x: 0, y: 0 }, { x: 10, y: 10, width: 20, height: 20 }))
+ expect(hit).toBe(zones[0])
+ })
+
+ it('moves to the next zone when the center crosses the boundary', () => {
+ // center at (50, 60) — inside second zone
+ const hit = centerPoint(ctx(zones, { x: 0, y: 0 }, { x: 40, y: 50, width: 20, height: 20 }))
+ expect(hit).toBe(zones[1])
+ })
+
+ it('returns null when no zone contains the center', () => {
+ const hit = centerPoint(
+ ctx(zones, { x: 0, y: 0 }, { x: 200, y: 200, width: 10, height: 10 })
+ )
+ expect(hit).toBeNull()
+ })
+
+ it('counts touching the right/bottom edge as inside (≤ comparison)', () => {
+ // center exactly on (100, 50) — at the bottom-right corner of zones[0]
+ const hit = centerPoint(ctx(zones, { x: 0, y: 0 }, { x: 90, y: 40, width: 20, height: 20 }))
+ expect(hit).toBe(zones[0])
+ })
+
+ it('ignores pointer position entirely (only ghost rect matters)', () => {
+ const hit = centerPoint(
+ ctx(zones, { x: 999, y: 999 }, { x: 10, y: 10, width: 20, height: 20 })
+ )
+ expect(hit).toBe(zones[0])
+ })
+})
+
+describe('cursorOver', () => {
+ const zones = [zone('a', 0, 0, 0, 100, 50), zone('a', 1, 0, 50, 100, 50)]
+
+ it('returns the zone whose rect contains the pointer', () => {
+ const hit = cursorOver(ctx(zones, { x: 30, y: 20 }, { x: 0, y: 0, width: 0, height: 0 }))
+ expect(hit).toBe(zones[0])
+ })
+
+ it('returns null when the pointer is outside all zones', () => {
+ const hit = cursorOver(ctx(zones, { x: 200, y: 200 }, { x: 0, y: 0, width: 0, height: 0 }))
+ expect(hit).toBeNull()
+ })
+
+ it('ignores ghost size entirely', () => {
+ // Ghost would overlap zones[0] but cursor is in zones[1]
+ const hit = cursorOver(ctx(zones, { x: 30, y: 80 }, { x: 0, y: 0, width: 100, height: 50 }))
+ expect(hit).toBe(zones[1])
+ })
+})
+
+describe('overlap', () => {
+ const zones = [zone('a', 0, 0, 0, 100, 100)]
+
+ it('returns the zone when the ghost overlaps any amount above the threshold', () => {
+ const hit = overlap(0)(ctx(zones, { x: 0, y: 0 }, { x: 50, y: 50, width: 60, height: 60 }))
+ expect(hit).toBe(zones[0])
+ })
+
+ it('returns null when the overlap is smaller than the pixel threshold on either axis', () => {
+ // 5px overlap on both axes — threshold of 10 rejects
+ const hit = overlap(10)(ctx(zones, { x: 0, y: 0 }, { x: 95, y: 95, width: 60, height: 60 }))
+ expect(hit).toBeNull()
+ })
+
+ it('respects a percentage threshold relative to min(ghost.width, ghost.height)', () => {
+ // 50% of min(40, 40) = 20px required. Ghost overlaps zone by 30px on both axes.
+ const hit = overlap('50%')(
+ ctx(zones, { x: 0, y: 0 }, { x: 70, y: 70, width: 40, height: 40 })
+ )
+ expect(hit).toBe(zones[0])
+ })
+
+ it('rejects when percentage threshold is not met on one axis', () => {
+ // 50% of min(40, 40) = 20px. Ghost overlaps 25px horizontally but only 5px vertically.
+ const hit = overlap('50%')(
+ ctx(zones, { x: 0, y: 0 }, { x: 75, y: 95, width: 40, height: 40 })
+ )
+ expect(hit).toBeNull()
+ })
+
+ it('returns the first zone hit when several zones qualify', () => {
+ const multi = [zone('a', 0, 0, 0, 100, 100), zone('a', 1, 0, 0, 200, 200)]
+ const hit = overlap(0)(ctx(multi, { x: 0, y: 0 }, { x: 10, y: 10, width: 50, height: 50 }))
+ expect(hit).toBe(multi[0])
+ })
+
+ it('returns null when there are no zones', () => {
+ expect(
+ overlap(0)(ctx([], { x: 0, y: 0 }, { x: 0, y: 0, width: 50, height: 50 }))
+ ).toBeNull()
+ })
+})
+
+describe('closestCenter', () => {
+ it('picks the zone whose center is nearest to the ghost center', () => {
+ const zones = [
+ zone('a', 0, 0, 0, 100, 100), // center 50,50
+ zone('a', 1, 200, 0, 100, 100), // center 250,50
+ zone('a', 2, 0, 200, 100, 100) // center 50,250
+ ]
+ // Ghost center 60,60 → zones[0] is closest
+ const hit = closestCenter(
+ ctx(zones, { x: 0, y: 0 }, { x: 10, y: 10, width: 100, height: 100 })
+ )
+ expect(hit).toBe(zones[0])
+ })
+
+ it('returns null on an empty zone list', () => {
+ const hit = closestCenter(ctx([], { x: 0, y: 0 }, { x: 0, y: 0, width: 10, height: 10 }))
+ expect(hit).toBeNull()
+ })
+
+ it('breaks ties by preferring the first equidistant zone in the list', () => {
+ const zones = [zone('a', 0, 0, 0, 100, 100), zone('a', 1, 200, 0, 100, 100)]
+ // Ghost center 150,50 — equidistant from both
+ const hit = closestCenter(
+ ctx(zones, { x: 0, y: 0 }, { x: 100, y: 0, width: 100, height: 100 })
+ )
+ expect(hit).toBe(zones[0])
+ })
+
+ it('always returns a zone — even far ones — unlike point-in-rect algorithms', () => {
+ const zones = [zone('a', 0, 1000, 1000, 50, 50)]
+ const hit = closestCenter(ctx(zones, { x: 0, y: 0 }, { x: 0, y: 0, width: 10, height: 10 }))
+ expect(hit).toBe(zones[0])
+ })
+})
diff --git a/packages/svelte-dnd/tests/entities/droppable-accepts.test.ts b/packages/svelte-dnd/tests/entities/droppable-accepts.test.ts
new file mode 100644
index 0000000..bcaf9fa
--- /dev/null
+++ b/packages/svelte-dnd/tests/entities/droppable-accepts.test.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest'
+import { Droppable } from '../../src/lib/core/entities/droppable.svelte.js'
+import { sortable } from '../../src/lib/core/containers/strategies/sortable-container-strategy.js'
+import { noopController } from '../helpers/fixtures.js'
+
+function makeDroppable(accepts?: string | string[]): Droppable {
+ return new Droppable({ id: 'd', strategy: sortable(), accepts }, noopController())
+}
+
+describe('Droppable.acceptsType', () => {
+ it('accepts every type when no filter is configured', () => {
+ const d = makeDroppable()
+ expect(d.acceptsType('task')).toBe(true)
+ expect(d.acceptsType('card')).toBe(true)
+ expect(d.acceptsType(undefined)).toBe(true)
+ })
+
+ it('accepts every untyped item even when a filter is set', () => {
+ const d = makeDroppable('task')
+ expect(d.acceptsType(undefined)).toBe(true)
+ })
+
+ it('matches a single string accept exactly', () => {
+ const d = makeDroppable('task')
+ expect(d.acceptsType('task')).toBe(true)
+ expect(d.acceptsType('card')).toBe(false)
+ })
+
+ it('matches any element of an array accept', () => {
+ const d = makeDroppable(['task', 'card'])
+ expect(d.acceptsType('task')).toBe(true)
+ expect(d.acceptsType('card')).toBe(true)
+ expect(d.acceptsType('column')).toBe(false)
+ })
+})
diff --git a/packages/svelte-dnd/tests/entities/slot.test.ts b/packages/svelte-dnd/tests/entities/slot.test.ts
new file mode 100644
index 0000000..62cf95c
--- /dev/null
+++ b/packages/svelte-dnd/tests/entities/slot.test.ts
@@ -0,0 +1,134 @@
+import { describe, it, expect } from 'vitest'
+import { Slot } from '../../src/lib/core/entities/slot.js'
+import { makeElement, setRect } from '../helpers/dom.js'
+
+interface FakeDroppable {
+ getSlotAt(position: number): Slot | undefined
+ spacing?: number
+ layout: 'vertical' | 'horizontal' | 'grid'
+}
+
+interface SlotFixture {
+ position: number
+ slotRect: { x: number; y: number; width: number; height: number }
+ draggableRect: { x: number; y: number; width: number; height: number }
+}
+
+function buildSlot(droppable: FakeDroppable, fixture: SlotFixture): Slot {
+ const slot = new Slot(fixture.position)
+ slot.element = makeElement()
+ setRect(slot.element, fixture.slotRect)
+
+ const draggableEl = makeElement()
+ setRect(draggableEl, fixture.draggableRect)
+ // Slot only reads .draggable.element offsetWidth/Height, so the minimal stub is enough.
+ slot.draggable = { element: draggableEl } as Slot['draggable']
+
+ slot.droppable = droppable as unknown as Slot['droppable']
+ return slot
+}
+
+describe('Slot.getSize', () => {
+ it('uses the next sibling rect when one exists (vertical step)', () => {
+ const slots = new Map()
+ const droppable: FakeDroppable = {
+ getSlotAt: (p) => slots.get(p),
+ layout: 'vertical'
+ }
+
+ const slot0 = buildSlot(droppable, {
+ position: 0,
+ slotRect: { x: 0, y: 0, width: 100, height: 50 },
+ draggableRect: { x: 0, y: 0, width: 100, height: 40 }
+ })
+ const slot1 = buildSlot(droppable, {
+ position: 1,
+ slotRect: { x: 0, y: 60, width: 100, height: 50 },
+ draggableRect: { x: 0, y: 60, width: 100, height: 40 }
+ })
+ slots.set(0, slot0)
+ slots.set(1, slot1)
+
+ expect(slot0.getSize()).toEqual({ width: 0, height: 60 })
+ })
+
+ it('uses the previous sibling and the actual gap when there is no next slot (vertical)', () => {
+ const slots = new Map()
+ const droppable: FakeDroppable = {
+ getSlotAt: (p) => slots.get(p),
+ layout: 'vertical'
+ }
+
+ const slot0 = buildSlot(droppable, {
+ position: 0,
+ slotRect: { x: 0, y: 0, width: 100, height: 50 },
+ draggableRect: { x: 0, y: 0, width: 100, height: 40 }
+ })
+ const slot1 = buildSlot(droppable, {
+ position: 1,
+ slotRect: { x: 0, y: 60, width: 100, height: 50 },
+ draggableRect: { x: 0, y: 60, width: 100, height: 40 }
+ })
+ slots.set(0, slot0)
+ slots.set(1, slot1)
+
+ // gap = slot1.top(60) - (slot0.top(0) + slot0.draggable.offsetHeight(40)) = 20
+ // height = slot1.draggable.offsetHeight(40) + max(0, 20) = 60
+ expect(slot1.getSize()).toEqual({ width: 100, height: 60 })
+ })
+
+ it('falls back to draggable size + spacing on a vertical-only solitary slot', () => {
+ const slots = new Map()
+ const droppable: FakeDroppable = {
+ getSlotAt: (p) => slots.get(p),
+ layout: 'vertical',
+ spacing: 12
+ }
+
+ const solo = buildSlot(droppable, {
+ position: 0,
+ slotRect: { x: 0, y: 0, width: 100, height: 40 },
+ draggableRect: { x: 0, y: 0, width: 100, height: 40 }
+ })
+ slots.set(0, solo)
+
+ // width takes nothing from spacing in vertical layout; height adds spacing.
+ expect(solo.getSize()).toEqual({ width: 100, height: 52 })
+ })
+
+ it('falls back to draggable size + spacing on a horizontal-only solitary slot', () => {
+ const slots = new Map()
+ const droppable: FakeDroppable = {
+ getSlotAt: (p) => slots.get(p),
+ layout: 'horizontal',
+ spacing: 8
+ }
+
+ const solo = buildSlot(droppable, {
+ position: 0,
+ slotRect: { x: 0, y: 0, width: 60, height: 60 },
+ draggableRect: { x: 0, y: 0, width: 60, height: 60 }
+ })
+ slots.set(0, solo)
+
+ expect(solo.getSize()).toEqual({ width: 68, height: 60 })
+ })
+
+ it('treats undefined spacing as zero on a solitary slot', () => {
+ const slots = new Map()
+ const droppable: FakeDroppable = {
+ getSlotAt: (p) => slots.get(p),
+ layout: 'vertical'
+ // spacing intentionally omitted
+ }
+
+ const solo = buildSlot(droppable, {
+ position: 0,
+ slotRect: { x: 0, y: 0, width: 100, height: 40 },
+ draggableRect: { x: 0, y: 0, width: 100, height: 40 }
+ })
+ slots.set(0, solo)
+
+ expect(solo.getSize()).toEqual({ width: 100, height: 40 })
+ })
+})
diff --git a/packages/svelte-dnd/tests/helpers/dom.ts b/packages/svelte-dnd/tests/helpers/dom.ts
new file mode 100644
index 0000000..9620bac
--- /dev/null
+++ b/packages/svelte-dnd/tests/helpers/dom.ts
@@ -0,0 +1,37 @@
+/**
+ * jsdom does not run a real layout engine, so getBoundingClientRect / offsetWidth
+ * / offsetHeight return zeros. These helpers stamp deterministic geometry onto an
+ * element so layout-driven code paths (Slot.getSize, DropResolver, etc.) can be
+ * exercised in unit tests.
+ */
+
+export interface FakeRect {
+ x: number
+ y: number
+ width: number
+ height: number
+}
+
+export function setRect(el: HTMLElement, rect: FakeRect): void {
+ const { x, y, width, height } = rect
+ const domRect: DOMRect = {
+ x,
+ y,
+ width,
+ height,
+ top: y,
+ left: x,
+ right: x + width,
+ bottom: y + height,
+ toJSON() {
+ return { x, y, width, height, top: y, left: x, right: x + width, bottom: y + height }
+ }
+ }
+ el.getBoundingClientRect = () => domRect
+ Object.defineProperty(el, 'offsetWidth', { configurable: true, value: width })
+ Object.defineProperty(el, 'offsetHeight', { configurable: true, value: height })
+}
+
+export function makeElement(tag = 'div'): HTMLElement {
+ return document.createElement(tag)
+}
diff --git a/packages/svelte-dnd/tests/helpers/fixtures.ts b/packages/svelte-dnd/tests/helpers/fixtures.ts
new file mode 100644
index 0000000..983ce6c
--- /dev/null
+++ b/packages/svelte-dnd/tests/helpers/fixtures.ts
@@ -0,0 +1,131 @@
+import { vi } from 'vitest'
+import { setRect, makeElement, type FakeRect } from './dom.js'
+import type { DroppableControllerRef } from '../../src/lib/core/entities/droppable.svelte.js'
+import type { ZoneGeometryContext } from '../../src/lib/core/zones/zone-geometry.js'
+import type { SlotLayoutRect } from '../../src/lib/core/zones/layout-snapshot.js'
+import type { DropZone, DndLayout } from '../../src/lib/types.js'
+import type { SensorCallbacks } from '../../src/lib/core/sensors/sensor.js'
+
+export function noopController(): DroppableControllerRef {
+ return {
+ session: null,
+ draggedElement: null,
+ cancelSession: () => {},
+ slots: new Map(),
+ onDragStart: () => () => {},
+ onZonesInvalidated: () => () => {},
+ onDragEnd: () => () => {},
+ refreshDroppableZones: () => {},
+ dragging: false
+ }
+}
+
+export function geometryCtx(partial: Partial = {}): ZoneGeometryContext {
+ const containerRect =
+ partial.containerRect ??
+ ({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 200,
+ bottom: 600,
+ width: 200,
+ height: 600,
+ toJSON: () => ({})
+ } as DOMRect)
+ return {
+ containerId: 'list',
+ containerRect,
+ scrollLeft: 0,
+ scrollTop: 0,
+ draggedIndex: -1,
+ ...partial
+ }
+}
+
+export function slotRect(
+ slotId: string,
+ position: number,
+ x: number,
+ y: number,
+ w = 100,
+ h = 50
+): SlotLayoutRect {
+ return { slotId, position, offsetLeft: x, offsetTop: y, width: w, height: h }
+}
+
+export function dropZone(
+ containerId: string,
+ position: number,
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+ layout: DndLayout = 'vertical'
+): DropZone {
+ return { containerId, position, layout, rect: { x, y, width: w, height: h } }
+}
+
+export function scrollableEl(
+ rect: FakeRect,
+ scroll: { left?: number; top?: number } = {}
+): HTMLElement {
+ const el = makeElement()
+ setRect(el, rect)
+ Object.defineProperty(el, 'scrollLeft', {
+ configurable: true,
+ writable: true,
+ value: scroll.left ?? 0
+ })
+ Object.defineProperty(el, 'scrollTop', {
+ configurable: true,
+ writable: true,
+ value: scroll.top ?? 0
+ })
+ return el
+}
+
+export interface PointerEventInit {
+ type?: string
+ clientX?: number
+ clientY?: number
+ button?: number
+ pointerType?: 'mouse' | 'touch' | 'pen'
+ pointerId?: number
+ target?: HTMLElement
+}
+
+export function pointerEvent(opts: PointerEventInit = {}): PointerEvent {
+ const event = new PointerEvent(opts.type ?? 'pointerdown', {
+ clientX: opts.clientX ?? 100,
+ clientY: opts.clientY ?? 100,
+ button: opts.button ?? 0,
+ pointerType: opts.pointerType ?? 'mouse',
+ pointerId: opts.pointerId ?? 1,
+ bubbles: true,
+ cancelable: true
+ })
+ if (opts.target) {
+ Object.defineProperty(event, 'target', { configurable: true, value: opts.target })
+ }
+ return event
+}
+
+export type SpyCallbacks = {
+ onStart: ReturnType>
+ onMove: ReturnType>
+ onEnd: ReturnType>
+ onCancel: ReturnType>
+ onNavigate: ReturnType>>
+}
+
+export function spyCallbacks(): SpyCallbacks {
+ return {
+ onStart: vi.fn(),
+ onMove: vi.fn(),
+ onEnd: vi.fn(),
+ onCancel: vi.fn(),
+ onNavigate: vi.fn>()
+ }
+}
diff --git a/packages/svelte-dnd/tests/modifiers/modifiers.test.ts b/packages/svelte-dnd/tests/modifiers/modifiers.test.ts
new file mode 100644
index 0000000..be257e3
--- /dev/null
+++ b/packages/svelte-dnd/tests/modifiers/modifiers.test.ts
@@ -0,0 +1,104 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { restrictToVerticalAxis } from '../../src/lib/core/modifiers/restrict-to-vertical-axis.js'
+import { restrictToHorizontalAxis } from '../../src/lib/core/modifiers/restrict-to-horizontal-axis.js'
+import { restrictToContainer } from '../../src/lib/core/modifiers/restrict-to-container.js'
+import { snapToGrid } from '../../src/lib/core/modifiers/snap-to-grid.js'
+import type { ModifierContext } from '../../src/lib/core/modifiers/modifier.js'
+import { setRect } from '../helpers/dom.js'
+
+function ctx(partial: Partial): ModifierContext {
+ return {
+ transform: { x: 0, y: 0 },
+ initialTransform: { x: 0, y: 0 },
+ ghostSize: { width: 0, height: 0 },
+ originContainerId: 'container',
+ ...partial
+ }
+}
+
+describe('restrictToVerticalAxis', () => {
+ it('locks x to the initial value while letting y move freely', () => {
+ const result = restrictToVerticalAxis(
+ ctx({
+ transform: { x: 99, y: 50 },
+ initialTransform: { x: 10, y: 0 }
+ })
+ )
+ expect(result).toEqual({ x: 10, y: 50 })
+ })
+})
+
+describe('restrictToHorizontalAxis', () => {
+ it('locks y to the initial value while letting x move freely', () => {
+ const result = restrictToHorizontalAxis(
+ ctx({
+ transform: { x: 50, y: 99 },
+ initialTransform: { x: 0, y: 20 }
+ })
+ )
+ expect(result).toEqual({ x: 50, y: 20 })
+ })
+})
+
+describe('restrictToContainer', () => {
+ let container: HTMLElement
+
+ beforeEach(() => {
+ container = document.createElement('div')
+ container.setAttribute('data-dnd-drop-id', 'container')
+ setRect(container, { x: 100, y: 200, width: 400, height: 300 })
+ document.body.appendChild(container)
+ })
+
+ afterEach(() => {
+ container.remove()
+ })
+
+ it('clamps the transform inside the container bounds accounting for ghost size', () => {
+ const result = restrictToContainer(
+ ctx({
+ transform: { x: 1000, y: 1000 },
+ ghostSize: { width: 50, height: 40 }
+ })
+ )
+ expect(result).toEqual({ x: 100 + 400 - 50, y: 200 + 300 - 40 })
+ })
+
+ it('clamps the transform up to the top-left corner', () => {
+ const result = restrictToContainer(
+ ctx({
+ transform: { x: -500, y: -500 },
+ ghostSize: { width: 50, height: 40 }
+ })
+ )
+ expect(result).toEqual({ x: 100, y: 200 })
+ })
+
+ it('returns the transform unchanged when the container element cannot be found', () => {
+ const result = restrictToContainer(
+ ctx({
+ transform: { x: 9999, y: 9999 },
+ ghostSize: { width: 50, height: 50 },
+ originContainerId: 'missing'
+ })
+ )
+ expect(result).toEqual({ x: 9999, y: 9999 })
+ })
+})
+
+describe('snapToGrid', () => {
+ it('snaps both axes to a single grid size when given a number', () => {
+ const modifier = snapToGrid(20)
+ expect(modifier(ctx({ transform: { x: 23, y: 31 } }))).toEqual({ x: 20, y: 40 })
+ })
+
+ it('uses independent x and y grid sizes when given an object', () => {
+ const modifier = snapToGrid({ x: 10, y: 25 })
+ expect(modifier(ctx({ transform: { x: 47, y: 38 } }))).toEqual({ x: 50, y: 50 })
+ })
+
+ it('rounds half-step values upward (Math.round behaviour)', () => {
+ const modifier = snapToGrid(10)
+ expect(modifier(ctx({ transform: { x: 5, y: 5 } }))).toEqual({ x: 10, y: 10 })
+ })
+})
diff --git a/packages/svelte-dnd/tests/sensors/activation-constraints.test.ts b/packages/svelte-dnd/tests/sensors/activation-constraints.test.ts
new file mode 100644
index 0000000..78fcab9
--- /dev/null
+++ b/packages/svelte-dnd/tests/sensors/activation-constraints.test.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect } from 'vitest'
+import { Distance, Delay } from '../../src/lib/core/sensors/activation-constraints.js'
+import type { ActivationState } from '../../src/lib/core/sensors/sensor.js'
+
+function state(partial: Partial): ActivationState {
+ return {
+ startX: 0,
+ startY: 0,
+ currentX: 0,
+ currentY: 0,
+ elapsedMs: 0,
+ pointerType: 'mouse',
+ ...partial
+ }
+}
+
+describe('Distance', () => {
+ it('returns pending while distance is below the threshold', () => {
+ const cond = new Distance({ value: 10 })
+ expect(cond.evaluate(state({ currentX: 5 }))).toBe('pending')
+ })
+
+ it('returns satisfied once the diagonal distance reaches the threshold', () => {
+ const cond = new Distance({ value: 5 })
+ expect(cond.evaluate(state({ currentX: 3, currentY: 4 }))).toBe('satisfied')
+ })
+
+ it('aborts when tolerance is exceeded before the threshold is met', () => {
+ const cond = new Distance({ value: 100, tolerance: 10 })
+ expect(cond.evaluate(state({ currentX: 20 }))).toBe('aborted')
+ })
+
+ it('does not require a hold duration', () => {
+ expect(new Distance({ value: 10 }).getRequiredDuration()).toBeNull()
+ })
+})
+
+describe('Delay', () => {
+ it('returns pending while elapsed time is below the threshold', () => {
+ const cond = new Delay({ value: 200 })
+ expect(cond.evaluate(state({ elapsedMs: 100 }))).toBe('pending')
+ })
+
+ it('returns satisfied once the elapsed time reaches the threshold', () => {
+ const cond = new Delay({ value: 200 })
+ expect(cond.evaluate(state({ elapsedMs: 200 }))).toBe('satisfied')
+ })
+
+ it('aborts when the pointer drifts past the tolerance during the wait', () => {
+ const cond = new Delay({ value: 500, tolerance: 5 })
+ expect(cond.evaluate(state({ currentX: 8, elapsedMs: 100 }))).toBe('aborted')
+ })
+
+ it('reports the configured wait as its required duration', () => {
+ expect(new Delay({ value: 250 }).getRequiredDuration()).toBe(250)
+ })
+})
diff --git a/packages/svelte-dnd/tests/sensors/keyboard-sensor.test.ts b/packages/svelte-dnd/tests/sensors/keyboard-sensor.test.ts
new file mode 100644
index 0000000..a825beb
--- /dev/null
+++ b/packages/svelte-dnd/tests/sensors/keyboard-sensor.test.ts
@@ -0,0 +1,120 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { KeyboardSensor } from '../../src/lib/core/sensors/keyboard-sensor.js'
+import { setRect, makeElement } from '../helpers/dom.js'
+import { spyCallbacks } from '../helpers/fixtures.js'
+
+let element: HTMLElement
+beforeEach(() => {
+ element = makeElement()
+ setRect(element, { x: 100, y: 80, width: 200, height: 60 })
+ document.body.appendChild(element)
+})
+
+async function flushTimers() {
+ await new Promise((resolve) => setTimeout(resolve, 1))
+}
+
+describe('KeyboardSensor.activate — guards', () => {
+ it('returns null for non-keyboard events', () => {
+ const sensor = new KeyboardSensor()
+ const result = sensor.activate(new MouseEvent('click'), element, spyCallbacks())
+ expect(result).toBeNull()
+ })
+
+ it('returns null for keys other than Enter or Space', () => {
+ const sensor = new KeyboardSensor()
+ const event = new KeyboardEvent('keydown', { key: 'Tab' })
+ expect(sensor.activate(event, element, spyCallbacks())).toBeNull()
+ })
+})
+
+describe('KeyboardSensor.activate — lifecycle', () => {
+ it('starts the drag immediately on Enter and reports the element top-left as initialTransform', () => {
+ const sensor = new KeyboardSensor()
+ const callbacks = spyCallbacks()
+ const event = new KeyboardEvent('keydown', { key: 'Enter' })
+
+ const result = sensor.activate(event, element, callbacks)
+
+ expect(result).not.toBeNull()
+ expect(result!.initialTransform).toEqual({ x: 100, y: 80 })
+ // offset = element center
+ expect(result!.offset).toEqual({ x: 100, y: 30 })
+ expect(callbacks.onStart).toHaveBeenCalledTimes(1)
+ expect(callbacks.onStart).toHaveBeenCalledWith({ x: 100, y: 80 })
+ })
+
+ it('also activates on Space', () => {
+ const sensor = new KeyboardSensor()
+ const callbacks = spyCallbacks()
+ const event = new KeyboardEvent('keydown', { key: ' ' })
+
+ expect(sensor.activate(event, element, callbacks)).not.toBeNull()
+ expect(callbacks.onStart).toHaveBeenCalledTimes(1)
+ })
+
+ it('translates ArrowDown into an onNavigate("down") callback', async () => {
+ const sensor = new KeyboardSensor()
+ const callbacks = spyCallbacks()
+ sensor.activate(new KeyboardEvent('keydown', { key: 'Enter' }), element, callbacks)
+ await flushTimers()
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
+ expect(callbacks.onNavigate).toHaveBeenCalledTimes(1)
+ expect(callbacks.onNavigate).toHaveBeenCalledWith('down')
+ })
+
+ it('maps every arrow direction', async () => {
+ const sensor = new KeyboardSensor()
+ const callbacks = spyCallbacks()
+ sensor.activate(new KeyboardEvent('keydown', { key: 'Enter' }), element, callbacks)
+ await flushTimers()
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }))
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
+ expect(callbacks.onNavigate.mock.calls.map((c) => c[0])).toEqual(['up', 'left', 'right'])
+ })
+
+ it('calls onCancel when Escape is pressed and stops listening afterwards', async () => {
+ const sensor = new KeyboardSensor()
+ const callbacks = spyCallbacks()
+ sensor.activate(new KeyboardEvent('keydown', { key: 'Enter' }), element, callbacks)
+ await flushTimers()
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+ expect(callbacks.onCancel).toHaveBeenCalledTimes(1)
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
+ expect(callbacks.onNavigate).not.toHaveBeenCalled()
+ })
+
+ it('calls onEnd when Enter is pressed a second time and unbinds the listener', async () => {
+ const sensor = new KeyboardSensor()
+ const callbacks = spyCallbacks()
+ sensor.activate(new KeyboardEvent('keydown', { key: 'Enter' }), element, callbacks)
+ await flushTimers()
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
+ expect(callbacks.onEnd).toHaveBeenCalledTimes(1)
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
+ expect(callbacks.onNavigate).not.toHaveBeenCalled()
+ })
+
+ it('destroy() cancels the deferred listener so a follow-up Enter does not fire onEnd', () => {
+ const sensor = new KeyboardSensor()
+ const callbacks = spyCallbacks()
+ const result = sensor.activate(
+ new KeyboardEvent('keydown', { key: 'Enter' }),
+ element,
+ callbacks
+ )!
+
+ // destroy before the deferred setTimeout(0) attaches the window listener
+ result.destroy()
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
+ expect(callbacks.onEnd).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/svelte-dnd/tests/sensors/pointer-sensor.test.ts b/packages/svelte-dnd/tests/sensors/pointer-sensor.test.ts
new file mode 100644
index 0000000..31f02ff
--- /dev/null
+++ b/packages/svelte-dnd/tests/sensors/pointer-sensor.test.ts
@@ -0,0 +1,139 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { PointerSensor } from '../../src/lib/core/sensors/pointer-sensor.js'
+import { setRect, makeElement } from '../helpers/dom.js'
+import { pointerEvent, spyCallbacks } from '../helpers/fixtures.js'
+
+let element: HTMLElement
+beforeEach(() => {
+ element = makeElement()
+ setRect(element, { x: 50, y: 50, width: 200, height: 100 })
+ document.body.appendChild(element)
+})
+
+describe('PointerSensor.activate — guards', () => {
+ it('returns null when the event is not a PointerEvent', () => {
+ const sensor = new PointerSensor()
+ const result = sensor.activate(new MouseEvent('mousedown'), element, spyCallbacks())
+ expect(result).toBeNull()
+ })
+
+ it('returns null for non-primary mouse buttons', () => {
+ const sensor = new PointerSensor()
+ const event = pointerEvent({ button: 2, target: element })
+ expect(sensor.activate(event, element, spyCallbacks())).toBeNull()
+ })
+
+ it('returns null when the pointer is outside the padding box', () => {
+ const sensor = new PointerSensor()
+ // container goes from x=50..250, y=50..150. Click at (300, 300) is outside.
+ const event = pointerEvent({ clientX: 300, clientY: 300, target: element })
+ expect(sensor.activate(event, element, spyCallbacks())).toBeNull()
+ })
+
+ it('returns null when the pointerdown lands on a data-dnd-no-drag descendant', () => {
+ const sensor = new PointerSensor()
+ const child = document.createElement('button')
+ child.setAttribute('data-dnd-no-drag', '')
+ element.appendChild(child)
+
+ const event = pointerEvent({ target: child })
+ expect(sensor.activate(event, element, spyCallbacks())).toBeNull()
+ })
+
+ it('returns null when the element has handles but the target is not inside one', () => {
+ const sensor = new PointerSensor()
+ const handle = document.createElement('span')
+ handle.setAttribute('data-dnd-handle', '')
+ element.appendChild(handle)
+ const other = document.createElement('span')
+ element.appendChild(other)
+
+ const event = pointerEvent({ target: other })
+ expect(sensor.activate(event, element, spyCallbacks())).toBeNull()
+ })
+
+ it('activates when the pointer hits an element-with-handle through that handle', () => {
+ const sensor = new PointerSensor()
+ const handle = document.createElement('span')
+ handle.setAttribute('data-dnd-handle', '')
+ setRect(handle, { x: 50, y: 50, width: 30, height: 30 })
+ element.appendChild(handle)
+
+ const event = pointerEvent({ target: handle, clientX: 60, clientY: 60 })
+ const result = sensor.activate(event, element, spyCallbacks())
+ expect(result).not.toBeNull()
+ })
+})
+
+describe('PointerSensor.activate — activation lifecycle', () => {
+ it('returns an activation with offset and initialTransform anchored to the click point', () => {
+ const sensor = new PointerSensor()
+ const event = pointerEvent({ target: element, clientX: 80, clientY: 70 })
+ const result = sensor.activate(event, element, spyCallbacks())
+
+ expect(result).not.toBeNull()
+ // offset = clientX/Y - rect.left/top
+ expect(result!.offset).toEqual({ x: 30, y: 20 })
+ // initialTransform = client - offset → element top-left
+ expect(result!.initialTransform).toEqual({ x: 50, y: 50 })
+ })
+
+ it('does not call onStart on a tiny mouse move below the 5px threshold', () => {
+ const sensor = new PointerSensor()
+ const callbacks = spyCallbacks()
+ const event = pointerEvent({ target: element, clientX: 100, clientY: 100 })
+ sensor.activate(event, element, callbacks)
+
+ element.dispatchEvent(pointerEvent({ type: 'pointermove', clientX: 102, clientY: 101 }))
+ expect(callbacks.onStart).not.toHaveBeenCalled()
+ })
+
+ it('calls onStart once a mouse move exceeds the 5px Distance default', () => {
+ const sensor = new PointerSensor()
+ const callbacks = spyCallbacks()
+ const event = pointerEvent({ target: element, clientX: 100, clientY: 100 })
+ sensor.activate(event, element, callbacks)
+
+ element.dispatchEvent(pointerEvent({ type: 'pointermove', clientX: 110, clientY: 100 }))
+ expect(callbacks.onStart).toHaveBeenCalledTimes(1)
+ // transform = client - offset = (110 - 50, 100 - 50) = (60, 50)
+ expect(callbacks.onStart).toHaveBeenCalledWith({ x: 60, y: 50 })
+ })
+
+ it('routes subsequent window pointermove events to onMove after start', () => {
+ const sensor = new PointerSensor()
+ const callbacks = spyCallbacks()
+ const event = pointerEvent({ target: element, clientX: 100, clientY: 100 })
+ sensor.activate(event, element, callbacks)
+
+ // Trigger start
+ element.dispatchEvent(pointerEvent({ type: 'pointermove', clientX: 110, clientY: 100 }))
+ callbacks.onMove.mockClear()
+ // Now window-level moves should propagate
+ window.dispatchEvent(pointerEvent({ type: 'pointermove', clientX: 120, clientY: 130 }))
+ expect(callbacks.onMove).toHaveBeenCalledTimes(1)
+ expect(callbacks.onMove).toHaveBeenCalledWith({ x: 70, y: 80 }, 120, 130)
+ })
+
+ it('calls onEnd on a window pointerup after the drag has started', () => {
+ const sensor = new PointerSensor()
+ const callbacks = spyCallbacks()
+ const event = pointerEvent({ target: element, clientX: 100, clientY: 100 })
+ sensor.activate(event, element, callbacks)
+
+ element.dispatchEvent(pointerEvent({ type: 'pointermove', clientX: 110, clientY: 100 }))
+ window.dispatchEvent(pointerEvent({ type: 'pointerup' }))
+ expect(callbacks.onEnd).toHaveBeenCalledTimes(1)
+ })
+
+ it('destroy() removes element listeners — subsequent moves do not fire onStart', () => {
+ const sensor = new PointerSensor()
+ const callbacks = spyCallbacks()
+ const event = pointerEvent({ target: element, clientX: 100, clientY: 100 })
+ const result = sensor.activate(event, element, callbacks)!
+
+ result.destroy()
+ element.dispatchEvent(pointerEvent({ type: 'pointermove', clientX: 200, clientY: 200 }))
+ expect(callbacks.onStart).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/svelte-dnd/tests/setup.ts b/packages/svelte-dnd/tests/setup.ts
new file mode 100644
index 0000000..16b301b
--- /dev/null
+++ b/packages/svelte-dnd/tests/setup.ts
@@ -0,0 +1,26 @@
+// jsdom 29 ships without CSS.escape — polyfill it for tests that exercise
+// DOMHelper selectors (CSS.escape escapes special characters in CSS selectors).
+if (typeof globalThis.CSS === 'undefined' || typeof globalThis.CSS.escape !== 'function') {
+ const cssShim = {
+ escape(value: string): string {
+ return String(value).replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, '\\$&')
+ }
+ }
+ ;(globalThis as unknown as { CSS: typeof globalThis.CSS }).CSS =
+ cssShim as unknown as typeof globalThis.CSS
+}
+
+// jsdom 29 also lacks the Pointer Capture APIs that PointerSensor calls.
+// The library only uses these for browser pointer routing, so a no-op shim
+// is sufficient under jsdom.
+if (typeof Element.prototype.hasPointerCapture !== 'function') {
+ Element.prototype.hasPointerCapture = function () {
+ return false
+ }
+}
+if (typeof Element.prototype.setPointerCapture !== 'function') {
+ Element.prototype.setPointerCapture = function () {}
+}
+if (typeof Element.prototype.releasePointerCapture !== 'function') {
+ Element.prototype.releasePointerCapture = function () {}
+}
diff --git a/packages/svelte-dnd/tests/strategies/sortable-translations.test.ts b/packages/svelte-dnd/tests/strategies/sortable-translations.test.ts
new file mode 100644
index 0000000..b4332a5
--- /dev/null
+++ b/packages/svelte-dnd/tests/strategies/sortable-translations.test.ts
@@ -0,0 +1,177 @@
+import { describe, it, expect } from 'vitest'
+import { sortable } from '../../src/lib/core/containers/strategies/sortable-container-strategy.js'
+import type { DragSession } from '../../src/lib/core/dnd/drag-session.svelte.js'
+import type { Droppable } from '../../src/lib/core/entities/droppable.svelte.js'
+import type { LayoutSnapshot } from '../../src/lib/core/zones/layout-snapshot.js'
+import { DomSortableSource } from '../../src/lib/core/zones/sortable-source.js'
+import { slotRect } from '../helpers/fixtures.js'
+
+const rect = (slotId: string, position: number, x: number, y: number, w = 100, h = 50) =>
+ slotRect(slotId, position, x, y, w, h)
+
+interface FakeSessionInit {
+ snapshot: LayoutSnapshot
+ dropPreview: { containerId: string; position: number } | null
+ slotSize: { width: number; height: number } | null
+ originContainerId: string
+ itemId?: string
+}
+
+function fakeSession(init: FakeSessionInit): DragSession {
+ const itemId = init.itemId ?? 'dragged'
+ const source = new DomSortableSource(init.snapshot, itemId)
+ return {
+ getSource: (id: string) =>
+ id === init.originContainerId || init.dropPreview?.containerId === id
+ ? source
+ : undefined,
+ dropPreview: init.dropPreview,
+ slotSize: init.slotSize,
+ originContainerId: init.originContainerId,
+ itemId
+ } as unknown as DragSession
+}
+
+const droppable = (id: string) => ({ id }) as unknown as Droppable
+
+describe('SortableContainerStrategy.getTranslations — vertical', () => {
+ const strategy = sortable({ layout: 'vertical' })
+ const snapshot: LayoutSnapshot = {
+ containerId: 'list',
+ rects: [rect('a', 0, 0, 0), rect('b', 1, 0, 60), rect('c', 2, 0, 120)],
+ draggedIndex: 2
+ }
+
+ it('shifts earlier items forward when the drag moves to an earlier position', () => {
+ const session = fakeSession({
+ snapshot,
+ dropPreview: { containerId: 'list', position: 0 },
+ slotSize: { width: 100, height: 60 },
+ originContainerId: 'list'
+ })
+
+ const map = strategy.getTranslations(droppable('list'), session)
+
+ // Items in [P..D-1] = [0..1] shift forward by the dragged slot's step (60).
+ expect(map.get('a')).toEqual({ x: 0, y: 60 })
+ expect(map.get('b')).toEqual({ x: 0, y: 60 })
+ expect(map.has('c')).toBe(false)
+ })
+
+ it('shifts later items backward when the drag moves to a later position', () => {
+ const earlySnapshot: LayoutSnapshot = {
+ containerId: 'list',
+ rects: [rect('a', 0, 0, 0), rect('b', 1, 0, 60), rect('c', 2, 0, 120)],
+ draggedIndex: 0
+ }
+ const session = fakeSession({
+ snapshot: earlySnapshot,
+ dropPreview: { containerId: 'list', position: 2 },
+ slotSize: { width: 100, height: 60 },
+ originContainerId: 'list'
+ })
+
+ const map = strategy.getTranslations(droppable('list'), session)
+
+ // targetIdx = P+1=3, items in (D..targetIdx) = (0..3) -> indices 1,2 shift back.
+ expect(map.get('b')).toEqual({ x: 0, y: -60 })
+ expect(map.get('c')).toEqual({ x: 0, y: -60 })
+ expect(map.has('a')).toBe(false)
+ })
+
+ it('collapses the gap behind the dragged item when there is no drop preview', () => {
+ const session = fakeSession({
+ snapshot,
+ dropPreview: null,
+ slotSize: { width: 100, height: 60 },
+ originContainerId: 'list'
+ })
+
+ const map = strategy.getTranslations(droppable('list'), session)
+
+ // D=2, items at indices > 2 shift back by step. There are none, so map stays empty.
+ expect(map.size).toBe(0)
+ })
+
+ it('collapses the origin gap when hovering a different container', () => {
+ const earlySnapshot: LayoutSnapshot = {
+ containerId: 'list',
+ rects: [rect('a', 0, 0, 0), rect('b', 1, 0, 60), rect('c', 2, 0, 120)],
+ draggedIndex: 0
+ }
+ const session = fakeSession({
+ snapshot: earlySnapshot,
+ dropPreview: { containerId: 'other', position: 0 },
+ slotSize: { width: 100, height: 60 },
+ originContainerId: 'list'
+ })
+
+ const map = strategy.getTranslations(droppable('list'), session)
+
+ // Source container collapses items at index > D back by step.
+ expect(map.get('b')).toEqual({ x: 0, y: -60 })
+ expect(map.get('c')).toEqual({ x: 0, y: -60 })
+ expect(map.has('a')).toBe(false)
+ })
+
+ it('shifts target items forward when the dragged item arrives from another container', () => {
+ const targetSnapshot: LayoutSnapshot = {
+ containerId: 'target',
+ rects: [rect('x', 0, 0, 0), rect('y', 1, 0, 60), rect('z', 2, 0, 120)],
+ draggedIndex: -1 // dragged item is not native to this snapshot
+ }
+ const session = fakeSession({
+ snapshot: targetSnapshot,
+ dropPreview: { containerId: 'target', position: 1 },
+ slotSize: { width: 100, height: 60 },
+ originContainerId: 'source',
+ itemId: 'dragged'
+ })
+
+ const map = strategy.getTranslations(droppable('target'), session)
+
+ // Items at positions P..end (1..2) shift forward by step.
+ expect(map.get('y')).toEqual({ x: 0, y: 60 })
+ expect(map.get('z')).toEqual({ x: 0, y: 60 })
+ expect(map.has('x')).toBe(false)
+ })
+
+ it('returns an empty map when slotSize is missing', () => {
+ const session = fakeSession({
+ snapshot,
+ dropPreview: { containerId: 'list', position: 0 },
+ slotSize: null,
+ originContainerId: 'list'
+ })
+
+ const map = strategy.getTranslations(droppable('list'), session)
+ expect(map.size).toBe(0)
+ })
+})
+
+describe('SortableContainerStrategy.getTranslations — horizontal', () => {
+ const strategy = sortable({ layout: 'horizontal' })
+
+ it('shifts later items backward along the x axis', () => {
+ const snapshot: LayoutSnapshot = {
+ containerId: 'row',
+ rects: [
+ rect('a', 0, 0, 0, 80, 50),
+ rect('b', 1, 90, 0, 80, 50),
+ rect('c', 2, 180, 0, 80, 50)
+ ],
+ draggedIndex: 0
+ }
+ const session = fakeSession({
+ snapshot,
+ dropPreview: { containerId: 'row', position: 2 },
+ slotSize: { width: 90, height: 50 },
+ originContainerId: 'row'
+ })
+
+ const map = strategy.getTranslations(droppable('row'), session)
+ expect(map.get('b')).toEqual({ x: -90, y: 0 })
+ expect(map.get('c')).toEqual({ x: -90, y: 0 })
+ expect(map.has('a')).toBe(false)
+ })
+})
diff --git a/packages/svelte-dnd/tests/strategies/sortable-virtual.test.ts b/packages/svelte-dnd/tests/strategies/sortable-virtual.test.ts
new file mode 100644
index 0000000..806588a
--- /dev/null
+++ b/packages/svelte-dnd/tests/strategies/sortable-virtual.test.ts
@@ -0,0 +1,178 @@
+import { describe, it, expect } from 'vitest'
+import { sortable } from '../../src/lib/core/containers/strategies/sortable-container-strategy.js'
+import type { DragSession } from '../../src/lib/core/dnd/drag-session.svelte.js'
+import type { Droppable } from '../../src/lib/core/entities/droppable.svelte.js'
+import type { Slot } from '../../src/lib/core/entities/slot.js'
+import type { VirtualSource } from '../../src/lib/core/zones/sortable-source.js'
+import { scrollableEl } from '../helpers/fixtures.js'
+import { setRect, makeElement } from '../helpers/dom.js'
+
+interface MountedSlotInit {
+ id: string
+ position: number
+ rect?: { x: number; y: number; width: number; height: number }
+}
+
+const mountedSlot = ({ id, position, rect }: MountedSlotInit): Slot => {
+ const element = makeElement()
+ if (rect) setRect(element, rect)
+ return { position, draggable: { id }, element } as unknown as Slot
+}
+
+const droppableWith = (
+ id: string,
+ rect: { x: number; y: number; width: number; height: number },
+ slots: Slot[]
+) => {
+ const element = scrollableEl(rect)
+ return {
+ id,
+ element,
+ getSortedSlots: () => slots
+ } as unknown as Droppable
+}
+
+const itemSize = 50
+const itemCount = 5000
+const virtual: VirtualSource = {
+ itemCount: () => itemCount,
+ getOffset: (i: number) => i * itemSize,
+ getSize: () => itemSize
+}
+
+interface SessionInit {
+ itemId: string
+ originContainerId?: string
+ originPosition?: number
+ dropPreview?: { containerId: string; position: number } | null
+ slotSize?: { width: number; height: number } | null
+}
+
+function makeSession(init: SessionInit): DragSession {
+ const sources = new Map()
+ return {
+ setSource: (id: string, src: unknown) => sources.set(id, src),
+ getSource: (id: string) => sources.get(id),
+ itemId: init.itemId,
+ originContainerId: init.originContainerId ?? 'virt',
+ originPosition: init.originPosition ?? 0,
+ dropPreview: init.dropPreview ?? null,
+ slotSize: init.slotSize ?? null
+ } as unknown as DragSession
+}
+
+describe('SortableContainerStrategy.calculateDropZones — virtual mode', () => {
+ const strategy = sortable({ layout: 'vertical', virtual })
+
+ it('emits zones only for slots mounted in the visible viewport window', () => {
+ // Container 200x400 at viewport (0, 0). 8 mounted slots stack inside the viewport
+ // — that's what virtua would render at this scroll position.
+ const slots = [100, 101, 102, 103, 104, 105, 106, 107].map((p, i) =>
+ mountedSlot({
+ id: `item-${p}`,
+ position: p,
+ rect: { x: 0, y: i * itemSize, width: 200, height: itemSize }
+ })
+ )
+ const droppable = droppableWith('virt', { x: 0, y: 0, width: 200, height: 400 }, slots)
+ const session = makeSession({ itemId: 'none', originContainerId: 'other' })
+
+ strategy.onSessionStart(droppable, session)
+
+ const zones = strategy.calculateDropZones(droppable, session)
+ const positions = zones.map((z) => z.position).sort((a, b) => a - b)
+
+ expect(positions[0]).toBe(100)
+ expect(positions[positions.length - 1]).toBe(108)
+ })
+
+ it('drops zones for slots whose rect falls outside the container viewport', () => {
+ // item-100 is mounted but offscreen (e.g. virtua keeps it via keepMounted).
+ // Two more slots are inside the viewport.
+ const slots = [
+ mountedSlot({
+ id: 'item-100',
+ position: 100,
+ rect: { x: 0, y: -200, width: 200, height: itemSize }
+ }),
+ mountedSlot({
+ id: 'item-201',
+ position: 201,
+ rect: { x: 0, y: 0, width: 200, height: itemSize }
+ }),
+ mountedSlot({
+ id: 'item-202',
+ position: 202,
+ rect: { x: 0, y: itemSize, width: 200, height: itemSize }
+ })
+ ]
+ const droppable = droppableWith('virt', { x: 0, y: 0, width: 200, height: 400 }, slots)
+ const session = makeSession({ itemId: 'none', originContainerId: 'other' })
+
+ strategy.onSessionStart(droppable, session)
+ const zones = strategy.calculateDropZones(droppable, session)
+ const positions = new Set(zones.map((z) => z.position))
+
+ // item-100 is offscreen, so position 100 is unreachable from this viewport.
+ expect(positions.has(100)).toBe(false)
+ expect(positions.has(201)).toBe(true)
+ expect(positions.has(203)).toBe(true)
+ })
+
+ it('returns the empty zone when no slots are mounted', () => {
+ const droppable = droppableWith('virt', { x: 0, y: 0, width: 200, height: 400 }, [])
+ const session = makeSession({ itemId: 'none', originContainerId: 'other' })
+
+ strategy.onSessionStart(droppable, session)
+ const zones = strategy.calculateDropZones(droppable, session)
+ expect(zones).toHaveLength(1)
+ expect(zones[0].position).toBe(0)
+ })
+})
+
+describe('SortableContainerStrategy.getTranslations — virtual mode', () => {
+ const strategy = sortable({ layout: 'vertical', virtual })
+
+ it('shifts later mounted slots back when the drag moves to a higher position', () => {
+ const slots = [100, 101, 102, 103, 104].map((p) =>
+ mountedSlot({ id: `item-${p}`, position: p })
+ )
+ const droppable = droppableWith('virt', { x: 0, y: 0, width: 200, height: 400 }, slots)
+ const session = makeSession({
+ itemId: 'item-100',
+ originPosition: 100,
+ dropPreview: { containerId: 'virt', position: 103 },
+ slotSize: { width: 200, height: itemSize }
+ })
+
+ strategy.onSessionStart(droppable, session)
+ const map = strategy.getTranslations(droppable, session)
+
+ // D=100, P=103, targetIdx=104. Items at positions 101..103 fill the gap by shifting up by 50.
+ expect(map.get('item-101')).toEqual({ x: 0, y: -itemSize })
+ expect(map.get('item-102')).toEqual({ x: 0, y: -itemSize })
+ expect(map.get('item-103')).toEqual({ x: 0, y: -itemSize })
+ expect(map.has('item-104')).toBe(false)
+ })
+
+ it('does not produce translations for slots that were unmounted by the virtualizer', () => {
+ // Only positions 100 and 102 are mounted; 101 was unmounted (some weird virtualizer).
+ // We should still emit translations for the mounted ones based on their slot.position.
+ const slots = [100, 102, 103].map((p) => mountedSlot({ id: `item-${p}`, position: p }))
+ const droppable = droppableWith('virt', { x: 0, y: 0, width: 200, height: 400 }, slots)
+ const session = makeSession({
+ itemId: 'item-100',
+ originPosition: 100,
+ dropPreview: { containerId: 'virt', position: 103 },
+ slotSize: { width: 200, height: itemSize }
+ })
+
+ strategy.onSessionStart(droppable, session)
+ const map = strategy.getTranslations(droppable, session)
+
+ // 102 is in (D=100..targetIdx=104) → shift back. 101 is not mounted → skipped.
+ expect(map.get('item-102')).toEqual({ x: 0, y: -itemSize })
+ expect(map.get('item-103')).toEqual({ x: 0, y: -itemSize })
+ expect(map.has('item-101')).toBe(false)
+ })
+})
diff --git a/packages/svelte-dnd/tests/strategies/sortable-zones.test.ts b/packages/svelte-dnd/tests/strategies/sortable-zones.test.ts
new file mode 100644
index 0000000..7e07c29
--- /dev/null
+++ b/packages/svelte-dnd/tests/strategies/sortable-zones.test.ts
@@ -0,0 +1,99 @@
+import { describe, it, expect } from 'vitest'
+import { sortable } from '../../src/lib/core/containers/strategies/sortable-container-strategy.js'
+import type { DragSession } from '../../src/lib/core/dnd/drag-session.svelte.js'
+import type { Droppable } from '../../src/lib/core/entities/droppable.svelte.js'
+import type { LayoutSnapshot } from '../../src/lib/core/zones/layout-snapshot.js'
+import { DomSortableSource } from '../../src/lib/core/zones/sortable-source.js'
+import { scrollableEl, slotRect } from '../helpers/fixtures.js'
+
+const droppableWith = (
+ id: string,
+ rect: { x: number; y: number; width: number; height: number },
+ scroll = { top: 0, left: 0 }
+) => {
+ const element = scrollableEl(rect, scroll)
+ return { id, element } as unknown as Droppable
+}
+
+const sessionWith = (snapshot: LayoutSnapshot, itemId = 'dragged'): DragSession => {
+ const source = new DomSortableSource(snapshot, itemId)
+ return {
+ getSource: (id: string) => (id === snapshot.containerId ? source : undefined),
+ itemId
+ } as unknown as DragSession
+}
+
+describe('SortableContainerStrategy.calculateDropZones — scroll viewport clipping', () => {
+ const strategy = sortable({ layout: 'vertical' })
+
+ it('skips slots scrolled out of the container viewport', () => {
+ // Container 200x400 at (0, 0). Items each 50 tall, 50 of them ⇒ content 2500.
+ // scrollTop=0 ⇒ only items whose offsetTop < 400 are visible (≤ first 8).
+ const rects = Array.from({ length: 50 }, (_, i) =>
+ slotRect(`item-${i}`, i, 0, i * 50, 200, 50)
+ )
+ const snapshot: LayoutSnapshot = { containerId: 'list', rects, draggedIndex: -1 }
+ const droppable = droppableWith('list', { x: 0, y: 0, width: 200, height: 400 })
+ const session = sessionWith(snapshot, 'none')
+
+ const zones = strategy.calculateDropZones(droppable, session)
+
+ // 8 visible items (item-0..item-7) ⇒ 9 insert positions (0..8).
+ // item-8 onward is clipped by overflow ⇒ no zones for them.
+ const positions = zones.map((z) => z.position).sort((a, b) => a - b)
+ expect(positions).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
+ })
+
+ it('does not emit zones below the visible container bottom', () => {
+ const rects = Array.from({ length: 50 }, (_, i) =>
+ slotRect(`item-${i}`, i, 0, i * 50, 200, 50)
+ )
+ const snapshot: LayoutSnapshot = { containerId: 'list', rects, draggedIndex: -1 }
+ const droppable = droppableWith('list', { x: 0, y: 0, width: 200, height: 400 })
+ const session = sessionWith(snapshot, 'none')
+
+ const zones = strategy.calculateDropZones(droppable, session)
+
+ // Bug guard: pointing at y=800 (well beyond container.bottom=400) must not fall
+ // inside any drop zone — historically zones for scrolled-out slots leaked there.
+ for (const zone of zones) {
+ expect(zone.rect.y).toBeLessThan(400)
+ expect(zone.rect.y + zone.rect.height).toBeLessThanOrEqual(400)
+ }
+ })
+
+ it('shifts the visible window with scrollTop', () => {
+ const rects = Array.from({ length: 50 }, (_, i) =>
+ slotRect(`item-${i}`, i, 0, i * 50, 200, 50)
+ )
+ const snapshot: LayoutSnapshot = { containerId: 'list', rects, draggedIndex: -1 }
+ // Scrolled to y=500 ⇒ content rows 10..17 become visible.
+ const droppable = droppableWith(
+ 'list',
+ { x: 0, y: 0, width: 200, height: 400 },
+ { top: 500, left: 0 }
+ )
+ const session = sessionWith(snapshot, 'none')
+
+ const zones = strategy.calculateDropZones(droppable, session)
+ const positions = new Set(zones.map((z) => z.position))
+
+ // Position 0 (insert before item-0) is no longer reachable — that slot is far above.
+ expect(positions.has(0)).toBe(false)
+ // Position 10 (before item-10) is the new "first visible" insert point.
+ expect(positions.has(10)).toBe(true)
+ expect(positions.has(18)).toBe(true) // after item-17, still inside the window
+ })
+
+ it('returns the empty zone when every slot is scrolled out', () => {
+ const rects = [slotRect('only', 0, 0, 5000, 200, 50)]
+ const snapshot: LayoutSnapshot = { containerId: 'list', rects, draggedIndex: -1 }
+ const droppable = droppableWith('list', { x: 0, y: 0, width: 200, height: 400 })
+ const session = sessionWith(snapshot, 'none')
+
+ const zones = strategy.calculateDropZones(droppable, session)
+
+ expect(zones).toHaveLength(1)
+ expect(zones[0].position).toBe(0)
+ })
+})
diff --git a/packages/svelte-dnd/tests/utils/dom-helper.test.ts b/packages/svelte-dnd/tests/utils/dom-helper.test.ts
new file mode 100644
index 0000000..0958096
--- /dev/null
+++ b/packages/svelte-dnd/tests/utils/dom-helper.test.ts
@@ -0,0 +1,135 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { DOMHelper } from '../../src/lib/core/utils/dom-helper.js'
+import { setRect, makeElement } from '../helpers/dom.js'
+
+function mountContainer(id = 'list'): HTMLElement {
+ const el = makeElement()
+ el.setAttribute('data-dnd-drop-id', id)
+ document.body.appendChild(el)
+ return el
+}
+
+describe('DOMHelper.findContainer', () => {
+ let container: HTMLElement
+
+ beforeEach(() => {
+ container = mountContainer()
+ })
+ afterEach(() => container.remove())
+
+ it('locates a container by its data-dnd-drop-id', () => {
+ expect(DOMHelper.findContainer('list')).toBe(container)
+ })
+
+ it('returns null when no container matches the id', () => {
+ expect(DOMHelper.findContainer('missing')).toBeNull()
+ })
+
+ it('escapes ids that contain special CSS characters', () => {
+ const fancy = makeElement()
+ fancy.setAttribute('data-dnd-drop-id', 'list[1].column')
+ document.body.appendChild(fancy)
+ try {
+ expect(DOMHelper.findContainer('list[1].column')).toBe(fancy)
+ } finally {
+ fancy.remove()
+ }
+ })
+})
+
+describe('DOMHelper.getContainerRect', () => {
+ it('returns the container rect when found, null when missing', () => {
+ const container = mountContainer()
+ setRect(container, { x: 10, y: 20, width: 200, height: 100 })
+ try {
+ const rect = DOMHelper.getContainerRect('list')
+ expect(rect?.left).toBe(10)
+ expect(rect?.width).toBe(200)
+ expect(DOMHelper.getContainerRect('missing')).toBeNull()
+ } finally {
+ container.remove()
+ }
+ })
+})
+
+describe('DOMHelper.isElementVisibleInContainer', () => {
+ let container: HTMLElement
+
+ beforeEach(() => {
+ container = makeElement()
+ setRect(container, { x: 0, y: 0, width: 200, height: 200 })
+ })
+
+ it('returns true when the element fits inside on all four sides', () => {
+ const child = makeElement()
+ setRect(child, { x: 10, y: 10, width: 100, height: 100 })
+ expect(DOMHelper.isElementVisibleInContainer(child, container)).toBe(true)
+ })
+
+ it('returns false when the element overhangs the top edge', () => {
+ const child = makeElement()
+ setRect(child, { x: 10, y: -10, width: 100, height: 50 })
+ expect(DOMHelper.isElementVisibleInContainer(child, container)).toBe(false)
+ })
+
+ it('returns false when the element overhangs the bottom edge', () => {
+ const child = makeElement()
+ setRect(child, { x: 10, y: 180, width: 100, height: 30 })
+ expect(DOMHelper.isElementVisibleInContainer(child, container)).toBe(false)
+ })
+
+ it('returns false when the element overhangs the left edge', () => {
+ const child = makeElement()
+ setRect(child, { x: -10, y: 10, width: 50, height: 50 })
+ expect(DOMHelper.isElementVisibleInContainer(child, container)).toBe(false)
+ })
+
+ it('returns false when the element overhangs the right edge', () => {
+ const child = makeElement()
+ setRect(child, { x: 180, y: 10, width: 50, height: 50 })
+ expect(DOMHelper.isElementVisibleInContainer(child, container)).toBe(false)
+ })
+})
+
+describe('DOMHelper.findPreview / findPreviewSlot', () => {
+ let container: HTMLElement
+
+ beforeEach(() => {
+ container = mountContainer()
+ })
+ afterEach(() => container.remove())
+
+ it('finds a preview owned by the given container', () => {
+ const slot = document.createElement('div')
+ const preview = document.createElement('div')
+ preview.setAttribute('data-dnd-preview-position', '2')
+ slot.appendChild(preview)
+ container.appendChild(slot)
+
+ expect(DOMHelper.findPreview(container, 2)).toBe(preview)
+ })
+
+ it('skips previews that belong to a nested droppable', () => {
+ const inner = document.createElement('div')
+ inner.setAttribute('data-dnd-drop-id', 'inner')
+ const innerPreview = document.createElement('div')
+ innerPreview.setAttribute('data-dnd-preview-position', '0')
+ inner.appendChild(innerPreview)
+ container.appendChild(inner)
+
+ // only the inner droppable owns this preview, the outer one has none
+ expect(DOMHelper.findPreview(container, 0)).toBeNull()
+ })
+
+ it('returns the preview wrapper element for the slot lookup', () => {
+ const slot = document.createElement('div')
+ slot.className = 'wrapper'
+ const preview = document.createElement('div')
+ preview.setAttribute('data-dnd-preview-position', '1')
+ slot.appendChild(preview)
+ container.appendChild(slot)
+
+ expect(DOMHelper.findPreviewSlot(container, 1)).toBe(slot)
+ expect(DOMHelper.findPreviewSlot(container, 999)).toBeNull()
+ })
+})
diff --git a/packages/svelte-dnd/tests/zones/axis-zone-geometry.test.ts b/packages/svelte-dnd/tests/zones/axis-zone-geometry.test.ts
new file mode 100644
index 0000000..18bd1c6
--- /dev/null
+++ b/packages/svelte-dnd/tests/zones/axis-zone-geometry.test.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect } from 'vitest'
+import { AxisZoneGeometry } from '../../src/lib/core/zones/geometries/axis-zone-geometry.js'
+import { geometryCtx as ctx, slotRect as rect } from '../helpers/fixtures.js'
+
+describe('AxisZoneGeometry — vertical', () => {
+ const geometry = new AxisZoneGeometry('vertical')
+
+ it('emits a single empty zone for an empty list', () => {
+ const zones = geometry.buildZones([], ctx())
+ expect(zones).toHaveLength(1)
+ expect(zones[0].position).toBe(0)
+ expect(zones[0].rect.height).toBeGreaterThanOrEqual(20)
+ })
+
+ it('emits N+1 zones for N visible items (insert before/after)', () => {
+ const rects = [
+ rect('a', 0, 0, 0, 200, 50),
+ rect('b', 1, 0, 60, 200, 50),
+ rect('c', 2, 0, 120, 200, 50)
+ ]
+ const zones = geometry.buildZones(rects, ctx())
+ expect(zones.map((z) => z.position)).toEqual([0, 1, 2, 3])
+ })
+
+ it('zone 0 spans from container top to the first item midpoint', () => {
+ const rects = [rect('a', 0, 0, 0, 200, 50)]
+ const [first] = geometry.buildZones(rects, ctx())
+ // Item starts at y=0 and is 50 tall, so the first zone covers y=0..(half=25).
+ expect(first.position).toBe(0)
+ expect(first.rect.y).toBe(0)
+ expect(first.rect.height).toBe(25)
+ })
+
+ it('inter-item zone spans from one midpoint to the next', () => {
+ const rects = [
+ rect('a', 0, 0, 0, 200, 50), // mid y=25
+ rect('b', 1, 0, 60, 200, 50) // mid y=85
+ ]
+ const zones = geometry.buildZones(rects, ctx())
+ // zone position 1 (between a and b) goes from y=25 to y=85 → height 60.
+ const between = zones.find((z) => z.position === 1)!
+ expect(between.rect.y).toBe(25)
+ expect(between.rect.height).toBe(60)
+ })
+
+ it('last zone extends to the container bottom', () => {
+ const rects = [rect('a', 0, 0, 0, 200, 50)]
+ const zones = geometry.buildZones(rects, ctx())
+ const last = zones.find((z) => z.position === 1)!
+ // container bottom = 600, last item ends at y=50, mid at 25; last zone covers from 25 down.
+ expect(last.rect.y).toBe(25)
+ // remaining = 600 - 25 = 575
+ expect(last.rect.height).toBe(575)
+ })
+
+ it('subtracts scrollTop from offsetTop when projecting to viewport', () => {
+ const rects = [rect('a', 0, 0, 100, 200, 50)] // offsetTop 100
+ const zones = geometry.buildZones(rects, ctx({ scrollTop: 40 }))
+ // viewport y = offsetTop - scrollTop = 60. Zone 0 covers from 0 to mid=60+25=85.
+ expect(zones[0].rect.y).toBe(0)
+ expect(zones[0].rect.height).toBe(85)
+ })
+})
+
+describe('AxisZoneGeometry — horizontal', () => {
+ const geometry = new AxisZoneGeometry('horizontal')
+
+ it('emits zones along the x axis using container height as zone height', () => {
+ const rects = [rect('a', 0, 0, 0, 80, 100), rect('b', 1, 90, 0, 80, 100)]
+ const zones = geometry.buildZones(
+ rects,
+ ctx({
+ containerRect: {
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 400,
+ bottom: 100,
+ width: 400,
+ height: 100,
+ toJSON: () => ({})
+ } as DOMRect
+ })
+ )
+
+ // 2 items → 3 zones (positions 0, 1, 2)
+ expect(zones.map((z) => z.position)).toEqual([0, 1, 2])
+ // Every zone should be the full container height
+ for (const z of zones) {
+ expect(z.rect.height).toBe(100)
+ }
+ })
+})
diff --git a/packages/svelte-dnd/tests/zones/drop-resolver.test.ts b/packages/svelte-dnd/tests/zones/drop-resolver.test.ts
new file mode 100644
index 0000000..3580768
--- /dev/null
+++ b/packages/svelte-dnd/tests/zones/drop-resolver.test.ts
@@ -0,0 +1,108 @@
+import { describe, it, expect } from 'vitest'
+import { DropResolver } from '../../src/lib/core/zones/drop-resolver.js'
+import { closestCenter } from '../../src/lib/core/collision/closest-center.js'
+import type { DndState } from '../../src/lib/core/dnd/dnd-state.svelte.js'
+import type { Droppable } from '../../src/lib/core/entities/droppable.svelte.js'
+import type { DropZone } from '../../src/lib/types.js'
+import { dropZone as zone } from '../helpers/fixtures.js'
+
+interface FakeDroppable {
+ accepts?: string | string[]
+ collision?: Droppable['collision']
+}
+
+interface FakeStateInit {
+ draggedItem?: string | null
+ draggedType?: string | undefined
+ zones: DropZone[]
+ transform?: { x: number; y: number }
+ ghostSize?: { width: number; height: number }
+}
+
+function fakeState(init: FakeStateInit): DndState {
+ return {
+ // Preserve an explicitly passed `null` — `??` would replace it with the default.
+ draggedItem: 'draggedItem' in init ? init.draggedItem : 'item-1',
+ draggedType: init.draggedType,
+ zones: init.zones,
+ transform: init.transform ?? { x: 0, y: 0 },
+ ghostSize: init.ghostSize ?? { width: 0, height: 0 }
+ } as unknown as DndState
+}
+
+const fakeDroppables = (map: Record): Map =>
+ new Map(Object.entries(map).map(([id, d]) => [id, d as unknown as Droppable]))
+
+describe('DropResolver.findZoneAt', () => {
+ it('returns null when no item is being dragged', () => {
+ const state = fakeState({ draggedItem: null, zones: [zone('a', 0, 0, 0, 50, 50)] })
+ const resolver = new DropResolver(state, fakeDroppables({ a: {} }))
+
+ expect(resolver.findZoneAt({ x: 25, y: 25 })).toBeNull()
+ })
+
+ it('matches a zone via the default centerPoint algorithm', () => {
+ const z = zone('a', 0, 0, 0, 100, 100)
+ const state = fakeState({
+ zones: [z],
+ transform: { x: 20, y: 20 },
+ ghostSize: { width: 30, height: 30 }
+ })
+ const resolver = new DropResolver(state, fakeDroppables({ a: {} }))
+
+ // ghost center is (20+15, 20+15) = (35, 35), inside the zone
+ expect(resolver.findZoneAt({ x: 0, y: 0 })).toBe(z)
+ })
+
+ it('skips zones whose container does not accept the dragged type', () => {
+ const accepted = zone('a', 0, 0, 0, 100, 100)
+ const rejected = zone('b', 0, 200, 0, 100, 100)
+ const state = fakeState({
+ draggedType: 'card',
+ zones: [accepted, rejected],
+ transform: { x: 220, y: 20 },
+ ghostSize: { width: 30, height: 30 }
+ })
+ const resolver = new DropResolver(
+ state,
+ fakeDroppables({
+ a: { accepts: 'card' },
+ b: { accepts: 'task' }
+ })
+ )
+
+ // ghost center sits inside rejected, but `b` does not accept 'card'.
+ expect(resolver.findZoneAt({ x: 0, y: 0 })).toBeNull()
+ })
+
+ it('uses the per-container collision algorithm when one is set', () => {
+ const z = zone('a', 0, 1000, 0, 50, 50)
+ const state = fakeState({
+ zones: [z],
+ transform: { x: 0, y: 0 },
+ ghostSize: { width: 10, height: 10 }
+ })
+ // Default centerPoint would not match (ghost far from zone), but closestCenter always picks the
+ // nearest zone within a container.
+ const resolver = new DropResolver(
+ state,
+ fakeDroppables({
+ a: { collision: closestCenter }
+ })
+ )
+
+ expect(resolver.findZoneAt({ x: 0, y: 0 })).toBe(z)
+ })
+
+ it('falls back to the global algorithm when no per-container one is set', () => {
+ const z = zone('a', 0, 1000, 0, 50, 50)
+ const state = fakeState({
+ zones: [z],
+ transform: { x: 0, y: 0 },
+ ghostSize: { width: 10, height: 10 }
+ })
+ const resolver = new DropResolver(state, fakeDroppables({ a: {} }), closestCenter)
+
+ expect(resolver.findZoneAt({ x: 0, y: 0 })).toBe(z)
+ })
+})
diff --git a/packages/svelte-dnd/tests/zones/grid-zone-geometry.test.ts b/packages/svelte-dnd/tests/zones/grid-zone-geometry.test.ts
new file mode 100644
index 0000000..0552df7
--- /dev/null
+++ b/packages/svelte-dnd/tests/zones/grid-zone-geometry.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect } from 'vitest'
+import { GridZoneGeometry } from '../../src/lib/core/zones/geometries/grid-zone-geometry.js'
+import { geometryCtx, slotRect } from '../helpers/fixtures.js'
+
+const GRID_RECT = {
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 400,
+ bottom: 400,
+ width: 400,
+ height: 400,
+ toJSON: () => ({})
+} as DOMRect
+
+const ctx = (partial: Parameters[0] = {}) =>
+ geometryCtx({ containerId: 'grid', containerRect: GRID_RECT, ...partial })
+
+const rect = (slotId: string, position: number, x: number, y: number, w = 100, h = 100) =>
+ slotRect(slotId, position, x, y, w, h)
+
+describe('GridZoneGeometry — row flow', () => {
+ const geometry = new GridZoneGeometry('row')
+
+ it('emits an empty zone with at-least 20px height for an empty grid', () => {
+ const zones = geometry.buildZones([], ctx())
+ expect(zones).toHaveLength(1)
+ expect(zones[0].layout).toBe('grid')
+ expect(zones[0].rect.height).toBeGreaterThanOrEqual(20)
+ })
+
+ it('groups items on the same row into one track and emits 2 zones per item', () => {
+ // Two items on the same row at y=0
+ const rects = [rect('a', 0, 0, 0), rect('b', 1, 100, 0)]
+ const zones = geometry.buildZones(rects, ctx())
+ // 2 items × 2 zones each = 4 zones. Positions overlap (afterMe[i] === beforeMe[i+1]),
+ // so unique positions span 0..N (3 distinct values for 2 items).
+ expect(zones).toHaveLength(4)
+ expect(new Set(zones.map((z) => z.position))).toEqual(new Set([0, 1, 2]))
+ // Same row → all zones share roughly the same y span
+ expect(zones[0].rect.y).toBe(zones[2].rect.y)
+ })
+
+ it('separates items on different rows into different tracks', () => {
+ // 2x2 grid
+ const rects = [
+ rect('a', 0, 0, 0),
+ rect('b', 1, 100, 0),
+ rect('c', 2, 0, 100),
+ rect('d', 3, 100, 100)
+ ]
+ const zones = geometry.buildZones(rects, ctx())
+ // 4 items × 2 zones each = 8 zones
+ expect(zones).toHaveLength(8)
+ // First two items are on the top row (y around 0..50), last two on bottom row (y around 50..)
+ const topZones = zones.filter((z) => z.rect.y < 100)
+ const bottomZones = zones.filter((z) => z.rect.y >= 100)
+ expect(topZones.length).toBeGreaterThan(0)
+ expect(bottomZones.length).toBeGreaterThan(0)
+ })
+
+ it('uses primary axis = X for row flow', () => {
+ // Two adjacent items: "before a" zone should sit to the left, "after a" between a and b.
+ const rects = [rect('a', 0, 100, 0), rect('b', 1, 200, 0)]
+ const zones = geometry.buildZones(rects, ctx())
+ const beforeA = zones.find((z) => z.position === 0)!
+ const afterA = zones.find((z) => z.position === 1)!
+ // "before" zone's x is the lesser, "after" zone's x is greater.
+ expect(beforeA.rect.x).toBeLessThan(afterA.rect.x)
+ })
+})
+
+describe('GridZoneGeometry — column flow', () => {
+ const geometry = new GridZoneGeometry('column')
+
+ it('emits zones along the y axis using primary = Y', () => {
+ const rects = [rect('a', 0, 0, 0), rect('b', 1, 0, 100)]
+ const zones = geometry.buildZones(rects, ctx())
+ const beforeA = zones.find((z) => z.position === 0)!
+ const afterA = zones.find((z) => z.position === 1)!
+ // In column flow the primary axis is Y, so "after" zone is below "before".
+ expect(beforeA.rect.y).toBeLessThan(afterA.rect.y)
+ })
+
+ it('still produces 2 zones per item', () => {
+ const rects = [rect('a', 0, 0, 0), rect('b', 1, 0, 100), rect('c', 2, 0, 200)]
+ const zones = geometry.buildZones(rects, ctx())
+ expect(zones).toHaveLength(6)
+ // Adjacent before/after zones share a position, so 3 items yield 4 unique positions (0..3).
+ expect(new Set(zones.map((z) => z.position))).toEqual(new Set([0, 1, 2, 3]))
+ })
+})
diff --git a/packages/svelte-dnd/tests/zones/layout-snapshot.test.ts b/packages/svelte-dnd/tests/zones/layout-snapshot.test.ts
new file mode 100644
index 0000000..e5db40e
--- /dev/null
+++ b/packages/svelte-dnd/tests/zones/layout-snapshot.test.ts
@@ -0,0 +1,131 @@
+import { describe, it, expect } from 'vitest'
+import { captureLayoutSnapshot, toViewportRect } from '../../src/lib/core/zones/layout-snapshot.js'
+import { Droppable } from '../../src/lib/core/entities/droppable.svelte.js'
+import { Slot } from '../../src/lib/core/entities/slot.js'
+import { sortable } from '../../src/lib/core/containers/strategies/sortable-container-strategy.js'
+import { setRect, makeElement, type FakeRect } from '../helpers/dom.js'
+import { noopController, scrollableEl } from '../helpers/fixtures.js'
+
+interface SlotInit {
+ id: string
+ position: number
+ rect: FakeRect
+}
+
+function buildDroppable(
+ containerRect: FakeRect,
+ slotInits: SlotInit[],
+ scroll = { left: 0, top: 0 }
+): Droppable {
+ const droppable = new Droppable({ id: 'list', strategy: sortable() }, noopController())
+ droppable.element = scrollableEl(containerRect, scroll)
+
+ for (const init of slotInits) {
+ const slot = new Slot(init.position)
+ const slotEl = makeElement()
+ setRect(slotEl, init.rect)
+ slot.element = slotEl
+ slot.draggable = { id: init.id } as Slot['draggable']
+ slot.droppable = droppable
+ droppable.slots.set(slotEl, slot)
+ }
+
+ return droppable
+}
+
+describe('captureLayoutSnapshot', () => {
+ it('returns an empty rects array and draggedIndex=-1 for an empty droppable', () => {
+ const droppable = buildDroppable({ x: 0, y: 0, width: 200, height: 600 }, [])
+ const snapshot = captureLayoutSnapshot(droppable, null)
+
+ expect(snapshot.containerId).toBe('list')
+ expect(snapshot.rects).toHaveLength(0)
+ expect(snapshot.draggedIndex).toBe(-1)
+ })
+
+ it('records each slot in content-space coordinates relative to the container', () => {
+ const droppable = buildDroppable({ x: 100, y: 200, width: 200, height: 600 }, [
+ { id: 'a', position: 0, rect: { x: 100, y: 200, width: 200, height: 50 } },
+ { id: 'b', position: 1, rect: { x: 100, y: 260, width: 200, height: 50 } }
+ ])
+
+ const snapshot = captureLayoutSnapshot(droppable, null)
+
+ // containerRect.left = 100, containerRect.top = 200 → offsets are slot.left - container.left.
+ expect(snapshot.rects[0]).toMatchObject({
+ slotId: 'a',
+ position: 0,
+ offsetLeft: 0,
+ offsetTop: 0,
+ width: 200,
+ height: 50
+ })
+ expect(snapshot.rects[1]).toMatchObject({
+ slotId: 'b',
+ position: 1,
+ offsetLeft: 0,
+ offsetTop: 60,
+ width: 200,
+ height: 50
+ })
+ })
+
+ it('adds container scroll offsets back into the captured offsets', () => {
+ const droppable = buildDroppable(
+ { x: 0, y: 0, width: 200, height: 200 },
+ [{ id: 'a', position: 0, rect: { x: 0, y: -50, width: 200, height: 50 } }],
+ { left: 0, top: 100 }
+ )
+
+ const snapshot = captureLayoutSnapshot(droppable, null)
+ // content-space top = viewport top (-50) - container top (0) + scrollTop (100) = 50
+ expect(snapshot.rects[0].offsetTop).toBe(50)
+ })
+
+ it('finds the dragged slot index by id and orders rects by slot.position', () => {
+ // Insert in scrambled order to verify sorting.
+ const droppable = buildDroppable({ x: 0, y: 0, width: 200, height: 600 }, [
+ { id: 'b', position: 1, rect: { x: 0, y: 60, width: 200, height: 50 } },
+ { id: 'c', position: 2, rect: { x: 0, y: 120, width: 200, height: 50 } },
+ { id: 'a', position: 0, rect: { x: 0, y: 0, width: 200, height: 50 } }
+ ])
+
+ const snapshot = captureLayoutSnapshot(droppable, 'b')
+
+ // Sorted by position
+ expect(snapshot.rects.map((r) => r.slotId)).toEqual(['a', 'b', 'c'])
+ expect(snapshot.draggedIndex).toBe(1)
+ })
+
+ it('returns draggedIndex=-1 when the dragged id is not in the snapshot', () => {
+ const droppable = buildDroppable({ x: 0, y: 0, width: 200, height: 600 }, [
+ { id: 'a', position: 0, rect: { x: 0, y: 0, width: 200, height: 50 } }
+ ])
+ const snapshot = captureLayoutSnapshot(droppable, 'missing')
+ expect(snapshot.draggedIndex).toBe(-1)
+ })
+})
+
+describe('toViewportRect', () => {
+ it('reverses captureLayoutSnapshot — projects content space back to viewport', () => {
+ const containerRect = { left: 100, top: 200 } as DOMRect
+ const result = toViewportRect(
+ { slotId: 'a', position: 0, offsetLeft: 0, offsetTop: 60, width: 200, height: 50 },
+ containerRect,
+ 0,
+ 0
+ )
+ expect(result).toEqual({ x: 100, y: 260, width: 200, height: 50 })
+ })
+
+ it('subtracts the current scroll offset from the projected rect', () => {
+ const containerRect = { left: 0, top: 0 } as DOMRect
+ const result = toViewportRect(
+ { slotId: 'a', position: 0, offsetLeft: 0, offsetTop: 100, width: 200, height: 50 },
+ containerRect,
+ 0,
+ 40
+ )
+ expect(result.y).toBe(60)
+ })
+})
diff --git a/packages/svelte-dnd/tsconfig.json b/packages/svelte-dnd/tsconfig.json
new file mode 100644
index 0000000..ef685d4
--- /dev/null
+++ b/packages/svelte-dnd/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "rewriteRelativeImportExtensions": true,
+ "allowJs": true,
+ "checkJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "stripInternal": true,
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext"
+ }
+}
diff --git a/packages/svelte-dnd/vite.config.ts b/packages/svelte-dnd/vite.config.ts
new file mode 100644
index 0000000..9c691c5
--- /dev/null
+++ b/packages/svelte-dnd/vite.config.ts
@@ -0,0 +1,12 @@
+import { sveltekit } from '@sveltejs/kit/vite'
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ test: {
+ environment: 'jsdom',
+ include: ['tests/**/*.test.ts'],
+ setupFiles: ['tests/setup.ts']
+ },
+ resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined
+})
diff --git a/src/docs/components/contentsList.svelte b/src/docs/components/contentsList.svelte
deleted file mode 100644
index dfac334..0000000
--- a/src/docs/components/contentsList.svelte
+++ /dev/null
@@ -1,199 +0,0 @@
-
-
-
- On this page
-
- {#each headings as heading (heading.id)}
-
- scrollTo(heading.id)}
- >
- {heading.text}
-
-
- {/each}
-
-
\ No newline at end of file
diff --git a/src/docs/components/sidebar.svelte b/src/docs/components/sidebar.svelte
deleted file mode 100644
index 8a4da9e..0000000
--- a/src/docs/components/sidebar.svelte
+++ /dev/null
@@ -1,83 +0,0 @@
-
-
-{#if $sidebarOpen}
-
-
-
@horuse/svelte-dnd
-
($sidebarOpen = false)}
- class="black-button size-9"
- aria-label="Close sidebar"
- >
-
-
-
-
-
-
-
-
-
Examples
-
- {#each exampleLinks as link}
-
-
-
- {/each}
-
-
-
-
-
Docs
-
- {#each docsLinks as link}
-
-
-
- {/each}
-
-
-
-
-
-
-{/if}
diff --git a/src/lib/components/DndDraggable.svelte b/src/lib/components/DndDraggable.svelte
deleted file mode 100644
index 3b94fdc..0000000
--- a/src/lib/components/DndDraggable.svelte
+++ /dev/null
@@ -1,246 +0,0 @@
-
-
-
- {@render children()}
-
-
-
diff --git a/src/lib/components/DndDroppable.svelte b/src/lib/components/DndDroppable.svelte
deleted file mode 100644
index 8f2c4bd..0000000
--- a/src/lib/components/DndDroppable.svelte
+++ /dev/null
@@ -1,131 +0,0 @@
-
-
-
- {@render children()}
-
-
-
diff --git a/src/lib/components/DndPreview.svelte b/src/lib/components/DndPreview.svelte
deleted file mode 100644
index ba03cce..0000000
--- a/src/lib/components/DndPreview.svelte
+++ /dev/null
@@ -1,119 +0,0 @@
-
-
-{#if visible}
-
- {#if previewHeight > height * 0.85}
-
- {/if}
-
-{/if}
-
-
\ No newline at end of file
diff --git a/src/lib/components/DndProvider.svelte b/src/lib/components/DndProvider.svelte
deleted file mode 100644
index 870d93a..0000000
--- a/src/lib/components/DndProvider.svelte
+++ /dev/null
@@ -1,132 +0,0 @@
-
-
-{@render children()}
-
-{#if (dragController.dragging || dragController.animatingReturn) && dragController.element && dragController.transform && dragController.size}
-
- {#if ghost && dragController.draggedItem}
- {@render ghost({
- element: dragController.element,
- data: dragController.draggedItemData,
- itemId: dragController.draggedItem
- })}
- {:else}
- {@html dragController.element.outerHTML}
- {/if}
-
-{/if}
-
-{#if dragController.debugZones && dragController.dropZones}
- {#each dragController.dragging ? dragController.filteredDropZones : dragController.dropZones as zone, index}
-
-
- {zone.containerId} pos:{zone.position}
-
-
- {/each}
-{/if}
-
-
diff --git a/src/lib/core/animation-controller.ts b/src/lib/core/animation-controller.ts
deleted file mode 100644
index b312357..0000000
--- a/src/lib/core/animation-controller.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { DragState } from './drag-state.svelte.js'
-import type { DropZone } from '../types.js'
-import { ReturnAnimationStrategy } from './animation/strategies/return-animation.js'
-import { DropAnimationStrategy } from './animation/strategies/drop-animation.js'
-
-export class AnimationController {
- constructor(private state: DragState) {}
-
- animateReturn(onComplete?: () => void) {
- const strategy = new ReturnAnimationStrategy(this.state)
- strategy.execute(onComplete)
- }
-
- animateToTarget(targetZone: DropZone, onComplete?: () => void) {
- const strategy = new DropAnimationStrategy(this.state, targetZone)
- strategy.execute(onComplete)
- }
-}
diff --git a/src/lib/core/animation/strategies/animation-strategy.ts b/src/lib/core/animation/strategies/animation-strategy.ts
deleted file mode 100644
index 7dc78f2..0000000
--- a/src/lib/core/animation/strategies/animation-strategy.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export interface AnimationStrategy {
- execute(onComplete?: () => void): void
-}
diff --git a/src/lib/core/animation/strategies/drop-animation.ts b/src/lib/core/animation/strategies/drop-animation.ts
deleted file mode 100644
index 6d142bb..0000000
--- a/src/lib/core/animation/strategies/drop-animation.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import type { AnimationStrategy } from './animation-strategy.js'
-import type { DragState } from '../../drag-state.svelte.js'
-import type { DropZone } from '../../../types.js'
-import { DOMHelper } from '../../dom-helper.js'
-
-const ANIMATION_DURATION = 250
-const easing = {
- outQuad: (t: number) => 1 - Math.pow(1 - t, 2)
-}
-
-export class DropAnimationStrategy implements AnimationStrategy {
- private domHelper = new DOMHelper()
-
- constructor(
- private state: DragState,
- private targetZone: DropZone
- ) {}
-
- execute(onComplete?: () => void): void {
- if (!this.state.element || !this.state.transform) {
- onComplete?.()
- return
- }
-
- this.state.setAnimating(true)
-
- const startPos = { ...this.state.transform }
- const startTime = Date.now()
-
- const animate = () => {
- const elapsed = Date.now() - startTime
- const progress = Math.min(elapsed / ANIMATION_DURATION, 1)
- const easedProgress = easing.outQuad(progress)
-
- // Recalculate target position each frame in case container scrolls
- const targetPos = this.calculatePlaceholderPosition()
-
- this.state.setTransform({
- x: startPos.x + (targetPos.x - startPos.x) * easedProgress,
- y: startPos.y + (targetPos.y - startPos.y) * easedProgress
- })
-
- if (progress < 1) {
- requestAnimationFrame(animate)
- } else {
- this.state.setAnimating(false)
- onComplete?.()
- }
- }
-
- requestAnimationFrame(animate)
- }
-
- private calculatePlaceholderPosition(): { x: number; y: number } {
- const container = this.domHelper.findContainer(this.targetZone.containerId)
- if (!container) {
- return this.calculateFallbackPosition()
- }
-
- const placeholder = this.domHelper.findPlaceholder(container, this.targetZone.position)
- if (placeholder) {
- const rect = placeholder.getBoundingClientRect()
- return { x: rect.left, y: rect.top }
- }
-
- const containerRect = container.getBoundingClientRect()
- return {
- x: containerRect.left,
- y: this.targetZone.rect.y
- }
- }
-
- private calculateFallbackPosition(): { x: number; y: number } {
- return {
- x: this.targetZone.rect.x + (this.targetZone.rect.width - (this.state.size?.width || 0)) / 2,
- y: this.targetZone.rect.y + (this.targetZone.rect.height - (this.state.size?.height || 0)) / 2
- }
- }
-}
diff --git a/src/lib/core/animation/strategies/return-animation.ts b/src/lib/core/animation/strategies/return-animation.ts
deleted file mode 100644
index 6d4e62c..0000000
--- a/src/lib/core/animation/strategies/return-animation.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import type { AnimationStrategy } from './animation-strategy.js'
-import type { DragState } from '../../drag-state.svelte.js'
-import { DOMHelper } from '../../dom-helper.js'
-import { ScrollSyncAnimationStrategy } from './scroll-sync-animation.js'
-
-const ANIMATION_DURATION = 300
-const easing = {
- outCubic: (t: number) => 1 - Math.pow(1 - t, 3)
-}
-
-export class ReturnAnimationStrategy implements AnimationStrategy {
- private domHelper = new DOMHelper()
-
- constructor(private state: DragState) {}
-
- execute(onComplete?: () => void): void {
- if (!this.canAnimate()) {
- onComplete?.()
- return
- }
-
- const originContainerId = this.state.originContainerId
- const originPosition = this.state.originPosition
-
- if (!originContainerId) {
- this.startSimpleReturnAnimation(onComplete)
- return
- }
-
- this.handlePlaceholderBasedReturn(originContainerId, originPosition, onComplete)
- }
-
- private canAnimate(): boolean {
- return !!(this.state.element && this.state.transform && this.state.originalPosition)
- }
-
- private handlePlaceholderBasedReturn(
- containerId: string,
- position: number,
- onComplete?: () => void
- ): void {
- const container = this.domHelper.findContainer(containerId)
- if (!container) {
- this.startSimpleReturnAnimation(onComplete)
- return
- }
-
- const placeholder = this.domHelper.findPlaceholder(container, position)
- if (placeholder) {
- this.handleFoundPlaceholder(container, placeholder, containerId, position, onComplete)
- } else {
- this.retryPlaceholderSearch(container, containerId, position, onComplete)
- }
- }
-
- private handleFoundPlaceholder(
- container: HTMLElement,
- placeholder: HTMLElement,
- containerId: string,
- position: number,
- onComplete?: () => void
- ): void {
- if (this.domHelper.isElementVisibleInContainer(placeholder, container)) {
- this.startSimpleReturnAnimation(onComplete)
- } else {
- // Delegate to scroll sync strategy
- const scrollSyncStrategy = new ScrollSyncAnimationStrategy(
- this.state,
- containerId,
- position
- )
- scrollSyncStrategy.execute(onComplete)
- }
- }
-
- private retryPlaceholderSearch(
- container: HTMLElement,
- containerId: string,
- position: number,
- onComplete?: () => void
- ): void {
- // Placeholder might not be rendered yet, retry next frame
- requestAnimationFrame(() => {
- const placeholder = this.domHelper.findPlaceholder(container, position)
- if (placeholder) {
- this.handleFoundPlaceholder(container, placeholder, containerId, position, onComplete)
- } else {
- this.startSimpleReturnAnimation(onComplete)
- }
- })
- }
-
- private startSimpleReturnAnimation(onComplete?: () => void): void {
- const originContainerId = this.state.originContainerId
- const originPosition = this.state.originPosition
- const fallbackPos = { ...this.state.originalPosition! }
- const startPos = { ...this.state.transform! }
-
- this.state.setAnimating(true)
-
- const startTime = Date.now()
-
- const animate = () => {
- const progress = Math.min((Date.now() - startTime) / ANIMATION_DURATION, 1)
- const easedProgress = easing.outCubic(progress)
-
- const targetPos = this.getCurrentPlaceholderPosition(
- originContainerId,
- originPosition,
- fallbackPos
- )
-
- this.state.setTransform({
- x: startPos.x + (targetPos.x - startPos.x) * easedProgress,
- y: startPos.y + (targetPos.y - startPos.y) * easedProgress
- })
-
- if (progress < 1) {
- requestAnimationFrame(animate)
- } else {
- this.state.setAnimating(false)
- onComplete?.()
- }
- }
-
- requestAnimationFrame(animate)
- }
-
- private getCurrentPlaceholderPosition(
- containerId: string | null,
- position: number,
- fallback: { x: number; y: number }
- ): { x: number; y: number } {
- if (!containerId) return fallback
-
- const container = this.domHelper.findContainer(containerId)
- if (!container) return fallback
-
- const placeholder = this.domHelper.findPlaceholder(container, position)
- if (!placeholder) return fallback
-
- const rect = placeholder.getBoundingClientRect()
- return { x: rect.left, y: rect.top }
- }
-}
diff --git a/src/lib/core/animation/strategies/scroll-sync-animation.ts b/src/lib/core/animation/strategies/scroll-sync-animation.ts
deleted file mode 100644
index bffac68..0000000
--- a/src/lib/core/animation/strategies/scroll-sync-animation.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import type { AnimationStrategy } from './animation-strategy.js'
-import type { DragState } from '../../drag-state.svelte.js'
-import { DOMHelper } from '../../dom-helper.js'
-import { ScrollSyncCalculator } from '../scroll-sync-calculator.js'
-import { getDirectionAdapter } from '../direction-adapter.js'
-
-const easing = {
- outCubic: (t: number) => 1 - Math.pow(1 - t, 3)
-}
-
-export class ScrollSyncAnimationStrategy implements AnimationStrategy {
- private domHelper = new DOMHelper()
- private scrollCalc = new ScrollSyncCalculator()
-
- constructor(
- private state: DragState,
- private containerId: string,
- private position: number
- ) {}
-
- execute(onComplete?: () => void): void {
- const container = this.domHelper.findContainer(this.containerId)
- if (!container) {
- onComplete?.()
- return
- }
-
- const placeholder = this.domHelper.findPlaceholder(container, this.position)
- if (!placeholder) {
- // Retry next frame if placeholder not rendered yet
- requestAnimationFrame(() => {
- const retryPlaceholder = this.domHelper.findPlaceholder(container, this.position)
- if (retryPlaceholder) {
- this.executeScrollSync(container, retryPlaceholder, onComplete)
- } else {
- onComplete?.()
- }
- })
- return
- }
-
- this.executeScrollSync(container, placeholder, onComplete)
- }
-
- private executeScrollSync(
- container: HTMLElement,
- placeholder: HTMLElement,
- onComplete?: () => void
- ): void {
- this.state.setAnimating(true)
-
- const direction = this.domHelper.getContainerDirection(container)
- const adapter = getDirectionAdapter(direction)
-
- const startScroll = adapter.getScroll(container)
- const startGhostPos = { ...this.state.transform! }
- const placeholderRect = placeholder.getBoundingClientRect()
-
- const expectedSize =
- direction === 'horizontal'
- ? this.state.dropPreview?.draggedElementWidth || this.state.elementSize?.width || 0
- : this.state.dropPreview?.draggedElementHeight || this.state.elementSize?.height || 0
-
- const { targetScroll, scrollDelta } = this.scrollCalc.calculateScrollTarget({
- placeholder,
- container,
- expectedSize,
- direction
- })
-
- const scrollDistance = Math.abs(scrollDelta)
- const duration = this.scrollCalc.calculateAdaptiveDuration(scrollDistance)
-
- const finalGhostPos = this.scrollCalc.calculateFinalGhostPosition({
- placeholderRect,
- scrollDelta,
- direction
- })
-
- this.runScrollAnimation({
- container,
- placeholder,
- adapter,
- startScroll,
- targetScroll,
- scrollDelta,
- startGhostPos,
- finalGhostPos,
- duration,
- onComplete
- })
- }
-
- private runScrollAnimation(params: {
- container: HTMLElement
- placeholder: HTMLElement
- adapter: ReturnType
- startScroll: number
- targetScroll: number
- scrollDelta: number
- startGhostPos: { x: number; y: number }
- finalGhostPos: { x: number; y: number }
- duration: number
- onComplete?: () => void
- }): void {
- const startTime = Date.now()
-
- const animate = () => {
- const progress = Math.min((Date.now() - startTime) / params.duration, 1)
- const easedProgress = easing.outCubic(progress)
-
- params.adapter.setScroll(
- params.container,
- params.startScroll + params.scrollDelta * easedProgress
- )
-
- this.state.setTransform({
- x: params.startGhostPos.x + (params.finalGhostPos.x - params.startGhostPos.x) * easedProgress,
- y: params.startGhostPos.y + (params.finalGhostPos.y - params.startGhostPos.y) * easedProgress
- })
-
- if (progress < 1) {
- requestAnimationFrame(animate)
- } else {
- this.finalizeScrollAnimation(params.container, params.placeholder, params.adapter, params.targetScroll, params.onComplete)
- }
- }
-
- requestAnimationFrame(animate)
- }
-
- private finalizeScrollAnimation(
- container: HTMLElement,
- placeholder: HTMLElement,
- adapter: ReturnType,
- targetScroll: number,
- onComplete?: () => void
- ): void {
- adapter.setScroll(container, targetScroll)
- const finalRect = placeholder.getBoundingClientRect()
- this.state.setTransform({ x: finalRect.left, y: finalRect.top })
- this.state.setAnimating(false)
- onComplete?.()
- }
-}
diff --git a/src/lib/core/dom-helper.ts b/src/lib/core/dom-helper.ts
deleted file mode 100644
index 00a983f..0000000
--- a/src/lib/core/dom-helper.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-// DOM selectors
-const SELECTORS = {
- container: (id: string) => `[data-dnd-drop-id="${id}"]`,
- placeholder: (position: number) => `[data-dnd-preview-position="${position}"]`,
- draggableItems: ':scope > [data-dnd-draggable-item]'
-} as const
-
-export class DOMHelper {
- // Container queries
- findContainer(containerId: string): HTMLElement | null {
- return document.querySelector(SELECTORS.container(containerId))
- }
-
- getContainerRect(containerId: string): DOMRect | null {
- const container = this.findContainer(containerId)
- return container ? container.getBoundingClientRect() : null
- }
-
- getContainerDirection(container: HTMLElement): 'vertical' | 'horizontal' {
- return (container.dataset.dndDirection as 'vertical' | 'horizontal') || 'vertical'
- }
-
- // Placeholder queries
- findPlaceholder(container: HTMLElement, position: number): HTMLElement | null {
- return container.querySelector(SELECTORS.placeholder(position))
- }
-
- // Draggable items queries
- findDraggableItems(container: HTMLElement): HTMLElement[] {
- return Array.from(container.querySelectorAll(SELECTORS.draggableItems))
- }
-
- filterItemsByContainer(items: HTMLElement[], containerElement: HTMLElement): HTMLElement[] {
- return items.filter((item) => {
- const closestDropZone = item.closest('[data-dnd-drop-id]')
- return closestDropZone === containerElement
- })
- }
-
- // Visibility checks
- isElementVisibleInContainer(element: HTMLElement, container: HTMLElement): boolean {
- const containerRect = container.getBoundingClientRect()
- const elementRect = element.getBoundingClientRect()
-
- return (
- elementRect.top >= containerRect.top &&
- elementRect.bottom <= containerRect.bottom &&
- elementRect.left >= containerRect.left &&
- elementRect.right <= containerRect.right
- )
- }
-
- // Rect helpers
- getRect(element: HTMLElement): DOMRect {
- return element.getBoundingClientRect()
- }
-}
diff --git a/src/lib/core/drag-controller.svelte.ts b/src/lib/core/drag-controller.svelte.ts
deleted file mode 100644
index 59e7038..0000000
--- a/src/lib/core/drag-controller.svelte.ts
+++ /dev/null
@@ -1,285 +0,0 @@
-import { DragState } from './drag-state.svelte.js'
-import { AnimationController } from './animation-controller.js'
-import { ScrollController } from './scroll-controller.js'
-import { DropZoneCalculator } from './dropzone-calculator.js'
-import type { DndDragEvent, DndDropEvent, DropZone, DndDirection } from '../types.js'
-
-export type DragStartCallback = (itemId: string) => void
-export type DragEndCallback = (itemId: string) => void
-export type DropCallback = (
- sourceId: string,
- sourceData: any,
- targetContainerId: string,
- position: number
-) => void
-
-export class DragController {
- private state = new DragState()
- private animationController = new AnimationController(this.state)
- private scrollController = new ScrollController(this.state, {
- onZoneRefresh: () => this.refreshDropZones(),
- onMouseUpdate: (x, y) => this.updateMousePosition(x, y)
- })
- private droppableDataRegistry = new Map>()
- private dropZoneCalculator = new DropZoneCalculator(this.state, this.droppableDataRegistry)
-
- private dragStartCallbacks = new Set()
- private dragEndCallbacks = new Set()
- private dropCallbacks = new Set()
-
- get dragging() {
- return this.state.dragging
- }
- get element() {
- return this.state.element
- }
- get transform() {
- return this.state.transform
- }
- get draggedItem() {
- return this.state.draggedItem
- }
- get draggedType() {
- return this.state.draggedType
- }
- get draggedItemData() {
- return this.state.draggedItemData
- }
- get size() {
- return this.state.size
- }
- get animatingReturn() {
- return this.state.animating
- }
- get dropPreview() {
- return this.state.dropPreview
- }
- get dropZones() {
- return this.state.zones
- }
- get debugZones() {
- return this.state.debugZones
- }
- get filteredDropZones() {
- return this.dropZoneCalculator.filterZonesByDraggedItemType(
- this.state.zones,
- this.state.draggedItem || ''
- )
- }
- get performingDrop() {
- return this.state.performingDrop
- }
- get skipDropPreviewAnimation() {
- return this.state.skipDropPreviewAnimation
- }
-
- setSkipDropPreviewAnimation(value: boolean) {
- this.state.setSkipDropPreviewAnimation(value)
- }
-
- registerDroppableData(id: string, data: Record) {
- this.droppableDataRegistry.set(id, data)
- }
-
- unregisterDroppableData(id: string) {
- this.droppableDataRegistry.delete(id)
- }
-
- onDragStart(callback: DragStartCallback) {
- this.dragStartCallbacks.add(callback)
- return () => this.dragStartCallbacks.delete(callback)
- }
-
- onDragEnd(callback: DragEndCallback) {
- this.dragEndCallbacks.add(callback)
- return () => this.dragEndCallbacks.delete(callback)
- }
-
- onDrop(callback: DropCallback) {
- this.dropCallbacks.add(callback)
- return () => this.dropCallbacks.delete(callback)
- }
-
- startDrag(
- element: HTMLElement,
- itemId: string,
- initialPosition: { x: number; y: number },
- data?: Record
- ) {
- const rect = element.getBoundingClientRect()
-
- const containerEl = element.closest('[data-dnd-drop-id]')
- if (containerEl) {
- const containerId = containerEl.getAttribute('data-dnd-drop-id')!
- const items = containerEl.querySelectorAll(':scope > [data-dnd-draggable-item]')
- const position = Array.from(items).indexOf(element)
- this.state.setOriginContainerId(containerId)
- this.state.setOriginPosition(position >= 0 ? position : 0)
- }
-
- this.state.setDragging(true)
- this.state.setElement(element)
- this.state.setDraggedItemId(itemId)
- this.state.setDraggedItemType(data?.type || null)
- this.state.setDraggedItemData(data)
- this.state.setTransform(initialPosition)
- this.state.setElementSize({
- width: element.offsetWidth,
- height: element.offsetHeight
- })
- this.state.setOriginalPosition({
- x: rect.left,
- y: rect.top
- })
-
- this.state.setSkipDropPreviewAnimation(true)
- this.notifyDragStart(itemId)
- }
-
- updateTransform(transform: { x: number; y: number }) {
- this.state.setTransform(transform)
- }
-
- updateMousePosition(mouseX: number, mouseY: number) {
- if (this.state.dragging) {
- const ghostCenter = this.getGhostCenter()
- this.dropZoneCalculator.updateDropPreview(ghostCenter)
- this.scrollController.handleAutoScroll(mouseX, mouseY)
- }
- }
-
- private getGhostCenter(): { x: number; y: number } {
- const transform = this.state.transform
- const size = this.state.elementSize
- if (transform && size) {
- return {
- x: transform.x + size.width / 2,
- y: transform.y + size.height / 2
- }
- }
- return this.state.transform ?? { x: 0, y: 0 }
- }
-
- performDrop(
- sourceId: string,
- sourceData: any,
- targetContainerId: string,
- position: number
- ) {
- const targetZone = this.state.zones.find(
- (zone) => zone.containerId === targetContainerId && zone.position === position
- )
-
- this.state.setPerformingDrop(true)
-
- if (targetZone && this.state.element && this.state.transform) {
- this.animationController.animateToTarget(targetZone, () => {
- this.state.setDropPreview(null)
- this.notifyDrop(sourceId, sourceData, targetContainerId, position)
- this.finalizeDragEnd(sourceId)
- })
- } else {
- this.state.setDropPreview(null)
- this.notifyDrop(sourceId, sourceData, targetContainerId, position)
- this.finalizeDragEnd(sourceId)
- }
- }
-
- endDrag(shouldAnimate = true) {
- const itemId = this.state.draggedItem
-
- if (shouldAnimate && this.state.originContainerId !== null) {
- this.state.setDropPreview({
- containerId: this.state.originContainerId,
- position: this.state.originPosition,
- visible: true,
- draggedElementHeight: this.state.elementSize?.height,
- draggedElementWidth: this.state.elementSize?.width
- })
- }
-
- if (shouldAnimate && this.state.element && this.state.transform) {
- // Wait for next frame to ensure placeholder is rendered in DOM
- requestAnimationFrame(() => {
- this.animationController.animateReturn(() => {
- this.finalizeDragEnd(itemId)
- })
- })
- } else {
- this.finalizeDragEnd(itemId)
- }
-
- setTimeout(() => {
- this.state.setSkipDropPreviewAnimation(false)
- }, 100)
- }
-
- registerDropZones(zones: DropZone[]) {
- this.state.setDropZones(zones)
- }
-
- refreshDropZones() {
- this.notifyDragStart(this.state.draggedItem || '')
- }
-
- calculateDropZones(
- containerId: string,
- containerElement: HTMLElement,
- direction: DndDirection = 'vertical'
- ): DropZone[] {
- return this.dropZoneCalculator.calculateDropZones(containerId, containerElement, direction)
- }
-
- mergeDropZones(
- existingZones: DropZone[],
- newZones: DropZone[],
- containerId: string
- ): DropZone[] {
- return this.dropZoneCalculator.mergeZones(existingZones, newZones, containerId)
- }
-
- toggleDebugZones() {
- this.state.toggleDebugZones()
- }
-
- private finalizeDragEnd(itemId: string | null) {
- this.state.setSkipDropPreviewAnimation(true)
- this.scrollController.clearAll()
- this.state.reset()
-
- if (itemId) {
- this.notifyDragEnd(itemId)
- }
-
- setTimeout(() => {
- this.state.setSkipDropPreviewAnimation(false)
- }, 100)
- }
-
- private notifyDragStart(itemId: string) {
- this.dragStartCallbacks.forEach((callback) => callback(itemId))
- }
-
- private notifyDragEnd(itemId: string) {
- this.dragEndCallbacks.forEach((callback) => callback(itemId))
- }
-
- private notifyDrop(
- sourceId: string,
- sourceData: any,
- targetContainerId: string,
- position: number
- ) {
- this.dropCallbacks.forEach((callback) =>
- callback(sourceId, sourceData, targetContainerId, position)
- )
- }
-
- destroy() {
- this.scrollController.destroy()
- this.droppableDataRegistry.clear()
- this.dragStartCallbacks.clear()
- this.dragEndCallbacks.clear()
- this.dropCallbacks.clear()
- }
-}
diff --git a/src/lib/core/drag-state.svelte.ts b/src/lib/core/drag-state.svelte.ts
deleted file mode 100644
index ccbb077..0000000
--- a/src/lib/core/drag-state.svelte.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-import type { DropZone, DropPreview } from '../types.js'
-
-export class DragState {
- isDragging = $state(false)
- dragElement = $state(null)
- draggedItemId = $state(null)
- draggedItemType = $state(null)
- draggedItemData = $state | undefined>(undefined)
- currentTransform = $state<{ x: number; y: number } | null>(null)
- elementSize = $state<{ width: number; height: number } | null>(null)
- originalPosition = $state<{ x: number; y: number } | null>(null)
- isAnimating = $state(false)
- dropZones = $state([])
- currentDropPreview = $state(null)
- showDebugZones = $state(false)
- isPerformingDrop = $state(false)
- shouldSkipDropPreviewAnimation = $state(false)
- originContainerId = $state(null)
- originPosition = $state(0)
-
- get dragging() {
- return this.isDragging
- }
-
- get element() {
- return this.dragElement
- }
-
- get transform() {
- return this.currentTransform
- }
-
- get draggedItem() {
- return this.draggedItemId
- }
-
- get draggedType() {
- return this.draggedItemType
- }
-
- get size() {
- return this.elementSize
- }
-
- get animating() {
- return this.isAnimating
- }
-
- get dropPreview() {
- return this.currentDropPreview
- }
-
- get zones() {
- return this.dropZones
- }
-
- get debugZones() {
- return this.showDebugZones
- }
-
- get performingDrop() {
- return this.isPerformingDrop
- }
-
- get skipDropPreviewAnimation() {
- return this.shouldSkipDropPreviewAnimation
- }
-
- setDragging(value: boolean) {
- this.isDragging = value
- }
-
- setElement(element: HTMLElement | null) {
- this.dragElement = element
- }
-
- setDraggedItemId(id: string | null) {
- this.draggedItemId = id
- }
-
- setDraggedItemType(type: string | null) {
- this.draggedItemType = type
- }
-
- setDraggedItemData(data: Record | undefined) {
- this.draggedItemData = data
- }
-
- setTransform(transform: { x: number; y: number } | null) {
- this.currentTransform = transform
- }
-
- setElementSize(size: { width: number; height: number } | null) {
- this.elementSize = size
- }
-
- setOriginalPosition(position: { x: number; y: number } | null) {
- this.originalPosition = position
- }
-
- setAnimating(value: boolean) {
- this.isAnimating = value
- }
-
- setDropZones(zones: DropZone[]) {
- this.dropZones = zones
- }
-
- setDropPreview(preview: DropPreview | null) {
- this.currentDropPreview = preview
- }
-
- setDebugZones(value: boolean) {
- this.showDebugZones = value
- }
-
- setPerformingDrop(value: boolean) {
- this.isPerformingDrop = value
- }
-
- setSkipDropPreviewAnimation(value: boolean) {
- this.shouldSkipDropPreviewAnimation = value
- }
-
- setOriginContainerId(id: string | null) {
- this.originContainerId = id
- }
-
- setOriginPosition(position: number) {
- this.originPosition = position
- }
-
- toggleDebugZones() {
- this.showDebugZones = !this.showDebugZones
- }
-
- reset() {
- this.isDragging = false
- this.dragElement = null
- this.currentTransform = null
- this.draggedItemId = null
- this.draggedItemType = null
- this.draggedItemData = undefined
- this.elementSize = null
- this.originalPosition = null
- this.isAnimating = false
- this.isPerformingDrop = false
- this.originContainerId = null
- this.originPosition = 0
- this.dropZones = []
- this.currentDropPreview = null
- }
-}
diff --git a/src/lib/core/dropzone-calculator.ts b/src/lib/core/dropzone-calculator.ts
deleted file mode 100644
index 65ae066..0000000
--- a/src/lib/core/dropzone-calculator.ts
+++ /dev/null
@@ -1,376 +0,0 @@
-import type { DropZone, DropPreview, DndDirection } from '../types.js'
-import type { DragState } from './drag-state.svelte.js'
-import { DOMHelper } from './dom-helper.js'
-
-export class DropZoneCalculator {
- private domHelper = new DOMHelper()
-
- constructor(
- private state: DragState,
- private droppableDataRegistry: Map>
- ) {}
-
- calculateDropZones(
- containerId: string,
- containerElement: HTMLElement,
- direction: DndDirection = 'vertical'
- ): DropZone[] {
- if (!containerElement) return []
-
- const containerRect = this.domHelper.getRect(containerElement)
-
- const allDraggableItems = this.domHelper.findDraggableItems(containerElement)
- const draggedId = this.state.draggedItem
- const draggableItems = allDraggableItems.filter((item) => {
- if (item.getAttribute('data-dnd-drag-id') === draggedId) return false
- return this.domHelper.filterItemsByContainer([item], containerElement).length > 0
- })
-
- if (draggableItems.length === 0) {
- return this.createEmptyContainerZone(containerId, containerRect, direction)
- }
-
- switch (direction) {
- case 'horizontal':
- return this.createHorizontalZones(containerId, containerRect, draggableItems)
- case 'grid':
- return this.createGridZones(containerId, containerRect, draggableItems)
- default:
- return this.createVerticalZones(containerId, containerRect, draggableItems)
- }
- }
-
- private createEmptyContainerZone(
- containerId: string,
- containerRect: DOMRect,
- direction: DndDirection
- ): DropZone[] {
- return [
- {
- containerId,
- position: 0,
- direction,
- rect: {
- x: containerRect.left,
- y: containerRect.top,
- width: containerRect.width,
- height: Math.max(containerRect.height, 20)
- }
- }
- ]
- }
-
- private createVerticalZones(
- containerId: string,
- containerRect: DOMRect,
- items: HTMLElement[]
- ): DropZone[] {
- const zones: DropZone[] = []
-
- items.forEach((item, index) => {
- const itemRect = this.domHelper.getRect(item)
- const halfHeight = itemRect.height / 2
-
- if (index === 0) {
- const zoneTop = Math.min(containerRect.top, itemRect.top)
- const zoneHeight = Math.max(
- halfHeight,
- itemRect.top - containerRect.top + halfHeight
- )
-
- zones.push({
- containerId,
- position: 0,
- direction: 'vertical',
- rect: {
- x: containerRect.left,
- y: zoneTop,
- width: containerRect.width,
- height: zoneHeight
- }
- })
- }
-
- const nextItem = items[index + 1]
- const zoneY = itemRect.top + halfHeight
- let zoneHeight = halfHeight
-
- if (nextItem) {
- const nextItemRect = this.domHelper.getRect(nextItem)
- const nextHalfHeight = nextItemRect.height / 2
- const gapBetweenItems = nextItemRect.top - itemRect.bottom
- zoneHeight = halfHeight + gapBetweenItems + nextHalfHeight
- } else {
- const remainingSpace = containerRect.bottom - zoneY
- zoneHeight = Math.max(halfHeight, remainingSpace)
- }
-
- zones.push({
- containerId,
- position: index + 1,
- direction: 'vertical',
- rect: {
- x: containerRect.left,
- y: zoneY,
- width: containerRect.width,
- height: zoneHeight
- }
- })
- })
-
- return zones
- }
-
- private createHorizontalZones(
- containerId: string,
- containerRect: DOMRect,
- items: HTMLElement[]
- ): DropZone[] {
- const zones: DropZone[] = []
-
- items.forEach((item, index) => {
- const itemRect = this.domHelper.getRect(item)
- const halfWidth = itemRect.width / 2
-
- if (index === 0) {
- const zoneLeft = Math.min(containerRect.left, itemRect.left)
- const zoneWidth = Math.max(
- halfWidth,
- itemRect.left - containerRect.left + halfWidth
- )
-
- zones.push({
- containerId,
- position: 0,
- direction: 'horizontal',
- rect: {
- x: zoneLeft,
- y: containerRect.top,
- width: zoneWidth,
- height: containerRect.height
- }
- })
- }
-
- const nextItem = items[index + 1]
- const zoneX = itemRect.left + halfWidth
- let zoneWidth = halfWidth
-
- if (nextItem) {
- const nextItemRect = this.domHelper.getRect(nextItem)
- const nextHalfWidth = nextItemRect.width / 2
- const gapBetweenItems = nextItemRect.left - itemRect.right
- zoneWidth = halfWidth + gapBetweenItems + nextHalfWidth
- } else {
- const remainingSpace = containerRect.right - zoneX
- zoneWidth = Math.max(halfWidth, remainingSpace)
- }
-
- zones.push({
- containerId,
- position: index + 1,
- direction: 'horizontal',
- rect: {
- x: zoneX,
- y: containerRect.top,
- width: zoneWidth,
- height: containerRect.height
- }
- })
- })
-
- return zones
- }
-
- private createGridZones(
- containerId: string,
- containerRect: DOMRect,
- items: HTMLElement[]
- ): DropZone[] {
- const zones: DropZone[] = []
- const rows = this.groupItemsIntoRows(items)
- let positionIndex = 0
-
- rows.forEach((row, rowIndex) => {
- const nextRow = rows[rowIndex + 1]
-
- row.forEach((item, colIndex) => {
- const itemRect = this.domHelper.getRect(item)
- const halfWidth = itemRect.width / 2
- const halfHeight = itemRect.height / 2
-
- const zoneTop =
- rowIndex === 0
- ? Math.min(containerRect.top, itemRect.top)
- : itemRect.top - halfHeight
- const zoneBottom = nextRow
- ? itemRect.bottom + (this.domHelper.getRect(nextRow[0]).top - itemRect.bottom) / 2
- : Math.max(containerRect.bottom, itemRect.bottom)
-
- if (colIndex === 0) {
- const zoneLeft = Math.min(containerRect.left, itemRect.left)
- const zoneRight = itemRect.left + halfWidth
-
- zones.push({
- containerId,
- position: positionIndex,
- direction: 'grid',
- rect: {
- x: zoneLeft,
- y: zoneTop,
- width: zoneRight - zoneLeft,
- height: zoneBottom - zoneTop
- }
- })
- positionIndex++
- }
-
- const nextItem = row[colIndex + 1]
- const zoneLeft = itemRect.left + halfWidth
- let zoneRight: number
-
- if (nextItem) {
- const nextRect = this.domHelper.getRect(nextItem)
- zoneRight = nextRect.left + nextRect.width / 2
- } else {
- zoneRight = Math.max(containerRect.right, itemRect.right)
- }
-
- zones.push({
- containerId,
- position: positionIndex,
- direction: 'grid',
- rect: {
- x: zoneLeft,
- y: zoneTop,
- width: zoneRight - zoneLeft,
- height: zoneBottom - zoneTop
- }
- })
- positionIndex++
- })
- })
-
- return zones
- }
-
- private groupItemsIntoRows(items: HTMLElement[]): HTMLElement[][] {
- if (items.length === 0) return []
-
- const rows: HTMLElement[][] = []
- let currentRow: HTMLElement[] = [items[0]]
- let currentRowTop = this.domHelper.getRect(items[0]).top
-
- for (let i = 1; i < items.length; i++) {
- const itemRect = this.domHelper.getRect(items[i])
- const rowThreshold = this.domHelper.getRect(items[i]).height * 0.5
-
- if (Math.abs(itemRect.top - currentRowTop) < rowThreshold) {
- currentRow.push(items[i])
- } else {
- rows.push(currentRow)
- currentRow = [items[i]]
- currentRowTop = itemRect.top
- }
- }
-
- rows.push(currentRow)
- return rows
- }
-
- updateDropPreview(mousePos: { x: number; y: number }) {
- if (!this.state.dragging) {
- this.state.setDropPreview(null)
- return
- }
-
- const targetZone = this.findZoneAtPosition(mousePos)
-
- if (targetZone) {
- const preview: DropPreview = {
- containerId: targetZone.containerId,
- position: targetZone.position,
- visible: true,
- draggedElementHeight: this.state.size?.height,
- draggedElementWidth: this.state.size?.width
- }
- this.state.setDropPreview(preview)
-
- if (this.state.skipDropPreviewAnimation) {
- requestAnimationFrame(() => {
- this.state.setSkipDropPreviewAnimation(false)
- })
- }
- } else {
- this.state.setDropPreview(null)
- }
- }
-
- private findZoneAtPosition(mousePos: { x: number; y: number }): DropZone | null {
- const draggedItemId = this.state.draggedItem
- if (!draggedItemId) return null
-
- const filteredZones = this.filterZonesByDraggedItemType(
- this.state.zones,
- draggedItemId
- )
-
- for (const zone of filteredZones) {
- if (this.isPointInZone(mousePos, zone) && this.isPointInContainer(mousePos, zone.containerId)) {
- return zone
- }
- }
- return null
- }
-
- private isPointInContainer(point: { x: number; y: number }, containerId: string): boolean {
- const containerElement = this.domHelper.findContainer(containerId)
- if (!containerElement) return true
-
- const rect = this.domHelper.getRect(containerElement)
- return (
- point.x >= rect.left &&
- point.x <= rect.right &&
- point.y >= rect.top &&
- point.y <= rect.bottom
- )
- }
-
- filterZonesByDraggedItemType(zones: DropZone[], draggedItemId: string): DropZone[] {
- const draggedType = this.state.draggedType
- if (!draggedType) return zones
-
- return zones.filter((zone) => {
- const droppableData = this.droppableDataRegistry.get(zone.containerId)
- if (!droppableData) return true
-
- const accepts = droppableData.accepts || droppableData.type
-
- if (!accepts) return true
-
- if (Array.isArray(accepts)) {
- return accepts.includes(draggedType)
- }
-
- return accepts === draggedType
- })
- }
-
- private isPointInZone(point: { x: number; y: number }, zone: DropZone): boolean {
- return (
- point.x >= zone.rect.x &&
- point.x <= zone.rect.x + zone.rect.width &&
- point.y >= zone.rect.y &&
- point.y <= zone.rect.y + zone.rect.height
- )
- }
-
- mergeZones(
- existingZones: DropZone[],
- newZones: DropZone[],
- containerId: string
- ): DropZone[] {
- const otherZones = existingZones.filter((z) => z.containerId !== containerId)
- return [...otherZones, ...newZones]
- }
-}
diff --git a/src/lib/index.ts b/src/lib/index.ts
deleted file mode 100644
index 4a90b73..0000000
--- a/src/lib/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export { default as DndProvider } from './components/DndProvider.svelte'
-export { default as DndDraggable } from './components/DndDraggable.svelte'
-export { default as DndDroppable } from './components/DndDroppable.svelte'
-export { default as DndPreview } from './components/DndPreview.svelte'
-export { DragController } from './core/drag-controller.svelte.js'
-export { createConditionalSlide, createConditionalScale } from './utils/conditional-transition.js'
-export type { DndDragEvent, DndDropEvent, DropZone, DropPreview, DndDirection } from './types.js'
diff --git a/src/lib/types.ts b/src/lib/types.ts
deleted file mode 100644
index ebfed3a..0000000
--- a/src/lib/types.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import type { Snippet } from 'svelte'
-
-export type DndDirection = 'vertical' | 'horizontal' | 'grid'
-
-export interface DndDragEvent {
- source: {
- id: string
- element: HTMLElement
- data?: Record
- }
- target?: {
- id: string
- element: HTMLElement
- data?: Record
- } | null
- transform: {
- x: number
- y: number
- }
-}
-
-export interface DndDropEvent {
- source: {
- id: string
- element: HTMLElement
- data?: Record
- }
- target: {
- id: string
- element: HTMLElement
- data?: Record
- } | null
-}
-
-export interface DropZone {
- containerId: string
- position: number
- direction: DndDirection
- itemId?: string
- rect: {
- x: number
- y: number
- width: number
- height: number
- }
-}
-
-export interface DropPreview {
- containerId: string
- position: number
- visible: boolean
- draggedElementHeight?: number
- draggedElementWidth?: number
-}
-
-export interface GhostSnippetProps {
- element: HTMLElement
- data?: Record
- itemId: string
-}
-
-export type GhostSnippet = Snippet<[GhostSnippetProps]>
diff --git a/src/lib/utils/conditional-transition.ts b/src/lib/utils/conditional-transition.ts
deleted file mode 100644
index f915243..0000000
--- a/src/lib/utils/conditional-transition.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { slide, scale } from 'svelte/transition'
-import type { DragController } from '../core/drag-controller.svelte.js'
-
-export function createConditionalSlide(dndManager: DragController) {
- return function conditionalSlide(node: HTMLElement, options: any) {
- if (dndManager?.skipDropPreviewAnimation) {
- return { duration: 0 }
- }
- if (dndManager?.animatingReturn) {
- return slide(node, { ...options, duration: 200 })
- }
- return slide(node, options)
- }
-}
-
-export function createConditionalScale(dndManager: DragController) {
- return function conditionalScale(node: HTMLElement, options: any) {
- if (dndManager?.skipDropPreviewAnimation) {
- return { duration: 0 }
- }
- if (dndManager?.animatingReturn) {
- return scale(node, { ...options, duration: 100 })
- }
- return scale(node, options)
- }
-}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
deleted file mode 100644
index 92f42cf..0000000
--- a/src/routes/+page.svelte
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
svelte-dnd
-
- A drag-and-drop library for Svelte 5 with support for vertical, horizontal, and grid layouts.
-
-
-
-
-
-
-
-
-
-
Documentation
-
Learn how to install and use the library.
-
- Read Docs →
-
-
-
-
diff --git a/src/routes/docs/+layout.svelte b/src/routes/docs/+layout.svelte
deleted file mode 100644
index 5ef1466..0000000
--- a/src/routes/docs/+layout.svelte
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
- {@render children()}
-
-
-
-
\ No newline at end of file
diff --git a/src/routes/docs/components-api/+page.md b/src/routes/docs/components-api/+page.md
deleted file mode 100644
index f8d8207..0000000
--- a/src/routes/docs/components-api/+page.md
+++ /dev/null
@@ -1,172 +0,0 @@
-# Components API
-
-## DndProvider
-
-Wraps your drag-and-drop area and provides the `DragController` context to all child components.
-
-### Props
-
-| Prop | Type | Default | Description |
-|------|------|---------|-------------|
-| `controller` | `DragController` | auto-created | Optional pre-created controller instance |
-| `ghost` | `Snippet<[GhostSnippetProps]>` | — | Custom ghost element renderer during drag |
-
-### GhostSnippetProps
-
-```ts
-interface GhostSnippetProps {
- element: HTMLElement;
- data?: Record;
- itemId: string;
-}
-```
-
-If no `ghost` snippet is provided, the dragged element's HTML is cloned as the ghost.
-
----
-
-## DndDraggable
-
-Wraps a single draggable element. Must be a child of `DndDroppable` inside a `DndProvider`.
-
-### Props
-
-| Prop | Type | Default | Description |
-|------|------|---------|-------------|
-| `id` | `string` | **required** | Unique identifier for this draggable item |
-| `data` | `Record` | `{}` | Arbitrary data attached to the item |
-| `disabled` | `boolean` | `false` | Disables dragging when `true` |
-| `class` | `string` | — | Additional CSS class names |
-
-### Events
-
-| Event | Type | Description |
-|-------|------|-------------|
-| `onDragStart` | `(event: DndDragEvent) => void` | Fired when the drag begins |
-| `onDrag` | `(event: DndDragEvent) => void` | Fired on every pointer move during drag |
-| `onDragEnd` | `(event: DndDragEvent) => void` | Fired when the drag ends |
-
-### No drag
-
-Add `data-dnd-no-drag` to any element inside `DndDraggable` to prevent drag handling on it. Useful for buttons, inputs, and links that need to receive native events.
-
-```svelte
-
- alert("Test")}>Alert
- {task.label}
-
-```
-
-### Drag handle
-
-Add `data-dnd-handle` to restrict dragging to specific elements. When at least one handle is present, `data-dnd-no-drag` is ignored — only handle elements can initiate a drag.
-
-```svelte
-
- ☰ {column.title}
-
-
-```
-
-
-### DndDragEvent
-
-```ts
-interface DndDragEvent {
- source: {
- id: string;
- element: HTMLElement;
- data?: Record;
- };
- target?: {
- id: string;
- element: HTMLElement;
- data?: Record;
- } | null;
- transform: { x: number; y: number };
-}
-```
-
----
-
-## DndDroppable
-
-Defines a container that accepts draggable items. Automatically registers and calculates drop zones.
-
-### Props
-
-| Prop | Type | Default | Description |
-|------|------|---------|-------------|
-| `id` | `string` | **required** | Unique container identifier |
-| `data` | `Record` | `{}` | Container data (supports `type` or `accepts` for filtering) |
-| `disabled` | `boolean` | `false` | Disables dropping when `true` |
-| `direction` | `DndDirection` | `'vertical'` | Layout direction: `'vertical'`, `'horizontal'` |
-| `class` | `string` | — | Additional CSS class names |
-
-### Auto-scroll
-
-`DndDroppable` automatically marks itself with a `data-dnd-scroll` attribute. The auto-scroll feature only activates for containers that carry this attribute, so only `DndDroppable` elements (not arbitrary scrollable ancestors) will be scrolled during a drag.
-
-### Type Filtering
-
-Use the `data` prop to control which items can be dropped into a container. The `accepts` field can be a string or an array of strings:
-
-```svelte
-
-
-
-
-
-```
-
----
-
-## DndPreview
-
-Renders a placeholder at a specific position within a container to indicate where a dragged item will be dropped.
-
-### Props
-
-| Prop | Type | Default | Description |
-|------|------|---------|-------------|
-| `containerId` | `string` | **required** | Which container this preview belongs to |
-| `position` | `number` | **required** | Position index within the container |
-| `show` | `boolean` | `true` | Whether the preview should be visible |
-| `direction` | `DndDirection` | `'vertical'` | Layout direction — must match the parent `DndDroppable` |
-| `fallbackHeight` | `number` | `48` | Default height in pixels if element height is unknown |
-| `fallbackWidth` | `number` | `48` | Default width in pixels if element width is unknown |
-| `class` | `string` | — | Additional CSS class names |
-
-### Usage Pattern
-
-Place a `DndPreview` before each `DndDraggable` and one after the last item:
-
-```svelte
-{#each items as item, index (item.id)}
-
- ...
-{/each}
-
-
-```
-
----
-
-## DndDropEvent
-
-Exported type describing a drop event (available from `@horuse/svelte-dnd`).
-
-```ts
-interface DndDropEvent {
- source: {
- id: string;
- element: HTMLElement;
- data?: Record;
- };
- target: {
- id: string;
- element: HTMLElement;
- data?: Record;
- } | null;
-}
-```
diff --git a/src/routes/docs/drag-controller-api/+page.md b/src/routes/docs/drag-controller-api/+page.md
deleted file mode 100644
index 9a4c703..0000000
--- a/src/routes/docs/drag-controller-api/+page.md
+++ /dev/null
@@ -1,222 +0,0 @@
-# DragController API
-
-The `DragController` is the core engine that manages all drag-and-drop state and logic. You can create one explicitly or let `DndProvider` create one automatically.
-
-## Constructor
-
-```ts
-import { DragController } from '@horuse/svelte-dnd';
-
-const controller = new DragController();
-```
-
-Pass it to the provider via the `controller` prop:
-
-```svelte
-
-
-
-```
-
-This gives you direct access to the controller's methods and reactive getters.
-
-## Reactive Getters
-
-All getters are Svelte 5 reactive (`$state` / `$derived` internally).
-
-| Getter | Type | Description |
-|--------|------|-------------|
-| `dragging` | `boolean` | Whether a drag is currently in progress |
-| `element` | `HTMLElement \| null` | The DOM element being dragged |
-| `transform` | `{ x: number; y: number } \| null` | Current ghost position |
-| `draggedItem` | `string \| null` | ID of the item being dragged |
-| `draggedType` | `string \| null` | Type of the dragged item (from `data.type`) |
-| `draggedItemData` | `Record \| undefined` | Data attached to the dragged item |
-| `size` | `{ width: number; height: number } \| null` | Size of the dragged element |
-| `animatingReturn` | `boolean` | Whether the ghost is animating back to origin |
-| `dropPreview` | `DropPreview \| null` | Current drop preview state |
-| `dropZones` | `DropZone[]` | All registered drop zones |
-| `filteredDropZones` | `DropZone[]` | Drop zones filtered by the dragged item's type |
-| `debugZones` | `boolean` | Whether debug zone visualization is enabled |
-| `performingDrop` | `boolean` | Whether a drop animation is in progress |
-| `skipDropPreviewAnimation` | `boolean` | Whether preview animations are skipped |
-
-## Event Callbacks
-
-All event methods return an unsubscribe function.
-
-### onDragStart
-
-```ts
-const unsubscribe = controller.onDragStart((itemId: string) => {
- console.log('Started dragging:', itemId);
-});
-
-// Later: unsubscribe()
-```
-
-### onDragEnd
-
-```ts
-controller.onDragEnd((itemId: string) => {
- console.log('Stopped dragging:', itemId);
-});
-```
-
-### onDrop
-
-The main callback for handling item reordering or moving between containers.
-
-```ts
-controller.onDrop((sourceId, sourceData, targetContainerId, position) => {
- // sourceId: ID of the dragged item
- // sourceData: data attached to the dragged item
- // targetContainerId: ID of the container where item was dropped
- // position: index within the target container
-});
-```
-
-## Methods
-
-### startDrag
-
-```ts
-controller.startDrag(
- element: HTMLElement,
- itemId: string,
- initialPosition: { x: number; y: number },
- data?: Record
-): void
-```
-
-Initiates a drag operation. Called internally by `DndDraggable`.
-
-### updateTransform
-
-```ts
-controller.updateTransform(transform: { x: number; y: number }): void
-```
-
-Updates the ghost element position during drag.
-
-### updateMousePosition
-
-```ts
-controller.updateMousePosition(mouseX: number, mouseY: number): void
-```
-
-Updates the mouse position for drop zone detection and auto-scroll.
-
-### performDrop
-
-```ts
-controller.performDrop(
- sourceId: string,
- sourceData: any,
- targetContainerId: string,
- position: number
-): void
-```
-
-Performs a drop with animation to the target zone.
-
-### endDrag
-
-```ts
-controller.endDrag(shouldAnimate?: boolean): void
-```
-
-Ends the drag. When `shouldAnimate` is `true` (default), the ghost animates back to its origin.
-
-### setSkipDropPreviewAnimation
-
-```ts
-controller.setSkipDropPreviewAnimation(value: boolean): void
-```
-
-Controls whether `DndPreview` open/close animations are skipped. Useful when you need previews to appear or disappear instantly (e.g. during rapid container switches).
-
-### toggleDebugZones
-
-```ts
-controller.toggleDebugZones(): void
-```
-
-Toggles visual overlay of all drop zones. Useful for development and debugging.
-
-### destroy
-
-```ts
-controller.destroy(): void
-```
-
-Cleans up all internal state, listeners, and registries. Called automatically when `DndProvider` unmounts (if it created the controller).
-
-## Drop Zone Management
-
-These methods are used internally by `DndDroppable` but can be called directly for advanced use cases.
-
-| Method | Description |
-|--------|-------------|
-| `registerDropZones(zones)` | Register all drop zones |
-| `calculateDropZones(containerId, element, direction?)` | Calculate zones for a container |
-| `mergeDropZones(existing, newZones, containerId)` | Merge new zones into existing set |
-| `refreshDropZones()` | Recalculate all drop zones |
-| `registerDroppableData(id, data)` | Register container data |
-| `unregisterDroppableData(id)` | Unregister container data |
-
-## Transition Utilities
-
-Two helper functions are exported for building custom drop preview components. They produce Svelte transition functions that are aware of the controller's animation state — skipping animation when previews should appear instantly, and using shorter durations during ghost return.
-
-### createConditionalSlide
-
-```ts
-import { createConditionalSlide } from '@horuse/svelte-dnd';
-
-const slide = createConditionalSlide(controller);
-```
-
-Returns a Svelte `slide`-based transition function. Behaves as follows:
-- If `controller.skipDropPreviewAnimation` is `true` — duration is `0ms`
-- If `controller.animatingReturn` is `true` — duration is `200ms`
-- Otherwise — uses the options passed to the transition
-
-### createConditionalScale
-
-```ts
-import { createConditionalScale } from '@horuse/svelte-dnd';
-
-const scale = createConditionalScale(controller);
-```
-
-Returns a Svelte `scale`-based transition function. Behaves as follows:
-- If `controller.skipDropPreviewAnimation` is `true` — duration is `0ms`
-- If `controller.animatingReturn` is `true` — duration is `100ms`
-- Otherwise — uses the options passed to the transition
-
-These are used internally by `DndPreview`. Use them if you implement a fully custom preview component.
-
----
-
-## Types
-
-```ts
-interface DropPreview {
- containerId: string;
- position: number;
- visible: boolean;
- draggedElementHeight?: number;
- draggedElementWidth?: number;
-}
-
-interface DropZone {
- containerId: string;
- position: number;
- direction: DndDirection;
- itemId?: string;
- rect: { x: number; y: number; width: number; height: number };
-}
-
-type DndDirection = 'vertical' | 'horizontal';
-```
diff --git a/src/routes/docs/faq/+page.md b/src/routes/docs/faq/+page.md
deleted file mode 100644
index f7ca80a..0000000
--- a/src/routes/docs/faq/+page.md
+++ /dev/null
@@ -1,189 +0,0 @@
-# FAQ
-
-Common questions
-
----
-
-## The button doesn't click
-
-Add `data-dnd-no-drag` attribute to the element so that it behaves as usual and does not start dragging the element.
-
-```svelte
-
- alert("Test")}>Alert
- {task.label}
-
-```
-
----
-
-## How do I drag only by a handle?
-
-Add `data-dnd-handle` to the element that should act as the drag trigger. Once at least one handle is present, dragging from anywhere else on the item is blocked automatically - no need for `data-dnd-no-drag`.
-
-```svelte
-
- ☰ {column.title}
-
-
-```
-
-Multiple handles are supported — just add the attribute to each element:
-
-```svelte
-
-
- content
-
-
-```
-
----
-
-## How do I enable auto-scroll in a scrollable container?
-
-Auto-scroll activates for any element that has the `data-dnd-scroll` attribute **and** `overflow: auto` or `overflow: scroll`, when the pointer is near its edge during a drag.
-
-`DndDroppable` adds `data-dnd-scroll` automatically, so its own scrolling works out of the box.
-
-If you have an **external** scrollable wrapper around several droppables (e.g. a horizontal kanban board), add `data-dnd-scroll` manually:
-
-```svelte
-
-
- {#each columns as col}
-
- ...
-
- {/each}
-
-```
-
----
-
-## How do I restrict which items can be dropped into a container?
-
-Set a `type` on each `DndDraggable` and an `accepts` list on each `DndDroppable`:
-
-```svelte
-Task
-
-
-...
-
-
-...
-```
-
----
-
-## How do I move items between multiple containers?
-
-Use a single `DragController` shared across all containers. Read `targetContainerId` inside `onDrop` to determine where the item landed:
-
-```svelte
-
-
-
-
-```
-
----
-
-## How do I create a custom ghost element?
-
-Pass a `ghost` snippet to `DndProvider`. The snippet receives `{ element, data, itemId }`:
-
-```svelte
-
- {#snippet ghost({ element, data })}
-
- {data.label}
-
- {/snippet}
-
-
-
-```
-
----
-
-## Items aren't draggable on touch / mobile devices
-
-Make sure `touch-action: none` is set on draggable elements. The library sets this via `.dnd-draggable` — do not override it in your own CSS:
-
-```css
-/* ✗ don't do this */
-.my-item {
- touch-action: auto;
-}
-```
-
-If you apply `touch-action` somewhere in your layout that affects draggable children, remove it or scope it away from `.dnd-draggable`.
-
----
-
-## The drop preview doesn't appear
-
-Check two things:
-
-1. The `containerId` prop on `DndPreview` exactly matches the `id` prop on the target `DndDroppable`.
-2. The `position` prop is in the range `0 … items.length` (inclusive).
-
-```svelte
-
-
-
-
-```
-
----
-
-See the **Sortable Containers** example for a working implementation.
-
----
-
-## Ghost returns to the wrong position after a cancelled drag
-
-The library animates the ghost back to the position of the placeholder element. Make sure you restore the original item in `onDragEnd` so the placeholder is still in the DOM at the correct index when the animation starts:
-
-```svelte
-controller.onDragEnd(() => {
- hiddenId = null;
- draggedType = null;
-});
-```
-
----
-
-## How do I debug drop zones?
-
-Call `controller.toggleDebugZones()` — blue overlays will appear on all drop zones so you can verify their boundaries:
-
-```svelte
- controller.toggleDebugZones()}>
- Toggle debug zones
-
-```
-
-See the [CSS Custom Properties & Classes](/docs/css-custom-props) page for the classes used by the overlay elements.
-
----
-
-## How do I disable dragging conditionally?
-
-Pass the `disabled` prop to `DndDraggable`:
-
-```svelte
-
- {item.label}
-
-```
-
-A disabled item is not draggable and receives the `.dnd-draggable--disabled` CSS class.
diff --git a/src/routes/docs/getting-started/+page.md b/src/routes/docs/getting-started/+page.md
deleted file mode 100644
index 2f44d83..0000000
--- a/src/routes/docs/getting-started/+page.md
+++ /dev/null
@@ -1,100 +0,0 @@
-# Getting Started
-
-## Installation
-
-```bash
-bun add @horuse/svelte-dnd
-```
-
-Or with npm:
-
-```bash
-npm install @horuse/svelte-dnd
-```
-
-## Basic Example
-
-A minimal working drag-and-drop setup requires four components: `DndProvider`, `DndDroppable`, `DndDraggable`, and `DndPreview`.
-
-```svelte
-
-
-
-
- {#each visibleItems as item, index (item.id)}
-
-
-
- {item.label}
-
- {/each}
-
-
-
-
-```
-
-## How It Works
-
-1. **DndProvider** wraps your app and creates a `DragController` context.
-2. **DndDroppable** defines a container where items can be dropped. Set `direction` to `"vertical"` or `"horizontal"`.
-3. **DndDraggable** wraps each draggable item. Each must have a unique `id`.
-4. **DndPreview** renders a placeholder at each potential drop position. Place one before each item and one after the last item.
-5. **Hide the dragged item** — subscribe to `onDragStart` / `onDragEnd` and filter out the dragged item from the rendered list. This removes the original element from the DOM flow so only the ghost follows the cursor.
-6. Use `controller.onDrop()` to handle reordering logic when an item is dropped.
diff --git a/src/routes/docs/html-attributes/+page.md b/src/routes/docs/html-attributes/+page.md
deleted file mode 100644
index 27769af..0000000
--- a/src/routes/docs/html-attributes/+page.md
+++ /dev/null
@@ -1,63 +0,0 @@
-# HTML Attributes
-
-The library sets these attributes automatically — you don't need to add them manually. However, knowing them is useful for CSS targeting and debugging.
-
----
-
-## Data Attributes
-
-| Attribute | Set by | Purpose |
-|-----------------------------|--------|---------|
-| `data-dnd-drop-id` | `DndDroppable` | Unique container ID — used for DOM lookup of the container |
-| `data-dnd-direction` | `DndDroppable` | `"vertical"` or `"horizontal"` — read when calculating drop zones |
-| `data-dnd-scroll` | `DndDroppable` | Flag — auto-scroll activates only for elements with this attribute |
-| `data-dnd-drag-id` | `DndDraggable` | Unique item ID — used when identifying a draggable element |
-| `data-dnd-handle` | `DndDraggable` | Marks an element as the drag handle. When present, dragging starts only from handle elements; `data-dnd-no-drag` is ignored. |
-| `data-dnd-no-drag` | `DndDraggable` | Disables dragging for this element. Ignored when `data-dnd-handle` is used. |
-| `data-dnd-draggable-item` | `DndDraggable` | Marker — the selector `:scope > [data-dnd-draggable-item]` collects container items |
-| `data-dnd-preview` | `DndPreview` | Marks a preview (placeholder) element |
-| `data-dnd-preview-position` | `DndPreview` | Numeric position of the preview — used to find a specific placeholder |
-| `data-dnd-dragged-element` | `DndProvider` | Marks the ghost element (the div that follows the cursor) |
-
-### External scrollable containers
-
-`DndDroppable` automatically adds `data-dnd-scroll` to itself, so its own scrolling works out of the box. If you have an **external** scrollable wrapper around several droppables, add `data-dnd-scroll` manually:
-
-```html
-
-
-
-
-```
-
-### Disabling drag on child elements
-
-Add `data-dnd-no-drag` to any element inside a `DndDraggable` to let it receive native pointer events without triggering a drag:
-
-```svelte
-
- alert("clicked!")}>Click me
- {item.label}
-
-```
-
-### Restricting drag to a handle
-
-Add `data-dnd-handle` to one or more elements inside a `DndDraggable`. When at least one handle is present, dragging starts only from those elements — everything else is automatically blocked:
-
-```svelte
-
- ☰ Drag here
- clicks here won't drag
-
-```
-
-Multiple handles are supported — just add the attribute to each:
-
-```svelte
-
-
- content
-
-
-```
\ No newline at end of file
diff --git a/src/routes/examples/+layout.svelte b/src/routes/examples/+layout.svelte
deleted file mode 100644
index a60a733..0000000
--- a/src/routes/examples/+layout.svelte
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-{@render children()}
diff --git a/src/routes/examples/custom-ghost/+page.svelte b/src/routes/examples/custom-ghost/+page.svelte
deleted file mode 100644
index 6f8e7bb..0000000
--- a/src/routes/examples/custom-ghost/+page.svelte
+++ /dev/null
@@ -1,149 +0,0 @@
-
-
-
-
-
-
-
-
- {#snippet ghost({ data, itemId })}
-
- {data?.label ?? itemId}
-
- {/snippet}
-
-
- {#each Object.entries(columns) as [columnId, columnItems] (columnId)}
- {@const visible = getVisibleItems(columnItems)}
-
-
{columnMeta[columnId]}
-
- {#each visible as item, index (item.id)}
-
-
-
- {item.label}
-
-
- {/each}
-
-
-
- {/each}
-
-
-
-
-
diff --git a/src/routes/examples/custom-ghost/description.md b/src/routes/examples/custom-ghost/description.md
deleted file mode 100644
index 26dc2b1..0000000
--- a/src/routes/examples/custom-ghost/description.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# Custom Ghost & Preview Styling
-
-This example combines two features: a **custom ghost** snippet and **scoped preview styling** via CSS custom properties.
-
-Pass a `ghost` snippet to `DndProvider` to replace the default cloned element with a custom drag ghost. The snippet receives `{ element, data, itemId }` — use `data` to render a colored card that differs from the list item.
-
-Each column wrapper sets its own `--dnd-preview-bg`, `--dnd-preview-border`, and `--dnd-preview-border-radius` values. Because `DndPreview` reads these properties from its nearest ancestor, the preview automatically adopts the target container's style as you drag between columns.
-
-[view code](https://github.com/Horuse/svelte-dnd/blob/main/src/routes/examples/custom-ghost/%2Bpage.svelte)
diff --git a/src/routes/examples/horizontal/+page.svelte b/src/routes/examples/horizontal/+page.svelte
deleted file mode 100644
index 9e2715c..0000000
--- a/src/routes/examples/horizontal/+page.svelte
+++ /dev/null
@@ -1,87 +0,0 @@
-
-
-
-
-
-
-
-
-
- {#each visibleItems as item, index (item.id)}
-
-
-
-
- {item.label}
-
-
-
-
- {/each}
-
-
-
-
-
-
-
diff --git a/src/routes/examples/horizontal/description.md b/src/routes/examples/horizontal/description.md
deleted file mode 100644
index f49f5e3..0000000
--- a/src/routes/examples/horizontal/description.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Horizontal List
-
-Set `direction="horizontal"` on both `DndDroppable` and `DndPreview`.
-
-Without `direction="horizontal"` on `DndPreview`, the open/close animation slides vertically instead of horizontally. The reorder logic is the same as the vertical example — only the layout direction changes.
-
-[view code](https://github.com/Horuse/svelte-dnd/blob/main/src/routes/examples/horizontal/%2Bpage.svelte)
\ No newline at end of file
diff --git a/src/routes/examples/multi-container/+page.svelte b/src/routes/examples/multi-container/+page.svelte
deleted file mode 100644
index 8ae8ef6..0000000
--- a/src/routes/examples/multi-container/+page.svelte
+++ /dev/null
@@ -1,118 +0,0 @@
-
-
-
-
-
-
-
-
-
- {#each Object.entries(columns) as [columnId, columnItems] (columnId)}
- {@const visible = getVisibleItems(columnItems)}
-
-
{columnMeta[columnId]}
-
- {#each visible as item, index (item.id)}
-
-
-
- {item.label}
-
-
- {/each}
-
-
-
- {/each}
-
-
-
-
-
\ No newline at end of file
diff --git a/src/routes/examples/multi-container/description.md b/src/routes/examples/multi-container/description.md
deleted file mode 100644
index c6725f4..0000000
--- a/src/routes/examples/multi-container/description.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Multi Container
-
-A kanban board with three columns — items can be moved between containers.
-
-Each column is its own `DndDroppable`, and all share one `DragController` via `DndProvider`. The `onDrop` callback receives `targetContainerId` — find the source column, remove the item, and insert it into the target column at `position`.
-
-[view code](https://github.com/Horuse/svelte-dnd/blob/main/src/routes/examples/multi-container/%2Bpage.svelte)
\ No newline at end of file
diff --git a/src/routes/examples/sortable-containers/+page.svelte b/src/routes/examples/sortable-containers/+page.svelte
deleted file mode 100644
index 1206938..0000000
--- a/src/routes/examples/sortable-containers/+page.svelte
+++ /dev/null
@@ -1,185 +0,0 @@
-
-
-
-
-
-
-
-
-
Debug: {controller.debugZones}
-
controller.toggleDebugZones()}>Show debug zones
-
- To see, start dragging
-
-
-
-
- {#each visibleColumns as column, colIndex (column.id)}
-
-
-
- ☰
- {column.title}
-
-
-
- {@const visible = getVisibleTasks(column.tasks)}
- {#each visible as task, taskIndex (task.id)}
-
-
- {task.label}
- alert("Test")}>Alert
-
- {/each}
-
-
-
- {/each}
-
-
-
-
-
-
diff --git a/src/routes/examples/sortable-containers/description.md b/src/routes/examples/sortable-containers/description.md
deleted file mode 100644
index 0f48f60..0000000
--- a/src/routes/examples/sortable-containers/description.md
+++ /dev/null
@@ -1,13 +0,0 @@
-# Sortable Containers
-
-A horizontal board where both **containers** and **tasks** within them can be dragged.
-
-Drag a container by its header to reorder columns horizontally. Drag tasks within or between containers to move them. This is achieved with a single `DragController` and nested `DndDroppable` components:
-
-- The **board** droppable uses `direction="horizontal"` and accepts `'column'` type items
-- Each **column** droppable uses `direction="vertical"` and accepts `'task'` type items
-- `stopPropagation` on the task area prevents `pointerdown` from bubbling to the column's `DndDraggable`, so dragging from the header moves the column while dragging from the task area moves a task
-
-The existing drop zone calculator filters `[data-dnd-draggable-item]` elements by their closest `[data-dnd-drop-id]` parent, so nested droppables work correctly without extra configuration.
-
-[view code](https://github.com/Horuse/svelte-dnd/blob/main/src/routes/examples/sortable-containers/%2Bpage.svelte)
diff --git a/src/routes/examples/vertical/+page.svelte b/src/routes/examples/vertical/+page.svelte
deleted file mode 100644
index 7170ce2..0000000
--- a/src/routes/examples/vertical/+page.svelte
+++ /dev/null
@@ -1,80 +0,0 @@
-
-
-
-
-
-
-
-
-
- {#each visibleItems as item, index (item.id)}
-
-
-
-
- {item.label}
-
-
- {/each}
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/routes/examples/vertical/description.md b/src/routes/examples/vertical/description.md
deleted file mode 100644
index 6b62ffd..0000000
--- a/src/routes/examples/vertical/description.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# Vertical List
-
-The simplest setup — a single `DndDroppable` with `direction="vertical"` (the default).
-
-This example uses 50 items to demonstrate **auto-scroll** near the container edges. A `DndPreview` is placed before each item and one after the last item to show where the dragged item will land.
-
-The `onDrop` callback receives the drop `position` — splice the source item out and insert it at the new position.
-
-[view code](https://github.com/Horuse/svelte-dnd/blob/main/src/routes/examples/vertical/%2Bpage.svelte)
\ No newline at end of file
diff --git a/static/favicon.svg b/static/favicon.svg
deleted file mode 100644
index cc5dc66..0000000
--- a/static/favicon.svg
+++ /dev/null
@@ -1 +0,0 @@
-svelte-logo
\ No newline at end of file
diff --git a/static/preview.gif b/static/preview.gif
deleted file mode 100644
index ce0231f..0000000
Binary files a/static/preview.gif and /dev/null differ
diff --git a/svelte.config.js b/svelte.config.js
deleted file mode 100644
index 28c9779..0000000
--- a/svelte.config.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import adapter from '@sveltejs/adapter-auto';
-import { mdsvex } from 'mdsvex';
-import { createHighlighter } from 'shiki'
-
-const highlighterPromise = createHighlighter({
- themes: ['github-dark'],
- langs: ['javascript', 'bash', 'typescript', 'css', 'html', 'svelte']
-})
-
-/** @type {import('@sveltejs/kit').Config} */
-const config = {
- extensions: ['.svelte', '.svx', '.md'],
- preprocess: [mdsvex({
- extensions: ['.svx', '.md'],
- highlight: {
- highlighter: async (code, lang = 'text') => {
- const highlighter = await highlighterPromise
- const html = highlighter.codeToHtml(code, {
- lang,
- theme: 'github-dark'
- });
- return `{@html \`${html}\` }`
- }
- },
- })],
- kit: {
- alias: {
- '$docs/*': 'src/docs/*',
- },
- adapter: adapter()
- }
-};
-
-export default config;
diff --git a/tsconfig.json b/tsconfig.json
index e7c4a9a..146fe4e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,15 +1,29 @@
{
- "extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
- "rewriteRelativeImportExtensions": true,
+ // Environment setup & latest features
+ "lib": ["ESNext"],
+ "target": "ESNext",
+ "module": "Preserve",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
"allowJs": true,
- "checkJs": true,
- "forceConsistentCasingInFileNames": true,
- "resolveJsonModule": true,
- "skipLibCheck": true,
- "sourceMap": true,
+
+ // Bundler mode
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+
+ // Best practices
"strict": true,
- "module": "NodeNext",
- "moduleResolution": "NodeNext"
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+
+ // Some stricter flags (disabled by default)
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false
}
}
diff --git a/turbo.json b/turbo.json
new file mode 100644
index 0000000..c376d5f
--- /dev/null
+++ b/turbo.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://turborepo.dev/schema.json",
+ "tasks": {
+ "build": {
+ "dependsOn": ["^build"],
+ "outputs": ["dist/**", ".svelte-kit/**", "./build/**", ".vercel/**"]
+ },
+ "dev": {
+ "persistent": true,
+ "cache": false
+ },
+ "test": {
+ "cache": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
deleted file mode 100644
index 56f40c7..0000000
--- a/vite.config.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import tailwindcss from '@tailwindcss/vite';
-import { sveltekit } from '@sveltejs/kit/vite';
-import { defineConfig } from 'vite';
-
-export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });