Dokumentation/Leitfäden

Sortierbare Listen

Listen, Tab-Leisten und Raster, die sich beim Ziehen neu ordnen – makeSortableList rendert einen Entwurf, makeSortableItem macht die Zeilen, und der Drop schreibt die neue Reihenfolge in dein Ref. Handles, eine Drop-Linie, Tauschen.

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

Live

Demo wird geladen…

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

Eine Liste und ihre Zeilen

Eine sortierbare Liste braucht zwei Komponenten: die Liste und ihre Zeile.

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>

Die Zeile registriert sich bei der Liste, in der sie gerendert wird. Eine Komponente kann mehrere Listen anlegen (zwei Spalten, eine Liste in einer Liste): Jede Zeile gehört zu der, deren Element sie enthält, bei Listen in Listen zur innersten.

Der Entwurf

makeSortableList gibt items zurück, einen Entwurf deines Arrays. Während du ziehst, ordnet sich der Entwurf live neu: Die gezogene Zeile nimmt ihren neuen Slot ein, und die anderen gleiten aus dem Weg. Dein Array ändert sich bis zum Drop nicht. Dann schreibt die Bibliothek die neue Reihenfolge hinein.

Das Ebenen-Panel oben zeigt beides: Das Panel rendert items und bewegt sich, während du ziehst; die Zeichenfläche liest layers, das Array selbst, und stapelt sich erst um, wenn du loslässt.

  • Rendere items, nicht dein Array. Renderst du dein Array, bewegt sich bis zum Drop nichts.
  • Das Schreiben passiert nach dem onDrop der Liste. In onDrop enthält dein Array noch die alte Reihenfolge und e.result die neue.
  • layers ist ein normales Ref (hier ein defineModel, und das Schreiben emittiert update:modelValue), der Rest deiner App sieht die Änderung also wie jede andere.
makeSortableList(list, {
  items: layers,
  onDrop: (e) => {
    console.log(e.from.index, '→', e.to.index, e.result.get(e.to.list));
  },
});
Ein Store, Serverzustand oder unveränderliche Daten? Übergib einen Getter oder ein computed statt eines Refs. In eine schreibgeschützte Quelle schreibt die Bibliothek nicht: Ruf e.preventDefault() auf und wende e.result selbst an. Siehe kontrollierte Listen.

Schlüssel

Zeilen werden über ihre Daten identifiziert, nie über den Index oder eine automatische ID. Standardmäßig ist der Schlüssel item.id, bei Strings und Zahlen das Element selbst.

// 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 });
  • Nimm denselben Schlüssel im v-for: :key="layer.uuid". Mit :key="index" animieren die falschen Zeilen, und ihr Zustand springt zwischen den Zeilen.
  • Schlüssel müssen über alle Listen hinweg eindeutig sein, die Elemente austauschen.
  • Übergib item und id als Getter, damit eine Zeile synchron bleibt, wenn sich ihre Props ändern.

Handles

Standardmäßig startet die ganze Zeile einen Drag, außer den Buttons, Eingabefeldern und anderen Bedienelementen darin: Das Auge und das Schloss einer Ebene bleiben einfache Buttons. Übergib handle, um nur an einem Teil zu ziehen:

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 ist ein Selektor innerhalb des Elements oder ein Element-Ref.
  • Ein Maus-Drag startet am Handle sofort, ohne die üblichen 4 px. Ein Druck ohne Bewegung bleibt ein Klick: Das Handle kann bei click ein Menü öffnen.
  • Das Handle bekommt die Attribute des Drags (role, tabindex, aria-*), und Drags per Tastatur starten an ihm.

Mach das Handle zu einem <button type="button"> mit einem aria-label: Es ist von sich aus fokussierbar, und Screenreader sagen sein Label an. Ein nacktes <span> mit einem Icon funktioniert auch (die Bibliothek gibt ihm tabindex="0" und role="button"), hat aber keinen aussagekräftigen Namen.

Touch-Drags starten nach einem langen Drücken von 200 ms, damit die Seite weiter scrollen kann. Um am Handle sofort zu ziehen, gib dem Handle touch-action: none und dem Element activation: { touch: { distance: 4 } }.

Strategien

strategy sagt, wie die Liste angeordnet ist, damit die Zeilen richtig Platz machen:

strategyFür
'vertical' (Standard)Eine Spalte aus Zeilen.
'horizontal'Eine einzelne Zeile: Tabs, Chips, eine Zeitleiste.
'grid'CSS-Grids und umbrechende Flex-Zeilen, auch mit Elementen unterschiedlicher Größe.

Eine Reihe von Tabs ist eine horizontale Liste:

Live

Demo wird geladen…

<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
});

Der Schließen-Button des Tabs funktioniert im ziehbaren Tab weiter, und ein Klick, der einen Drag beendet, wählt keinen Tab aus. Zeilen gleiten beim Ziehen von selbst; animateChanges ergänzt dasselbe für Änderungen, die kein Drag gemacht hat, hier die der Buttons (siehe Animationen).

Ein Raster sortiert in zwei Dimensionen, mit Kacheln jeder Größe:

Live

Demo wird geladen…

<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 hält das Raster dicht gepackt, wenn sich eine große Kachel bewegt. Ohne das lässt das Raster Löcher, wo eine Kachel nicht mehr passt, und wächst um eine Zeile.

Die Achse sperren

makeSortableList(list, { items: layers, axis: 'y' });

axis: 'y' hält den Drag auf der vertikalen Achse, 'x' auf der horizontalen: Die Ebenen und die Tabs oben nutzen das. Die eigene axis eines Elements gewinnt gegen die seiner Liste. Um den Drag zusätzlich in der Liste zu halten, füge den Modifier restrictToParent hinzu. Mehr unter Achsen, Grenzen und Raster.

Den Slot stylen

Die gezogene Zeile bleibt als der Slot, in dem sie landen wird, in der Liste und bekommt data-dnd-placeholder. Der Standardstil blendet sie auf 40 % ab, mit Spezifität null, also gewinnt jede deiner Regeln:

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 gibt denselben Zustand als Refs zurück, plus ein paar weitere:

RückgabeWas es ist
isPlaceholderDiese Zeile ist der Slot, in dem ein gezogenes Element landen wird.
isDraggingDiese Zeile wird gezogen (bei einem Multi-Drag jede gezogene Zeile).
indexIhr Index im Entwurf: Er ändert sich während des Ziehens.
selectedBeschreibbar, an die Auswahl des Providers gebunden.
indicatorIm Indikator-Modus, wo die Linie hinkommt.

Die Liste gibt isOver und isAccepting für ihre eigene Hervorhebung zurück, außerdem activeKey und activeIndex, das gezogene Element und seinen Platz im Entwurf dieser Liste. Jeder Zustand, mit Live-Demos, steht unter Styling.

Dinge abschalten

OptionWas sie stoppt
disabled an einem ElementDieses Element lässt sich nicht ziehen. Die anderen bewegen sich trotzdem an ihm vorbei: Sperre oben eine Ebene und ziehe dann eine andere darüber.
disabled an der ListeKeine Drags heraus, keine Drops hinein.
sort: false an der ListeIhre Elemente lassen sich darin nicht neu ordnen, können sie aber verlassen: eine Palette, siehe Listen mit gemeinsamen Elementen.

Alle nehmen einen Getter oder ein Ref und gelten ab dem nächsten Drag:

LayerRow.vue
makeSortableItem(el, { item: () => props.layer, handle: '.grip', disabled: () => props.layer.locked });

Eine Zeile, die immer erste oder letzte bleiben muss, egal was die anderen tun, ist gar kein Element: Rendere sie außerhalb der Liste. Die Ebene „Background“ des Panels sitzt unterhalb des <ul>.

Nur einen Teil der Elemente zeigen

Ein Suchfeld, ein Tag: filter zeigt die Elemente, für die es true zurückgibt, und ein Drag sortiert unter diesen. Die anderen bleiben in deinem Array, an ihren Plätzen:

Live

Demo wird geladen…

<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 enthält die passenden Elemente: Rendere es wie gewohnt. Der Filter ist reaktiv: Tippen im Suchfeld aktualisiert die Liste sofort.
  • Der Drop schreibt das ganze Array. Jedes ausgeblendete Element bleibt direkt hinter dem gezeigten Element, dem es folgte; ist genau dieses das bewegte, bleibt es hinter dem nächsten davor, das sich nicht bewegt hat.
  • e.to.index und activeIndex der Liste zählen die gezeigten Elemente. e.result enthält das ganze Array, die ausgeblendeten Elemente eingeschlossen.
Ein computed, das dein Array filtert und als items übergeben wird, funktioniert auch, ist aber nur lesbar: Du müsstest e.result selbst anwenden und selbst ausrechnen, wohin die ausgeblendeten Elemente gehören. filter erledigt beides.

Eine Einfügelinie

Mit placeholder: 'indicator' bleibt die Liste beim Ziehen still, und eine Linie zeigt, wohin der Drop geht. Die Linie zeichnest du:

Live

Demo wird geladen…

<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>
Feld von indicatorWas es enthält
listDie Liste, in die der Drop geht.
indexDer Einfügeindex in der Liste, wie sie gerendert ist.
keyDas Element, an dem die Linie hängt; null in einer leeren Liste (zeichne sie dann in der Liste).
side'before' oder 'after' dieses Elements; 'swap' in einer Liste, die tauscht. Bäume ergänzen 'inside'.
depth, parentKeyNur Bäume: wo der Drop landet. Siehe Bäume.
  • Das indicator der Liste ist gesetzt, solange die Linie in dieser Liste ist (die Tabelle gibt es aus), und das indicator eines Elements, solange die Linie an diesem Element hängt.
  • Die gezogene Zeile bleibt, wo sie war, als Platzhalter markiert: Blende sie ab.
  • Die Tastatur bewegt die Linie ebenfalls.
  • Ein Drop außerhalb aller Ziele bricht in diesem Modus ab (dropOutside: 'cancel'), da die Liste nie eine andere Reihenfolge gezeigt hat.
  • Das Overlay verdeckt einen Teil der Zeile darunter. Mach es ein wenig durchsichtig, dann scheint die Linie durch: [data-dnd-overlay] .row { opacity: 0.85 }.

Tauschen statt verschieben

Mit behavior: 'swap' tauscht das gezogene Element den Platz mit dem darunter, und die anderen bleiben, wo sie sind:

Live

Demo wird geladen…

<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}`);
  },
});
  • Live (placeholder: 'item', Standard): Der Tausch ist beim Ziehen zu sehen. Zurückziehen oder die Liste verlassen macht ihn rückgängig.
  • Beim Drop (placeholder: 'indicator'): Bis zum Drop bewegt sich nichts. Der Indikator markiert das Element, mit dem der Drop tauscht, side: 'swap': Umrande dieses Element, statt eine Linie zu zeichnen. Die Pfeiltasten bewegen die Markierung von Element zu Element; zurück auf dem gezogenen Element gibt es keine.
PersonCell.vue
<li ref="el" :class="{ target: indicator?.side === 'swap' }">…</li>

Das Drop-Event hat mode: 'swap' und partner, das Element, mit dem getauscht wurde: seinen key, item und sein eigenes from und to. Zwei Listen einer Gruppe tauschen untereinander, wenn beide behavior: 'swap' haben, wie die zwei Spalten. Das überfahrene Element wandert dann in die andere Liste, es muss also hinaus (group.pull) und hinein (group.put) dürfen: siehe Listen mit gemeinsamen Elementen. Ein Multi-Drag sortiert, statt zu tauschen.

Tastatur

Fokussiere eine Zeile oder ihr Handle und drücke Space oder Enter, um sie aufzunehmen. Die Pfeile entlang der Liste bewegen sie um einen Slot, in Rastern gehen hoch und runter in die nächste Zeile, und die Pfeile quer zur Liste bewegen sie in die nächste Liste ihrer Gruppe. Hinter dem Ende einer Liste geht der Pfeil weiter in die nächste Liste in dieser Richtung (Listen übereinander), sonst auf ein Drop-Ziel dort, etwa eine Papierkorb-Zone. Drücke erneut Space oder Enter zum Ablegen, Escape, um sie zurückzustellen. Jede Demo auf dieser Seite funktioniert so. Die Screenreader-Texte stehen unter Barrierefreiheit.

Animationen

Zeilen gleiten beim Ziehen an ihre neuen Plätze, und die abgelegte Zeile landet auf ihrem Slot. Die Liste selbst wächst oder schrumpft auf ihre neue Größe, wenn Zeilen hinzukommen und gehen (animateSize). Das ist standardmäßig an und braucht keinen Code. Umschließe Zeilen nicht mit <TransitionGroup>: Bewegungen würden doppelt animiert. Setz auch keine CSS-transition auf das transform der Zeilen. Für Änderungen, die kein Drag gemacht hat (Hinzufügen, Entfernen, Mischen), schalte die Option animateChanges der Liste ein, wie es die Tabs tun. Siehe Animationen.

Die vollständige Liste der Optionen steht in der API von makeSortableList und makeSortableItem.