Manual Completo de Drag and Drop en TypeScript

Una guía exhaustiva sobre la API de Drag and Drop con TypeScript, ejemplos prácticos y explicaciones detalladas.

Introducción a Drag and Drop con TypeScript

TypeScript añade tipos estáticos a JavaScript, lo que nos permite escribir código más seguro y mantenible para implementaciones de Drag and Drop.

Nota: TypeScript proporciona interfaces específicas para los eventos de Drag and Drop en lib.dom.d.ts.

Tipos e Interfaces Importantes

// Interfaces principales para Drag and Drop
interface DragEvent extends MouseEvent {
    dataTransfer: DataTransfer;
}

interface DataTransfer {
    dropEffect: 'none' | 'copy' | 'link' | 'move';
    effectAllowed: 'none' | 'copy' | 'copyLink' | 'copyMove' | 
                   'link' | 'linkMove' | 'move' | 'all' | 'uninitialized';
    files: FileList;
    items: DataTransferItemList;
    types: string[];
    
    clearData(format?: string): void;
    getData(format: string): string;
    setData(format: string, data: string): void;
    setDragImage(image: Element, x: number, y: number): void;
}

Implementación Básica con TypeScript

HTML

<div class="demo-container">
    <div class="demo-box">
        <div id="draggable-item" class="draggable" draggable="true">
            Arrástrame
        </div>
    </div>
    
    <div class="demo-box">
        <div id="dropzone" class="dropzone">
            Suéltame aquí
        </div>
    </div>
</div>

TypeScript

// Definimos tipos para nuestros elementos
type DraggableElement = HTMLElement & { 
    draggable: true;
    id: string;
};

type DropzoneElement = HTMLElement & {
    classList: DOMTokenList;
};

// Obtenemos los elementos con aserción de tipo
const draggableItem = document.getElementById('draggable-item') as DraggableElement;
const dropzone = document.getElementById('dropzone') as DropzoneElement;

// Manejador de dragstart con tipo DragEvent
const handleDragStart = (e: DragEvent): void => {
    e.dataTransfer.setData('text/plain', draggableItem.id);
    setTimeout(() => {
        draggableItem.classList.add('hide');
    }, 0);
};

// Manejador de dragend
const handleDragEnd = (): void => {
    draggableItem.classList.remove('hide');
};

// Manejadores para la zona de destino
const handleDragEnter = (e: DragEvent): void => {
    e.preventDefault();
    dropzone.classList.add('active');
};

const handleDragOver = (e: DragEvent): void => {
    e.preventDefault();
};

const handleDragLeave = (): void => {
    dropzone.classList.remove('active');
};

const handleDrop = (e: DragEvent): void => {
    e.preventDefault();
    dropzone.classList.remove('active');
    
    const data = e.dataTransfer.getData('text/plain');
    const draggedItem = document.getElementById(data) as DraggableElement;
    
    if (draggedItem) {
        dropzone.appendChild(draggedItem);
        draggedItem.classList.remove('hide');
    }
};

// Añadimos los event listeners con los tipos correctos
draggableItem.addEventListener('dragstart', handleDragStart);
draggableItem.addEventListener('dragend', handleDragEnd);

dropzone.addEventListener('dragenter', handleDragEnter);
dropzone.addEventListener('dragover', handleDragOver);
dropzone.addEventListener('dragleave', handleDragLeave);
dropzone.addEventListener('drop', handleDrop);
Arrástrame
Suéltame aquí

Transferencia de Datos Tipados

Con TypeScript podemos definir interfaces para los datos transferidos:

// Definimos una interfaz para nuestros datos transferidos
interface DraggableItemData {
    id: string;
    content: string;
    timestamp: number;
    metadata?: {
        category: string;
        priority: number;
    };
}

// Manejador de dragstart con datos tipados
const handleDragStartWithTypes = (e: DragEvent, item: DraggableElement): void => {
    const itemData: DraggableItemData = {
        id: item.id,
        content: item.textContent || '',
        timestamp: Date.now(),
        metadata: {
            category: 'demo',
            priority: 1
        }
    };
    
    e.dataTransfer.setData('application/json', JSON.stringify(itemData));
    e.dataTransfer.effectAllowed = 'move';
};

// Manejador de drop con datos tipados
const handleDropWithTypes = (e: DragEvent): void => {
    e.preventDefault();
    
    const jsonData = e.dataTransfer.getData('application/json');
    try {
        const data: DraggableItemData = JSON.parse(jsonData);
        console.log('Datos recibidos:', data);
        
        // Podemos acceder a las propiedades con autocompletado
        if (data.metadata) {
            console.log('Categoría:', data.metadata.category);
        }
    } catch (error) {
        console.error('Error parsing JSON:', error);
    }
};

// Uso con nuestros elementos
draggableItem.addEventListener('dragstart', (e) => 
    handleDragStartWithTypes(e, draggableItem));

dropzone.addEventListener('drop', handleDropWithTypes);

Ejemplo Avanzado: Lista Reordenable Tipada

HTML

<ul id="sortable-list">
    <li draggable="true" data-id="1">Item 1</li>
    <li draggable="true" data-id="2">Item 2</li>
    <li draggable="true" data-id="3">Item 3</li>
    <li draggable="true" data-id="4">Item 4</li>
    <li draggable="true" data-id="5">Item 5</li>
</ul>

TypeScript

// Definimos tipos para nuestros elementos de lista
type SortableListItem = HTMLLIElement & {
    draggable: true;
    dataset: {
        id: string;
    };
};

// Definimos una interfaz para el estado de la lista
interface ListState {
    items: {
        id: string;
        content: string;
        order: number;
    }[];
    draggedItemId: string | null;
}

// Creamos el estado inicial
const listState: ListState = {
    items: [],
    draggedItemId: null
};

// Obtenemos la lista y sus elementos
const sortableList = document.getElementById('sortable-list') as HTMLUListElement;
const listItems = Array.from(
    document.querySelectorAll('#sortable-list li')
) as SortableListItem[];

// Inicializamos el estado
listState.items = listItems.map((item, index) => ({
    id: item.dataset.id,
    content: item.textContent || '',
    order: index
}));

// Función para encontrar la posición de inserción
const getDragAfterElement = (
    container: HTMLUListElement, 
    y: number
): SortableListItem | null => {
    const draggableElements = Array.from(
        container.querySelectorAll('li:not(.dragging)')
    ) as SortableListItem[];

    return draggableElements.reduce<{
        offset: number;
        element: SortableListItem | null;
    }>((closest, child) => {
        const box = child.getBoundingClientRect();
        const offset = y - box.top - box.height / 2;
        
        if (offset < 0 && offset > closest.offset) {
            return { offset: offset, element: child };
        } else {
            return closest;
        }
    }, { offset: Number.NEGATIVE_INFINITY, element: null }).element;
};

// Manejador de dragstart
const handleListDragStart = (e: DragEvent, item: SortableListItem): void => {
    listState.draggedItemId = item.dataset.id;
    e.dataTransfer.effectAllowed = 'move';
    setTimeout(() => {
        item.classList.add('dragging');
    }, 0);
};

// Manejador de dragover
const handleListDragOver = (e: DragEvent): void => {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    
    if (!listState.draggedItemId) return;
    
    const afterElement = getDragAfterElement(sortableList, e.clientY);
    const draggedItem = document.querySelector(
        `#sortable-list li[data-id="${listState.draggedItemId}"]`
    ) as SortableListItem | null;
    
    if (draggedItem) {
        if (afterElement) {
            sortableList.insertBefore(draggedItem, afterElement);
        } else {
            sortableList.appendChild(draggedItem);
        }
    }
};

// Manejador de dragend
const handleListDragEnd = (item: SortableListItem): void => {
    item.classList.remove('dragging');
    listState.draggedItemId = null;
    
    // Actualizamos el estado con el nuevo orden
    listState.items = Array.from(sortableList.children)
        .map((child, index) => {
            const listItem = child as SortableListItem;
            return {
                id: listItem.dataset.id,
                content: listItem.textContent || '',
                order: index
            };
        });
    
    console.log('Nuevo orden:', listState.items);
};

// Añadimos los event listeners
listItems.forEach(item => {
    item.addEventListener('dragstart', (e) => handleListDragStart(e, item));
    item.addEventListener('dragend', () => handleListDragEnd(item));
});

sortableList.addEventListener('dragover', handleListDragOver);

Drag and Drop de Archivos con TypeScript

// Definimos tipos para nuestros elementos de archivos
type FileDropzoneElement = HTMLElement & {
    files: FileList | null;
};

// Manejador de dragover para archivos
const handleFileDragOver = (e: DragEvent): void => {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'copy';
    if (e.currentTarget) {
        (e.currentTarget as FileDropzoneElement).classList.add('active');
    }
};

// Manejador de drop para archivos
const handleFileDrop = (e: DragEvent): void => {
    e.preventDefault();
    if (e.currentTarget) {
        (e.currentTarget as FileDropzoneElement).classList.remove('active');
    }
    
    if (e.dataTransfer.files.length) {
        handleFiles(e.dataTransfer.files);
    }
};

// Función para procesar archivos con tipos
const handleFiles = (files: FileList): void => {
    Array.from(files).forEach((file: File) => {
        console.log('Archivo:', file.name, file.type, file.size);
        
        if (file.type.startsWith('image/')) {
            const reader = new FileReader();
            
            reader.onload = (e: ProgressEvent) => {
                if (e.target?.result) {
                    const img = document.createElement('img');
                    img.src = e.target.result as string;
                    document.body.appendChild(img);
                }
            };
            
            reader.readAsDataURL(file);
        }
    });
};

// Uso con un elemento dropzone
const fileDropzone = document.createElement('div');
fileDropzone.className = 'dropzone';
fileDropzone.textContent = 'Arrastra archivos aquí';

fileDropzone.addEventListener('dragover', handleFileDragOver);
fileDropzone.addEventListener('drop', handleFileDrop);

Patrones y Mejores Prácticas con TypeScript

Patrón de Fábrica para Elementos Arrastrables

interface DraggableFactoryOptions {
    id: string;
    content: string;
    type: 'default' | 'special';
}

class DraggableFactory {
    static create(options: DraggableFactoryOptions): HTMLElement {
        const element = document.createElement('div');
        element.id = options.id;
        element.textContent = options.content;
        element.draggable = true;
        element.classList.add('draggable', options.type);
        
        // Añadir event listeners con tipos
        element.addEventListener('dragstart', (e: DragEvent) => {
            e.dataTransfer.setData('application/json', JSON.stringify({
                id: options.id,
                type: options.type
            }));
        });
        
        return element;
    }
}

// Uso
const specialDraggable = DraggableFactory.create({
    id: 'special-1',
    content: 'Elemento especial',
    type: 'special'
});

document.body.appendChild(specialDraggable);

Uso de Enums para Tipos de Arrastre

enum DragType {
    DEFAULT = 'default',
    SPECIAL = 'special',
    IMPORTANT = 'important'
}

interface DraggableItem {
    id: string;
    type: DragType;
    content: string;
}

function handleDragStart(e: DragEvent, item: DraggableItem): void {
    e.dataTransfer.setData('application/json', JSON.stringify(item));
    
    switch(item.type) {
        case DragType.SPECIAL:
            e.dataTransfer.effectAllowed = 'copy';
            break;
        case DragType.IMPORTANT:
            e.dataTransfer.effectAllowed = 'move';
            break;
        default:
            e.dataTransfer.effectAllowed = 'all';
    }
}