Dokumentation/Leitfäden

Listen mit gemeinsamen Elementen

Listen in einer Gruppe verschieben, kopieren und tauschen Elemente – die ganze Spalte nimmt Drops an, pull und put entscheiden, was hinein- und hinausgeht, Paletten kopieren, und kontrollierte Listen wenden jeden Drop auf einen Store mit Rückgängig-Verlauf an.

Diese Seite wurde von KI übersetzt und kann Ungenauigkeiten enthalten. Englisches Original lesen

Live

Demo wird geladen…

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

Eine Gruppe

Listen mit demselben group-Namen tauschen Elemente aus. Standardmäßig ist jede Liste eine eigene Gruppe.

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>
  • Jede Spalte ist hier eine Komponente, wie Spalten es meistens sind. Eine Komponente kann auch mehrere Listen anlegen, etwa feste Listen Todo und Done nebeneinander: Jede Karte gehört zu der Liste, deren Element sie enthält.
  • toRef(props.column, 'stories') ist beschreibbar, weil das Elternelement ein reaktives Objekt übergibt. Der Drop schreibt beide Arrays, das, das die Story verlassen hat, und das, in das sie gekommen ist, und die Punkte beider Spalten ziehen nach.
  • id erscheint in Drop-Events als from.list und to.list und ist der Schlüssel in e.result, den neuen Arrays jeder Liste, die der Drop ändert. id und group werden einmal gelesen.
  • Schlüssel müssen in der ganzen Gruppe eindeutig sein.

Das onDrop einer Liste läuft für die Drops in sie hinein. Ein Verschieben von backlog nach sprint ruft das onDrop der Liste sprint auf, und deren e.result enthält die neuen Arrays beider Listen.

Die ganze Spalte nimmt Drops an: dropzone

Eine kurze Liste in einer hohen Spalte lässt unter der letzten Karte leeren Platz. Mit dropzone nimmt ein anderes Element die Drops für die Liste an: hier die ganze Spalte. Eine Story, die unterhalb der letzten losgelassen wird, landet am Ende. Die Storys bleiben im <ul>; nur der Bereich, der Drops annimmt, wächst. Die Demo umrandet diesen Bereich beim Ziehen: Schalte ihn ab, und nur die Liste selbst nimmt die Story an.

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)
});
Ohne dropzone braucht eine leere Liste trotzdem eine Größe, um Drops anzunehmen: Gib ihr eine min-height.

Was hinein- und hinausgeht: pull und put

group nimmt auch ein Objekt. pull sagt, was eine Liste verlassen darf, put, was hineindarf:

Live

Demo wird geladen…

<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>
OptionWerte
pulltrue (Standard): Elemente dürfen hinaus. false: Sie dürfen nicht. 'clone': Kopien gehen hinaus, die Elemente bleiben.
puttrue (Standard): Elemente dürfen hinein. false: Nichts kommt hinein. Ein Typ oder mehrere ('image', ['image', 'video']): Elemente dieser Typen kommen hinein. Eine Funktion: Du entscheidest pro Drag.
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' }

Typen sind die type-Optionen der gezogenen Elemente (makeSortableItem(el, { item, type })); Elemente ohne eine zählen als der Name der Gruppe. Mit Typen in put muss jedes gezogene Element einen davon haben (mit acceptMode: 'some' kommen die hinein, die einen haben).

Eine put-Funktion bekommt { keys, items, types, from, to }: die gezogenen Schlüssel und Elemente, ihre Typen, die Liste, aus der sie kommen (null bei einfachen ziehbaren Elementen), und diese Liste. Verschieben innerhalb einer Liste ist immer erlaubt: Die Inbox sortiert ihre eigenen Dateien trotzdem.

Über einer Liste, die den Drag ablehnt, geht der Slot dorthin zurück, wo der Drag gestartet ist, damit die Listen zeigen, was ein Drop tun würde: nichts. Ein Drop dort wird abgebrochen, und das Element fliegt zurück. isAccepting der Liste ist während eines solchen Drags false, und isOver über ihr trotzdem true: So weiß jede Liste in der Demo, was sie sagen soll.

Auch über einem eigenen Drop-Ziel außerhalb der Listen, etwa einem Papierkorb, geht der Slot zurück: Der Drop dort gehört dem Ziel, und die Listen behalten das Element, außer sein onDrop nimmt es heraus (event.source.remove()). Ziele innerhalb der Listen (ein Drop in ein Element) und um sie herum (ein Panel, das die Listen enthält) lassen den Slot, wo er ist.

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

Limits

Eine Liste, die höchstens drei Dateien hält, die Shortlist:

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

Sie liest ihre eigenen Elemente, wenn ein Drag fragt. Füll sie, und sie lehnt die nächste Datei ab, mit dem Grund in ihrer Kopfzeile.

Wenn mehrere ausgewählte Elemente verschiedener Typen gezogen werden, sagt acceptMode, ob 'all' (Standard) oder 'some' von ihnen put bestehen müssen. Mit 'some' landen die, die bestehen, und die anderen fliegen zurück. Siehe Multi-Drag und Auswahl.

Eine Gruppe pro Liste

Eine Liste gehört zu einer Gruppe: group nimmt einen Namen, nie mehrere. Die Listen einer Gruppe teilen sich jeden Drag (ihre Entwürfe, was gezogen wird, welche von ihnen ihn annehmen), deshalb wechseln Elemente nie zwischen Gruppen. Sollen Elemente nur in manche Richtungen fließen, steck die Listen in eine Gruppe und lass put nach from entscheiden, der Liste, aus der ein Drag kommt:

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 } });
Wenn du von SortableJS kommst: Dort nennt put: ['a', 'b'] die Gruppen, aus denen eine Liste Elemente nimmt. Hier zählt ein Array in put die Typen der Elemente auf, die eine Liste annimmt. Um Elemente aus anderen Listen zu nehmen, gib ihnen eine Gruppe und entscheide nach from, wie oben.

Kopieren: Paletten und die Alt-Taste

Live

Demo wird geladen…

<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' kopiert jeden Drag aus der Liste heraus. Das Original bleibt, und die Kopie reist und landet.
  • Alt beim Ziehen gedrückt halten kopiert ebenfalls, aus jeder Liste mit einer clone-Funktion: Sie erzeugt die Kopie. Ändere die Taste mit copyModifier des Providers oder schalte sie mit null ab.
  • So oder so braucht die Liste, aus der das Element kommt, clone. Es muss die Kopie mit einem neuen Schlüssel zurückgeben. Ohne clone wird nichts kopiert.
  • Das Drop-Event hat mode: 'copy', und e.items sind die Kopien. Eine Overlay-Komponente bekommt mode ebenfalls, um beim Kopieren ein „+“ zu zeigen (siehe Das Overlay).

Zwischen Listen tauschen

Zwei Listen einer Gruppe, die beide behavior: 'swap' haben, tauschen Elemente untereinander: Das gezogene Element nimmt den Platz des überfahrenen ein, und das überfahrene wandert in die Liste des gezogenen. Es muss seine Liste verlassen (pull: true) und in die andere hinein (put) dürfen. Der Bereitschaftsplan in Sortierbare Listen tauscht über seine zwei Spalten hinweg.

Einfache ziehbare Elemente

Eine Liste nimmt Elemente auf, die mit makeDraggable gezogen werden (ein Chip, eine Vorlage, eine Datei aus einem Baum), wenn sie createItem hat. group.put entscheidet auch über sie, mit from: null. Siehe einfache ziehbare Elemente in eine Liste.

Außerhalb ablegen

Was passiert, wenn ein Drag über gar keinem Ziel endet, bestimmt dropOutside der Liste:

dropOutsideDie Elemente
'placeholder' (Standard)Landen dort, wo der Slot zuletzt gezeigt wurde.
'cancel'Fliegen zurück. Der Standard bei placeholder: 'indicator'.
'remove'Verlassen ihre Liste: zum Löschen hinausziehen. Das Blatt der Paletten-Demo macht das.

'remove' fragt zuerst das onBeforeDrop der Liste. Dann läuft das onDrop der Liste mit to.index === -1, und e.result enthält die Liste ohne die Elemente. Ein Papierkorb, der Drops annimmt, funktioniert genauso: siehe Papierkorb-Zonen.

Kontrollierte Listen

Die Listen können einen Store lesen, der ihnen nicht gehört: Pinia, Serverzustand, unveränderliche Daten. Übergib items als Getter (oder als computed oder schreibgeschütztes Ref): Die Bibliothek schreibt nie hinein. Ruf in onDrop e.preventDefault() auf und wende e.result selbst an:

Live

Demo wird geladen…

<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 ordnet jeder Liste, die der Drop ändert, ihr neues Array zu: beide Tische nach einem Verschieben, einen nach einem Umsortieren. Es sind neue Arrays, der Store kann die alten also fürs Rückgängigmachen behalten.
  • Der Entwurf folgt dem Store: Wende result in onDrop an (oder bevor ein asynchrones onBeforeDrop sich auflöst), und das Overlay landet in der neuen Reihenfolge.
  • preventDefault() überspringt das Schreiben in jede Liste in result. Wird es nirgends angewendet, bleiben die Listen, wie sie waren, und die Elemente fliegen zurück.
  • Entwicklungs-Builds warnen, wenn der Drop einer schreibgeschützten Liste der Bibliothek überlassen wird: Es würde sich nichts ändern.

Um vor dem Landen des Drops auf einem Server zu speichern oder zuerst den Nutzer zu fragen, siehe Über einen Drop entscheiden. Alle Optionen der Liste stehen in der makeSortableList-API.