Documentación/Guías
Listas ordenables
Listas, barras de pestañas y cuadrículas que se reordenan mientras arrastras — makeSortableList renderiza un borrador, makeSortableItem crea las filas y el drop escribe el nuevo orden en tu ref. Asas, línea de inserción, intercambios.
Esta página la ha traducido una IA y puede contener imprecisiones. Leer el original en inglés
Cargando la demo…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import LayersPanel from './LayersPanel.vue';
// Front to back, as a design tool lists them.
const layers = ref([
{ id: 'title', name: 'Title', kind: 'text' as const, visible: true, locked: false },
{ id: 'badge', name: 'Badge', kind: 'shape' as const, visible: true, locked: false },
{ id: 'photo', name: 'Photo', kind: 'image' as const, visible: true, locked: false },
{ id: 'card', name: 'Card', kind: 'shape' as const, visible: true, locked: true },
]);
</script>
<template>
<DnDProvider>
<div class="editor">
<!-- The artboard reads `layers`: it restacks when a drop writes the new order. -->
<div class="artboard" aria-hidden="true">
<template v-for="(layer, index) in layers" :key="layer.id">
<div v-show="layer.visible" class="shape" :class="layer.id" :style="{ zIndex: layers.length - index }">
<template v-if="layer.id === 'title'">Summer sale</template>
<template v-if="layer.id === 'badge'">-30%</template>
</div>
</template>
</div>
<LayersPanel v-model="layers" />
</div>
</DnDProvider>
</template>
<style scoped>
.editor {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 24px;
}
.artboard {
position: relative;
overflow: hidden;
width: 240px;
height: 170px;
border-radius: 4px;
background: #fff;
box-shadow: 0 10px 30px -12px rgb(0 0 0 / 0.5);
}
.shape {
position: absolute;
}
.card {
inset: 18px;
border-radius: 12px;
background: #1e3a8a;
}
.photo {
top: 34px;
left: 66px;
width: 120px;
height: 84px;
border-radius: 8px;
background:
radial-gradient(circle at 75% 30%, #fde68a 0 12px, transparent 13px),
linear-gradient(170deg, #67e8f9, #0e7490);
}
.badge {
top: 20px;
left: 160px;
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: #facc15;
color: #1e3a8a;
font: 800 13px/1 system-ui, sans-serif;
}
.title {
top: 104px;
left: 30px;
color: #fff;
font: 800 24px/1 system-ui, sans-serif;
letter-spacing: -0.02em;
text-shadow: 0 2px 6px rgb(0 0 0 / 0.35);
}
</style>Una lista y sus filas
Una lista ordenable necesita dos componentes: la lista y su fila.
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableList } from '@vue-dnd-kit/core';
import LayerRow from './LayerRow.vue';
const layers = defineModel<Layer[]>({ required: true });
const list = useTemplateRef<HTMLElement>('list');
const { items } = makeSortableList(list, { items: layers });
</script>
<template>
<ul ref="list">
<LayerRow v-for="layer in items" :key="layer.id" :layer="layer" />
</ul>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableItem } from '@vue-dnd-kit/core';
const props = defineProps<{ layer: Layer }>();
const el = useTemplateRef<HTMLElement>('el');
makeSortableItem(el, { item: () => props.layer });
</script>
<template>
<li ref="el">{{ layer.name }}</li>
</template>
La fila se registra en la lista dentro de la que se renderiza. Un componente puede crear varias listas (dos columnas, una lista dentro de otra): cada fila se une a aquella cuyo elemento la contiene, la más interior en listas anidadas.
El borrador
makeSortableList devuelve items, un borrador de tu array. Mientras arrastras, el borrador
se reordena en vivo: la fila arrastrada ocupa su nuevo hueco y las demás se apartan. Tu array no
cambia hasta el drop. Entonces la biblioteca escribe en él el nuevo orden.
El panel de capas de arriba muestra las dos cosas: el panel renderiza items y se mueve mientras
arrastras; el lienzo lee layers, el propio array, y solo se reapila cuando sueltas.
- Renderiza
items, no tu array. Si renderizas tu array, nada se mueve hasta el drop. - La escritura ocurre después del
onDropde la lista. EnonDrop, tu array todavía tiene el orden anterior ye.resultel nuevo. layerses una ref normal (aquí undefineModel, y la escritura emiteupdate:modelValue), así que el resto de tu app ve el cambio como cualquier otro.
makeSortableList(list, {
items: layers,
onDrop: (e) => {
console.log(e.from.index, '→', e.to.index, e.result.get(e.to.list));
},
});
computed en lugar de una
ref. La biblioteca no escribe en una fuente de solo lectura: llama a e.preventDefault() y aplica
e.result tú mismo. Consulta listas controladas.Claves
Las filas se identifican por sus datos, nunca por índice ni por un id automático. Por defecto la
clave es item.id, o el propio ítem para strings y números.
// LayersPanel.vue: your items have another key
makeSortableList(list, { items: layers, getKey: (layer) => layer.uuid });
// LayerRow.vue: `item` is keyed with the list's getKey…
makeSortableItem(el, { item: () => props.layer });
// …or pass the key yourself
makeSortableItem(el, { id: () => props.layer.uuid });
- Usa la misma clave en
v-for::key="layer.uuid". Con:key="index"se animan las filas equivocadas y su estado salta de una fila a otra. - Las claves deben ser únicas entre todas las listas que intercambian ítems.
- Pasa
itemeidcomo getters, para que una fila siga sincronizada cuando cambian sus props.
Asas
Por defecto toda la fila inicia un arrastre, salvo los botones, inputs y demás controles que
contiene: el ojo y el candado de una capa siguen siendo botones normales. Pasa handle para
arrastrar solo desde una parte:
<script setup lang="ts">
makeSortableItem(el, { item: () => props.layer, handle: '.grip' });
</script>
<template>
<li ref="el">
<button type="button" class="grip" :aria-label="`Move ${layer.name}`">⠿</button>
{{ layer.name }}
</li>
</template>
handlees un selector dentro del ítem, o una ref de elemento.- Un arrastre con ratón empieza desde el asa de inmediato, sin los 4 px habituales. Una
pulsación que no se mueve sigue siendo un clic: el asa puede abrir un menú en
click. - El asa recibe los atributos del arrastre (
role,tabindex,aria-*), y los arrastres con teclado empiezan desde ella.
Haz que el asa sea un <button type="button"> con un aria-label: puede recibir el foco tal
cual, y los lectores de pantalla anuncian su etiqueta. Un <span> sin más con un icono también
funciona (la biblioteca le da tabindex="0" y role="button"), pero no tiene un nombre con
sentido.
touch-action: none y
al ítem activation: { touch: { distance: 4 } }.Estrategias
strategy indica cómo está dispuesta la lista, para que las filas hagan sitio de la forma
correcta:
strategy | Para |
|---|---|
'vertical' (por defecto) | Una columna de filas. |
'horizontal' | Una sola fila: pestañas, chips, una línea de tiempo. |
'grid' | Cuadrículas CSS y filas flex que saltan de línea, también con ítems de distintos tamaños. |
Una fila de pestañas es una lista horizontal:
Cargando la demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Browser from './Browser.vue';
</script>
<template>
<DnDProvider>
<Browser />
</DnDProvider>
</template>const { items } = makeSortableList(strip, {
items: tabs,
strategy: 'horizontal',
axis: 'x', // the tabs stay on their row
animateChanges: true, // the buttons open and close tabs: slide the others then too
});
El botón de cerrar de la pestaña sigue funcionando dentro de la pestaña arrastrable, y un clic que
termina un arrastre no selecciona ninguna pestaña. Las filas se deslizan solas mientras
arrastras; animateChanges añade lo mismo para los cambios que no hizo ningún arrastre, aquí los
botones (consulta animaciones).
Una cuadrícula ordena en dos dimensiones, con mosaicos de cualquier tamaño:
Cargando la demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import PhotoGrid from './PhotoGrid.vue';
</script>
<template>
<DnDProvider>
<PhotoGrid />
</DnDProvider>
</template>const { items } = makeSortableList(grid, { items: photos, strategy: 'grid' });
.grid { display: grid; grid-template-columns: repeat(4, 76px); grid-auto-rows: 76px; grid-auto-flow: dense; }
.wide { grid-column: span 2; }
.tall { grid-row: span 2; }
grid-auto-flow: dense mantiene la cuadrícula compacta cuando se mueve un mosaico grande. Sin él,
la cuadrícula deja huecos donde un mosaico ya no cabe y crece una fila.
Bloquear el eje
makeSortableList(list, { items: layers, axis: 'y' });
axis: 'y' mantiene el arrastre en el eje vertical, 'x' en el horizontal: las capas y las
pestañas de arriba lo usan. El axis propio de un ítem gana al de su lista. Para mantener además
el arrastre dentro de la lista, añade el modificador restrictToParent. Más en
restricciones.
Dar estilo al hueco
La fila arrastrada se queda en la lista como el hueco donde aterrizará, y recibe
data-dnd-placeholder. El estilo por defecto la atenúa al 40 %, con especificidad cero, así que
cualquier regla tuya gana:
.layer[data-dnd-placeholder] {
opacity: 1;
box-shadow: inset 0 0 0 1.5px #42b883;
}
.layer[data-dnd-placeholder] > * {
visibility: hidden; /* an empty slot of the row's exact size */
}
makeSortableItem devuelve el mismo estado como refs, y algunos más:
| Devuelve | Qué es |
|---|---|
isPlaceholder | Esta fila es el hueco donde aterrizará un ítem arrastrado. |
isDragging | Esta fila se está arrastrando (cada fila arrastrada, en un multi-drag). |
index | Su índice en el borrador: cambia mientras arrastras. |
selected | Modificable, ligado a la selección del provider. |
indicator | En modo indicador, dónde va la línea. |
La lista devuelve isOver e isAccepting para su propio resaltado, y activeKey y
activeIndex, el ítem arrastrado y su lugar en el borrador de esta lista. Todos los estados, con
demos en vivo, están en estilos.
Desactivar cosas
| Opción | Qué impide |
|---|---|
disabled en un ítem | Este ítem no se puede arrastrar. Los demás siguen pasando por delante de él: bloquea una capa arriba y luego arrastra otra por encima. |
disabled en la lista | Ningún arrastre sale de ella, ningún drop entra. |
sort: false en la lista | Sus ítems no se pueden reordenar dentro, pero sí pueden salir: una paleta, consulta listas que comparten ítems. |
Todas aceptan un getter o una ref, y se aplican al siguiente arrastre:
makeSortableItem(el, { item: () => props.layer, handle: '.grip', disabled: () => props.layer.locked });
Una fila que debe quedarse primera o última, hagan lo que hagan las demás, no es un ítem:
renderízala fuera de la lista. La capa Background del panel está debajo del <ul>.
Mostrar solo algunos ítems
Un cuadro de búsqueda, una etiqueta: filter muestra los ítems para los que devuelve true, y un
arrastre ordena entre esos. Los demás se quedan en tu array, en su sitio:
Cargando la demo…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Backlog from './Backlog.vue';
import type { Task } from './TaskRow.vue';
// In priority order: the array is the backlog.
const tasks = ref<Task[]>([
{ id: 1, title: 'Login fails on Safari', tag: 'bug' },
{ id: 2, title: 'Dark mode for settings', tag: 'feature' },
{ id: 3, title: 'Document the export API', tag: 'docs' },
{ id: 4, title: 'Crash when the list is empty', tag: 'bug' },
{ id: 5, title: 'Keyboard shortcuts', tag: 'feature' },
{ id: 6, title: 'Typo in the pricing page', tag: 'docs' },
{ id: 7, title: 'Slow search with 10k rows', tag: 'bug' },
{ id: 8, title: 'Share a board by link', tag: 'feature' },
]);
</script>
<template>
<DnDProvider>
<Backlog v-model="tasks" />
</DnDProvider>
</template>const query = ref('');
const tag = ref<Tag | null>(null);
const { items } = makeSortableList(list, {
items: tasks,
filter: (task) => (!tag.value || task.tag === tag.value) && task.title.includes(query.value),
});
itemscontiene los ítems que coinciden: renderízalo como siempre. El filtro es reactivo: escribir en el cuadro de búsqueda actualiza la lista al momento.- El drop escribe el array entero. Cada ítem oculto se queda justo después del ítem visible al que seguía; si ese es el que se movió, se queda después del más cercano anterior a él que no se movió.
e.to.indexy elactiveIndexde la lista cuentan los ítems visibles.e.resultcontiene el array entero, con los ítems ocultos.
computed que filtra tu array y se pasa como items también funciona, pero es de solo
lectura: tendrías que aplicar e.result tú mismo y calcular dónde van los ítems ocultos.
filter hace las dos cosas.Una línea de inserción
Con placeholder: 'indicator', la lista se queda quieta mientras arrastras, y una línea muestra
adónde va el drop. La línea la dibujas tú:
Cargando la demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import IssueTable from './IssueTable.vue';
</script>
<template>
<DnDProvider>
<IssueTable />
</DnDProvider>
</template>const { items, indicator } = makeSortableList(body, { items: issues, placeholder: 'indicator' });
<script setup lang="ts">
const { indicator } = makeSortableItem(el, { item: () => props.issue });
</script>
<template>
<div ref="el" class="row" :class="indicator && `drop-${indicator.side}`">…</div>
</template>
<style scoped>
.row.drop-before::before { content: ''; position: absolute; top: -1.5px; left: 6px; right: 6px; height: 2px; background: #42b883; }
.row.drop-after::before { content: ''; position: absolute; bottom: -1.5px; left: 6px; right: 6px; height: 2px; background: #42b883; }
</style>
Campo de indicator | Qué contiene |
|---|---|
list | La lista en la que cae el drop. |
index | El índice de inserción en la lista tal como se renderiza. |
key | El ítem al que va unida la línea; null en una lista vacía (dibújala entonces en la lista). |
side | 'before' o 'after' de ese ítem; 'swap' en una lista que intercambia. Los árboles añaden 'inside'. |
depth, parentKey | Solo árboles: dónde cae el drop. Consulta árboles. |
- El
indicatorde la lista tiene valor mientras la línea está en esa lista (la tabla lo muestra), y el de un ítem mientras la línea está unida a ese ítem. - La fila arrastrada se queda donde estaba, marcada como placeholder: atenúala.
- El teclado también mueve la línea.
- Un drop fuera de cualquier destino cancela en este modo (
dropOutside: 'cancel'), ya que la lista nunca mostró otro orden. - El overlay tapa parte de la fila que tiene debajo. Hazlo un poco transparente y la línea se verá
a través:
[data-dnd-overlay] .row { opacity: 0.85 }.
Intercambiar en vez de desplazar
Con behavior: 'swap', el ítem arrastrado cambia el sitio con el que tiene debajo, y los demás
no se mueven:
Cargando la demo…
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Rotation from './Rotation.vue';
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const columns = reactive([
{
id: 'primary',
title: 'Primary',
people: [
{ id: 'ava', name: 'Ava', hue: 160 },
{ id: 'ben', name: 'Ben', hue: 20 },
{ id: 'cleo', name: 'Cleo', hue: 280 },
{ id: 'dev', name: 'Dev', hue: 210 },
{ id: 'eli', name: 'Eli', hue: 45 },
],
},
{
id: 'backup',
title: 'Backup',
people: [
{ id: 'fay', name: 'Fay', hue: 330 },
{ id: 'gus', name: 'Gus', hue: 100 },
{ id: 'hana', name: 'Hana', hue: 190 },
{ id: 'ivo', name: 'Ivo', hue: 0 },
{ id: 'jun', name: 'Jun', hue: 250 },
],
},
]);
/** Live: the swap shows while you drag. On drop: nothing moves, the partner is marked. */
const placeholder = ref<'item' | 'indicator'>('item');
const last = ref('');
</script>
<template>
<DnDProvider>
<div class="schedule">
<div class="modes" role="group" aria-label="Swap preview">
<button type="button" :aria-pressed="placeholder === 'item'" @click="placeholder = 'item'">Live</button>
<button type="button" :aria-pressed="placeholder === 'indicator'" @click="placeholder = 'indicator'">On drop</button>
</div>
<div class="week">
<ol class="days">
<li v-for="day in DAYS" :key="day">{{ day }}</li>
</ol>
<Rotation v-for="column in columns" :key="column.id" :column="column" :placeholder="placeholder" @swap="last = $event" />
</div>
<p class="last">{{ last ? `Swapped ${last}` : 'Drag a person onto another' }}</p>
</div>
</DnDProvider>
</template>
<style scoped>
.schedule {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.modes {
display: flex;
padding: 3px;
border-radius: 10px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
.modes button {
padding: 6px 12px;
border: 0;
border-radius: 7px;
background: none;
color: inherit;
font: inherit;
font-size: 12px;
font-weight: 500;
opacity: 0.6;
cursor: pointer;
}
.modes button[aria-pressed='true'] {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
opacity: 1;
}
.week {
display: flex;
gap: 10px;
width: 400px;
max-width: 100%;
}
.days {
display: flex;
flex-direction: column;
gap: 6px;
margin: 21px 0 0;
padding: 0;
list-style: none;
}
.days li {
height: 36px;
font: 11px/36px ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
.last {
margin: 0;
font: 12px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.6;
}
</style>const { items } = makeSortableList(list, {
id: props.column.id,
items: toRef(props.column, 'people'),
group: 'rotation',
behavior: 'swap',
placeholder: () => props.placeholder, // 'item' (live) or 'indicator' (on drop)
onDrop: (e) => {
if (e.partner) emit('swap', `${e.items[0]?.name} ⇄ ${e.partner.item.name}`);
},
});
- En vivo (
placeholder: 'item', por defecto): el intercambio se ve mientras arrastras. Volver atrás, o salir de la lista, lo deshace. - Al soltar (
placeholder: 'indicator'): nada se mueve hasta el drop. El indicador marca el ítem con el que el drop hace el intercambio,side: 'swap': resalta el contorno de ese ítem en lugar de dibujar una línea. Las flechas mueven la marca de ítem en ítem; de vuelta sobre el ítem arrastrado, no hay ninguna.
<li ref="el" :class="{ target: indicator?.side === 'swap' }">…</li>
El evento de drop tiene mode: 'swap' y partner, el ítem con el que se intercambió: su key,
su item y sus propios from y to. Dos listas de un mismo grupo intercambian entre sí cuando
ambas tienen behavior: 'swap', como hacen las dos columnas. El ítem que está debajo pasa
entonces a la otra lista, así que debe poder salir (group.pull) y entrar (group.put):
consulta listas que comparten ítems. Un multi-drag ordena en lugar
de intercambiar.
Teclado
Pon el foco en una fila, o en su asa, y pulsa Space o Enter para agarrarla. Las flechas a lo largo de la lista la mueven un hueco, en las cuadrículas arriba y abajo pasan a la fila siguiente, y las flechas transversales a la lista la mueven a la siguiente lista de su grupo. Pasado el final de una lista, la flecha sigue hacia la siguiente lista en esa dirección (listas apiladas una encima de otra) o, si no la hay, hacia un destino de drop que esté ahí, una papelera. Pulsa Space o Enter otra vez para soltarla, y Escape para devolverla a su sitio. Todas las demos de esta página funcionan así. Los textos para lectores de pantalla están en accesibilidad.
Animaciones
Las filas se deslizan a sus nuevos sitios mientras arrastras, y la fila soltada aterriza en su
hueco. La propia lista crece o se encoge hasta su nuevo tamaño cuando entran y salen filas
(animateSize). Está activado por defecto y no necesita código. No envuelvas las filas en
<TransitionGroup>: los movimientos se animarían dos veces. Tampoco pongas una transition CSS
en el transform de las filas. Para los cambios que no hizo ningún arrastre (añadir, quitar,
barajar), activa la opción animateChanges de la lista, como hacen las pestañas. Consulta
animaciones.
La lista completa de opciones está en la API de makeSortableList y makeSortableItem.