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.

Editar esta página

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.

Live

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:

RespuestaQué pasa
'accept', true o nadaEl drop se completa.
'reject' o falseNo hay drop: los ítems vuelven a su sitio. Un error lanzado también rechaza.
Un array de clavesSolo 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 estasEl 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.

Live

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>
Column.vue
// 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 preguntaDurante el arrastre, en cuanto el arrastre llega a la lista.Una vez, cuando se suelta el arrastre.
El usuario veLa 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.
SabeQué se arrastra: keys, items, types, from, to.Todo el drop: to.index, sameList, mode, result.
PuedeDecir 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.

Live

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>
Column.vue
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:

Column.vue
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'),
});
Board.vue
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, y status es '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.
Un <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.

Live

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>
  1. El onBeforeDrop de cada useDnDMonitor, por turnos. Un 'reject' o un 'continue' termina las preguntas; unas claves acotan el drop.
  2. El onBeforeDrop del destino: la lista o el árbol al que van los ítems, o el destino de drop.
  3. El onDrop solo del destino: no se pregunta a una lista que los ítems dejan por otra, y su nuevo array está en el e.result del destino. Cuando se quitan ítems (una zona de papelera, dropOutside: 'remove'), se ejecuta el onDrop de su lista, con to.index igual a -1.
  4. La escritura por defecto: cada lista que cambia el drop recibe su nuevo array.
  5. El onDragEnd del provider (@drag-end) y de los monitores. Luego aterriza el overlay, y onDragSettle (@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:

Monitor.vue
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ó.

Live

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>
Column.vue
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:

index.vue
<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.

Live

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>
Dock.vue
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(', ')}`;
  },
});
dropOutsideUn 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.