Documentation/Guides
Listes triables
Des listes, des barres d'onglets et des grilles qui se réordonnent pendant le glissement — makeSortableList rend un brouillon, makeSortableItem crée les lignes, et le dépôt écrit le nouvel ordre dans votre ref. Poignées, ligne d'insertion, échanges.
Cette page a été traduite par IA et peut contenir des inexactitudes. Lire l'original en anglais
Chargement de la démo…
<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>Une liste et ses lignes
Une liste triable demande deux composants : la liste, et sa ligne.
<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 ligne s'enregistre auprès de la liste dans laquelle elle est rendue. Un composant peut créer plusieurs listes (deux colonnes, une liste dans une liste) : chaque ligne rejoint celle dont l'élément la contient, la plus intérieure pour des listes imbriquées.
Le brouillon
makeSortableList renvoie items, un brouillon de votre tableau. Pendant le glissement, le
brouillon se réordonne en direct : la ligne glissée prend son nouvel emplacement, et les autres
s'écartent. Votre tableau ne change pas avant le dépôt. La bibliothèque y écrit alors le nouvel
ordre.
Le panneau des calques ci-dessus montre les deux : le panneau rend items et bouge pendant le
glissement ; le plan de travail lit layers, le tableau lui-même, et ne réempile qu'au moment où
vous relâchez.
- Rendez
items, pas votre tableau. Si vous rendez votre tableau, rien ne bouge avant le dépôt. - L'écriture a lieu après l'
onDropde la liste. DansonDrop, votre tableau contient encore l'ancien ordre ete.resultle nouveau. layersest une ref ordinaire (ici undefineModel, et l'écriture émetupdate:modelValue) : le reste de votre application voit donc le changement comme n'importe quel autre.
makeSortableList(list, {
items: layers,
onDrop: (e) => {
console.log(e.from.index, '→', e.to.index, e.result.get(e.to.list));
},
});
computed au lieu
d'une ref. La bibliothèque n'écrit pas dans une source en lecture seule : appelez
e.preventDefault() et appliquez e.result vous-même. Voir les
listes contrôlées.Clés
Les lignes sont identifiées par leurs données, jamais par l'index ni par un id automatique. Par
défaut, la clé est item.id, ou l'item lui-même pour les chaînes et les nombres.
// 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 });
- Utilisez la même clé dans
v-for::key="layer.uuid". Avec:key="index", ce ne sont pas les bonnes lignes qui s'animent, et leur état saute d'une ligne à l'autre. - Les clés doivent être uniques parmi toutes les listes qui s'échangent des items.
- Passez
itemetidsous forme de getters, pour qu'une ligne reste synchronisée quand ses props changent.
Poignées
Par défaut, toute la ligne démarre un glissement, sauf les boutons, les champs et les autres
contrôles qu'elle contient : l'œil et le cadenas d'un calque restent de simples boutons. Passez
handle pour ne faire glisser que par une partie :
<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>
handleest un sélecteur à l'intérieur de l'item, ou une ref d'élément.- Un glissement à la souris démarre tout de suite depuis la poignée, sans les 4 px habituels. Un
appui qui ne bouge pas reste un clic : la poignée peut ouvrir un menu sur
click. - La poignée reçoit les attributs du glissement (
role,tabindex,aria-*), et les glissements au clavier démarrent depuis elle.
Faites de la poignée un <button type="button"> avec un aria-label : il peut recevoir le focus
tel quel, et les lecteurs d'écran annoncent son libellé. Un simple <span> avec une icône
fonctionne aussi (la bibliothèque lui donne tabindex="0" et role="button"), mais il n'a pas
de nom explicite.
touch-action: none à la poignée
et activation: { touch: { distance: 4 } } à l'item.Stratégies
strategy indique comment la liste est disposée, pour que les lignes fassent de la place
correctement :
strategy | Pour |
|---|---|
'vertical' (par défaut) | Une colonne de lignes. |
'horizontal' | Une seule rangée : onglets, chips, frise chronologique. |
'grid' | Les grilles CSS et les rangées flex qui passent à la ligne, y compris avec des items de tailles différentes. |
Une rangée d'onglets est une liste horizontale :
Chargement de la démo…
<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
});
Le bouton de fermeture de l'onglet continue de fonctionner à l'intérieur de l'onglet déplaçable,
et un clic qui termine un glissement ne sélectionne pas d'onglet. Les lignes glissent d'elles-mêmes
pendant le glissement ; animateChanges ajoute la même chose pour les changements qui ne viennent pas
d'un glissement, ici les boutons (voir les animations).
Une grille se trie en deux dimensions, avec des tuiles de toutes tailles :
Chargement de la démo…
<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 garde la grille compacte quand une grande tuile se déplace. Sans cela, la
grille laisse des trous là où une tuile ne tient plus, et s'agrandit d'une rangée.
Verrouiller l'axe
makeSortableList(list, { items: layers, axis: 'y' });
axis: 'y' garde le glissement sur l'axe vertical, 'x' sur l'axe horizontal : les calques et
les onglets ci-dessus l'utilisent. L'axis propre à un item l'emporte sur celui de sa liste.
Pour garder aussi le glissement à l'intérieur de la liste, ajoutez le modificateur
restrictToParent. Plus de détails dans les contraintes.
Styliser l'emplacement
La ligne glissée reste dans la liste comme l'emplacement où elle va atterrir, et reçoit
data-dnd-placeholder. Le style par défaut l'estompe à 40 %, avec une spécificité nulle : la
moindre de vos règles l'emporte donc :
.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 renvoie le même état sous forme de refs, et quelques autres :
| Renvoie | Ce que c'est |
|---|---|
isPlaceholder | Cette ligne est l'emplacement où un item glissé va atterrir. |
isDragging | Cette ligne est en cours de glissement (chaque ligne glissée, en multi-drag). |
index | Son index dans le brouillon : il change pendant le glissement. |
selected | Modifiable, lié à la sélection du provider. |
indicator | En mode indicateur, l'endroit où va la ligne. |
La liste renvoie isOver et isAccepting pour sa propre mise en valeur, ainsi que activeKey
et activeIndex, l'item glissé et sa place dans le brouillon de cette liste. Tous les états,
avec des démos en direct, se trouvent dans le style.
Désactiver
| Option | Ce qu'elle empêche |
|---|---|
disabled sur un item | Cet item ne peut pas être glissé. Les autres passent toujours devant lui : verrouillez un calque ci-dessus, puis faites-en glisser un autre par-dessus. |
disabled sur la liste | Aucun glissement n'en sort, aucun dépôt n'y entre. |
sort: false sur la liste | Ses items ne peuvent pas être réordonnés à l'intérieur, mais peuvent toujours en sortir : une palette, voir les listes qui partagent des items. |
Toutes acceptent un getter ou une ref, et s'appliquent au glissement suivant :
makeSortableItem(el, { item: () => props.layer, handle: '.grip', disabled: () => props.layer.locked });
Une ligne qui doit rester en premier ou en dernier, quoi que fassent les autres, n'est pas du
tout un item : rendez-la en dehors de la liste. Le calque Background du panneau se trouve sous le
<ul>.
N'afficher qu'une partie des items
Un champ de recherche, une étiquette : filter affiche les items pour lesquels il renvoie true,
et un glissement trie parmi ceux-là. Les autres restent dans votre tableau, à leur place :
Chargement de la démo…
<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),
});
itemscontient les items qui correspondent : rendez-le comme d'habitude. Le filtre est réactif : taper dans le champ de recherche met la liste à jour aussitôt.- Le dépôt écrit le tableau entier. Chaque item masqué reste juste après l'item affiché qu'il suivait ; quand c'est celui-là qui a bougé, il reste après le plus proche avant lui qui n'a pas bougé.
e.to.indexet l'activeIndexde la liste comptent les items affichés.e.resultcontient le tableau entier, items masqués compris.
computed qui filtre votre tableau et qui est passé en items fonctionne aussi, mais il est
en lecture seule : il vous faudrait appliquer e.result vous-même et calculer où vont les items
masqués. filter fait les deux.Une ligne d'insertion
Avec placeholder: 'indicator', la liste reste immobile pendant le glissement, et une ligne
montre où va le dépôt. C'est vous qui dessinez la ligne :
Chargement de la démo…
<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>
Champ d'indicator | Ce qu'il contient |
|---|---|
list | La liste dans laquelle va le dépôt. |
index | L'index d'insertion dans la liste telle qu'elle est rendue. |
key | L'item auquel la ligne est rattachée ; null dans une liste vide (dessinez-la alors dans la liste). |
side | 'before' ou 'after' cet item ; 'swap' dans une liste qui échange. Les arbres ajoutent 'inside'. |
depth, parentKey | Arbres uniquement : là où atterrit le dépôt. Voir les arbres. |
- L'
indicatorde la liste est renseigné tant que la ligne est dans cette liste (le tableau l'affiche), et celui d'un item tant que la ligne est rattachée à cet item. - La ligne glissée reste là où elle était, marquée comme placeholder : estompez-la.
- Le clavier déplace aussi la ligne.
- Un dépôt en dehors de toute cible annule dans ce mode (
dropOutside: 'cancel'), puisque la liste n'a jamais montré d'autre ordre. - L'overlay couvre une partie de la ligne en dessous. Rendez-le un peu transparent, et la ligne
apparaît à travers :
[data-dnd-overlay] .row { opacity: 0.85 }.
Échanger au lieu de décaler
Avec behavior: 'swap', l'item glissé échange sa place avec celui qui est en dessous, et les
autres ne bougent pas :
Chargement de la démo…
<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 direct (
placeholder: 'item', par défaut) : l'échange s'affiche pendant le glissement. Revenir en arrière, ou sortir de la liste, l'annule. - Au dépôt (
placeholder: 'indicator') : rien ne bouge avant le dépôt. L'indicateur marque l'item avec lequel le dépôt fait l'échange,side: 'swap': entourez cet item au lieu de dessiner une ligne. Les flèches déplacent la marque d'item en item ; de retour sur l'item glissé, il n'y en a aucune.
<li ref="el" :class="{ target: indicator?.side === 'swap' }">…</li>
L'événement de dépôt a mode: 'swap' et partner, l'item avec lequel il a échangé : sa key,
son item, et ses propres from et to. Deux listes d'un même groupe font des échanges entre
elles quand elles ont toutes deux behavior: 'swap', comme les deux colonnes. L'item survolé
passe alors dans l'autre liste : il doit donc avoir le droit de sortir (group.pull) et d'entrer
(group.put) ; voir les listes qui partagent des items. Un
multi-drag trie au lieu d'échanger.
Clavier
Donnez le focus à une ligne, ou à sa poignée, et appuyez sur Space ou Enter pour la saisir. Les flèches le long de la liste la déplacent d'un emplacement, dans les grilles haut et bas passent à la rangée suivante, et les flèches en travers de la liste la déplacent vers la liste suivante de son groupe. Au-delà de la fin d'une liste, la flèche continue dans la liste suivante dans cette direction (des listes empilées les unes au-dessus des autres), sinon sur une cible de dépôt à cet endroit, une corbeille. Appuyez de nouveau sur Space ou Enter pour la déposer, sur Escape pour la remettre en place. Chaque démo de cette page fonctionne ainsi. Les textes pour les lecteurs d'écran se trouvent dans l'accessibilité.
Animations
Les lignes glissent vers leurs nouvelles places pendant le glissement, et la ligne déposée
atterrit sur son emplacement. La liste elle-même grandit ou rétrécit jusqu'à sa nouvelle taille
quand des lignes arrivent et partent (animateSize). C'est activé par défaut et ne demande
aucun code. N'enveloppez pas
les lignes dans un <TransitionGroup> : les déplacements s'animeraient deux fois. Ne mettez pas
non plus de transition CSS sur le transform des lignes. Pour les changements qui ne viennent
pas d'un glissement (ajout, suppression, mélange), activez l'option animateChanges de la liste, comme
le font les onglets. Voir les animations.
La liste complète des options se trouve dans l'API de makeSortableList et de makeSortableItem.