Mira la lección en vídeo
Accede al vídeo completo de esta lección y a más contenido exclusivo con el Plan Plus.
Desbloquear Plan PlusAPI nativa de Drag and Drop en HTML5
HTML5 introdujo una API nativa para implementar funcionalidades de arrastrar y soltar (drag and drop) en aplicaciones web sin necesidad de bibliotecas externas. Esta característica permite a los usuarios mover elementos en la página de forma intuitiva, similar a como lo harían en aplicaciones de escritorio.
La API de Drag and Drop de HTML5 permite que cualquier elemento de la página pueda convertirse en un elemento arrastrable o en una zona de destino donde soltar elementos. Esto abre un abanico de posibilidades para crear interfaces interactivas como:
- Reordenación de listas
- Movimiento de elementos entre contenedores
- Cargas de archivos mediante arrastre
- Interfaces tipo kanban o tableros de tareas
Fundamentos de la API
La API de Drag and Drop se basa en un modelo de eventos que se activan durante las diferentes fases del proceso de arrastrar y soltar. Para implementar esta funcionalidad necesitamos:
- Hacer que un elemento sea arrastrable
- Definir qué ocurre durante el arrastre
- Especificar dónde se pueden soltar los elementos
- Manejar la acción de soltar
Veamos un ejemplo básico de cómo hacer que un elemento sea arrastrable:
<div id="draggable" draggable="true">
Arrástrame
</div>
El atributo draggable="true"
es el punto de partida para cualquier implementación de drag and drop. Este atributo puede aplicarse a casi cualquier elemento HTML, aunque algunos elementos como las imágenes y los enlaces son arrastrables por defecto.
Transferencia de datos
Un concepto fundamental en la API de Drag and Drop es el objeto DataTransfer, que actúa como un contenedor para los datos que se transfieren durante la operación. Este objeto permite:
- Especificar qué datos se están arrastrando
- Definir el tipo de operación (copiar, mover, enlazar)
- Establecer una imagen personalizada durante el arrastre
Para utilizar este objeto, necesitamos acceder a la propiedad dataTransfer
del evento de arrastre:
<div id="source" draggable="true">Elemento arrastrable</div>
<script>
const source = document.getElementById('source');
source.addEventListener('dragstart', function(event) {
// Guardamos datos en el objeto dataTransfer
event.dataTransfer.setData('text/plain', 'Este elemento fue arrastrado');
// Definimos el efecto visual permitido
event.dataTransfer.effectAllowed = 'move';
});
</script>
En este ejemplo, cuando comienza el arrastre:
- Almacenamos datos en formato texto plano
- Especificamos que la operación será de tipo "mover"
Zonas de destino (Drop Zones)
Para que un elemento pueda recibir elementos arrastrados, debemos configurarlo como una zona de destino. Esto implica prevenir el comportamiento predeterminado de los navegadores mediante los eventos dragover
y drop
:
<div id="source" draggable="true">Arrástrame</div>
<div id="target" class="dropzone">Suéltame aquí</div>
<script>
const target = document.getElementById('target');
// Prevenir el comportamiento predeterminado para permitir soltar
target.addEventListener('dragover', function(event) {
event.preventDefault();
// Cambiamos el cursor para indicar que es una zona válida
event.dataTransfer.dropEffect = 'move';
});
// Manejar el evento de soltar
target.addEventListener('drop', function(event) {
event.preventDefault();
// Recuperamos los datos transferidos
const data = event.dataTransfer.getData('text/plain');
console.log('Datos recibidos:', data);
// Aquí podríamos actualizar la interfaz
this.textContent = 'Elemento recibido!';
});
</script>
Es crucial llamar a event.preventDefault()
en los eventos dragover
y drop
, ya que el comportamiento predeterminado de los navegadores es rechazar las operaciones de soltar.
Ejemplo completo
Veamos un ejemplo completo de una lista con elementos que pueden reordenarse mediante drag and drop:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Drag and Drop Example</title>
<style>
.draggable-item {
padding: 10px;
margin: 5px;
background-color: #f0f0f0;
border: 1px solid #ccc;
cursor: move;
}
.drop-zone {
border: 2px dashed #aaa;
min-height: 50px;
padding: 10px;
margin: 10px 0;
}
.dragging {
opacity: 0.5;
}
</style>
</head>
<body>
<h2>Lista reordenable</h2>
<div id="container" class="drop-zone">
<div class="draggable-item" draggable="true">Elemento 1</div>
<div class="draggable-item" draggable="true">Elemento 2</div>
<div class="draggable-item" draggable="true">Elemento 3</div>
<div class="draggable-item" draggable="true">Elemento 4</div>
</div>
<script>
// Seleccionamos todos los elementos arrastrables
const items = document.querySelectorAll('.draggable-item');
const container = document.getElementById('container');
// Configuramos cada elemento como arrastrable
items.forEach(item => {
// Al comenzar a arrastrar
item.addEventListener('dragstart', function(e) {
// Añadimos una clase para cambiar la apariencia
this.classList.add('dragging');
// Guardamos el ID del elemento
e.dataTransfer.setData('text/plain', this.textContent);
});
// Al terminar de arrastrar
item.addEventListener('dragend', function() {
this.classList.remove('dragging');
});
});
// Configuramos el contenedor como zona de destino
container.addEventListener('dragover', function(e) {
e.preventDefault();
// Encontramos el elemento sobre el que estamos arrastrando
const afterElement = getDragAfterElement(container, e.clientY);
const draggable = document.querySelector('.dragging');
if (afterElement == null) {
container.appendChild(draggable);
} else {
container.insertBefore(draggable, afterElement);
}
});
// Función para determinar la posición donde insertar el elemento
function getDragAfterElement(container, y) {
// Obtenemos todos los elementos arrastrables que no están siendo arrastrados
const draggableElements = [...container.querySelectorAll('.draggable-item:not(.dragging)')];
// Encontramos el elemento más cercano a la posición del cursor
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
// Si estamos por encima del elemento y más cerca que el anterior "más cercano"
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
</script>
</body>
</html>
En este ejemplo:
- Creamos una lista de elementos con el atributo
draggable="true"
- Aplicamos estilos para mejorar la experiencia visual
- Implementamos la lógica para reordenar los elementos dentro del contenedor
- Utilizamos una función auxiliar para determinar la posición donde insertar el elemento arrastrado
Compatibilidad con navegadores
La API de Drag and Drop de HTML5 tiene amplio soporte en navegadores modernos, pero existen algunas diferencias en la implementación entre ellos. Los principales aspectos a considerar son:
- Firefox requiere que se establezca explícitamente algún dato en el objeto
dataTransfer
durante el eventodragstart
- Safari tiene algunas limitaciones con elementos anidados
- Los navegadores móviles tienen soporte limitado para esta API
Para aplicaciones que necesiten funcionar en dispositivos móviles, es recomendable complementar esta API con bibliotecas o implementaciones personalizadas que utilicen eventos táctiles.
Limitaciones de la API nativa
Aunque la API de Drag and Drop de HTML5 es potente, tiene algunas limitaciones:
- No proporciona animaciones fluidas por defecto
- El soporte en dispositivos móviles es inconsistente
- La personalización visual durante el arrastre es limitada
- No incluye funcionalidades avanzadas como arrastrar entre frames o ventanas
Para casos de uso más complejos, puede ser necesario complementar la API nativa con bibliotecas JavaScript especializadas o implementaciones personalizadas.
La API nativa de Drag and Drop es una herramienta fundamental para crear interfaces interactivas en aplicaciones web modernas, proporcionando una base sólida sobre la cual construir experiencias de usuario intuitivas y eficientes.
Atributos draggable y eventos asociados
El atributo draggable es el punto de partida para implementar funcionalidades de arrastrar y soltar en HTML5. Este atributo permite especificar si un elemento puede ser arrastrado por el usuario, convirtiendo elementos estáticos en componentes interactivos de la interfaz.
Para hacer que un elemento sea arrastrable, simplemente necesitamos añadir el atributo draggable="true"
al elemento HTML:
<div id="element" draggable="true">Este elemento puede arrastrarse</div>
Valores del atributo draggable
El atributo draggable
puede tener tres valores posibles:
true
- El elemento puede ser arrastradofalse
- El elemento no puede ser arrastradoauto
- Se aplica el comportamiento predeterminado del navegador (valor por defecto)
Es importante mencionar que algunos elementos HTML son arrastrables por defecto, como las imágenes y los enlaces. Para estos elementos, el valor predeterminado es auto
, lo que permite arrastrarlos sin configuración adicional:
<!-- Las imágenes son arrastrables por defecto -->
<img src="imagen.jpg" alt="Imagen arrastrable">
<!-- Los enlaces también son arrastrables por defecto -->
<a href="https://ejemplo.com">Enlace arrastrable</a>
Si queremos deshabilitar esta funcionalidad en elementos que son arrastrables por defecto, podemos establecer explícitamente draggable="false"
:
<!-- Esta imagen no podrá ser arrastrada -->
<img src="imagen.jpg" alt="Imagen no arrastrable" draggable="false">
Eventos del ciclo de arrastre
Para controlar el comportamiento durante las operaciones de arrastre, HTML5 proporciona un conjunto de eventos específicos que se activan en diferentes momentos del proceso. Estos eventos se dividen en dos categorías principales:
Eventos en el elemento arrastrable
Estos eventos se disparan en el elemento que está siendo arrastrado:
- dragstart: Se activa cuando comienza la operación de arrastre
- drag: Se activa continuamente mientras el elemento está siendo arrastrado
- dragend: Se activa cuando finaliza la operación de arrastre
Eventos en la zona de destino
Estos eventos se disparan en los elementos que pueden recibir elementos arrastrados:
- dragenter: Se activa cuando un elemento arrastrable entra en la zona de destino
- dragover: Se activa continuamente mientras un elemento arrastrable está sobre la zona de destino
- dragleave: Se activa cuando un elemento arrastrable sale de la zona de destino
- drop: Se activa cuando se suelta un elemento arrastrable en la zona de destino
Implementación básica con eventos
Veamos cómo implementar una funcionalidad básica de arrastrar y soltar utilizando estos eventos:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Drag Events Example</title>
<style>
#draggable {
width: 100px;
height: 100px;
background-color: #3498db;
color: white;
text-align: center;
line-height: 100px;
cursor: move;
}
#dropzone {
width: 200px;
height: 200px;
border: 2px dashed #2c3e50;
margin-top: 20px;
padding: 10px;
text-align: center;
}
.dragging {
opacity: 0.5;
}
.highlight {
background-color: #ecf0f1;
}
</style>
</head>
<body>
<div id="draggable" draggable="true">Arrástrame</div>
<div id="dropzone">Zona de destino</div>
<script>
const draggable = document.getElementById('draggable');
const dropzone = document.getElementById('dropzone');
// Eventos en el elemento arrastrable
draggable.addEventListener('dragstart', function(event) {
// Añadimos una clase para cambiar la apariencia
this.classList.add('dragging');
// Guardamos el ID del elemento en el dataTransfer
event.dataTransfer.setData('text/plain', this.id);
});
draggable.addEventListener('dragend', function() {
// Restauramos la apariencia original
this.classList.remove('dragging');
});
// Eventos en la zona de destino
dropzone.addEventListener('dragenter', function(event) {
// Prevenimos el comportamiento predeterminado
event.preventDefault();
// Añadimos una clase para resaltar la zona
this.classList.add('highlight');
});
dropzone.addEventListener('dragover', function(event) {
// Este preventDefault es CRUCIAL para permitir el drop
event.preventDefault();
});
dropzone.addEventListener('dragleave', function() {
// Quitamos el resaltado cuando el elemento sale
this.classList.remove('highlight');
});
dropzone.addEventListener('drop', function(event) {
// Prevenimos la acción predeterminada (como abrir como enlace)
event.preventDefault();
// Quitamos la clase de resaltado
this.classList.remove('highlight');
// Obtenemos el ID del elemento arrastrado
const id = event.dataTransfer.getData('text/plain');
const draggedElement = document.getElementById(id);
// Añadimos el elemento a la zona de destino
this.appendChild(draggedElement);
});
</script>
</body>
</html>
En este ejemplo:
- Creamos un elemento arrastrable con
draggable="true"
- Definimos una zona donde se puede soltar el elemento
- Implementamos los manejadores de eventos para controlar el comportamiento durante el arrastre
- Utilizamos clases CSS para proporcionar retroalimentación visual al usuario
Personalización del cursor durante el arrastre
Podemos personalizar la apariencia del cursor durante la operación de arrastre utilizando la propiedad dropEffect
del objeto dataTransfer
:
draggable.addEventListener('dragstart', function(event) {
// Especificamos qué efectos están permitidos
event.dataTransfer.effectAllowed = 'move';
// Guardamos datos
event.dataTransfer.setData('text/plain', this.id);
});
dropzone.addEventListener('dragover', function(event) {
event.preventDefault();
// Establecemos el efecto visual (cambia el cursor)
event.dataTransfer.dropEffect = 'move';
});
Los valores posibles para dropEffect
son:
copy
- Indica que se creará una copia del elemento arrastradomove
- Indica que el elemento arrastrado se moverá a la nueva ubicaciónlink
- Indica que se creará un enlace a la ubicación originalnone
- No se permite soltar el elemento
Imagen personalizada durante el arrastre
También podemos personalizar la imagen que se muestra durante el arrastre utilizando el método setDragImage()
:
draggable.addEventListener('dragstart', function(event) {
// Creamos una imagen personalizada
const img = new Image();
img.src = 'custom-drag-image.png';
// Establecemos la imagen personalizada
// Parámetros: imagen, offsetX, offsetY
event.dataTransfer.setDragImage(img, 0, 0);
// Guardamos datos
event.dataTransfer.setData('text/plain', this.id);
});
Si no queremos usar una imagen externa, podemos crear un elemento HTML, renderizarlo y usarlo como imagen de arrastre:
draggable.addEventListener('dragstart', function(event) {
// Creamos un elemento personalizado
const dragIcon = document.createElement('div');
dragIcon.textContent = 'Arrastrando...';
dragIcon.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
dragIcon.style.color = 'white';
dragIcon.style.padding = '10px';
dragIcon.style.borderRadius = '5px';
// Añadimos el elemento al DOM (necesario para renderizarlo)
document.body.appendChild(dragIcon);
// Lo hacemos invisible
dragIcon.style.position = 'absolute';
dragIcon.style.top = '-1000px';
// Establecemos el elemento como imagen de arrastre
event.dataTransfer.setDragImage(dragIcon, 0, 0);
// Guardamos datos
event.dataTransfer.setData('text/plain', this.id);
// Eliminamos el elemento después de un breve retraso
setTimeout(() => {
document.body.removeChild(dragIcon);
}, 0);
});
Transferencia de datos complejos
Aunque en los ejemplos anteriores hemos transferido datos simples (como el ID de un elemento), el objeto dataTransfer
permite transferir datos más complejos utilizando diferentes formatos:
draggable.addEventListener('dragstart', function(event) {
// Transferir datos en formato texto
event.dataTransfer.setData('text/plain', 'Texto simple');
// Transferir datos en formato HTML
event.dataTransfer.setData('text/html', '<p>Contenido <strong>HTML</strong></p>');
// Transferir datos en formato JSON
const data = {
id: this.id,
content: this.textContent,
position: {
x: this.offsetLeft,
y: this.offsetTop
}
};
event.dataTransfer.setData('application/json', JSON.stringify(data));
});
// En el evento drop, podemos recuperar los datos según el formato
dropzone.addEventListener('drop', function(event) {
event.preventDefault();
// Recuperar datos en formato JSON
if (event.dataTransfer.types.includes('application/json')) {
const jsonData = event.dataTransfer.getData('application/json');
const data = JSON.parse(jsonData);
console.log('Datos JSON:', data);
}
// Recuperar datos en formato HTML
if (event.dataTransfer.types.includes('text/html')) {
const htmlContent = event.dataTransfer.getData('text/html');
console.log('Contenido HTML:', htmlContent);
}
// Recuperar datos en formato texto
const textContent = event.dataTransfer.getData('text/plain');
console.log('Texto plano:', textContent);
});
Ejemplo práctico: Lista de tareas con drag and drop
Veamos un ejemplo más completo de una lista de tareas donde podemos mover elementos entre dos columnas:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Task List with Drag and Drop</title>
<style>
.container {
display: flex;
gap: 20px;
}
.task-list {
width: 250px;
min-height: 300px;
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
}
.task-list h3 {
text-align: center;
margin-top: 0;
}
.task {
background-color: #f9f9f9;
padding: 10px;
margin-bottom: 5px;
border-radius: 3px;
cursor: move;
border-left: 3px solid #3498db;
}
.task.dragging {
opacity: 0.5;
}
.task-list.highlight {
background-color: #ecf0f1;
}
</style>
</head>
<body>
<h2>Lista de tareas</h2>
<div class="container">
<div id="pending" class="task-list">
<h3>Pendientes</h3>
<div class="task" draggable="true">Diseñar página de inicio</div>
<div class="task" draggable="true">Implementar API REST</div>
<div class="task" draggable="true">Crear base de datos</div>
</div>
<div id="completed" class="task-list">
<h3>Completadas</h3>
<div class="task" draggable="true">Configurar entorno de desarrollo</div>
</div>
</div>
<script>
// Seleccionamos todos los elementos necesarios
const tasks = document.querySelectorAll('.task');
const lists = document.querySelectorAll('.task-list');
// Configuramos cada tarea como arrastrable
tasks.forEach(task => {
task.addEventListener('dragstart', function(event) {
// Añadimos clase para cambiar apariencia
this.classList.add('dragging');
// Guardamos el ID de la tarea y su lista original
const data = {
taskId: this.textContent,
sourceListId: this.parentElement.id
};
event.dataTransfer.setData('application/json', JSON.stringify(data));
event.dataTransfer.effectAllowed = 'move';
});
task.addEventListener('dragend', function() {
// Restauramos apariencia
this.classList.remove('dragging');
});
});
// Configuramos cada lista como zona de destino
lists.forEach(list => {
list.addEventListener('dragenter', function(event) {
event.preventDefault();
// Solo resaltamos si no es un elemento hijo
if (event.target.classList.contains('task-list')) {
this.classList.add('highlight');
}
});
list.addEventListener('dragover', function(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
});
list.addEventListener('dragleave', function(event) {
// Solo quitamos el resaltado si salimos de la lista
// y no entramos en un hijo
if (event.relatedTarget && !this.contains(event.relatedTarget)) {
this.classList.remove('highlight');
}
});
list.addEventListener('drop', function(event) {
event.preventDefault();
this.classList.remove('highlight');
// Obtenemos los datos de la tarea
const data = JSON.parse(event.dataTransfer.getData('application/json'));
// Buscamos la tarea que se está arrastrando
const draggedTask = document.querySelector('.dragging');
// Si la tarea no existe en el DOM, la creamos
if (!draggedTask) {
const newTask = document.createElement('div');
newTask.classList.add('task');
newTask.setAttribute('draggable', 'true');
newTask.textContent = data.taskId;
// Añadimos los eventos a la nueva tarea
newTask.addEventListener('dragstart', function(e) {
this.classList.add('dragging');
const taskData = {
taskId: this.textContent,
sourceListId: this.parentElement.id
};
e.dataTransfer.setData('application/json', JSON.stringify(taskData));
e.dataTransfer.effectAllowed = 'move';
});
newTask.addEventListener('dragend', function() {
this.classList.remove('dragging');
});
// Añadimos la tarea a la lista
this.appendChild(newTask);
} else {
// Movemos la tarea a la nueva lista
this.appendChild(draggedTask);
}
});
});
</script>
</body>
</html>
En este ejemplo:
- Creamos dos listas de tareas: "Pendientes" y "Completadas"
- Hacemos que cada tarea sea arrastrable con
draggable="true"
- Implementamos los eventos necesarios para permitir mover tareas entre las listas
- Proporcionamos retroalimentación visual durante el proceso de arrastre
El atributo draggable
y los eventos asociados forman la base de la API de Drag and Drop de HTML5, permitiendo crear interfaces interactivas y mejorando significativamente la experiencia de usuario en aplicaciones web modernas.
Eventos del ciclo de arrastrar y soltar
Guarda tu progreso
Inicia sesión para no perder tu progreso y accede a miles de tutoriales, ejercicios prácticos y nuestro asistente de IA.
Más de 25.000 desarrolladores ya confían en CertiDevs
El proceso de arrastrar y soltar en HTML5 sigue un ciclo de eventos bien definido que nos permite controlar cada fase de la interacción. Comprender estos eventos y su secuencia es fundamental para implementar funcionalidades de drag and drop que respondan adecuadamente a las acciones del usuario.
Secuencia de eventos
Cuando un usuario interactúa con elementos arrastrables, se desencadena una secuencia ordenada de eventos que podemos capturar para personalizar el comportamiento:
- dragstart → Se dispara cuando comienza la acción de arrastre
- drag → Se dispara repetidamente mientras se arrastra el elemento
- dragenter → Se dispara cuando el elemento arrastrado entra en una zona de destino
- dragover → Se dispara repetidamente mientras el elemento arrastrado está sobre una zona de destino
- dragleave → Se dispara cuando el elemento arrastrado sale de una zona de destino
- drop → Se dispara cuando el elemento es soltado en una zona de destino
- dragend → Se dispara cuando finaliza la operación de arrastre (tanto si se completó con éxito como si se canceló)
Esta secuencia nos permite implementar una retroalimentación visual durante todo el proceso y ejecutar la lógica necesaria en cada fase.
Visualización del flujo de eventos
Veamos un ejemplo que muestra cómo se activan estos eventos durante una operación de arrastre:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Drag and Drop Event Flow</title>
<style>
#draggable {
width: 150px;
height: 50px;
background-color: #3498db;
color: white;
text-align: center;
line-height: 50px;
margin-bottom: 20px;
cursor: move;
}
.dropzone {
width: 250px;
height: 150px;
border: 2px dashed #ccc;
margin: 10px 0;
padding: 10px;
}
#log {
margin-top: 20px;
padding: 10px;
border: 1px solid #eee;
height: 200px;
overflow-y: auto;
font-family: monospace;
}
.event-log {
margin: 5px 0;
padding: 3px;
}
.dragstart { background-color: #f1c40f; }
.drag { background-color: #e8f8f5; }
.dragenter { background-color: #d5f5e3; }
.dragover { background-color: #eafaf1; }
.dragleave { background-color: #fdedec; }
.drop { background-color: #d5f5e3; }
.dragend { background-color: #ebdef0; }
</style>
</head>
<body>
<h2>Flujo de eventos de Drag and Drop</h2>
<div id="draggable" draggable="true">Arrástrame</div>
<div id="dropzone1" class="dropzone">Zona de destino 1</div>
<div id="dropzone2" class="dropzone">Zona de destino 2</div>
<div id="log">
<div>Registro de eventos:</div>
</div>
<script>
const draggable = document.getElementById('draggable');
const dropzones = document.querySelectorAll('.dropzone');
const log = document.getElementById('log');
// Función para registrar eventos
function logEvent(eventName, targetId) {
const entry = document.createElement('div');
entry.classList.add('event-log', eventName);
const time = new Date().toLocaleTimeString();
entry.textContent = `${time} - ${eventName} en ${targetId}`;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
// Eventos en el elemento arrastrable
draggable.addEventListener('dragstart', function(event) {
logEvent('dragstart', this.id);
event.dataTransfer.setData('text/plain', this.id);
});
draggable.addEventListener('drag', function() {
logEvent('drag', this.id);
});
draggable.addEventListener('dragend', function() {
logEvent('dragend', this.id);
});
// Eventos en las zonas de destino
dropzones.forEach(zone => {
zone.addEventListener('dragenter', function(event) {
event.preventDefault();
logEvent('dragenter', this.id);
});
zone.addEventListener('dragover', function(event) {
event.preventDefault();
logEvent('dragover', this.id);
});
zone.addEventListener('dragleave', function(event) {
logEvent('dragleave', this.id);
});
zone.addEventListener('drop', function(event) {
event.preventDefault();
logEvent('drop', this.id);
const id = event.dataTransfer.getData('text/plain');
const element = document.getElementById(id);
this.appendChild(element);
});
});
</script>
</body>
</html>
Este ejemplo muestra visualmente la secuencia de eventos que se activan durante una operación de arrastre y soltar. Observarás que:
- El evento
drag
se dispara continuamente mientras se mueve el elemento - Los eventos
dragover
se activan repetidamente mientras el elemento está sobre una zona de destino - El evento
dragend
siempre se dispara al final, independientemente de si el elemento fue soltado en una zona válida
Características específicas de cada evento
Cada evento del ciclo tiene propósitos específicos y características particulares que debemos conocer:
Evento dragstart
Este evento marca el inicio de la operación de arrastre y es el momento ideal para:
- Configurar los datos que se transferirán
- Establecer la imagen de arrastre personalizada
- Definir los efectos permitidos
- Aplicar estilos visuales al elemento que se está arrastrando
element.addEventListener('dragstart', function(event) {
// Almacenar datos para transferir
event.dataTransfer.setData('text/plain', this.id);
// Definir los efectos permitidos (copy, move, link)
event.dataTransfer.effectAllowed = 'move';
// Añadir clase para cambiar apariencia
this.classList.add('dragging');
});
Evento drag
Este evento se dispara continuamente mientras se arrastra el elemento. Es útil para:
- Actualizar elementos de la interfaz basados en la posición actual
- Implementar efectos visuales dinámicos
Sin embargo, es importante tener en cuenta que este evento se activa con alta frecuencia, por lo que las operaciones realizadas en su manejador deben ser ligeras para evitar problemas de rendimiento.
element.addEventListener('drag', function(event) {
// Actualizar un contador o indicador
document.getElementById('status').textContent = 'Arrastrando...';
// Evitar operaciones pesadas aquí
});
Eventos dragenter y dragover
Estos eventos se activan cuando un elemento arrastrado entra y permanece sobre una potencial zona de destino:
dragenter
se dispara una vez cuando el elemento entra en la zonadragover
se dispara repetidamente mientras el elemento permanece sobre la zona
El comportamiento predeterminado de los navegadores es rechazar las operaciones de soltar, por lo que es crucial llamar a event.preventDefault()
en estos eventos para permitir que se active el evento drop
:
dropzone.addEventListener('dragenter', function(event) {
// Prevenir comportamiento predeterminado
event.preventDefault();
// Añadir clase para resaltar la zona
this.classList.add('highlight');
});
dropzone.addEventListener('dragover', function(event) {
// Este preventDefault es ESENCIAL para que funcione el drop
event.preventDefault();
// Definir el efecto visual (cambia el cursor)
event.dataTransfer.dropEffect = 'move';
});
Evento dragleave
Este evento se activa cuando el elemento arrastrado sale de una zona de destino. Es el momento ideal para:
- Eliminar estilos visuales aplicados durante dragenter
- Cancelar cualquier preparación realizada para recibir el elemento
dropzone.addEventListener('dragleave', function(event) {
// Quitar resaltado visual
this.classList.remove('highlight');
});
Evento drop
Este evento se activa cuando el usuario suelta el elemento en una zona de destino válida. Aquí es donde implementamos la lógica principal:
- Recuperar los datos transferidos
- Realizar cambios en el DOM
- Actualizar el estado de la aplicación
dropzone.addEventListener('drop', function(event) {
// Prevenir comportamiento predeterminado (como abrir como enlace)
event.preventDefault();
// Quitar estilos visuales
this.classList.remove('highlight');
// Recuperar datos
const id = event.dataTransfer.getData('text/plain');
const element = document.getElementById(id);
// Realizar la acción deseada (por ejemplo, mover el elemento)
this.appendChild(element);
});
Evento dragend
Este evento se activa cuando finaliza la operación de arrastre, independientemente de si se completó con éxito (drop) o se canceló. Es útil para:
- Limpiar estados temporales
- Restaurar la apariencia original del elemento arrastrado
- Realizar acciones finales basadas en el resultado de la operación
element.addEventListener('dragend', function(event) {
// Quitar clase de arrastre
this.classList.remove('dragging');
// Verificar si la operación fue exitosa
if (event.dataTransfer.dropEffect === 'none') {
// La operación fue cancelada
document.getElementById('status').textContent = 'Arrastre cancelado';
} else {
// La operación fue exitosa
document.getElementById('status').textContent = 'Elemento movido';
}
});
Propagación de eventos y burbujas
Los eventos de drag and drop siguen las reglas estándar de propagación de eventos en el DOM. Esto significa que:
- Los eventos se propagan desde el elemento objetivo hacia arriba en el árbol DOM
- Podemos usar
event.stopPropagation()
para detener esta propagación - Podemos usar la delegación de eventos para manejar múltiples elementos arrastrables
Este comportamiento es especialmente relevante cuando trabajamos con elementos anidados:
<div id="outer-dropzone" class="dropzone">
Zona externa
<div id="inner-dropzone" class="dropzone">
Zona interna
</div>
</div>
En esta estructura, cuando arrastramos un elemento sobre la zona interna, los eventos dragenter
y dragover
se activarán tanto en la zona interna como en la externa (a través de la propagación).
Para manejar correctamente esta situación, podemos usar event.stopPropagation()
:
innerDropzone.addEventListener('dragover', function(event) {
event.preventDefault();
event.stopPropagation(); // Evita que el evento llegue a la zona externa
// Lógica específica para la zona interna
});
Ejemplo práctico: Tablero Kanban simple
Veamos un ejemplo práctico que implementa un tablero Kanban simple con tres columnas, utilizando los eventos del ciclo de arrastrar y soltar:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tablero Kanban Simple</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
}
.board {
display: flex;
gap: 20px;
}
.column {
flex: 1;
min-width: 250px;
background-color: #f5f5f5;
border-radius: 5px;
padding: 10px;
}
.column-header {
padding: 10px;
background-color: #e0e0e0;
border-radius: 3px;
margin-bottom: 10px;
text-align: center;
font-weight: bold;
}
.card {
background-color: white;
padding: 10px;
margin-bottom: 10px;
border-radius: 3px;
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
cursor: move;
}
.card.dragging {
opacity: 0.4;
}
.column.highlight {
background-color: #e8f4fc;
}
</style>
</head>
<body>
<h2>Tablero Kanban Simple</h2>
<div class="board">
<div id="todo" class="column">
<div class="column-header">Por hacer</div>
<div class="card" draggable="true">Diseñar página de inicio</div>
<div class="card" draggable="true">Crear base de datos</div>
<div class="card" draggable="true">Investigar API de pagos</div>
</div>
<div id="doing" class="column">
<div class="column-header">En progreso</div>
<div class="card" draggable="true">Implementar autenticación</div>
</div>
<div id="done" class="column">
<div class="column-header">Completado</div>
<div class="card" draggable="true">Configurar repositorio</div>
</div>
</div>
<script>
// Seleccionar todos los elementos necesarios
const cards = document.querySelectorAll('.card');
const columns = document.querySelectorAll('.column');
// Configurar eventos para las tarjetas
cards.forEach(card => {
// Evento dragstart - Inicio del arrastre
card.addEventListener('dragstart', function(event) {
// Marcar visualmente la tarjeta
this.classList.add('dragging');
// Guardar información sobre la tarjeta y su columna original
const data = {
cardId: this.textContent,
sourceColumnId: this.parentElement.id
};
event.dataTransfer.setData('application/json', JSON.stringify(data));
event.dataTransfer.effectAllowed = 'move';
});
// Evento drag - Durante el arrastre
card.addEventListener('drag', function() {
// Este evento se dispara continuamente
// Evitamos operaciones pesadas aquí
});
// Evento dragend - Fin del arrastre
card.addEventListener('dragend', function() {
// Restaurar apariencia
this.classList.remove('dragging');
});
});
// Configurar eventos para las columnas
columns.forEach(column => {
// Evento dragenter - Entrada en zona de destino
column.addEventListener('dragenter', function(event) {
// Solo si el objetivo directo es la columna (no una tarjeta dentro)
if (event.target.classList.contains('column')) {
event.preventDefault();
this.classList.add('highlight');
}
});
// Evento dragover - Permanencia sobre zona de destino
column.addEventListener('dragover', function(event) {
event.preventDefault(); // Crucial para permitir el drop
event.dataTransfer.dropEffect = 'move';
});
// Evento dragleave - Salida de zona de destino
column.addEventListener('dragleave', function(event) {
// Solo quitamos el resaltado si salimos realmente de la columna
// y no entramos en un hijo
if (event.relatedTarget && !this.contains(event.relatedTarget)) {
this.classList.remove('highlight');
}
});
// Evento drop - Soltar en zona de destino
column.addEventListener('drop', function(event) {
event.preventDefault();
this.classList.remove('highlight');
// Recuperar datos de la tarjeta
const data = JSON.parse(event.dataTransfer.getData('application/json'));
// Encontrar la tarjeta que se está arrastrando
const draggedCard = document.querySelector('.dragging');
// Insertar la tarjeta en la columna, después del encabezado
const header = this.querySelector('.column-header');
this.insertBefore(draggedCard, header.nextSibling);
// Aquí podríamos enviar una actualización al servidor
console.log(`Tarjeta "${data.cardId}" movida de "${data.sourceColumnId}" a "${this.id}"`);
});
});
</script>
</body>
</html>
En este ejemplo:
- Creamos un tablero Kanban con tres columnas: "Por hacer", "En progreso" y "Completado"
- Implementamos todos los eventos del ciclo de arrastrar y soltar para permitir mover tarjetas entre columnas
- Proporcionamos retroalimentación visual durante cada fase del proceso
- Manejamos correctamente la propagación de eventos para evitar comportamientos inesperados
Consideraciones importantes
Al trabajar con los eventos del ciclo de arrastrar y soltar, es importante tener en cuenta algunas consideraciones:
- Rendimiento: El evento
drag
ydragover
se disparan con alta frecuencia. Evita operaciones costosas en estos manejadores. - Prevención predeterminada: Siempre llama a
event.preventDefault()
en los eventosdragover
ydrop
para permitir que se complete la operación. - Compatibilidad: Aunque la API de Drag and Drop tiene buen soporte en navegadores de escritorio, el soporte en dispositivos móviles es limitado.
- Accesibilidad: La implementación nativa no proporciona soporte completo para accesibilidad, por lo que es necesario complementarla con controles adicionales.
Dominar los eventos del ciclo de arrastrar y soltar te permitirá crear interfaces interactivas y fluidas que mejoren significativamente la experiencia de usuario en tus aplicaciones web.
Implementación de interacciones drag and drop accesibles
La accesibilidad es un aspecto fundamental en el desarrollo web moderno que garantiza que todas las personas, independientemente de sus capacidades, puedan utilizar nuestras aplicaciones. Aunque la API nativa de Drag and Drop de HTML5 ofrece una interacción intuitiva para usuarios que utilizan dispositivos apuntadores, presenta importantes desafíos de accesibilidad para quienes utilizan lectores de pantalla, navegan exclusivamente con teclado o tienen limitaciones motoras.
Para crear experiencias de arrastrar y soltar verdaderamente inclusivas, necesitamos implementar soluciones complementarias que garanticen que todos los usuarios puedan realizar las mismas acciones.
Problemas de accesibilidad en la API nativa
Antes de abordar las soluciones, es importante entender las principales limitaciones de accesibilidad de la API nativa:
- No proporciona navegación por teclado por defecto
- Carece de anuncios adecuados para lectores de pantalla
- No comunica claramente los estados de la interacción
- Depende exclusivamente de eventos del ratón o táctiles
- No ofrece alternativas para usuarios que no pueden realizar acciones de arrastre
Estrategias para implementar drag and drop accesible
1. Proporcionar alternativas con teclado
La primera estrategia consiste en ofrecer una forma de realizar las mismas acciones utilizando solo el teclado:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Drag and Drop Accesible</title>
<style>
.container {
display: flex;
gap: 20px;
}
.list {
width: 200px;
border: 1px solid #ccc;
padding: 10px;
}
.item {
padding: 10px;
margin: 5px 0;
background-color: #f5f5f5;
border: 1px solid #ddd;
cursor: move;
}
.item:focus {
outline: 2px solid #4a90e2;
}
.item.dragging {
opacity: 0.5;
}
.keyboard-instructions {
margin-top: 20px;
padding: 10px;
background-color: #f8f9fa;
border-left: 3px solid #4a90e2;
}
</style>
</head>
<body>
<h2>Lista de tareas accesible</h2>
<div class="container">
<div id="list1" class="list" role="list" aria-label="Tareas pendientes">
<h3 id="list1-heading">Pendientes</h3>
<div class="item" draggable="true" tabindex="0" role="listitem"
aria-grabbed="false" id="item1">
Diseñar interfaz
</div>
<div class="item" draggable="true" tabindex="0" role="listitem"
aria-grabbed="false" id="item2">
Implementar API
</div>
</div>
<div id="list2" class="list" role="list" aria-label="Tareas completadas">
<h3 id="list2-heading">Completadas</h3>
<div class="item" draggable="true" tabindex="0" role="listitem"
aria-grabbed="false" id="item3">
Configurar proyecto
</div>
</div>
</div>
<div class="keyboard-instructions">
<p><strong>Instrucciones para teclado:</strong></p>
<ul>
<li>Presiona <kbd>Space</kbd> o <kbd>Enter</kbd> para seleccionar un elemento</li>
<li>Usa las teclas de flecha para navegar entre elementos</li>
<li>Presiona <kbd>Esc</kbd> para cancelar la selección</li>
<li>Presiona <kbd>Space</kbd> o <kbd>Enter</kbd> nuevamente para soltar el elemento en la ubicación actual</li>
</ul>
</div>
<script>
// Seleccionamos todos los elementos necesarios
const items = document.querySelectorAll('.item');
const lists = document.querySelectorAll('.list');
// Variable para almacenar el elemento seleccionado
let selectedItem = null;
// Configuramos los eventos para arrastrar con ratón
items.forEach(item => {
// Eventos de ratón para drag and drop
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragend', handleDragEnd);
// Eventos de teclado para accesibilidad
item.addEventListener('keydown', handleKeyDown);
});
lists.forEach(list => {
list.addEventListener('dragover', handleDragOver);
list.addEventListener('drop', handleDrop);
});
// Funciones para manejar eventos de ratón
function handleDragStart(event) {
this.classList.add('dragging');
this.setAttribute('aria-grabbed', 'true');
// Anunciamos para lectores de pantalla
announceToScreenReader(`Elemento ${this.textContent} seleccionado para mover`);
event.dataTransfer.setData('text/plain', this.id);
}
function handleDragEnd() {
this.classList.remove('dragging');
this.setAttribute('aria-grabbed', 'false');
}
function handleDragOver(event) {
event.preventDefault();
}
function handleDrop(event) {
event.preventDefault();
const id = event.dataTransfer.getData('text/plain');
const draggedItem = document.getElementById(id);
// Movemos el elemento a la nueva lista
this.appendChild(draggedItem);
// Anunciamos para lectores de pantalla
announceToScreenReader(`Elemento ${draggedItem.textContent} movido a ${this.getAttribute('aria-label')}`);
}
// Funciones para manejar eventos de teclado
function handleKeyDown(event) {
switch (event.key) {
case ' ': // Espacio
case 'Enter':
event.preventDefault();
if (selectedItem === this) {
// Si ya está seleccionado, lo soltamos
dropWithKeyboard(this);
} else {
// Si no está seleccionado, lo seleccionamos
selectWithKeyboard(this);
}
break;
case 'Escape':
// Cancelar la selección
if (selectedItem) {
cancelSelection();
}
break;
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
// Navegación entre elementos
navigateWithKeyboard(event);
break;
}
}
function selectWithKeyboard(item) {
// Si ya hay un elemento seleccionado, lo deseleccionamos
if (selectedItem) {
cancelSelection();
}
// Seleccionamos el nuevo elemento
selectedItem = item;
item.classList.add('dragging');
item.setAttribute('aria-grabbed', 'true');
// Anunciamos para lectores de pantalla
announceToScreenReader(`Elemento ${item.textContent} seleccionado. Use las teclas de flecha para navegar y Enter para soltar.`);
}
function dropWithKeyboard(item) {
// Obtenemos la lista actual
const currentList = item.parentNode;
// Deseleccionamos el elemento
item.classList.remove('dragging');
item.setAttribute('aria-grabbed', 'false');
// Anunciamos para lectores de pantalla
announceToScreenReader(`Elemento ${item.textContent} movido a ${currentList.getAttribute('aria-label')}`);
// Reseteamos la selección
selectedItem = null;
}
function cancelSelection() {
if (selectedItem) {
selectedItem.classList.remove('dragging');
selectedItem.setAttribute('aria-grabbed', 'false');
// Anunciamos para lectores de pantalla
announceToScreenReader(`Selección cancelada`);
selectedItem = null;
}
}
function navigateWithKeyboard(event) {
// Prevenimos el desplazamiento de la página
event.preventDefault();
// Si hay un elemento seleccionado, lo movemos
if (selectedItem) {
const currentList = selectedItem.parentNode;
let targetList;
switch (event.key) {
case 'ArrowLeft':
// Movemos a la lista anterior
targetList = currentList.previousElementSibling;
if (targetList && targetList.classList.contains('list')) {
targetList.appendChild(selectedItem);
announceToScreenReader(`Movido a ${targetList.getAttribute('aria-label')}`);
}
break;
case 'ArrowRight':
// Movemos a la lista siguiente
targetList = currentList.nextElementSibling;
if (targetList && targetList.classList.contains('list')) {
targetList.appendChild(selectedItem);
announceToScreenReader(`Movido a ${targetList.getAttribute('aria-label')}`);
}
break;
}
}
}
// Función para anunciar mensajes a lectores de pantalla
function announceToScreenReader(message) {
// Creamos o actualizamos un elemento live region
let announcer = document.getElementById('a11y-announcer');
if (!announcer) {
announcer = document.createElement('div');
announcer.id = 'a11y-announcer';
announcer.setAttribute('aria-live', 'assertive');
announcer.setAttribute('aria-atomic', 'true');
announcer.className = 'sr-only';
document.body.appendChild(announcer);
}
// Limpiamos y luego establecemos el contenido para asegurar que se anuncie
announcer.textContent = '';
setTimeout(() => {
announcer.textContent = message;
}, 100);
}
// Estilos para ocultar visualmente el anunciador pero mantenerlo accesible
const style = document.createElement('style');
style.textContent = `
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`;
document.head.appendChild(style);
</script>
</body>
</html>
En este ejemplo:
- Añadimos
tabindex="0"
para hacer que los elementos sean focusables - Implementamos controles de teclado para seleccionar, mover y soltar elementos
- Proporcionamos instrucciones claras para usuarios de teclado
- Utilizamos una región live para anunciar cambios a lectores de pantalla
2. Uso de atributos ARIA para mejorar la semántica
Los atributos ARIA (Accessible Rich Internet Applications) nos permiten mejorar la semántica de nuestros elementos para lectores de pantalla:
<div class="item"
draggable="true"
tabindex="0"
role="listitem"
aria-grabbed="false"
aria-describedby="drag-instructions">
Elemento arrastrable
</div>
<div id="drag-instructions" class="sr-only">
Presiona Espacio para seleccionar, luego usa las flechas para mover y Espacio para soltar.
</div>
Los atributos clave incluyen:
role="listitem"
- Define el rol semántico del elementoaria-grabbed
- Indica si el elemento está siendo arrastradoaria-dropeffect
- Indica qué ocurrirá si se suelta un elemento (copy, move, etc.)aria-describedby
- Vincula el elemento con sus instrucciones de uso
3. Retroalimentación visual clara
Es importante proporcionar retroalimentación visual que no dependa solo del color:
/* Estilo para elemento seleccionado */
.item.selected {
outline: 2px solid #4a90e2;
box-shadow: 0 0 0 4px rgba(74, 144, 226, 0.3);
position: relative;
}
/* Indicador visual adicional */
.item.selected::before {
content: "✓";
position: absolute;
top: 5px;
right: 5px;
background: #4a90e2;
color: white;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
/* Estilo para zonas de destino válidas */
.list.valid-target {
border: 2px dashed #4a90e2;
background-color: rgba(74, 144, 226, 0.1);
}
4. Implementación de un patrón de diálogo para dispositivos móviles
Para dispositivos móviles donde el drag and drop nativo no funciona bien, podemos implementar un patrón de diálogo:
<div class="item" onclick="showMoveDialog(this)">
Elemento móvil
<button class="move-button" aria-label="Mover elemento">
<span class="icon">↕</span>
</button>
</div>
<dialog id="move-dialog">
<h3>Mover elemento</h3>
<p>Selecciona dónde mover este elemento:</p>
<ul id="destination-options">
<!-- Se llenará dinámicamente -->
</ul>
<div class="dialog-buttons">
<button id="cancel-move">Cancelar</button>
</div>
</dialog>
<script>
function showMoveDialog(element) {
const dialog = document.getElementById('move-dialog');
const options = document.getElementById('destination-options');
// Guardamos referencia al elemento que se moverá
dialog.dataset.moveElement = element.id;
// Limpiamos opciones anteriores
options.innerHTML = '';
// Obtenemos todas las listas disponibles
const lists = document.querySelectorAll('.list');
// Creamos opciones para cada lista
lists.forEach(list => {
const option = document.createElement('li');
const button = document.createElement('button');
button.textContent = list.querySelector('h3').textContent;
button.dataset.targetList = list.id;
button.addEventListener('click', moveToSelected);
option.appendChild(button);
options.appendChild(option);
});
// Mostramos el diálogo
dialog.showModal();
}
function moveToSelected() {
const dialog = document.getElementById('move-dialog');
const elementId = dialog.dataset.moveElement;
const element = document.getElementById(elementId);
const targetListId = this.dataset.targetList;
const targetList = document.getElementById(targetListId);
// Movemos el elemento a la lista seleccionada
targetList.appendChild(element);
// Anunciamos para lectores de pantalla
announceToScreenReader(`Elemento movido a ${this.textContent}`);
// Cerramos el diálogo
dialog.close();
}
// Configurar botón de cancelar
document.getElementById('cancel-move').addEventListener('click', function() {
document.getElementById('move-dialog').close();
});
</script>
Este enfoque proporciona una alternativa táctil que funciona bien en dispositivos móviles y es completamente accesible.
Ejemplo completo de implementación accesible
A continuación, se presenta un ejemplo más completo que combina todas estas estrategias:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Drag and Drop Accesible Completo</title>
<style>
/* Estilos básicos */
.container {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
}
.list {
width: 250px;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
background-color: #f9f9f9;
}
.list h3 {
margin-top: 0;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.item {
padding: 12px;
margin: 8px 0;
background-color: white;
border: 1px solid #eee;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
cursor: move;
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Estilos para accesibilidad */
.item:focus {
outline: 2px solid #4a90e2;
box-shadow: 0 0 0 4px rgba(74, 144, 226, 0.3);
}
.item.dragging {
opacity: 0.6;
outline: 2px solid #4a90e2;
}
.list.drop-target {
background-color: #e8f4fc;
border-color: #4a90e2;
}
.move-button {
background: #f0f0f0;
border: none;
border-radius: 4px;
width: 30px;
height: 30px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.move-button:hover {
background: #e0e0e0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Estilos para el diálogo */
dialog {
padding: 20px;
border-radius: 8px;
border: 1px solid #ddd;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
max-width: 400px;
width: 100%;
}
dialog::backdrop {
background-color: rgba(0,0,0,0.3);
}
#destination-options {
list-style: none;
padding: 0;
}
#destination-options li {
margin: 8px 0;
}
#destination-options button {
width: 100%;
padding: 10px;
text-align: left;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
#destination-options button:hover,
#destination-options button:focus {
background: #e8f4fc;
border-color: #4a90e2;
}
.dialog-buttons {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.dialog-buttons button {
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
#cancel-move {
background: #f5f5f5;
border: 1px solid #ddd;
}
/* Instrucciones de accesibilidad */
.accessibility-instructions {
background-color: #f8f9fa;
border-left: 4px solid #4a90e2;
padding: 15px;
margin: 20px 0;
}
.accessibility-instructions h3 {
margin-top: 0;
}
kbd {
background-color: #f7f7f7;
border: 1px solid #ccc;
border-radius: 3px;
box-shadow: 0 1px 0 rgba(0,0,0,0.2);
color: #333;
display: inline-block;
font-size: 0.85em;
font-weight: 700;
line-height: 1;
padding: 2px 4px;
white-space: nowrap;
}
</style>
</head>
<body>
<h1>Organizador de tareas accesible</h1>
<div class="accessibility-instructions">
<h3>Instrucciones de uso</h3>
<p><strong>Con ratón:</strong> Arrastra y suelta los elementos entre las listas.</p>
<p><strong>Con teclado:</strong></p>
<ul>
<li>Usa <kbd>Tab</kbd> para navegar entre elementos</li>
<li>Presiona <kbd>Space</kbd> o <kbd>Enter</kbd> para seleccionar un elemento</li>
<li>Usa <kbd>←</kbd> <kbd>→</kbd> para mover entre listas</li>
<li>Presiona <kbd>Space</kbd> o <kbd>Enter</kbd> nuevamente para soltar</li>
<li>Presiona <kbd>Esc</kbd> para cancelar</li>
</ul>
<p><strong>En dispositivos táctiles:</strong> Toca el botón de mover y selecciona el destino.</p>
</div>
<div class="container">
<div id="list1" class="list" role="list" aria-label="Tareas pendientes">
<h3 id="list1-heading">Pendientes</h3>
<div id="item1" class="item" draggable="true" tabindex="0" role="listitem"
aria-grabbed="false" aria-describedby="drag-instructions">
<span>Diseñar interfaz</span>
<button class="move-button" aria-label="Mover tarea" onclick="showMoveDialog('item1')">
<span aria-hidden="true">↕</span>
</button>
</div>
<div id="item2" class="item" draggable="true" tabindex="0" role="listitem"
aria-grabbed="false" aria-describedby="drag-instructions">
<span>Implementar API</span>
<button class="move-button" aria-label="Mover tarea" onclick="showMoveDialog('item2')">
<span aria-hidden="true">↕</span>
</button>
</div>
</div>
<div id="list2" class="list" role="list" aria-label="Tareas en progreso">
<h3 id="list2-heading">En progreso</h3>
<div id="item3" class="item" draggable="true" tabindex="0" role="listitem"
aria-grabbed="false" aria-describedby="drag-instructions">
<span>Crear documentación</span>
<button class="move-button" aria-label="Mover tarea" onclick="showMoveDialog('item3')">
<span aria-hidden="true">↕</span>
</button>
</div>
</div>
<div id="list3" class="list" role="list" aria-label="Tareas completadas">
<h3 id="list3-heading">Completadas</h3>
<div id="item4" class="item" draggable="true" tabindex="0" role="listitem"
aria-grabbed="false" aria-describedby="drag-instructions">
<span>Configurar proyecto</span>
<button class="move-button" aria-label="Mover tarea" onclick="showMoveDialog('item4')">
<span aria-hidden="true">↕</span>
</button>
</div>
</div>
</div>
<!-- Instrucciones ocultas para lectores de pantalla -->
<div id="drag-instructions" class="sr-only">
Elemento arrastrable. Presiona Espacio para seleccionar, luego usa las flechas izquierda o derecha para mover entre listas, y Espacio nuevamente para soltar.
</div>
<!-- Diálogo para dispositivos móviles -->
<dialog id="move-dialog">
<h3>Mover tarea</h3>
<p>Selecciona a qué lista mover esta tarea:</p>
<ul id="destination-options">
<!-- Se llenará dinámicamente -->
</ul>
<div class="dialog-buttons">
<button id="cancel-move">Cancelar</button>
</div>
</dialog>
<script>
// Seleccionamos todos los elementos necesarios
const items = document.querySelectorAll('.item');
const lists = document.querySelectorAll('.list');
// Variable para almacenar el elemento seleccionado
let selectedItem = null;
// Configuramos los eventos para arrastrar con ratón
items.forEach(item => {
// Eventos de ratón para drag and drop
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragend', handleDragEnd);
// Eventos de teclado para accesibilidad
item.addEventListener('keydown', handleKeyDown);
});
lists.forEach(list => {
list.addEventListener('dragenter', handleDragEnter);
list.addEventListener('dragover', handleDragOver);
list.addEventListener('dragleave', handleDragLeave);
list.addEventListener('drop', handleDrop);
});
// Funciones para manejar eventos de ratón
function handleDragStart(event) {
this.classList.add('dragging');
this.setAttribute('aria-grabbed', 'true');
// Anunciamos para lectores de pantalla
announceToScreenReader(`Elemento ${this.querySelector('span').textContent} seleccionado para mover`);
event.dataTransfer.setData('text/plain', this.id);
}
function handleDragEnd() {
this.classList.remove('dragging');
this.setAttribute('aria-grabbed', 'false');
}
function handleDragEnter(event) {
if (event.target.classList.contains('list')) {
this.classList.add('drop-target');
}
}
function handleDragOver(event) {
event.preventDefault();
}
function handleDragLeave(event) {
if (event.relatedTarget && !this.contains(event.relatedTarget)) {
this.classList.remove('drop-target');
}
}
function handleDrop(event) {
event.preventDefault();
this.classList.remove('drop-target');
const id = event.dataTransfer.getData('text/plain');
const draggedItem = document.getElementById(id);
// Movemos el elemento a la nueva lista
this.appendChild(draggedItem);
// Anunciamos para lectores de pantalla
announceToScreenReader(`Elemento ${draggedItem.querySelector('span').textContent} movido a ${this.getAttribute('aria-label')}`);
}
// Funciones para manejar eventos de teclado
function handleKeyDown(event) {
switch (event.key) {
case ' ': // Espacio
case 'Enter':
event.preventDefault();
if (selectedItem === this) {
// Si ya está seleccionado, lo soltamos
dropWithKeyboard(this);
} else {
// Si no está seleccionado, lo seleccionamos
selectWithKeyboard(this);
}
break;
case 'Escape':
// Cancelar la selección
if (selectedItem) {
cancelSelection();
}
break;
case 'ArrowLeft':
case 'ArrowRight':
// Navegación entre listas
if (selectedItem) {
event.preventDefault();
moveToAdjacentList(event.key);
}
break;
}
}
function selectWithKeyboard(item) {
// Si ya hay un elemento seleccionado, lo deseleccionamos
if (selectedItem) {
cancelSelection();
}
// Seleccionamos el nuevo elemento
selectedItem = item;
item.classList.add('dragging');
item.setAttribute('aria-grabbed', 'true');
// Anunciamos para lectores de pantalla
announceToScreenReader(`Elemento ${item.querySelector('span').textContent} seleccionado. Use las flechas izquierda o derecha para mover entre listas y Enter para soltar.`);
}
function dropWithKeyboard(item) {
// Obtenemos la lista actual
const currentList = item.parentNode;
// Deseleccionamos el elemento
item.classList.remove('dragging');
item.setAttribute('aria-grabbed', 'false');
// Anunciamos para lectores de pantalla
announceToScreenReader(`Elemento ${item.querySelector('span').textContent} colocado en ${currentList.getAttribute('aria-label')}`);
// Reseteamos la selección
selectedItem = null;
}
function cancelSelection() {
if (selectedItem) {
selectedItem.classList.remove('dragging');
selectedItem.setAttribute('aria-grabbed', 'false');
// Anunciamos para lectores de pantalla
announceToScreenReader(`Selección cancelada`);
selectedItem = null;
}
}
function moveToAdjacentList(direction) {
if (!selectedItem) return;
const currentList = selectedItem.parentNode;
let targetList;
if (direction === 'ArrowLeft') {
targetList = getPreviousList(currentList);
} else if (direction === 'ArrowRight') {
targetList = getNextList(currentList);
}
if (targetList) {
targetList.appendChild(selectedItem);
announceToScreenReader(`Movido a ${targetList.getAttribute('aria-label')}`);
}
}
function getPreviousList(currentList) {
let prev = currentList.previousElementSibling;
while (prev) {
if (prev.classList.contains('list')) {
return prev;
}
prev = prev.previousElementSibling;
}
return null;
}
function getNextList(currentList) {
let next = currentList.nextElementSibling;
while (next) {
if (next.classList.contains('list')) {
return next;
}
next = next.nextElementSibling;
}
return null;
}
// Función para mostrar el diálogo de movimiento (para dispositivos táctiles)
function showMoveDialog(itemId) {
const dialog = document.getElementById('move-dialog');
const options = document.getElementById('destination-options');
const item = document.getElementById(itemId);
// Guardamos referencia al elemento que se moverá
dialog.dataset.moveElement = itemId;
// Limpiamos opciones anteriores
options.innerHTML = '';
// Obtenemos todas las listas disponibles
const lists = document.querySelectorAll('.list');
// Creamos opciones para cada lista
lists.forEach(list => {
const option = document.createElement('li');
const button = document.createElement('button');
button.textContent = list.querySelector('h3').textContent;
button.dataset.targetList = list.id;
button.addEventListener('click', moveToSelected);
// Destacamos la lista actual
if (item.parentNode === list) {
button.setAttribute('aria-current', 'true');
button.style.fontWeight = 'bold';
}
option.appendChild(button);
options.appendChild(option);
});
// Mostramos el diálogo
dialog.showModal();
}
function moveToSelected() {
const dialog = document.getElementById('move-dialog');
const elementId = dialog.dataset.moveElement;
const element = document.getElementById(elementId);
const targetListId = this.dataset.targetList;
const targetList = document.getElementById(targetListId);
// Movemos el elemento a la lista seleccionada
targetList.appendChild(element);
// Anunciamos para lectores de pantalla
announceToScreenReader(`Elemento ${element.querySelector('span').textContent} movido a ${this.textContent}`);
// Cerramos el diálogo
dialog.close();
}
// Configurar botón de cancelar
document.getElementById('cancel-move').addEventListener('click', function() {
document.getElementById('move-dialog').close();
});
// Función para anunciar mensajes a lectores de pantalla
function announceToScreenReader(message) {
// Creamos o actualizamos un elemento live region
let announcer = document.getElementById('a11y-announcer');
if (!announcer) {
announcer = document.createElement('div');
announcer.id = 'a11y-announcer';
announcer.setAttribute('aria-live', 'assertive');
announcer.setAttribute('aria-atomic', 'true');
announcer.className = 'sr-only';
document.body.appendChild(announcer);
}
// Limpiamos y luego establecemos el contenido para asegurar que se anuncie
announcer.textContent = '';
setTimeout(() => {
announcer.textContent = message;
}, 100);
}
</script>
</body>
</html>
Mejores prácticas para drag and drop accesible
Para garantizar que tus implementaciones de drag and drop sean verdaderamente accesibles, sigue estas mejores prácticas:
-
Proporciona múltiples métodos de interacción: Implementa alternativas con teclado y táctiles además del arrastre con ratón.
-
Usa atributos ARIA apropiados: Utiliza
aria-grabbed
,aria-dropeffect
y otros atributos relevantes para comunicar el estado a los lectores de pantalla. -
Ofrece retroalimentación clara: Proporciona indicaciones visuales, auditivas y táctiles sobre lo que está sucediendo.
-
Incluye instrucciones explícitas: Documenta claramente cómo usar la funcionalidad con diferentes dispositivos de entrada.
-
Utiliza regiones live para anuncios: Implementa regiones live de ARIA para comunicar cambios dinámicos a los lectores de pantalla.
-
Prueba con tecnologías de asistencia: Verifica que tu implementación funcione correctamente con lectores de pantalla como NVDA, JAWS o VoiceOver.
-
Mantén un alto contraste: Asegúrate de que los indicadores visuales tengan suficiente contraste para usuarios con baja visión.
-
Evita depender solo del color: Utiliza formas, iconos o patrones además del color para transmitir información.
-
Implementa atajos de teclado intuitivos: Usa combinaciones de teclas que sean coherentes con las convenciones de la plataforma.
-
Ofrece mecanismos de cancelación: Permite a los usuarios cancelar fácilmente una operación de arrastre en curso.
Pruebas de accesibilidad
Para verificar que tu implementación de drag and drop es realmente accesible, realiza las siguientes pruebas:
-
Navegación por teclado: Intenta completar todas las operaciones usando solo el teclado.
-
Lectores de pantalla: Prueba con NVDA, JAWS o VoiceOver para verificar que los anuncios son claros y útiles.
-
Modo de alto contraste: Verifica que la interfaz sigue siendo usable en modo de alto contraste.
-
Dispositivos táctiles: Comprueba que la alternativa táctil funciona correctamente en teléfonos y tabletas.
-
Diferentes tamaños de texto: Asegúrate de que la interfaz se adapta correctamente cuando los usuarios aumentan el tamaño del texto.
Implementar interacciones de drag and drop accesibles requiere más trabajo que simplemente usar la API nativa, pero el resultado es una experiencia inclusiva que beneficia a todos los usuarios, independientemente de sus capacidades o dispositivos. Al seguir estas pautas, crearás interfaces que no solo son más accesibles, sino también más usables y robustas para todos.
Aprendizajes de esta lección de HTML
- Comprender el funcionamiento básico de la API nativa de Drag and Drop en HTML5.
- Aprender a usar el atributo draggable y manejar los eventos asociados al ciclo de arrastrar y soltar.
- Implementar transferencia de datos entre elementos mediante el objeto DataTransfer.
- Crear zonas de destino para recibir elementos arrastrados y gestionar la interacción.
- Diseñar interacciones accesibles para usuarios con diferentes capacidades, incluyendo soporte para teclado y lectores de pantalla.
Completa este curso de HTML y certifícate
Únete a nuestra plataforma de cursos de programación y accede a miles de tutoriales, ejercicios prácticos, proyectos reales y nuestro asistente de IA personalizado para acelerar tu aprendizaje.
Asistente IA
Resuelve dudas al instante
Ejercicios
Practica con proyectos reales
Certificados
Valida tus conocimientos
Más de 25.000 desarrolladores ya se han certificado con CertiDevs