Documentação/Guias

Decidindo um drop

Aceite, recuse ou adie um drop — regras durante o arraste ou ao soltar, parte de um multi-drag, um diálogo, um servidor que precisa concordar antes, monitores que vetam e drops fora de qualquer alvo.

Editar esta página

Esta página foi traduzida por IA e pode conter imprecisões. Ler o original em inglês

Arraste um card para Done. O drop espera a sua resposta: mover o card, continuar arrastando para outro lugar, ou cancelar.

Live

Carregando a 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'),
});

Listas, árvores, alvos de drop (makeDroppable) e monitores (useDnDMonitor) aceitam um onBeforeDrop. Ele roda quando o arraste é solto, antes de qualquer coisa mudar, e a resposta dele decide:

RespostaO que acontece
'accept', true ou nadaO drop é concluído.
'reject' ou falseSem drop: os itens voltam ao lugar. Um erro lançado também rejeita.
Um array de keysSó esses itens são soltos; os outros voltam ao lugar.
'continue'Sem drop, e o arraste continua.
Uma promise de qualquer um dessesO overlay espera onde foi solto até a promise se resolver.

Listas e árvores passam um SortableDropEvent: keys, items, from, to, mode, sameList e result, os novos arrays de cada lista que o drop altera. Alvos de drop e monitores recebem o DnDEvent genérico: active, over, position e source. Veja eventos.

Uma regra durante o arraste, ou ao soltar

Email e Chat aceitam anexos de no máximo 10 MB. Arraste demo.mov sobre cada um: Email o recusa enquanto você passa por cima, Chat o aceita e o devolve quando você solta.

Live

Carregando a 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 (ou o accept de um alvo)onBeforeDrop
ConsultadoDurante o arraste, assim que ele chega à lista.Uma vez, quando o arraste é solto.
O usuário vêA lista recusa na hora: nenhuma posição se abre, isAccepting é false, over.accepted é false.A posição se abre como sempre; os itens voltam ao lugar ao soltar.
SabeO que é arrastado: keys, items, types, from, to.O drop inteiro: to.index, sameList, mode, result.
PodeDizer sim ou não.Esperar uma promise, aceitar algumas keys, manter o arraste em andamento.

Use group.put para o que se sabe só pelos itens (um tipo, uma flag, uma contagem): o usuário fica sabendo antes de soltar. Use onBeforeDrop para o que depende do lugar, da resposta do usuário ou da do servidor. As regras do próprio alvo vêm primeiro: sobre uma lista ou um alvo que recusa o arraste, o drop é cancelado e o onBeforeDrop dele não é consultado.

Aceitar alguns dos itens

Algumas regras só podem ser verificadas ao soltar, junto a um servidor. As vagas deste workshop também são reservadas por outras pessoas: ao soltar, a lista pergunta ao servidor quantas restam e aceita essa quantidade das pessoas arrastadas para dentro. Marque algumas e arraste-as juntas.

Live

Carregando a 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`;
  },
});

Em um multi-drag, as keys que você retorna pousam juntas e o resto volta aos seus lugares. O onDrop então as vê em e.accepted e e.rejected, e o e.result dele é o final. Um array vazio não aceita nada: o drop é recusado.

Continuar arrastando: 'continue'

'continue' cancela o drop, não o arraste. Responda Keep dragging no diálogo acima: o item continua na mão a partir de onde estava, a página volta para o modo de arraste, e o usuário segue em frente.

  • Com um mouse ou uma caneta, o item segue o ponteiro sem nenhum botão pressionado. O próximo soltar — um clique — solta o item, e esse clique nunca chega à página embaixo.
  • Um toque pega o item de novo onde o dedo encosta; levantar o dedo o solta.
  • Um arraste por teclado continua: as setas movem o item, Space ou Enter o soltam.
  • Escape o cancela, como qualquer arraste.
  • O próximo drop consulta o onBeforeDrop de novo.

Os monitores ouvem onDragResume, e os leitores de tela anunciam que o item não foi solto e ainda está se movendo (substitua o texto com o announcements.onDragResume do provider).

useDnDMonitor({
  onDragResume() {
    hint.value = 'Still dragging: click to drop, Esc to cancel';
  },
});

Perguntar ao usuário

onBeforeDrop pode retornar uma promise. Abra um diálogo, e resolva a promise com o botão que o usuário escolher:

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 };
  });

Enquanto a promise está pendente:

  • o overlay espera onde foi solto, marcado com data-dnd-overlay="dropping";
  • useDnD() mantém o estado que tinha no momento de soltar, e status é 'dropping';
  • a página sai do modo de arraste: o cursor e a seleção de texto voltam, então um diálogo funciona como sempre;
  • nenhum outro arraste pode começar.
Um <dialog> modal aberto com showModal() fica na top layer, acima do overlay que espera. Transforme o evento cancel dele (Escape) em 'reject', como o exemplo faz.

A ordem dos hooks

Reordene as notas, solte uma na lixeira, depois troque uma resposta e faça de novo. O painel lista os hooks do último drop na ordem em que rodaram.

Live

Carregando a 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. O onBeforeDrop de cada useDnDMonitor, um de cada vez. Um 'reject' ou um 'continue' encerra a consulta; keys restringem o drop.
  2. O onBeforeDrop do alvo: a lista ou a árvore em que os itens entram, ou o alvo de drop.
  3. O onDrop só do alvo: uma lista que os itens deixam rumo a outra não é consultada, e o novo array dela está no e.result do alvo. Quando itens são removidos (uma lixeira, dropOutside: 'remove'), o onDrop da lista deles roda, com to.index igual a -1.
  4. A gravação padrão: cada lista que o drop altera recebe o seu novo array.
  5. O onDragEnd do provider (@drag-end) e dos monitores. Depois o overlay pousa, e onDragSettle (@drag-settle) avisa que pousou.

Um drop recusado pula direto para onDragCancel (@drag-cancel), e os itens voltam ao lugar. Um 'continue' também encerra a consulta, com onDragResume: o arraste continua, e o próximo drop consulta de novo.

Os monitores decidem antes do alvo, então um veto nunca vem depois que uma lista já aplicou um drop por conta própria. Isso faz de um monitor o lugar para regras sobre todo drop do 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 da gravação

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));
  },
});

O onDrop de uma lista roda assim que o drop é aceito, de forma síncrona, logo antes de a lista gravá-lo. Para agir depois da gravação, use o @drag-end do provider ou useDnDMonitor({ onDragEnd }). Em um alvo de drop simples, o onDrop é onde você muda os seus dados: a biblioteca não move nada por ele.

Stores e 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);
  },
});

A biblioteca só grava um drop em um ref gravável. Um getter, um computed ou um ref readonly nunca é gravado: chame e.preventDefault() e aplique e.result você mesmo — os novos arrays de cada lista que o drop altera, por id de lista. preventDefault() pula a gravação em todas elas, já que gravar só em algumas perderia ou duplicaria itens. Os builds de desenvolvimento avisam quando o drop de uma lista somente leitura fica a cargo da biblioteca.

Salvar em um servidor primeiro

Mova um card com o servidor flaky (uma requisição sim, outra não, falha) ou down. O card espera com um selo enquanto a requisição roda, depois pousa, ou volta ao lugar se ela falhou.

Live

Carregando a 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';
  },
});

Aplique e.result antes de a promise resolver, e o overlay pousa na nova ordem. Quando a requisição falha, não aplique nada e rejeite: os itens voltam ao lugar, e os monitores ouvem onDragCancel.

O selo vem do slot #overlay do provider, que renderiza o overlay de todo arraste. Um DragPreview mostra a cópia de sempre do card, e o selo fica por cima enquanto a requisição roda:

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 fora de qualquer alvo

Arraste um app para fora do dock e solte, em cada modo.

Live

Carregando a 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(', ')}`;
  },
});
dropOutsideUm drop fora de qualquer alvo…
'placeholder'Pousa onde o placeholder está: o último lugar que a lista mostrou. O padrão.
'cancel'É cancelado: os itens voltam ao lugar. O padrão com placeholder: 'indicator'.
'remove'Remove os itens da lista deles. O onBeforeDrop da lista é consultado primeiro, e o onDrop dela recebe to.index igual a -1.

Ele é lido no drop, então um ref ou um getter pode trocá-lo. Uma lixeira faz o mesmo de propósito: makeDroppable(trash, { onDrop: (e) => e.source?.remove() }). Veja arrastar e soltar.