Documentação/Guias

Listas que compartilham itens

Listas de um mesmo grupo movem, copiam e trocam itens — a coluna inteira recebe drops, pull e put decidem o que entra e o que sai, paletas copiam, e listas controladas aplicam cada drop a uma store com histórico de desfazer.

Editar esta página

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

Live

Carregando a demo…

<script setup lang="ts">
import { reactive, ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';

const columns = reactive([
  {
    id: 'backlog',
    title: 'Backlog',
    stories: [
      { id: 's1', title: 'Saved searches', points: 5 },
      { id: 's2', title: 'Crash on empty board', points: 2, bug: true },
      { id: 's3', title: 'CSV export', points: 3 },
      { id: 's4', title: 'Dark mode email', points: 2 },
    ],
  },
  {
    id: 'sprint',
    title: 'Sprint 14',
    stories: [{ id: 's5', title: 'Onboarding tour', points: 8 }],
  },
]);

const wholeColumn = ref(true);
</script>

<template>
  <DnDProvider>
    <div class="planning">
      <label class="switch">
        <input v-model="wholeColumn" type="checkbox" />
        <code>dropzone</code>: the whole column takes drops
      </label>
      <div class="columns">
        <Column v-for="column in columns" :key="column.id" :column="column" :whole-column="wholeColumn" />
      </div>
    </div>
  </DnDProvider>
</template>

<style scoped>
.planning {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.switch {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  cursor: pointer;
}

.switch input {
  accent-color: #42b883;
}

.switch code {
  font: 12px/1 ui-monospace, Menlo, Consolas, monospace;
  color: #42b883;
}

.columns {
  display: flex;
  justify-content: center;
  gap: 14px;
  width: 100%;
}
</style>

Um grupo

Listas com o mesmo nome de group trocam itens. Por padrão, cada lista é um grupo só seu.

Column.vue
<script setup lang="ts">
import { toRef, useTemplateRef } from 'vue';
import { makeSortableList } from '@vue-dnd-kit/core';
import StoryCard from './StoryCard.vue';

type Story = { id: string; title: string; points: number };
const props = defineProps<{ column: { id: string; title: string; stories: Story[] } }>();

const list = useTemplateRef<HTMLElement>('list');
const { items, isOver } = makeSortableList(list, {
  id: props.column.id,                     // names the list in drop events
  items: toRef(props.column, 'stories'),   // a writable ref into the reactive column
  group: 'planning',                       // same name: stories move between these lists
});
</script>

<template>
  <section :class="{ over: isOver }">
    <h3>{{ column.title }}</h3>
    <ul ref="list">
      <StoryCard v-for="story in items" :key="story.id" :story="story" />
    </ul>
  </section>
</template>
index.vue
<script setup lang="ts">
import { reactive } from 'vue';
import Column from './Column.vue';

const columns = reactive([
  { id: 'backlog', title: 'Backlog', stories: [{ id: 's1', title: 'Saved searches', points: 5 }] },
  { id: 'sprint', title: 'Sprint 14', stories: [] },
]);
</script>

<template>
  <Column v-for="column in columns" :key="column.id" :column="column" />
</template>
  • Aqui cada coluna é um componente, como as colunas costumam ser. Um componente também pode criar várias listas, um Todo e um Done fixos lado a lado: cada cartão entra na lista cujo elemento o contém.
  • toRef(props.column, 'stories') é gravável porque o pai passa um objeto reativo. O drop grava os dois arrays, o que a história deixou e o em que ela entrou, e os pontos das duas colunas acompanham.
  • id aparece nos eventos de drop como from.list e to.list, e é a chave de e.result, os novos arrays de cada lista que o drop altera. id e group são lidos uma vez só.
  • As keys precisam ser únicas no grupo inteiro.

O onDrop de uma lista roda para os drops nela. Um movimento de backlog para sprint chama o onDrop da lista sprint, e o e.result dele contém os novos arrays das duas listas.

A coluna inteira recebe drops: dropzone

Uma lista curta em uma coluna alta deixa espaço vazio abaixo do último card. Com dropzone, outro elemento recebe os drops pela lista: aqui, a coluna inteira. Uma história solta abaixo da última pousa no fim. As histórias continuam no <ul>; só a área que recebe drops cresce. A demo contorna essa área enquanto você arrasta: desligue-a, e só a própria lista recebe a história.

Column.vue
const zone = useTemplateRef<HTMLElement>('zone');
const { items } = makeSortableList(list, {
  items: toRef(props.column, 'stories'),
  group: 'planning',
  dropzone: zone,   // or a getter: () => (wholeColumn.value ? zone.value : undefined)
});
Sem um dropzone, uma lista vazia ainda precisa de um tamanho para receber drops: dê a ela um min-height.

O que entra e o que sai: pull e put

group também aceita um objeto. pull diz o que pode sair de uma lista, put o que pode entrar:

Live

Carregando a demo…

<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider, type SortablePutContext } from '@vue-dnd-kit/core';
import RuleList from './RuleList.vue';

// Four lists of one group, `files`. What each gives and takes is its `group.pull` and `group.put`.
const shortlist = reactive({
  id: 'shortlist',
  title: 'Shortlist',
  code: 'put: ({ keys }) => shortlist.files.length + keys.length <= 3',
  refusal: 'full',
  group: { name: 'files', put: ({ keys }: SortablePutContext) => shortlist.files.length + keys.length <= 3 },
  files: [
    { id: 'pitch', name: 'pitch.pdf', kind: 'doc' as const },
    { id: 'mock', name: 'mockup.png', kind: 'image' as const },
  ],
});

const lists = reactive([
  {
    id: 'inbox',
    title: 'Inbox',
    code: '{ pull: true, put: false }',
    refusal: 'put: false',
    group: { name: 'files', pull: true, put: false },
    files: [
      { id: 'logo', name: 'logo.svg', kind: 'image' as const },
      { id: 'brief', name: 'brief.pdf', kind: 'doc' as const },
      { id: 'hero', name: 'hero.png', kind: 'image' as const },
    ],
  },
  {
    id: 'templates',
    title: 'Templates',
    code: "{ pull: 'clone', put: false }, sort: false",
    refusal: 'put: false',
    group: { name: 'files', pull: 'clone' as const, put: false },
    sort: false,
    files: [
      { id: 'invoice', name: 'invoice.doc', kind: 'doc' as const },
      { id: 'banner', name: 'banner.png', kind: 'image' as const },
    ],
  },
  {
    id: 'images',
    title: 'Images',
    code: "put: 'image'",
    refusal: 'images only',
    group: { name: 'files', put: 'image' },
    files: [],
  },
  shortlist,
]);
</script>

<template>
  <DnDProvider>
    <div class="rules">
      <RuleList v-for="list in lists" :key="list.id" :list="list" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.rules {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 260px));
  gap: 12px;
  justify-content: center;
}

@media (max-width: 480px) {
  .rules {
    grid-template-columns: minmax(0, 1fr);
  }
}
</style>
OpçãoValores
pulltrue (padrão): os itens podem sair. false: não podem. 'clone': saem cópias, os itens ficam.
puttrue (padrão): os itens podem entrar. false: nada entra. Um tipo ou tipos ('image', ['image', 'video']): entram os itens desses tipos. Uma função: você decide a cada arraste.
index.vue
// Inbox: gives, takes nothing.
group: { name: 'files', pull: true, put: false }

// Templates: gives copies, takes nothing, keeps its order.
group: { name: 'files', pull: 'clone', put: false }, sort: false

// Images: takes images only.
group: { name: 'files', put: 'image' }

Os tipos são as opções type dos itens arrastados (makeSortableItem(el, { item, type })); itens sem tipo contam como o nome do grupo. Com tipos no put, cada item arrastado precisa ter um deles (com acceptMode: 'some', entram os que têm).

Uma função put recebe { keys, items, types, from, to }: as keys e os itens arrastados, os tipos deles, a lista de onde vêm (null para draggables simples) e esta lista. Movimentos dentro de uma lista são sempre permitidos: a Inbox ainda ordena os próprios arquivos.

Sobre uma lista que recusa o arraste, a posição volta para onde o arraste começou, então as listas mostram o que um drop faria: nada. Um drop ali é cancelado e o item volta ao lugar. O isAccepting da lista é false durante um arraste assim, e o isOver continua true sobre ela: é assim que cada lista da demo sabe o que dizer.

A posição também volta para casa sobre um alvo de drop próprio fora das listas, como uma lixeira: o drop ali é do alvo, e as listas mantêm o item, a não ser que o onDrop dele o retire (event.source.remove()). Alvos dentro das listas (um drop dentro de um item) e em volta delas (um painel que contém as listas) deixam a posição onde está.

RuleList.vue
const { items, isOver, isAccepting } = makeSortableList(el, { … });
const { active } = useDnD();
const verdict = computed(() => (active.value ? (isAccepting.value ? 'takes' : 'refuses') : null));

Limites

Uma lista que comporta no máximo três arquivos, a Shortlist:

index.vue
const shortlist = reactive({
  files: [/* … */],
  group: { name: 'files', put: ({ keys }: SortablePutContext) => shortlist.files.length + keys.length <= 3 },
});

Ela lê os próprios itens quando um arraste pergunta. Encha-a, e ela recusa o próximo arquivo, com o motivo no cabeçalho.

Quando vários itens selecionados de tipos diferentes são arrastados, acceptMode diz se 'all' (padrão) ou 'some' deles precisam passar no put. Com 'some', os que passam pousam e os outros voltam ao lugar. Veja multi-drag.

Um grupo por lista

Uma lista pertence a um único grupo: group recebe um nome só, nunca vários. As listas de um grupo compartilham cada arraste (seus rascunhos, o que está sendo arrastado, quais delas o aceitam), então os itens nunca passam de um grupo para outro. Quando os itens devem ir só em alguns sentidos, coloque as listas em um grupo e deixe o put decidir pelo from, a lista de onde o arraste vem:

index.vue
// Review takes tasks from both teams. A team takes tasks back from Review,
// never from the other team.
const fromReview = ({ from }: SortablePutContext) => from === 'review';

makeSortableList(review, { id: 'review', items: reviewTasks, group: 'tasks' });
makeSortableList(frontend, { id: 'frontend', items: frontendTasks, group: { name: 'tasks', put: fromReview } });
makeSortableList(backend, { id: 'backend', items: backendTasks, group: { name: 'tasks', put: fromReview } });
Vindo do SortableJS: lá, put: ['a', 'b'] nomeia os grupos dos quais uma lista recebe itens. Aqui, um array no put lista os tipos dos itens que uma lista aceita. Para receber itens de outras listas, coloque-as em um grupo e decida pelo from, como acima.

Copiar: paletas e a tecla Alt

Live

Carregando a demo…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Palette from './Palette.vue';
import Sheet from './Sheet.vue';
</script>

<template>
  <!-- The palette and the page are lists of one group: copies go from one to the other. -->
  <DnDProvider>
    <div class="builder">
      <Palette />
      <Sheet />
    </div>
  </DnDProvider>
</template>

<style scoped>
.builder {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: center;
  gap: 24px;
}
</style>
Palette.vue
makeSortableList(list, {
  items: templates,
  group: { name: 'blocks', pull: 'clone', put: false },  // copies go out, nothing comes in
  sort: false,                                           // its own order stays
  clone: (block) => ({ ...block, id: crypto.randomUUID() }),
});
Sheet.vue
makeSortableList(list, {
  items: blocks,
  group: 'blocks',
  clone: (block) => ({ ...block, id: crypto.randomUUID() }),  // for Alt-drags
  dropOutside: 'remove',                                      // drag off the Page panel to delete
});
  • pull: 'clone' copia todo arraste que sai da lista. O original fica, e a cópia viaja e pousa.
  • Segurar Alt durante o arraste também copia, de qualquer lista com uma função clone: é ela que faz a cópia. Mude a tecla com o copyModifier do provider, ou desligue com null.
  • De qualquer forma, a lista de onde o item vem precisa de clone. Ele precisa retornar a cópia com uma key nova. Sem clone, nada é copiado.
  • O evento de drop tem mode: 'copy', e e.items são as cópias. Um componente de overlay também recebe mode, para mostrar um "+" ao copiar (veja o overlay).

Trocar de lugar entre listas

Duas listas de um mesmo grupo que têm behavior: 'swap' trocam itens entre si: o item arrastado ocupa o lugar daquele sob o ponteiro, e este vai para a lista do item arrastado. Ele precisa ter permissão para sair da sua lista (pull: true) e para entrar na outra (put). O rodízio de plantão em listas ordenáveis troca itens entre as suas duas colunas.

Draggables simples

Uma lista recebe itens arrastados com makeDraggable (um chip, um modelo, um arquivo de uma árvore) quando tem createItem. group.put também decide sobre eles, com from: null. Veja draggables simples em uma lista.

Soltar fora

O que acontece quando um arraste termina fora de qualquer alvo é definido pelo dropOutside da lista:

dropOutsideOs itens
'placeholder' (padrão)Pousam onde a posição foi mostrada pela última vez.
'cancel'Voltam ao lugar. O padrão com placeholder: 'indicator'.
'remove'Saem da lista: arraste para fora para excluir. A folha da demo da paleta faz isso.

'remove' consulta primeiro o onBeforeDrop da lista. Depois, o onDrop da lista roda com to.index === -1, e e.result contém a lista sem os itens. Uma lixeira que recebe drops funciona do mesmo jeito: veja lixeiras.

Listas controladas

As listas podem ler uma store que não é delas: Pinia, estado do servidor, dados imutáveis. Passe items como getter (ou um computed, ou um ref somente leitura): a biblioteca nunca grava nele. Chame e.preventDefault() no onDrop e aplique e.result você mesmo:

Live

Carregando a demo…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Table from './Table.vue';
import { createSeating } from './store';

const seating = createSeating();
const { state, undo } = seating;
</script>

<template>
  <DnDProvider>
    <div class="seating">
      <div class="tables">
        <Table id="a" title="Table A" :seating="seating" />
        <Table id="b" title="Table B" :seating="seating" />
      </div>
      <section class="history">
        <header>
          <h4>History</h4>
          <button type="button" :disabled="!state.history.length" @click="undo">Undo</button>
        </header>
        <ol>
          <li v-for="(entry, index) in state.history" :key="state.history.length - index">{{ entry.label }}</li>
          <li v-if="!state.history.length" class="none">Move a guest</li>
        </ol>
      </section>
    </div>
  </DnDProvider>
</template>

<style scoped>
.seating {
  display: flex;
  flex-direction: column;
  gap: 12px;
  width: 460px;
  max-width: 100%;
  margin: 0 auto;
}

.tables {
  display: flex;
  gap: 12px;
}

.history {
  padding: 10px 12px;
  border-radius: 12px;
  background: color-mix(in srgb, currentColor 5%, transparent);
}

header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

h4 {
  margin: 0;
  font: 600 11px/1 ui-monospace, Menlo, Consolas, monospace;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0.55;
}

button {
  padding: 5px 12px;
  border: 0;
  border-radius: 7px;
  background: #42b883;
  color: #fff;
  font: inherit;
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
}

button:disabled {
  background: color-mix(in srgb, currentColor 12%, transparent);
  color: inherit;
  opacity: 0.5;
  cursor: default;
}

ol {
  display: flex;
  flex-direction: column;
  gap: 4px;
  height: 66px;
  margin: 8px 0 0;
  padding: 0;
  overflow-y: auto;
  list-style: none;
  font: 12px/1.4 ui-monospace, Menlo, Consolas, monospace;
}

li:first-child {
  color: #42b883;
}

li.none {
  color: inherit;
  opacity: 0.5;
}
</style>
Table.vue
const { items } = makeSortableList(list, {
  id: props.id,
  items: () => props.seating.state.tables[props.id]!,   // read-only
  group: 'seating',
  onDrop: (e) => {
    e.preventDefault();                                   // no write
    props.seating.apply(e.result, `${e.items[0]?.name} → ${props.title}`);
  },
});
store.ts
const apply = (result: ReadonlyMap<Key, Guest[]>, label: string) => {
  const before = [...result.keys()].map((id): [Key, Guest[]] => [id, state.tables[id]!]);
  for (const [id, guests] of result) state.tables[id] = guests;
  state.history.unshift({ label, before });
};

const undo = () => {
  const last = state.history.shift();
  for (const [id, guests] of last?.before ?? []) state.tables[id] = guests;
};
  • e.result associa cada lista que o drop altera ao seu novo array: as duas mesas depois de um movimento, uma depois de uma reordenação. São arrays novos, então a store pode guardar os antigos para desfazer.
  • O rascunho segue a store: aplique result no onDrop (ou antes de um onBeforeDrop assíncrono resolver), e o overlay pousa na nova ordem.
  • preventDefault() pula a gravação em todas as listas de result. Sem aplicar em lugar nenhum, as listas ficam como estavam e os itens voltam ao lugar.
  • Os builds de desenvolvimento avisam quando o drop de uma lista somente leitura fica a cargo da biblioteca: nada mudaria.

Para salvar em um servidor antes de o drop pousar, ou para perguntar ao usuário primeiro, veja decidindo um drop. Todas as opções de lista estão na API do makeSortableList.