Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Features

- **#59** — First-class `{@attach}` factories: `attachDraggable` / `attachDroppable`
with generics and reactive getters; demo at `/attach`; README documents correct
`fromAction(..., () => options)` usage
- **#24** — Opt-in keyboard accessibility: `keyboard: true` on draggable
(Space/Enter grab & drop, arrows move preview, Escape cancel), droppable
registry, assertive live-region announcements, `/keyboard` demo

## [0.4.2](https://github.com/thisuxhq/sveltednd/compare/sveltednd-v0.4.1...sveltednd-v0.4.2) (2026-07-19)

Expand Down
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ A lightweight, flexible drag and drop library for Svelte 5 applications. Built w
- **Drop Indicators** — Visual feedback showing exactly where items will drop
- **Nested Support** — Works with nested containers and complex hierarchies
- **Attachments** — First-class `{@attach}` factories for components (Svelte 5.29+)
- **Keyboard** — Opt-in Space/arrows/Escape reordering with screen-reader announcements
- **Lightweight** — Minimal footprint with zero external dependencies

## Installation
Expand Down Expand Up @@ -415,6 +416,64 @@ Explore the demo pages for complete working examples:
- **[Interactive Elements](https://github.com/thisuxhq/SvelteDnD/blob/main/src/routes/interactive-elements/+page.svelte)** — Forms inside draggable items
- **[Conditional Check](https://github.com/thisuxhq/SvelteDnD/blob/main/src/routes/conditional-check/+page.svelte)** — Validation before drop
- **[Attachments](https://github.com/thisuxhq/sveltednd/blob/main/src/routes/attach/+page.svelte)** — `{@attach}` on components
- **[Keyboard](https://github.com/thisuxhq/sveltednd/blob/main/src/routes/keyboard/+page.svelte)** — Space / arrows / Escape reordering

## Keyboard accessibility (opt-in)

Enable keyboard reordering with `keyboard: true` on a **draggable**. Mouse and touch
behavior are unchanged when the option is omitted.

| Key | Action |
| ----------------------------------- | ------------------------------------------------- |
| **Tab** | Focus a keyboard-enabled item |
| **Space** / **Enter** | Pick up, or drop while dragging |
| **↑ / ↓** (or ← / → for horizontal) | Move the drop preview among registered drop zones |
| **Escape** | Cancel without calling `onDrop` |

```svelte
<script lang="ts">
import { draggable, droppable, type DragDropState } from '@thisux/sveltednd';

let items = $state(['Alpha', 'Beta', 'Gamma']);

function handleDrop(state: DragDropState<string>) {
const from = items.indexOf(state.draggedItem);
let to = parseInt(state.targetContainer ?? '0');
if (state.dropPosition === 'after') to += 1;
if (from === -1) return;
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(from < to ? to - 1 : to, 0, item);
items = next;
}
</script>

{#each items as item, index (item)}
<div
use:draggable={{
container: index.toString(),
dragData: item,
keyboard: true
}}
use:droppable={{
container: index.toString(),
callbacks: { onDrop: handleDrop }
}}
>
{item}
</div>
{/each}
```

Notes:

- Keyboard uses the **same** `onDrop` contract as pointer/HTML5 — your data model stays in the app.
- An assertive live region announces grab / move / drop / cancel for screen readers.
- Interactive children (inputs, buttons, …) still receive Space/Enter normally.
- Cross-column Kanban keyboard navigation is planned as a follow-up (`navigation: 'containers'`).

Live demo: [Keyboard](https://sveltednd.thisux.com/keyboard) ·
[source](https://github.com/thisuxhq/sveltednd/blob/main/src/routes/keyboard/+page.svelte)

## Using with Components — `{@attach}` (Svelte 5.29+)

Expand Down
124 changes: 120 additions & 4 deletions src/lib/actions/draggable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,20 @@
*/

import { dndState, resetDndState } from '$lib/stores/dnd.svelte.js';
import type { DraggableOptions, DragDropState } from '$lib/types/index.js';
import type { DraggableOptions, DragDropState, KeyboardOptions } from '$lib/types/index.js';
import { startAutoScroll, stopAutoScroll } from '$lib/utils/auto-scroll.js';
import {
cancelKeyboardSession,
isKeyboardSessionActive,
startKeyboardSession
} from '$lib/utils/keyboard-session.js';

/** Normalize keyboard option into options object or null when disabled. */
function resolveKeyboardOptions(keyboard: DraggableOptions['keyboard']): KeyboardOptions | null {
if (keyboard === true) return { enabled: true, navigation: 'list' };
if (!keyboard || keyboard.enabled === false) return null;
return { enabled: true, navigation: 'list', ...keyboard };
}

/**
* Default CSS class applied while dragging.
Expand Down Expand Up @@ -99,14 +111,58 @@ export function draggable<T>(node: HTMLElement, options: DraggableOptions<T>) {
* drag was cancelled after hovering an invalid zone, that flag must not leak
* into the next drag.
*/
function beginDragState() {
function beginDragState(input: 'html5' | 'pointer' | 'keyboard' = 'pointer') {
dndState.isDragging = true;
dndState.draggedItem = options.dragData;
dndState.sourceContainer = options.container;
dndState.targetContainer = null;
dndState.targetElement = null;
dndState.dropPosition = null;
dndState.invalidDrop = false;
dndState.dragInput = input;
}

function getItemLabel(): string {
const data = options.dragData as { title?: string; name?: string; id?: string } | unknown;
if (data && typeof data === 'object') {
const o = data as { title?: string; name?: string; id?: string };
if (o.title) return String(o.title);
if (o.name) return String(o.name);
if (o.id !== undefined) return String(o.id);
}
if (typeof data === 'string' || typeof data === 'number') return String(data);
const text = node.textContent?.trim();
return text ? text.slice(0, 80) : 'Item';
}

function applyKeyboardFocusability() {
const kb = resolveKeyboardOptions(options.keyboard);
if (options.disabled || !kb) {
if (node.getAttribute('data-sveltednd-keyboard') === 'true') {
node.removeAttribute('tabindex');
node.removeAttribute('data-sveltednd-keyboard');
node.removeAttribute('aria-describedby');
node.removeAttribute('aria-grabbed');
}
return;
}

// Prefer focusing the handle when one is configured
if (options.handle) {
const handleEl = node.querySelector(options.handle) as HTMLElement | null;
if (handleEl) {
handleEl.tabIndex = 0;
handleEl.setAttribute('data-sveltednd-keyboard-handle', 'true');
}
}
node.tabIndex = 0;
node.setAttribute('data-sveltednd-keyboard', 'true');
node.setAttribute('aria-grabbed', 'false');
// Instructions id is created lazily by live region; describe operation statically
node.setAttribute(
'aria-roledescription',
'draggable. Press Space or Enter to pick up, arrow keys to move, Space or Enter to drop, Escape to cancel.'
);
}

/**
Expand All @@ -130,7 +186,13 @@ export function draggable<T>(node: HTMLElement, options: DraggableOptions<T>) {
html5DragActive = false;
removePointerListeners();
stopAutoScroll();
// Keyboard session owns its own teardown (onDragEnd + reset)
if (isKeyboardSessionActive()) {
cancelKeyboardSession();
return;
}
node.classList.remove(...draggingClass);
if (node.isConnected) node.setAttribute('aria-grabbed', 'false');
// Best-effort: if this node was already removed, also clear any leftover class
document.querySelectorAll(`.${draggingClass[0]}`).forEach((el) => {
if (el instanceof HTMLElement) el.classList.remove(...draggingClass);
Expand Down Expand Up @@ -177,6 +239,11 @@ export function draggable<T>(node: HTMLElement, options: DraggableOptions<T>) {
*/
function handleDragStart(event: DragEvent) {
if (options.disabled) return;
// Keyboard session owns the drag — do not start HTML5 in parallel
if (isKeyboardSessionActive() || dndState.dragInput === 'keyboard') {
event.preventDefault();
return;
}

// Use the element that was actually pressed (captured in pointerdown), not
// event.target — dragstart always reports the draggable container as target,
Expand All @@ -201,7 +268,7 @@ export function draggable<T>(node: HTMLElement, options: DraggableOptions<T>) {
html5DragActive = true;

// Update global state - this triggers reactive updates across all components
beginDragState();
beginDragState('html5');

// Configure the native drag data transfer
// We stringify the data so it works across different browser contexts
Expand Down Expand Up @@ -257,13 +324,15 @@ export function draggable<T>(node: HTMLElement, options: DraggableOptions<T>) {
pointerDownTarget = event.target as HTMLElement;

if (options.disabled) return;
// Do not start pointer drag while keyboard session is active
if (isKeyboardSessionActive() || dndState.dragInput === 'keyboard') return;

if (!isHandleElement(event.target as HTMLElement)) return;

if (!options.handle && isInteractiveElement(event.target as HTMLElement)) return;

// Initialize the drag state (same as HTML5 path)
beginDragState();
beginDragState('pointer');

// Visual feedback
node.classList.add(...draggingClass);
Expand Down Expand Up @@ -305,9 +374,46 @@ export function draggable<T>(node: HTMLElement, options: DraggableOptions<T>) {
*/
function handlePointerCancel(event: PointerEvent) {
if (html5DragActive) return;
if (isKeyboardSessionActive() || dndState.dragInput === 'keyboard') return;
handlePointerUp(event);
}

/**
* Keyboard grab entry (Space / Enter) when `keyboard: true` (#24).
* Arrow / drop / cancel are handled on document by keyboard-session.
*/
function handleKeyDown(event: KeyboardEvent) {
const kb = resolveKeyboardOptions(options.keyboard);
if (!kb || options.disabled) return;

// Already in a keyboard session — document handler owns keys
if (isKeyboardSessionActive()) return;

const target = event.target as HTMLElement;
// Never hijack typing / native control activation
if (isInteractiveElement(target) && target !== node) return;

if (options.handle && !isHandleElement(target) && target !== node) return;

if (event.key !== ' ' && event.key !== 'Enter') return;
if (dndState.isDragging) return;

event.preventDefault();
event.stopPropagation();

startKeyboardSession({
sourceElement: node,
sourceContainer: options.container,
dragData: options.dragData,
draggingClass,
direction: options.direction ?? 'vertical',
keyboard: kb,
itemLabel: getItemLabel(),
onDragStart: options.callbacks?.onDragStart as ((state: DragDropState) => void) | undefined,
onDragEnd: options.callbacks?.onDragEnd as ((state: DragDropState) => void) | undefined
});
}

/**
* Handles pointerup - the "drop" moment in pointer mode.
*
Expand Down Expand Up @@ -373,6 +479,10 @@ export function draggable<T>(node: HTMLElement, options: DraggableOptions<T>) {
// Pointer events for broader device support
node.addEventListener('pointerdown', handlePointerDown);

// Keyboard accessibility (opt-in via keyboard: true) — issue #24
node.addEventListener('keydown', handleKeyDown);
applyKeyboardFocusability();

// Return Svelte action lifecycle methods
return {
/**
Expand All @@ -385,6 +495,7 @@ export function draggable<T>(node: HTMLElement, options: DraggableOptions<T>) {
node.draggable = !options.disabled;
node.style.touchAction = options.disabled ? '' : 'none';
node.style.userSelect = options.disabled ? '' : 'none';
applyKeyboardFocusability();
},

/**
Expand All @@ -399,6 +510,10 @@ export function draggable<T>(node: HTMLElement, options: DraggableOptions<T>) {
node.removeEventListener('dragstart', handleDragStart);
node.removeEventListener('dragend', handleDragEnd);
node.removeEventListener('pointerdown', handlePointerDown);
node.removeEventListener('keydown', handleKeyDown);
node.removeAttribute('data-sveltednd-keyboard');
node.removeAttribute('aria-roledescription');
node.removeAttribute('aria-grabbed');

// If this node is destroyed mid-drag (common when onDrop reorders lists
// and the source element unmounts before dragend), force full cleanup (#60).
Expand All @@ -410,6 +525,7 @@ export function draggable<T>(node: HTMLElement, options: DraggableOptions<T>) {
finishDrag();
} else {
removePointerListeners();
if (isKeyboardSessionActive()) cancelKeyboardSession();
}
}
};
Expand Down
61 changes: 61 additions & 0 deletions src/lib/actions/droppable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ import {
removeScrollExclusion,
stopAutoScroll
} from '$lib/utils/auto-scroll.js';
import {
registerDroppable,
unregisterDroppable,
type DroppableRegistration
} from '$lib/utils/dnd-registry.js';

/**
* Default CSS class applied when an item is dragged over this element.
Expand Down Expand Up @@ -566,11 +571,61 @@ export function droppable<T>(node: HTMLElement, options: DragDropOptions<T>) {
}
}

/**
* Keyboard hover preview — reuses drag-over class + drop indicators (#24).
*/
function setKeyboardHover(active: boolean, position: 'before' | 'after' | null = null) {
if (active) {
addDragOverClass();
if (position) {
setDropIndicator(position);
}
options.callbacks?.onDragOver?.(dndState as DragDropState<T>);
} else {
removeDragOverClass();
clearDropIndicator();
}
}

/**
* Keyboard drop commit — same onDrop + finalize pipeline as pointer (#24).
*/
async function commitDrop(state: DragDropState) {
if (options.disabled) return;

dragEnterCounter = 0;
removeDragOverClass();

const dropState = { ...state } as DragDropState<T>;
dndState.targetContainer = options.container;
dndState.targetElement = node;

try {
await options.callbacks?.onDrop?.(dropState);
} catch (error) {
console.error('Drop handling failed:', error);
} finally {
finalizeDropSession();
}
}

const registration: DroppableRegistration = {
element: node,
container: options.container,
direction: options.direction ?? 'vertical',
disabled: !!options.disabled,
setKeyboardHover,
commitDrop
};

// === Setup: Attach all event listeners ===

// Marker for nested deepest-target resolution (#27)
node.setAttribute('data-sveltednd-droppable', options.container);

// Keyboard navigation registry (#24)
registerDroppable(registration);

// HTML5 drag API events
node.addEventListener('dragenter', handleDragEnter);
node.addEventListener('dragleave', handleDragLeave);
Expand Down Expand Up @@ -618,6 +673,11 @@ export function droppable<T>(node: HTMLElement, options: DragDropOptions<T>) {
dragOverClass = getDragOverClass(options);
node.setAttribute('data-sveltednd-droppable', options.container);

// Keep keyboard registry in sync
registration.container = options.container;
registration.direction = options.direction ?? 'vertical';
registration.disabled = !!options.disabled;

removeDragOverClass(previousDragOverClass);
if (hadActiveState) addDragOverClass();
},
Expand All @@ -628,6 +688,7 @@ export function droppable<T>(node: HTMLElement, options: DragDropOptions<T>) {
* Removes all event listeners and clears any visual indicators.
*/
destroy() {
unregisterDroppable(registration);
clearDropIndicator();
removeDragOverClass();
clearTargetState();
Expand Down
Loading
Loading