Documentación/Guías
Decidir un drop
Aceptar, rechazar o aplazar un drop — reglas durante el arrastre o al soltar, parte de un multi-drag, un diálogo, un servidor que debe estar de acuerdo primero, monitores que vetan y drops fuera de cualquier destino.
Esta página la ha traducido una IA y puede contener imprecisiones. Leer el original en inglés
Arrastra una tarjeta a Done. El drop espera tu respuesta: mover la tarjeta, seguir arrastrándola a otro sitio o cancelar.
Cargando la demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Board from './Board.vue';
</script>
<template>
<DnDProvider>
<Board />
</DnDProvider>
</template>onBeforeDrop
makeSortableList(listRef, {
items: tasks,
onBeforeDrop: (e) => (e.items.some((task) => task.locked) ? 'reject' : 'accept'),
});
Las listas, los árboles, los destinos de drop (makeDroppable) y los monitores (useDnDMonitor)
aceptan un onBeforeDrop. Se ejecuta al soltar el arrastre, antes de que cambie nada, y su
respuesta decide:
| Respuesta | Qué pasa |
|---|---|
'accept', true o nada | El drop se completa. |
'reject' o false | No hay drop: los ítems vuelven a su sitio. Un error lanzado también rechaza. |
| Un array de claves | Solo esos ítems se sueltan; los demás vuelven a su sitio. |
'continue' | No hay drop y el arrastre continúa. |
| Una promesa de cualquiera de estas | El overlay espera donde se soltó hasta que la promesa se resuelva. |
Las listas y los árboles pasan un SortableDropEvent: keys, items, from, to, mode,
sameList y result, los nuevos arrays de cada lista que cambia el drop. Los destinos de drop y
los monitores reciben el DnDEvent genérico: active, over, position y source. Consulta
eventos.
Una regla durante el arrastre, o al soltar
Email y Chat aceptan adjuntos de 10 MB como máximo. Arrastra demo.mov sobre cada uno: Email lo rechaza mientras estás encima, Chat lo acepta y lo devuelve cuando sueltas.
Cargando la demo…
<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';
const columns = reactive([
{
id: 'desktop',
title: 'Desktop',
files: [
{ id: 'notes', name: 'notes.pdf', size: 2 },
{ id: 'demo', name: 'demo.mov', size: 48 },
{ id: 'logo', name: 'logo.png', size: 1 },
{ id: 'backup', name: 'backup.zip', size: 120 },
],
},
// Both take files of 10 MB at most, and say so at different times.
{ id: 'email', title: 'Email', rule: 'put' as const, files: [] },
{ id: 'chat', title: 'Chat', rule: 'onBeforeDrop' as const, files: [] },
]);
</script>
<template>
<DnDProvider>
<div class="board">
<Column v-for="column in columns" :key="column.id" :column="column" />
</div>
</DnDProvider>
</template>
<style scoped>
.board {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.board > * {
flex: 0 1 170px;
min-width: 150px;
}
</style>// Asked while dragging: a refused drag never enters the list.
group: { name: 'attachments', put: ({ items }) => fits(items as Attachment[]) },
// Asked on release: the drag enters the list, then flies back.
onBeforeDrop: (e) => (fits(e.items) ? 'accept' : 'reject'),
group.put (o el accept de un destino) | onBeforeDrop | |
|---|---|---|
| Se pregunta | Durante el arrastre, en cuanto el arrastre llega a la lista. | Una vez, cuando se suelta el arrastre. |
| El usuario ve | La lista rechaza al instante: no se abre ningún hueco, isAccepting es false, over.accepted es false. | El hueco se abre como siempre; los ítems vuelven a su sitio al soltar. |
| Sabe | Qué se arrastra: keys, items, types, from, to. | Todo el drop: to.index, sameList, mode, result. |
| Puede | Decir sí o no. | Esperar una promesa, aceptar algunas claves, mantener el arrastre. |
Usa group.put para lo que se sabe solo con los ítems (un tipo, una marca, una cantidad): el
usuario lo ve antes de soltar. Usa onBeforeDrop para lo que depende del sitio, de la respuesta
del usuario o de la del servidor. Las reglas propias del destino van primero: sobre una lista o
un destino que rechaza el arrastre, el drop se cancela y no se pregunta a su onBeforeDrop.
Aceptar algunos de los ítems
Algunas reglas solo se pueden comprobar al soltar, contra un servidor. Las plazas de este taller también las reservan otros: al soltar, la lista pregunta al servidor cuántas quedan, y acepta ese número de las personas arrastradas. Marca unas cuantas y arrástralas juntas.
Cargando la demo…
<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';
const columns = reactive([
{
id: 'waiting',
title: 'Waiting list',
people: [
{ id: 'ada', name: 'Ada', hue: 200 },
{ id: 'bo', name: 'Bo', hue: 30 },
{ id: 'cy', name: 'Cyrus', hue: 280 },
{ id: 'dee', name: 'Dee', hue: 140 },
],
},
{
id: 'workshop',
title: 'Workshop',
seats: true,
people: [
{ id: 'eli', name: 'Eli', hue: 340 },
{ id: 'fay', name: 'Fay', hue: 90 },
],
},
]);
</script>
<template>
<DnDProvider>
<div class="demo">
<p class="tip">Tick a few people, then drag one of them into the workshop</p>
<div class="board">
<Column v-for="column in columns" :key="column.id" :column="column" />
</div>
</div>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.tip {
margin: 0;
font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
.board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 230px));
justify-content: center;
gap: 14px;
width: 100%;
}
</style>makeSortableList(list, {
items: toRef(props.column, 'people'),
group: 'workshop',
// Some keys: those land, the others fly back to where they came from.
onBeforeDrop: async (e) => {
const { free } = await seatsLeft(props.column.people.length); // the overlay waits
return e.keys.slice(0, free);
},
onDrop: (e) => {
selection.delete(...e.accepted); // the ones sent back stay selected
notice.value = `${e.accepted.length} booked, ${e.rejected.length} back to the waiting list`;
},
});
En un multi-drag, las claves que devuelves aterrizan juntas y el resto
vuelve a su sitio. onDrop las ve luego en e.accepted y e.rejected, y su e.result es el
definitivo. Un array vacío no acepta nada: el drop se rechaza.
Seguir arrastrando: 'continue'
'continue' cancela el drop, no el arrastre. Responde Keep dragging en el diálogo de arriba:
el ítem sigue en la mano desde donde estaba, la página vuelve al modo de arrastre y el usuario
continúa.
- Con ratón o lápiz, el ítem sigue al puntero sin ningún botón pulsado. La siguiente vez que se suelta — un clic — lo suelta, y ese clic nunca llega a la página de debajo.
- Con el dedo, el ítem se vuelve a agarrar donde se posa el dedo; levantar el dedo lo suelta.
- Un arrastre con teclado continúa: las flechas mueven el ítem, Space o Enter lo sueltan.
- Escape lo cancela, como cualquier arrastre.
- El siguiente drop vuelve a preguntar a
onBeforeDrop.
Los monitores reciben onDragResume, y los lectores de pantalla anuncian que el ítem no se soltó
y que sigue moviéndose (cambia el texto con announcements.onDragResume del provider).
useDnDMonitor({
onDragResume() {
hint.value = 'Still dragging: click to drop, Esc to cancel';
},
});
Preguntar al usuario
onBeforeDrop puede devolver una promesa. Abre un diálogo y resuelve la promesa con el botón que
elija el usuario:
const { items } = makeSortableList(list, {
id: props.column.id,
items: toRef(props.column, 'tasks'),
group: 'release',
onBeforeDrop: (e) => (props.column.confirm && !e.sameList ? props.ask(e) : 'accept'),
});
type Answer = 'accept' | 'continue' | 'reject';
const question = shallowRef<{ title: string; resolve: (answer: Answer) => void } | null>(null);
/** Opens the dialog: `onBeforeDrop` waits for the button the user picks. */
const ask = (e: SortableDropEvent<Task>) =>
new Promise<Answer>((resolve) => {
question.value = { title: e.items[0]!.title, resolve };
});
Mientras la promesa está pendiente:
- el overlay espera donde se soltó, marcado con
data-dnd-overlay="dropping"; useDnD()conserva el estado que tenía al soltar, ystatuses'dropping';- la página sale del modo de arrastre: vuelven el cursor y la selección de texto, así que un diálogo funciona como siempre;
- no puede empezar ningún otro arrastre.
<dialog> modal abierto con showModal() está en la top layer, por encima del overlay que
espera. Convierte su evento cancel (Escape) en 'reject', como hace el ejemplo.El orden de los hooks
Reordena las notas, suelta una en la papelera, luego cambia una respuesta y vuelve a hacerlo. El panel muestra los hooks del último drop en el orden en que se ejecutaron.
Cargando la demo…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Monitor from './Monitor.vue';
import Notes from './Notes.vue';
import { ANSWERS, type Answer } from './types';
const monitorAnswer = ref<Answer>('accept');
const targetAnswer = ref<Answer>('accept');
// Every hook of the last drop, in the order they ran.
const steps = ref<{ who: string; what: string }[]>([]);
const trace = (who: string, what: string) => steps.value.push({ who, what });
</script>
<template>
<DnDProvider
@drag-start="steps = []"
@drag-end="trace('provider', '@drag-end')"
@drag-cancel="trace('provider', '@drag-cancel')"
@drag-settle="trace('provider', '@drag-settle: landed')"
>
<div class="demo">
<div class="switches">
<fieldset>
<legend>monitor answers</legend>
<label v-for="value in ANSWERS" :key="value" :class="{ on: monitorAnswer === value }">
<input v-model="monitorAnswer" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
<fieldset>
<legend>target answers</legend>
<label v-for="value in ANSWERS" :key="value" :class="{ on: targetAnswer === value }">
<input v-model="targetAnswer" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
</div>
<div class="layout">
<Notes :answer="targetAnswer" :trace="trace" />
<ol class="steps" aria-label="Hooks of the last drop">
<li v-for="(step, index) in steps" :key="index">
<span class="who">{{ step.who }}</span>{{ step.what }}
</li>
<li v-if="!steps.length" class="empty">Move a note, or drop one in the trash</li>
</ol>
</div>
<Monitor :answer="monitorAnswer" :trace="trace" />
</div>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.switches {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px 16px;
}
fieldset {
display: inline-flex;
margin: 0;
padding: 3px;
border: 0;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
legend {
float: left;
align-self: center;
padding: 0 8px 0 6px;
font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
label {
padding: 5px 10px;
border-radius: 6px;
font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
label.on {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
font-weight: 600;
}
label:has(input:focus-visible) {
outline: 2px solid #42b883;
}
input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.layout {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 16px;
}
.steps {
box-sizing: border-box;
min-width: 220px;
min-height: 150px;
margin: 0;
padding: 10px 14px 10px 32px;
border-radius: 10px;
background: color-mix(in srgb, currentColor 5%, transparent);
font: 11px/1.8 ui-monospace, Menlo, Consolas, monospace;
white-space: nowrap;
list-style: decimal;
}
.steps li::marker {
color: color-mix(in srgb, currentColor 45%, transparent);
}
.who {
display: inline-block;
min-width: 60px;
color: #42b883;
}
.steps li.empty {
margin-left: -18px;
list-style: none;
opacity: 0.45;
}
</style>- El
onBeforeDropde cadauseDnDMonitor, por turnos. Un'reject'o un'continue'termina las preguntas; unas claves acotan el drop. - El
onBeforeDropdel destino: la lista o el árbol al que van los ítems, o el destino de drop. - El
onDropsolo del destino: no se pregunta a una lista que los ítems dejan por otra, y su nuevo array está en ele.resultdel destino. Cuando se quitan ítems (una zona de papelera,dropOutside: 'remove'), se ejecuta elonDropde su lista, conto.indexigual a-1. - La escritura por defecto: cada lista que cambia el drop recibe su nuevo array.
- El
onDragEnddel provider (@drag-end) y de los monitores. Luego aterriza el overlay, yonDragSettle(@drag-settle) avisa de que lo ha hecho.
Un drop rechazado salta en su lugar a onDragCancel (@drag-cancel), y los ítems vuelven a su
sitio. Un 'continue' también termina las preguntas, con onDragResume: el arrastre sigue, y su
siguiente drop vuelve a preguntar.
Los monitores deciden antes que el destino, así que un veto nunca llega después de que una lista haya aplicado ya un drop propio. Eso convierte a un monitor en el sitio para las reglas sobre todos los drops de la app:
const { syncing } = useSync();
// Read-only while syncing: every drop under the provider flies back.
useDnDMonitor({
onBeforeDrop: () => (syncing.value ? 'reject' : 'accept'),
});
onDrop: avisado antes de la escritura
makeSortableList(listRef, {
items: tasks,
onDrop: (e) => {
// `tasks` still holds the old order, `e.result` the new one.
console.log(e.keys, e.from, e.to, e.result.get(e.to.list));
},
});
El onDrop de una lista se ejecuta una vez aceptado el drop, de forma síncrona, justo antes de
que la lista lo escriba. Para actuar después de la escritura, usa el @drag-end del provider o
useDnDMonitor({ onDragEnd }). En un destino de drop simple, onDrop es donde cambias tus datos:
la biblioteca no mueve nada por él.
Stores y servidores: listas controladas
const store = useBoardStore();
const { items } = makeSortableList(listRef, {
id: 'todo',
group: 'board',
items: () => store.lists.todo, // a getter: read-only
onDrop: (e) => {
e.preventDefault();
for (const [listId, next] of e.result) store.setList(listId, next);
},
});
La biblioteca solo escribe un drop en una ref modificable. Un getter, un computed o una ref
readonly nunca se escriben: llama a e.preventDefault() y aplica e.result tú mismo — los
nuevos arrays de cada lista que cambia el drop, por id de lista. preventDefault() omite la
escritura en todas ellas, ya que escribir solo algunas perdería o duplicaría ítems. Las builds de
desarrollo avisan cuando el drop de una lista de solo lectura se deja a la biblioteca.
Guardar primero en un servidor
Mueve una tarjeta con el servidor en flaky (falla una de cada dos peticiones) o en down. La tarjeta espera con una insignia mientras se ejecuta la petición, y luego aterriza, o vuelve a su sitio si falló.
Cargando la demo…
<script setup lang="ts">
import { DnDProvider, DragPreview } from '@vue-dnd-kit/core';
import Column from './Column.vue';
import { mode, requests, saving, type ServerMode } from './server';
const MODES: ServerMode[] = ['reliable', 'flaky', 'down'];
</script>
<template>
<DnDProvider>
<div class="demo">
<fieldset class="modes">
<legend>server</legend>
<label v-for="value in MODES" :key="value" :class="{ on: mode === value }">
<input v-model="mode" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
<div class="board">
<Column id="backlog" title="Backlog" />
<Column id="sprint" title="Sprint" />
</div>
<ol class="requests" aria-label="Requests">
<li v-for="request in requests" :key="request.id" :class="`status-${request.status}`">
{{ request.text }} <span>{{ request.status === 'pending' ? '…' : request.status }}</span>
</li>
<li v-if="!requests.length" class="empty">No requests yet</li>
</ol>
</div>
<!-- The overlay of every drag: the card's copy, and a badge while the server answers. -->
<template #overlay="{ active }">
<div class="carried">
<DragPreview :id="active.id" />
<span v-if="saving" class="saving">Saving…</span>
</div>
</template>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.modes {
display: inline-flex;
margin: 0;
padding: 3px;
border: 0;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
legend {
float: left;
align-self: center;
padding: 0 8px 0 6px;
font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
.modes label {
padding: 5px 10px;
border-radius: 6px;
font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
.modes label.on {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
font-weight: 600;
}
.modes label:has(input:focus-visible) {
outline: 2px solid #42b883;
}
.modes input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.board {
display: grid;
grid-template-columns: repeat(2, minmax(0, 220px));
gap: 14px;
justify-content: center;
width: 100%;
}
.requests {
width: min(100%, 454px);
min-height: 60px;
margin: 0;
padding: 8px 12px;
border-radius: 10px;
background: color-mix(in srgb, currentColor 5%, transparent);
font: 11px/1.6 ui-monospace, Menlo, Consolas, monospace;
list-style: none;
white-space: pre;
overflow-x: auto;
}
.requests li + li {
opacity: 0.55;
}
.requests .status-200 span {
color: #42b883;
}
.requests .status-503 span {
color: #e5484d;
}
.requests li.empty {
color: inherit;
opacity: 0.45;
}
.carried {
position: relative;
width: 100%;
height: 100%;
}
.saving {
position: absolute;
top: 50%;
right: 10px;
display: inline-flex;
align-items: center;
gap: 6px;
translate: 0 -50%;
padding: 4px 8px;
border-radius: 99px;
background: #42b883;
color: #fff;
font: 600 11px/1 system-ui, sans-serif;
}
.saving::before {
content: '';
width: 9px;
height: 9px;
border: 2px solid rgb(255 255 255 / 0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
rotate: 1turn;
}
}
</style>const { items } = makeSortableList(list, {
id: props.id,
// A getter: the library never writes it. The drop is applied below, once saved.
items: () => board[props.id]!,
group: 'sprint',
onBeforeDrop: async (e) => {
e.preventDefault();
try {
await saveMove(e.keys, e.to);
} catch {
return 'reject'; // nothing applied: the card flies back
}
for (const [id, tasks] of e.result) board[id] = tasks; // lands in its new place
return 'accept';
},
});
Aplica e.result antes de que se resuelva la promesa y el overlay aterrizará en el nuevo orden.
Cuando la petición falla, no apliques nada y rechaza: los ítems vuelven a su sitio y los monitores
reciben onDragCancel.
La insignia viene del slot #overlay del provider, que renderiza el overlay de cada arrastre. Un
DragPreview muestra la copia habitual de la tarjeta, y la insignia se coloca encima mientras se
ejecuta la petición:
<DnDProvider>
<Board />
<template #overlay="{ active }">
<div class="carried">
<DragPreview :id="active.id" />
<span v-if="saving" class="saving">Saving…</span>
</div>
</template>
</DnDProvider>
Soltar fuera de cualquier destino
Arrastra una app fuera del dock y suéltala, en cada modo.
Cargando la demo…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider, type DropOutside } from '@vue-dnd-kit/core';
import Dock from './Dock.vue';
const MODES: DropOutside[] = ['placeholder', 'cancel', 'remove'];
const dropOutside = ref<DropOutside>('remove');
</script>
<template>
<DnDProvider>
<div class="demo">
<fieldset class="modes">
<legend>dropOutside</legend>
<label v-for="value in MODES" :key="value" :class="{ on: dropOutside === value }">
<input v-model="dropOutside" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
<code class="call">makeSortableList(dock, { strategy: 'horizontal', dropOutside: '{{ dropOutside }}' })</code>
<Dock :drop-outside="dropOutside" />
</div>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.modes {
display: inline-flex;
margin: 0;
padding: 3px;
border: 0;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
legend {
float: left;
align-self: center;
padding: 0 8px 0 6px;
font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
.modes label {
padding: 5px 10px;
border-radius: 6px;
font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
.modes label.on {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
font-weight: 600;
}
.modes label:has(input:focus-visible) {
outline: 2px solid #42b883;
}
.modes input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.call {
max-width: 100%;
padding: 6px 10px;
overflow-x: auto;
border-radius: 8px;
background: color-mix(in srgb, currentColor 6%, transparent);
font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
white-space: nowrap;
color: #42b883;
}
</style>const { items } = makeSortableList(dock, {
items: apps,
strategy: 'horizontal',
dropOutside: () => props.dropOutside,
// Called on the list the items leave, with `to.index` -1, when they are removed.
onDrop: (e) => {
if (e.to.index === -1) notice.value = `Removed ${e.items.map((app) => app.name).join(', ')}`;
},
});
dropOutside | Un drop fuera de cualquier destino… |
|---|---|
'placeholder' | Aterriza donde está el placeholder: el último sitio que mostró la lista. El valor por defecto. |
'cancel' | Se cancela: los ítems vuelven a su sitio. El valor por defecto con placeholder: 'indicator'. |
'remove' | Quita los ítems de su lista. Primero se pregunta al onBeforeDrop de la lista, y su onDrop recibe to.index igual a -1. |
Se lee al soltar, así que una ref o un getter puede cambiarlo. Una zona de papelera hace lo mismo
a propósito: makeDroppable(trash, { onDrop: (e) => e.source?.remove() }). Consulta
arrastrar y soltar.