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.

Modifier cette page

Cette page a été traduite par IA et peut contenir des inexactitudes. Lire l'original en anglais

Live

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.

LayersPanel.vue
<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>
LayerRow.vue
<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'onDrop de la liste. Dans onDrop, votre tableau contient encore l'ancien ordre et e.result le nouveau.
  • layers est une ref ordinaire (ici un defineModel, et l'écriture émet update: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));
  },
});
Un store, un état serveur ou des données immuables ? Passez un getter ou un 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 item et id sous 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 :

LayerRow.vue
<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>
  • handle est 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.

Les glissements tactiles démarrent après un appui long de 200 ms, pour que la page puisse encore défiler. Pour glisser tout de suite depuis une poignée, donnez 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 :

strategyPour
'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 :

Live

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>
Browser.vue
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 :

Live

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>
PhotoGrid.vue
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 :

LayerRow.vue
.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 :

RenvoieCe que c'est
isPlaceholderCette ligne est l'emplacement où un item glissé va atterrir.
isDraggingCette ligne est en cours de glissement (chaque ligne glissée, en multi-drag).
indexSon index dans le brouillon : il change pendant le glissement.
selectedModifiable, lié à la sélection du provider.
indicatorEn 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

OptionCe qu'elle empêche
disabled sur un itemCet 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 listeAucun glissement n'en sort, aucun dépôt n'y entre.
sort: false sur la listeSes 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 :

LayerRow.vue
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 :

Live

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>
Backlog.vue
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),
});
  • items contient 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.index et l'activeIndex de la liste comptent les items affichés. e.result contient le tableau entier, items masqués compris.
Un 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 :

Live

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>
IssueTable.vue
const { items, indicator } = makeSortableList(body, { items: issues, placeholder: 'indicator' });
IssueRow.vue
<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'indicatorCe qu'il contient
listLa liste dans laquelle va le dépôt.
indexL'index d'insertion dans la liste telle qu'elle est rendue.
keyL'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, parentKeyArbres uniquement : là où atterrit le dépôt. Voir les arbres.
  • L'indicator de 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 :

Live

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>
Rotation.vue
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.
PersonCell.vue
<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.