Documentation/Guides

Listes qui partagent des items

Les listes d'un même groupe déplacent, copient et échangent des items — toute la colonne accepte les dépôts, pull et put décident de ce qui entre et sort, les palettes copient, et les listes contrôlées appliquent chaque dépôt à un store avec un historique d'annulation.

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 { 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 groupe

Les listes qui portent le même nom de group s'échangent des items. Par défaut, chaque liste est un groupe à elle seule.

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>
  • Chaque colonne est ici un composant, comme le sont souvent les colonnes. Un composant peut aussi créer plusieurs listes, un Todo et un Done fixes côte à côte : chaque carte rejoint la liste dont l'élément la contient.
  • toRef(props.column, 'stories') est modifiable parce que le parent passe un objet réactif. Le dépôt écrit les deux tableaux, celui que la story a quitté et celui dans lequel elle est entrée, et les points des deux colonnes suivent.
  • id apparaît dans les événements de dépôt sous la forme from.list et to.list, et sert de clé à e.result, les nouveaux tableaux de chaque liste que le dépôt modifie. id et group sont lus une seule fois.
  • Les clés doivent être uniques dans tout le groupe.

L'onDrop d'une liste s'exécute pour les dépôts dans cette liste. Un déplacement de backlog vers sprint appelle l'onDrop de la liste sprint, et son e.result contient les nouveaux tableaux des deux listes.

Toute la colonne accepte les dépôts : dropzone

Une liste courte dans une colonne haute laisse un espace vide sous la dernière carte. Avec dropzone, un autre élément accepte les dépôts pour la liste : ici, toute la colonne. Une story relâchée sous la dernière atterrit à la fin. Les stories restent dans le <ul> ; seule la zone qui accepte les dépôts s'agrandit. La démo entoure cette zone pendant le glissement : désactivez-la, et seule la liste elle-même accepte la story.

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)
});
Sans dropzone, une liste vide a toujours besoin d'une taille pour accepter les dépôts : donnez-lui une min-height.

Ce qui entre et ce qui sort : pull et put

group accepte aussi un objet. pull indique ce qui peut quitter une liste, put ce qui peut y entrer :

Live

Chargement de la démo…

<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>
OptionValeurs
pulltrue (par défaut) : les items peuvent sortir. false : ils ne le peuvent pas. 'clone' : des copies sortent, les items restent.
puttrue (par défaut) : les items peuvent entrer. false : rien n'entre. Un type ou plusieurs ('image', ['image', 'video']) : les items de ces types entrent. Une fonction : vous décidez pour chaque glissement.
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' }

Les types sont les options type des items glissés (makeSortableItem(el, { item, type })) ; les items qui n'en ont pas comptent comme le nom du groupe. Avec des types dans put, chaque item glissé doit avoir l'un d'eux (avec acceptMode: 'some', ceux qui en ont un entrent).

Une fonction put reçoit { keys, items, types, from, to } : les clés et les items glissés, leurs types, la liste d'où ils viennent (null pour les éléments déplaçables simples) et cette liste. Les déplacements à l'intérieur d'une liste sont toujours autorisés : l'Inbox trie toujours ses propres fichiers.

Au-dessus d'une liste qui refuse le glissement, l'emplacement revient là où le glissement a commencé : les listes montrent ainsi ce que ferait un dépôt, c'est-à-dire rien. Un dépôt à cet endroit est annulé et l'item revient à sa place. L'isAccepting de la liste vaut false pendant un tel glissement, et isOver reste true au-dessus d'elle : c'est ainsi que chaque liste de la démo sait quoi dire.

L'emplacement rentre aussi chez lui au-dessus d'une cible de dépôt à part, hors des listes, comme une corbeille : le dépôt à cet endroit revient à la cible, et les listes gardent l'item, sauf si son onDrop le retire (event.source.remove()). Les cibles à l'intérieur des listes (un dépôt dans un item) et autour d'elles (un panneau qui contient les listes) laissent l'emplacement où il est.

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

Limites

Une liste qui contient au plus trois fichiers, la Shortlist :

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

Elle lit ses propres items quand un glissement le lui demande. Remplissez-la, et elle refuse le fichier suivant, avec la raison dans son en-tête.

Quand plusieurs items sélectionnés de types différents sont glissés, acceptMode indique si 'all' (par défaut) ou 'some' d'entre eux doivent passer put. Avec 'some', ceux qui passent atterrissent et les autres reviennent à leur place. Voir le multi-drag.

Un groupe par liste

Une liste appartient à un seul groupe : group prend un nom, jamais plusieurs. Les listes d'un groupe partagent chaque glissement (leurs brouillons, ce qui est glissé, lesquelles l'acceptent), donc les items ne passent jamais d'un groupe à l'autre. Quand les items ne doivent aller que dans certains sens, mettez les listes dans un même groupe et laissez put décider selon from, la liste d'où vient le glissement :

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 vous venez de SortableJS : là-bas, put: ['a', 'b'] nomme les groupes dont une liste prend des items. Ici, un tableau dans put énumère les types des items qu'une liste accepte. Pour prendre des items d'autres listes, mettez-les dans un même groupe et décidez selon from, comme ci-dessus.

Copier : palettes et touche Alt

Live

Chargement de la démo…

<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' copie chaque glissement qui sort de la liste. L'original reste, et la copie voyage et atterrit.
  • Maintenir Alt pendant le glissement copie aussi, depuis n'importe quelle liste qui a une fonction clone : c'est elle qui fait la copie. Changez la touche avec le copyModifier du provider, ou désactivez-la avec null.
  • Dans les deux cas, la liste d'où vient l'item a besoin de clone. Elle doit renvoyer la copie avec une nouvelle clé. Sans clone, rien n'est copié.
  • L'événement de dépôt a mode: 'copy', et e.items sont les copies. Un composant d'overlay reçoit aussi mode, pour afficher un « + » pendant la copie (voir l'overlay).

Échanger entre des listes

Deux listes d'un même groupe qui ont toutes deux behavior: 'swap' s'échangent des items : l'item glissé prend la place de l'item survolé, et l'item survolé passe dans la liste de l'item glissé. Il doit avoir le droit de sortir de sa liste (pull: true) et d'entrer dans l'autre (put). Le planning d'astreinte des listes triables fait des échanges entre ses deux colonnes.

Éléments déplaçables simples

Une liste accueille les items glissés avec makeDraggable (une puce, un modèle, un fichier issu d'un arbre) quand elle a createItem. group.put décide aussi pour eux, avec from: null. Voir des éléments déplaçables simples dans une liste.

Déposer à l'extérieur

Ce qui se passe quand un glissement se termine au-dessus d'aucune cible dépend du dropOutside de la liste :

dropOutsideLes items
'placeholder' (par défaut)Atterrissent là où l'emplacement a été affiché en dernier.
'cancel'Reviennent à leur place. La valeur par défaut avec placeholder: 'indicator'.
'remove'Quittent leur liste : glisser au-dehors supprime. La page de la démo de palette le fait.

'remove' demande d'abord à l'onBeforeDrop de la liste. Ensuite, l'onDrop de la liste s'exécute avec to.index === -1, et e.result contient la liste sans les items. Une corbeille qui accepte les dépôts fonctionne de la même façon : voir les corbeilles.

Listes contrôlées

Les listes peuvent lire un store qui ne leur appartient pas : Pinia, un état serveur, des données immuables. Passez items sous forme de getter (ou de computed, ou de ref en lecture seule) : la bibliothèque n'y écrit jamais. Appelez e.preventDefault() dans onDrop et appliquez e.result vous-même :

Live

Chargement de la démo…

<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 associe chaque liste que le dépôt modifie à son nouveau tableau : les deux tables après un déplacement, une seule après un réordonnancement. Ce sont de nouveaux tableaux : le store peut donc garder les anciens pour l'annulation.
  • Le brouillon suit le store : appliquez result dans onDrop (ou avant qu'un onBeforeDrop asynchrone ne se résolve), et l'overlay atterrit dans le nouvel ordre.
  • preventDefault() saute l'écriture dans chaque liste de result. S'il n'est appliqué nulle part, les listes restent telles qu'elles étaient et les items reviennent à leur place.
  • Les builds de développement avertissent quand le dépôt d'une liste en lecture seule est laissé à la bibliothèque : rien ne changerait.

Pour enregistrer sur un serveur avant que le dépôt n'atterrisse, ou pour demander d'abord à l'utilisateur, voir les décisions de dépôt. Toutes les options des listes se trouvent dans l'API de makeSortableList.