|
|
5 months ago | |
|---|---|---|
| .. | ||
| components | 5 months ago | |
| README.md | 5 months ago | |
| drag-drop-provider.svelte.test.ts | 5 months ago | |
| drag-drop-provider.svelte.ts | 5 months ago | |
| exports.ts | 6 months ago | |
| index.ts | 6 months ago | |
| langs.ts | 6 months ago | |
| types.ts | 6 months ago | |
README.md
DragDrop
A headless drag-and-drop system — pointer + keyboard + screen reader. Coordinates draggable sources and drop targets within a Provider boundary, fires a single onDrop event with a typed payload, and announces each drag step through the global Announce live region.
Soma chooses this system over HTML5 native draggable because HTML5 DnD has poor mobile support, incompatible cross-browser dataTransfer semantics, and no built-in keyboard model. This primitive works everywhere pointer events work (mouse, touch, pen) plus a full WAI-ARIA-aligned keyboard contract.
Anatomy
<DragDrop.Provider onDrop={(e) => moveItem(e.value, e.target)}>
<DragDrop.Draggable value="task-1" data={{ kind: 'task' }}>
Drag me
</DragDrop.Draggable>
<DragDrop.Droppable accept={(data) => data.kind === 'task'} textValue="Done">
Drop here
</DragDrop.Droppable>
<DragDrop.Preview>
{#snippet children({ active })}
<div class="ghost">{active.label}</div>
{/snippet}
</DragDrop.Preview>
</DragDrop.Provider>
Parts
| Part | Element | Description |
|---|---|---|
Provider |
<div> |
Coordinates the drag gesture across descendants. Announces ARIA. |
Draggable |
<div> |
An item that can be picked up. Pointer + keyboard gesture host. |
Droppable |
<div> |
A target that accepts drops. Filters via accept(data, value). |
Preview |
<div> |
Optional floating ghost element that follows the pointer during drag. |
Props
Provider
| Prop | Type | Default | Description |
|---|---|---|---|
id |
string |
auto | DOM id. |
onDragStart |
(e: { value, data, preventDefault }) => void |
— | Fires before drag starts. Call preventDefault to cancel. |
onDragEnd |
(e: DragEndEvent) => void |
— | Fires on drop or cancel. |
onDrop |
(e: DropEvent) => void |
— | Fires on successful drop. |
announceEnabled |
boolean |
true |
Emit live-region announcements via Announce global API. |
Snippet props: { active, cancel }.
Draggable
| Prop | Type | Default | Description |
|---|---|---|---|
id |
string |
auto | DOM id. |
value |
string |
— | Required. Unique identifier; returned in onDrop.value. |
data |
Record<string, any> |
{} |
Payload; returned in onDrop.data. |
disabled |
boolean |
false |
Disables dragging. |
moveBuffer |
number |
5 |
Pixels the pointer must move before a drag is recognised. |
textValue |
string |
— | Label for announcements. Falls back to aria-label → textContent. |
Snippet props: { dragging, handleProps }.
Droppable
| Prop | Type | Default | Description |
|---|---|---|---|
id |
string |
auto | DOM id. |
accept |
(data, value) => boolean |
() => true |
Filter — returning false removes this target from keyboard nav. |
disabled |
boolean |
false |
Disables all drops on this element. |
textValue |
string |
— | Label for announcements. |
Snippet props: { over, accepting }.
ARIA
| Part | Attribute | Value |
|---|---|---|
| Draggable | role |
button |
| Draggable | aria-roledescription |
Translated 'draggable' |
| Draggable | aria-grabbed |
true during active drag (legacy but widely supported) |
| Draggable | tabindex |
0 (disabled: -1) |
| Droppable | role |
region |
| Droppable | aria-roledescription |
Translated 'drop zone' |
| Droppable | aria-dropeffect |
'move' while accepting, 'none' otherwise |
| Droppable | tabindex |
0 during drag if accepting; -1 otherwise |
Each drag step is announced via the Announce global API. Component-owned announcement strings live in the drag-drop morfo.translations catalog. Disable with announceEnabled={false} on the Provider when wiring your own.
Data Attributes
| Part | Attribute | Values |
|---|---|---|
| Provider | data-drag-drop |
Always present |
| Provider | data-dragging |
Present during active drag |
| Draggable | data-drag-drop-draggable |
Always present |
| Draggable | data-dragging |
This draggable is the source |
| Draggable | data-disabled / data-value / data-text-value |
— |
| Droppable | data-drag-drop-droppable |
Always present |
| Droppable | data-dragover |
Pointer / focus over this target |
| Droppable | data-accepting |
Would accept the current drag |
| Droppable | data-disabled |
Drops disabled |
| Preview | data-drag-drop-preview |
Always present |
| Preview | data-active |
Present during active drag |
Keyboard
| Focus on | Key | Action |
|---|---|---|
| Draggable | Space / Enter |
Begin drag. Focus jumps to the first accepting Droppable. |
| Droppable | ArrowDown / ArrowRight / Tab |
Move to next accepting Droppable (wraps). |
| Droppable | ArrowUp / ArrowLeft / Shift+Tab |
Move to previous accepting Droppable. |
| Droppable | Space / Enter |
Drop. |
| any | Escape |
Cancel the drag. Focus returns to the source Draggable. |
Droppables are explicitly added/removed from the tab order based on the accept filter — a disabled or non-accepting target is silently skipped during keyboard navigation.
Comparison
| Feature | Soma | Radix | Ark UI | react-aria | dnd-kit |
|---|---|---|---|---|---|
| Pointer (mouse + touch + pen) | ✅ | ❌ | ❌ | ✅ | ✅ |
| Keyboard drag (Space/Enter/Escape) | ✅ | — | — | ✅ | ✅ |
| Arrow / Tab to navigate drop targets | ✅ | — | — | ✅ | ⚠️¹ |
| Accept filter per target | ✅ | — | — | ✅ | ✅ |
Typed data payload |
✅ | — | — | ✅ | ✅ |
| Cancel (Escape → source focus) | ✅ | — | — | ✅ | ✅ |
| Live-region announcements | ✅ | — | — | ✅ | ⚠️² |
| Translated messages | ✅ | — | — | ❌ | ❌ |
| DragPreview (ghost element) | ✅ | — | — | ✅ | ✅ |
| Integration with Announce global API | ✅ | — | — | — | — |
| Sortable (within-list reorder) | ⚠️³ | — | — | ✅ | ✅ |
| Auto-scroll near viewport edges | ❌⁴ | — | — | ✅ | ✅ |
¹ dnd-kit uses keyboard sensors that move items directionally; no explicit Tab between targets.
² dnd-kit provides announcer hooks; consumers must wire the message strings themselves.
³ Consumers implement sort by rendering N Droppables between items (one before, one after each row) and collapsing empty slots. Works but more verbose than a <Sortable> wrapper. First-class Sortable primitive can land when a consumer hits the verbosity wall.
⁴ Use native CSS overflow + scroll behavior of the container. Auto-scroll on viewport edges is a plausible future addition — the API surface would be a autoScroll={true} prop on the Provider.
Usage
Kanban board
<DragDrop.Provider onDrop={moveTask}>
{#each columns as col (col.id)}
<DragDrop.Droppable
accept={(data) => col.accepts.includes(data.kind)}
textValue={col.title}
>
<h3>{col.title}</h3>
{#each col.tasks as t (t.id)}
<DragDrop.Draggable value={t.id} data={{ kind: t.kind }} textValue={t.title}>
{t.title}
</DragDrop.Draggable>
{/each}
</DragDrop.Droppable>
{/each}
<DragDrop.Preview>
{#snippet children({ active })}
<span class="chip">{active.label}</span>
{/snippet}
</DragDrop.Preview>
</DragDrop.Provider>
File explorer drop zone
<DragDrop.Provider onDrop={moveFile}>
<TreeGrid.Provider>
{#each files as f (f.id)}
<TreeGrid.Row value={f.id}>
<TreeGrid.Cell>
<DragDrop.Draggable value={f.id} data={{ kind: 'file' }} textValue={f.name}>
{f.name}
</DragDrop.Draggable>
</TreeGrid.Cell>
</TreeGrid.Row>
{/each}
</TreeGrid.Provider>
{#each folders as folder (folder.id)}
<DragDrop.Droppable
accept={(data, value) => data.kind === 'file' && !folder.contains(value)}
textValue={folder.name}
>
📁 {folder.name}
</DragDrop.Droppable>
{/each}
</DragDrop.Provider>
Sortable list (reorder)
Render a Droppable "gap" between every pair of items. Each gap's accept returns true when the dragged item isn't adjacent to that gap (the move would be a no-op).
<DragDrop.Provider onDrop={(e) => reorder(e.value, parseInt(e.target.dataset.gapIndex ?? '0'))}>
{#each items as item, i (item.id)}
<DragDrop.Droppable
accept={(_, v) => v !== item.id && v !== items[i - 1]?.id}
textValue="Gap {i}"
data-gap-index={i}
/>
<DragDrop.Draggable value={item.id} textValue={item.label}>
{item.label}
</DragDrop.Draggable>
{/each}
<DragDrop.Droppable textValue="End" data-gap-index={items.length} />
</DragDrop.Provider>