Documentación/Guías

Listas que comparten ítems

Las listas de un mismo grupo mueven, copian e intercambian ítems — toda la columna acepta drops, pull y put deciden qué entra y qué sale, las paletas copian y las listas controladas aplican cada drop a un store con historial para deshacer.

Editar esta página

Esta página la ha traducido una IA y puede contener imprecisiones. Leer el original en inglés

Live

Cargando la 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>

Un grupo

Las listas con el mismo nombre de group intercambian ítems. Por defecto, cada lista es un grupo por sí sola.

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>
  • Aquí cada columna es un componente, como suelen serlo las columnas. Un componente también puede crear varias listas, un Todo y un Done fijos uno al lado del otro: cada tarjeta se une a la lista cuyo elemento la contiene.
  • toRef(props.column, 'stories') es modificable porque el padre pasa un objeto reactivo. El drop escribe ambos arrays, el que dejó la historia y aquel en el que entró, y los puntos de las dos columnas se actualizan.
  • id aparece en los eventos de drop como from.list y to.list, y es la clave de e.result, los nuevos arrays de cada lista que cambia el drop. id y group se leen una sola vez.
  • Las claves deben ser únicas en todo el grupo.

El onDrop de una lista se ejecuta para los drops dentro de ella. Mover de backlog a sprint llama al onDrop de la lista sprint, y su e.result contiene los nuevos arrays de ambas listas.

Toda la columna acepta drops: dropzone

Una lista corta en una columna alta deja espacio vacío debajo de la última tarjeta. Con dropzone, otro elemento acepta los drops en nombre de la lista: aquí, toda la columna. Una historia soltada debajo de la última aterriza al final. Las historias se quedan en el <ul>; solo crece la zona que acepta drops. La demo resalta esa zona mientras arrastras: desactívala y solo la propia lista acepta la historia.

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)
});
Sin dropzone, una lista vacía sigue necesitando un tamaño para aceptar drops: dale un min-height.

Qué entra y qué sale: pull y put

group también acepta un objeto. pull indica qué puede salir de una lista, put qué puede entrar:

Live

Cargando la 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>
OpciónValores
pulltrue (por defecto): los ítems pueden salir. false: no pueden. 'clone': salen copias y los ítems se quedan.
puttrue (por defecto): los ítems pueden entrar. false: no entra nada. Un tipo o varios ('image', ['image', 'video']): entran los ítems de esos tipos. Una función: decides en cada arrastre.
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' }

Los tipos son las opciones type de los ítems arrastrados (makeSortableItem(el, { item, type })); los ítems sin tipo cuentan como el nombre del grupo. Con tipos en put, cada ítem arrastrado debe tener uno de ellos (con acceptMode: 'some', entran los que lo tienen).

Una función put recibe { keys, items, types, from, to }: las claves y los ítems arrastrados, sus tipos, la lista de la que vienen (null para los elementos arrastrables simples) y esta lista. Los movimientos dentro de una lista siempre están permitidos: Inbox sigue ordenando sus propios archivos.

Sobre una lista que rechaza el arrastre, el hueco vuelve a donde empezó el arrastre, así que las listas muestran lo que haría un drop: nada. Un drop ahí se cancela y el ítem vuelve a su sitio. El isAccepting de la lista es false durante un arrastre así, y isOver sigue siendo true sobre ella: así sabe cada lista de la demo qué decir.

El hueco también vuelve a casa sobre un destino de drop propio fuera de las listas, como una papelera: el drop ahí es del destino, y las listas conservan el ítem a menos que su onDrop lo saque (event.source.remove()). Los destinos dentro de las listas (un drop dentro de un ítem) y alrededor de ellas (un panel que contiene las listas) dejan el hueco donde está.

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

Límites

Una lista que guarda tres archivos como máximo, la Shortlist:

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

Lee sus propios ítems cuando un arrastre pregunta. Llénala y rechazará el siguiente archivo, con el motivo en su cabecera.

Cuando se arrastran varios ítems seleccionados de distintos tipos, acceptMode indica si deben pasar put 'all' (por defecto) o 'some'. Con 'some', los que pasan aterrizan y los demás vuelven a su sitio. Consulta multi-drag.

Un grupo por lista

Una lista pertenece a un solo grupo: group recibe un nombre, nunca varios. Las listas de un grupo comparten cada arrastre (sus borradores, lo que se arrastra, cuáles de ellas lo aceptan), así que los ítems nunca pasan de un grupo a otro. Cuando los ítems deben ir solo en algunas direcciones, pon las listas en un grupo y deja que put decida por from, la lista de la que viene el arrastre:

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 } });
Si vienes de SortableJS: allí put: ['a', 'b'] nombra los grupos de los que una lista toma ítems. Aquí un array en put enumera los tipos de los ítems que una lista acepta. Para tomar ítems de otras listas, ponlas en un grupo y decide por from, como arriba.

Copiar: paletas y la tecla Alt

Live

Cargando la 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 cada arrastre que sale de la lista. El original se queda, y la copia viaja y aterriza.
  • Mantener Alt mientras arrastras también copia, desde cualquier lista con una función clone: es la que hace la copia. Cambia la tecla con el copyModifier del provider, o desactívala con null.
  • En ambos casos, la lista de la que sale el ítem necesita clone. Debe devolver la copia con una clave nueva. Sin clone, no se copia nada.
  • El evento de drop tiene mode: 'copy', y e.items son las copias. Un componente de overlay también recibe mode, para mostrar un «+» al copiar (consulta el overlay).

Intercambiar entre listas

Dos listas de un mismo grupo que tienen ambas behavior: 'swap' intercambian ítems entre sí: el ítem arrastrado ocupa el sitio del que tiene debajo, y este pasa a la lista del arrastrado. Debe poder salir de su lista (pull: true) y entrar en la otra (put). La rotación de guardias de listas ordenables intercambia entre sus dos columnas.

Elementos arrastrables simples

Una lista acoge ítems arrastrados con makeDraggable (un chip, una plantilla, un archivo de un árbol) cuando tiene createItem. group.put también decide sobre ellos, con from: null. Consulta elementos arrastrables simples en una lista.

Soltar fuera

Lo que pasa cuando un arrastre termina sin ningún destino debajo lo decide el dropOutside de la lista:

dropOutsideLos ítems
'placeholder' (por defecto)Aterrizan donde se mostró el hueco por última vez.
'cancel'Vuelven a su sitio. Es el valor por defecto con placeholder: 'indicator'.
'remove'Salen de su lista: arrastrar fuera borra. La página de la demo de la paleta lo hace.

'remove' pregunta primero al onBeforeDrop de la lista. Después se ejecuta el onDrop de la lista con to.index === -1, y e.result contiene la lista sin los ítems. Una papelera que acepta drops funciona igual: consulta zonas de papelera.

Listas controladas

Las listas pueden leer un store que no es suyo: Pinia, estado del servidor, datos inmutables. Pasa items como getter (o un computed, o una ref de solo lectura): la biblioteca nunca escribe en él. Llama a e.preventDefault() en onDrop y aplica e.result tú mismo:

Live

Cargando la 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 asocia cada lista que cambia el drop con su nuevo array: las dos mesas tras un movimiento, una tras una reordenación. Son arrays nuevos, así que el store puede guardar los antiguos para deshacer.
  • El borrador sigue al store: aplica result en onDrop (o antes de que se resuelva un onBeforeDrop asíncrono) y el overlay aterriza en el nuevo orden.
  • preventDefault() omite la escritura en todas las listas de result. Si no lo aplicas en ningún sitio, las listas se quedan como estaban y los ítems vuelven a su sitio.
  • Las builds de desarrollo avisan cuando el drop de una lista de solo lectura se deja a la biblioteca: no cambiaría nada.

Para guardar en un servidor antes de que el drop aterrice, o para preguntar antes al usuario, consulta decidir un drop. Todas las opciones de las listas están en la API de makeSortableList.