// ============================================================================
// Stylescape | Drag and Drop Manager
// ============================================================================
// Facilitates drag-and-drop functionalities for UI elements.
// Supports data-ss-draggable and data-ss-dropzone attributes.
// ============================================================================
/**
* Configuration options for DragAndDropManager
*/
export interface DragAndDropOptions {
/** CSS class applied while dragging */
draggingClass?: string;
/** CSS class applied to valid drop target */
dragOverClass?: string;
/** Selector for drop zones */
dropZoneSelector?: string;
/** Allow dragging between containers */
allowCrossContainer?: boolean;
/** Data transfer effect */
effectAllowed?: DataTransfer["effectAllowed"];
/** Data transfer format */
dataFormat?: string;
/** Callback when drag starts */
onDragStart?: (element: HTMLElement, event: DragEvent) => void;
/** Callback when drag ends */
onDragEnd?: (element: HTMLElement, event: DragEvent) => void;
/** Callback when dropped */
onDrop?: (
dragged: HTMLElement,
dropZone: HTMLElement,
event: DragEvent,
) => void;
/** Callback for drag over */
onDragOver?: (element: HTMLElement, event: DragEvent) => boolean | void;
/** Handle for dragging (child selector) */
handleSelector?: string;
}
/**
* Comprehensive drag-and-drop manager with zones and callbacks.
*
* @example JavaScript
* ```typescript
* const dnd = new DragAndDropManager(".task-card", {
* dropZoneSelector: ".task-column",
* onDrop: (card, column) => {
* column.appendChild(card)
* updateTaskOrder()
* }
* })
* ```
*
* @example HTML with data-ss
* ```html
*
* ≡
* Draggable item
*
*
*
* Drop items here
*
* ```
*/
export class DragAndDropManager {
private draggables: Set = new Set();
private dropZones: Set = new Set();
private options: Required;
private currentDragged: HTMLElement | null = null;
constructor(
draggableSelector: string | HTMLElement | HTMLElement[],
options: DragAndDropOptions = {},
) {
this.options = {
draggingClass: options.draggingClass ?? "ss-dragging",
dragOverClass: options.dragOverClass ?? "ss-drag-over",
dropZoneSelector:
options.dropZoneSelector ?? "[data-ss='dropzone']",
allowCrossContainer: options.allowCrossContainer ?? true,
effectAllowed: options.effectAllowed ?? "move",
dataFormat: options.dataFormat ?? "text/plain",
onDragStart: options.onDragStart ?? (() => {}),
onDragEnd: options.onDragEnd ?? (() => {}),
onDrop: options.onDrop ?? (() => {}),
onDragOver: options.onDragOver ?? (() => true),
handleSelector: options.handleSelector ?? "",
};
this.initDraggables(draggableSelector);
this.initDropZones();
}
// ========================================================================
// Public Methods
// ========================================================================
/**
* Add a draggable element
*/
public addDraggable(element: HTMLElement): void {
this.setupDraggable(element);
this.draggables.add(element);
}
/**
* Remove a draggable element
*/
public removeDraggable(element: HTMLElement): void {
this.teardownDraggable(element);
this.draggables.delete(element);
}
/**
* Add a drop zone
*/
public addDropZone(element: HTMLElement): void {
this.setupDropZone(element);
this.dropZones.add(element);
}
/**
* Remove a drop zone
*/
public removeDropZone(element: HTMLElement): void {
this.teardownDropZone(element);
this.dropZones.delete(element);
}
/**
* Get the currently dragged element
*/
public getDragged(): HTMLElement | null {
return this.currentDragged;
}
/**
* Enable/disable drag on an element
*/
public setDraggable(element: HTMLElement, enabled: boolean): void {
element.setAttribute("draggable", String(enabled));
element.setAttribute(
"aria-grabbed",
String(enabled && this.currentDragged === element),
);
}
/**
* Destroy the manager and clean up
*/
public destroy(): void {
this.draggables.forEach((el) => this.teardownDraggable(el));
this.dropZones.forEach((el) => this.teardownDropZone(el));
this.draggables.clear();
this.dropZones.clear();
this.currentDragged = null;
}
// ========================================================================
// Static Factory
// ========================================================================
/**
* Initialize all draggable elements with data-ss="draggable"
*/
public static initDraggables(): DragAndDropManager[] {
const managers: DragAndDropManager[] = [];
const draggables = document.querySelectorAll(
'[data-ss="draggable"]',
);
// Group by parent container or handle individually
draggables.forEach((el) => {
const handle = el.dataset.ssDraggableHandle;
const manager = new DragAndDropManager(el, {
handleSelector: handle,
});
managers.push(manager);
});
return managers;
}
// ========================================================================
// Private Methods
// ========================================================================
private initDraggables(
selector: string | HTMLElement | HTMLElement[],
): void {
let elements: HTMLElement[];
if (typeof selector === "string") {
elements = Array.from(
document.querySelectorAll(selector),
);
} else if (Array.isArray(selector)) {
elements = selector;
} else {
elements = [selector];
}
elements.forEach((el) => {
this.setupDraggable(el);
this.draggables.add(el);
});
}
private initDropZones(): void {
const zones = document.querySelectorAll(
this.options.dropZoneSelector,
);
zones.forEach((zone) => {
this.setupDropZone(zone);
this.dropZones.add(zone);
});
}
private setupDraggable(element: HTMLElement): void {
element.setAttribute("draggable", "true");
element.setAttribute("role", "listitem");
element.setAttribute("aria-grabbed", "false");
// Handle selector support
if (this.options.handleSelector) {
const handle = element.querySelector(
this.options.handleSelector,
);
if (handle) {
handle.style.cursor = "grab";
element.setAttribute("draggable", "false");
handle.addEventListener("mousedown", () => {
element.setAttribute("draggable", "true");
});
handle.addEventListener("mouseup", () => {
element.setAttribute("draggable", "false");
});
}
}
element.addEventListener("dragstart", this.handleDragStart);
element.addEventListener("dragend", this.handleDragEnd);
}
private teardownDraggable(element: HTMLElement): void {
element.removeAttribute("draggable");
element.removeAttribute("aria-grabbed");
element.removeEventListener("dragstart", this.handleDragStart);
element.removeEventListener("dragend", this.handleDragEnd);
}
private setupDropZone(element: HTMLElement): void {
element.setAttribute("role", "list");
element.setAttribute("aria-dropeffect", "move");
element.addEventListener("dragover", this.handleDragOver);
element.addEventListener("dragenter", this.handleDragEnter);
element.addEventListener("dragleave", this.handleDragLeave);
element.addEventListener("drop", this.handleDrop);
}
private teardownDropZone(element: HTMLElement): void {
element.removeAttribute("aria-dropeffect");
element.removeEventListener("dragover", this.handleDragOver);
element.removeEventListener("dragenter", this.handleDragEnter);
element.removeEventListener("dragleave", this.handleDragLeave);
element.removeEventListener("drop", this.handleDrop);
}
// ========================================================================
// Event Handlers
// ========================================================================
private handleDragStart = (event: DragEvent): void => {
const target = event.currentTarget as HTMLElement;
this.currentDragged = target;
target.classList.add(this.options.draggingClass);
target.setAttribute("aria-grabbed", "true");
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = this.options.effectAllowed;
event.dataTransfer.setData(
this.options.dataFormat,
target.id || "dragged",
);
}
this.options.onDragStart(target, event);
};
private handleDragEnd = (event: DragEvent): void => {
const target = event.currentTarget as HTMLElement;
target.classList.remove(this.options.draggingClass);
target.setAttribute("aria-grabbed", "false");
// Remove drag-over class from all zones
this.dropZones.forEach((zone) => {
zone.classList.remove(this.options.dragOverClass);
});
this.options.onDragEnd(target, event);
this.currentDragged = null;
};
private handleDragOver = (event: DragEvent): void => {
const target = event.currentTarget as HTMLElement;
const result = this.options.onDragOver(target, event);
// Allow drop if callback returns true or undefined
if (result !== false) {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
}
};
private handleDragEnter = (event: DragEvent): void => {
const target = event.currentTarget as HTMLElement;
target.classList.add(this.options.dragOverClass);
};
private handleDragLeave = (event: DragEvent): void => {
const target = event.currentTarget as HTMLElement;
// Only remove class if leaving the dropzone entirely
const relatedTarget = event.relatedTarget as Node;
if (!target.contains(relatedTarget)) {
target.classList.remove(this.options.dragOverClass);
}
};
private handleDrop = (event: DragEvent): void => {
event.preventDefault();
const dropZone = event.currentTarget as HTMLElement;
dropZone.classList.remove(this.options.dragOverClass);
if (this.currentDragged) {
// Check cross-container constraint
if (!this.options.allowCrossContainer) {
const originalParent = this.currentDragged.parentElement;
if (originalParent !== dropZone) {
return;
}
}
this.options.onDrop(this.currentDragged, dropZone, event);
}
};
}
export default DragAndDropManager;