Dokumentation/Leitfäden

Über einen Drop entscheiden

Nimm einen Drop an, lehne ihn ab oder schiebe ihn auf – Regeln beim Ziehen oder beim Loslassen, ein Teil eines Multi-Drags, ein Dialog, ein Server, der zuerst zustimmen muss, Monitore mit Vetorecht und Drops außerhalb aller Ziele.

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

Ziehe eine Karte nach Done. Der Drop wartet auf deine Antwort: die Karte verschieben, sie woandershin weiterziehen oder abbrechen.

Live

Demo wird geladen…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Board from './Board.vue';
</script>

<template>
  <DnDProvider>
    <Board />
  </DnDProvider>
</template>

onBeforeDrop

makeSortableList(listRef, {
  items: tasks,
  onBeforeDrop: (e) => (e.items.some((task) => task.locked) ? 'reject' : 'accept'),
});

Listen, Bäume, Drop-Ziele (makeDroppable) und Monitore (useDnDMonitor) nehmen ein onBeforeDrop. Es läuft, wenn der Drag losgelassen wird, bevor sich irgendetwas ändert, und seine Antwort entscheidet:

AntwortWas passiert
'accept', true oder nichtsDer Drop geht durch.
'reject' oder falseKein Drop: Die Elemente fliegen zurück. Ein geworfener Fehler lehnt ebenfalls ab.
Ein Array von SchlüsselnNur diese Elemente werden abgelegt; die anderen fliegen zurück.
'continue'Kein Drop, und der Drag geht weiter.
Ein Promise auf eines davonDas Overlay wartet dort, wo es losgelassen wurde, bis das Promise erfüllt ist.

Listen und Bäume übergeben ein SortableDropEvent: keys, items, from, to, mode, sameList und result, die neuen Arrays jeder Liste, die der Drop ändert. Drop-Ziele und Monitore bekommen das allgemeine DnDEvent: active, over, position und source. Siehe Events.

Eine Regel beim Ziehen oder beim Loslassen

Email und Chat nehmen beide Anhänge bis höchstens 10 MB an. Ziehe demo.mov über beide: Email lehnt die Datei schon beim Überfahren ab, Chat nimmt sie an und schickt sie zurück, wenn du loslässt.

Live

Demo wird geladen…

<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';

const columns = reactive([
  {
    id: 'desktop',
    title: 'Desktop',
    files: [
      { id: 'notes', name: 'notes.pdf', size: 2 },
      { id: 'demo', name: 'demo.mov', size: 48 },
      { id: 'logo', name: 'logo.png', size: 1 },
      { id: 'backup', name: 'backup.zip', size: 120 },
    ],
  },
  // Both take files of 10 MB at most, and say so at different times.
  { id: 'email', title: 'Email', rule: 'put' as const, files: [] },
  { id: 'chat', title: 'Chat', rule: 'onBeforeDrop' as const, files: [] },
]);
</script>

<template>
  <DnDProvider>
    <div class="board">
      <Column v-for="column in columns" :key="column.id" :column="column" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.board {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 10px;
}

.board > * {
  flex: 0 1 170px;
  min-width: 150px;
}
</style>
Column.vue
// Asked while dragging: a refused drag never enters the list.
group: { name: 'attachments', put: ({ items }) => fits(items as Attachment[]) },

// Asked on release: the drag enters the list, then flies back.
onBeforeDrop: (e) => (fits(e.items) ? 'accept' : 'reject'),
group.put (oder das accept eines Ziels)onBeforeDrop
GefragtBeim Ziehen, sobald der Drag die Liste erreicht.Einmal, wenn der Drag losgelassen wird.
Der Nutzer siehtDie Liste lehnt sofort ab: Kein Slot öffnet sich, isAccepting ist false, over.accepted ist false.Der Slot öffnet sich wie gewohnt; die Elemente fliegen beim Loslassen zurück.
WeißWas gezogen wird: keys, items, types, from, to.Den ganzen Drop: to.index, sameList, mode, result.
KannJa oder Nein sagen.Auf ein Promise warten, einige Schlüssel annehmen, den Drag weiterlaufen lassen.

Nimm group.put für das, was allein aus den Elementen bekannt ist (ein Typ, ein Flag, eine Anzahl): Der Nutzer erfährt es, bevor er loslässt. Nimm onBeforeDrop für das, was vom Ort, von der Antwort des Nutzers oder der des Servers abhängt. Die eigenen Regeln des Ziels kommen zuerst: Über einer Liste oder einem Ziel, das den Drag ablehnt, wird der Drop abgebrochen, und sein onBeforeDrop wird nicht gefragt.

Einige der Elemente annehmen

Manche Regeln lassen sich erst beim Loslassen prüfen, bei einem Server. Plätze in diesem Workshop buchen auch andere: Beim Loslassen fragt die Liste den Server, wie viele noch frei sind, und nimmt so viele der hereingezogenen Personen an. Hake ein paar an und ziehe sie gemeinsam hinein.

Live

Demo wird geladen…

<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';

const columns = reactive([
  {
    id: 'waiting',
    title: 'Waiting list',
    people: [
      { id: 'ada', name: 'Ada', hue: 200 },
      { id: 'bo', name: 'Bo', hue: 30 },
      { id: 'cy', name: 'Cyrus', hue: 280 },
      { id: 'dee', name: 'Dee', hue: 140 },
    ],
  },
  {
    id: 'workshop',
    title: 'Workshop',
    seats: true,
    people: [
      { id: 'eli', name: 'Eli', hue: 340 },
      { id: 'fay', name: 'Fay', hue: 90 },
    ],
  },
]);
</script>

<template>
  <DnDProvider>
    <div class="demo">
      <p class="tip">Tick a few people, then drag one of them into the workshop</p>
      <div class="board">
        <Column v-for="column in columns" :key="column.id" :column="column" />
      </div>
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
}

.tip {
  margin: 0;
  font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.55;
}

.board {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 230px));
  justify-content: center;
  gap: 14px;
  width: 100%;
}
</style>
Column.vue
makeSortableList(list, {
  items: toRef(props.column, 'people'),
  group: 'workshop',
  // Some keys: those land, the others fly back to where they came from.
  onBeforeDrop: async (e) => {
    const { free } = await seatsLeft(props.column.people.length); // the overlay waits
    return e.keys.slice(0, free);
  },
  onDrop: (e) => {
    selection.delete(...e.accepted); // the ones sent back stay selected
    notice.value = `${e.accepted.length} booked, ${e.rejected.length} back to the waiting list`;
  },
});

Bei einem Multi-Drag landen die Schlüssel, die du zurückgibst, gemeinsam, und der Rest fliegt an seine Plätze zurück. onDrop sieht sie dann in e.accepted und e.rejected, und sein e.result ist das endgültige. Ein leeres Array nimmt nichts an: Der Drop wird abgelehnt.

Weiterziehen: 'continue'

'continue' bricht den Drop ab, nicht den Drag. Antworte im Dialog oben mit Keep dragging: Das Element bleibt von dort aus, wo es war, in der Hand, die Seite geht zurück in den Drag-Modus, und der Nutzer macht weiter.

  • Mit einer Maus oder einem Stift folgt das Element dem Zeiger, ohne dass eine Taste gehalten wird. Das nächste Loslassen – ein Klick – legt es ab, und dieser Klick erreicht die Seite darunter nie.
  • Ein Touch nimmt das Element dort wieder auf, wo der Finger aufsetzt; Anheben des Fingers legt es ab.
  • Ein Drag per Tastatur geht weiter: Die Pfeiltasten bewegen das Element, Space oder Enter legen es ab.
  • Escape bricht ihn ab, wie jeden Drag.
  • Der nächste Drop fragt onBeforeDrop erneut.

Monitore hören onDragResume, und Screenreader hören, dass das Element nicht abgelegt wurde und sich noch bewegt (überschreibe den Text mit announcements.onDragResume des Providers).

useDnDMonitor({
  onDragResume() {
    hint.value = 'Still dragging: click to drop, Esc to cancel';
  },
});

Den Nutzer fragen

onBeforeDrop darf ein Promise zurückgeben. Öffne einen Dialog und erfülle das Promise mit dem Button, den der Nutzer wählt:

Column.vue
const { items } = makeSortableList(list, {
  id: props.column.id,
  items: toRef(props.column, 'tasks'),
  group: 'release',
  onBeforeDrop: (e) => (props.column.confirm && !e.sameList ? props.ask(e) : 'accept'),
});
Board.vue
type Answer = 'accept' | 'continue' | 'reject';
const question = shallowRef<{ title: string; resolve: (answer: Answer) => void } | null>(null);

/** Opens the dialog: `onBeforeDrop` waits for the button the user picks. */
const ask = (e: SortableDropEvent<Task>) =>
  new Promise<Answer>((resolve) => {
    question.value = { title: e.items[0]!.title, resolve };
  });

Solange das Promise aussteht:

  • wartet das Overlay dort, wo es losgelassen wurde, markiert mit data-dnd-overlay="dropping";
  • behält useDnD() den Zustand vom Loslassen, und status ist 'dropping';
  • ist die Seite aus dem Drag-Modus: Cursor und Textauswahl sind zurück, ein Dialog funktioniert also wie gewohnt;
  • kann kein anderer Drag starten.
Ein modales <dialog>, das mit showModal() geöffnet wird, liegt im Top Layer, über dem wartenden Overlay. Mach aus seinem cancel-Event (Escape) ein 'reject', wie es das Beispiel tut.

Die Reihenfolge der Hooks

Ordne die Notizen um, wirf eine in den Papierkorb, stelle dann eine Antwort um und mach es noch einmal. Das Panel listet die Hooks des letzten Drops in der Reihenfolge auf, in der sie liefen.

Live

Demo wird geladen…

<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Monitor from './Monitor.vue';
import Notes from './Notes.vue';
import { ANSWERS, type Answer } from './types';

const monitorAnswer = ref<Answer>('accept');
const targetAnswer = ref<Answer>('accept');

// Every hook of the last drop, in the order they ran.
const steps = ref<{ who: string; what: string }[]>([]);
const trace = (who: string, what: string) => steps.value.push({ who, what });
</script>

<template>
  <DnDProvider
    @drag-start="steps = []"
    @drag-end="trace('provider', '@drag-end')"
    @drag-cancel="trace('provider', '@drag-cancel')"
    @drag-settle="trace('provider', '@drag-settle: landed')"
  >
    <div class="demo">
      <div class="switches">
        <fieldset>
          <legend>monitor answers</legend>
          <label v-for="value in ANSWERS" :key="value" :class="{ on: monitorAnswer === value }">
            <input v-model="monitorAnswer" type="radio" :value="value" />
            {{ value }}
          </label>
        </fieldset>
        <fieldset>
          <legend>target answers</legend>
          <label v-for="value in ANSWERS" :key="value" :class="{ on: targetAnswer === value }">
            <input v-model="targetAnswer" type="radio" :value="value" />
            {{ value }}
          </label>
        </fieldset>
      </div>
      <div class="layout">
        <Notes :answer="targetAnswer" :trace="trace" />
        <ol class="steps" aria-label="Hooks of the last drop">
          <li v-for="(step, index) in steps" :key="index">
            <span class="who">{{ step.who }}</span>{{ step.what }}
          </li>
          <li v-if="!steps.length" class="empty">Move a note, or drop one in the trash</li>
        </ol>
      </div>
      <Monitor :answer="monitorAnswer" :trace="trace" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.switches {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 10px 16px;
}

fieldset {
  display: inline-flex;
  margin: 0;
  padding: 3px;
  border: 0;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

legend {
  float: left;
  align-self: center;
  padding: 0 8px 0 6px;
  font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.55;
}

label {
  padding: 5px 10px;
  border-radius: 6px;
  font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
  cursor: pointer;
}

label.on {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  font-weight: 600;
}

label:has(input:focus-visible) {
  outline: 2px solid #42b883;
}

input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.layout {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: center;
  gap: 16px;
}

.steps {
  box-sizing: border-box;
  min-width: 220px;
  min-height: 150px;
  margin: 0;
  padding: 10px 14px 10px 32px;
  border-radius: 10px;
  background: color-mix(in srgb, currentColor 5%, transparent);
  font: 11px/1.8 ui-monospace, Menlo, Consolas, monospace;
  white-space: nowrap;
  list-style: decimal;
}

.steps li::marker {
  color: color-mix(in srgb, currentColor 45%, transparent);
}

.who {
  display: inline-block;
  min-width: 60px;
  color: #42b883;
}

.steps li.empty {
  margin-left: -18px;
  list-style: none;
  opacity: 0.45;
}
</style>
  1. onBeforeDrop jedes useDnDMonitor, der Reihe nach. Ein 'reject' oder ein 'continue' beendet das Fragen; Schlüssel grenzen den Drop ein.
  2. onBeforeDrop des Ziels: die Liste oder der Baum, in die die Elemente gehen, oder das Drop-Ziel.
  3. onDrop nur des Ziels: Eine Liste, die Elemente in eine andere verlassen, wird nicht gefragt, und ihr neues Array steht im e.result des Ziels. Wenn Elemente entfernt werden (eine Papierkorb-Zone, dropOutside: 'remove'), läuft das onDrop ihrer Liste, mit to.index von -1.
  4. Das Standard-Schreiben: Jede Liste, die der Drop ändert, übernimmt ihr neues Array.
  5. onDragEnd des Providers (@drag-end) und der Monitore. Dann landet das Overlay, und onDragSettle (@drag-settle) meldet, dass es gelandet ist.

Ein abgelehnter Drop springt stattdessen zu onDragCancel (@drag-cancel), und die Elemente fliegen zurück. Ein 'continue' beendet das Fragen ebenfalls, mit onDragResume: Der Drag geht weiter, und sein nächster Drop fragt erneut.

Monitore entscheiden vor dem Ziel, ein Veto kommt also nie, nachdem eine Liste schon einen eigenen Drop angewendet hat. Das macht einen Monitor zum Ort für Regeln über jeden Drop der App:

Monitor.vue
const { syncing } = useSync();

// Read-only while syncing: every drop under the provider flies back.
useDnDMonitor({
  onBeforeDrop: () => (syncing.value ? 'reject' : 'accept'),
});

onDrop: vor dem Schreiben informiert

makeSortableList(listRef, {
  items: tasks,
  onDrop: (e) => {
    // `tasks` still holds the old order, `e.result` the new one.
    console.log(e.keys, e.from, e.to, e.result.get(e.to.list));
  },
});

Das onDrop einer Liste läuft, sobald der Drop angenommen ist, synchron, direkt bevor die Liste ihn schreibt. Um nach dem Schreiben zu handeln, nimm @drag-end des Providers oder useDnDMonitor({ onDragEnd }). An einem einfachen Drop-Ziel ist onDrop der Ort, an dem du deine Daten änderst: Die Bibliothek verschiebt dafür nichts.

Stores und Server: kontrollierte Listen

const store = useBoardStore();
const { items } = makeSortableList(listRef, {
  id: 'todo',
  group: 'board',
  items: () => store.lists.todo, // a getter: read-only
  onDrop: (e) => {
    e.preventDefault();
    for (const [listId, next] of e.result) store.setList(listId, next);
  },
});

Die Bibliothek schreibt einen Drop nur in ein beschreibbares Ref. Ein Getter, ein computed oder ein readonly-Ref wird nie beschrieben: Ruf e.preventDefault() auf und wende e.result selbst an – die neuen Arrays jeder Liste, die der Drop ändert, nach Listen-ID. preventDefault() überspringt das Schreiben in alle davon, denn nur einige zu schreiben würde Elemente verlieren oder verdoppeln. Entwicklungs-Builds warnen, wenn der Drop einer schreibgeschützten Liste der Bibliothek überlassen wird.

Zuerst auf einem Server speichern

Verschiebe eine Karte, während der Server flaky (jede zweite Anfrage schlägt fehl) oder down ist. Die Karte wartet mit einem Badge, während die Anfrage läuft, und landet dann oder fliegt zurück, wenn sie fehlgeschlagen ist.

Live

Demo wird geladen…

<script setup lang="ts">
import { DnDProvider, DragPreview } from '@vue-dnd-kit/core';
import Column from './Column.vue';
import { mode, requests, saving, type ServerMode } from './server';

const MODES: ServerMode[] = ['reliable', 'flaky', 'down'];
</script>

<template>
  <DnDProvider>
    <div class="demo">
      <fieldset class="modes">
        <legend>server</legend>
        <label v-for="value in MODES" :key="value" :class="{ on: mode === value }">
          <input v-model="mode" type="radio" :value="value" />
          {{ value }}
        </label>
      </fieldset>
      <div class="board">
        <Column id="backlog" title="Backlog" />
        <Column id="sprint" title="Sprint" />
      </div>
      <ol class="requests" aria-label="Requests">
        <li v-for="request in requests" :key="request.id" :class="`status-${request.status}`">
          {{ request.text }} <span>{{ request.status === 'pending' ? '…' : request.status }}</span>
        </li>
        <li v-if="!requests.length" class="empty">No requests yet</li>
      </ol>
    </div>

    <!-- The overlay of every drag: the card's copy, and a badge while the server answers. -->
    <template #overlay="{ active }">
      <div class="carried">
        <DragPreview :id="active.id" />
        <span v-if="saving" class="saving">Saving…</span>
      </div>
    </template>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.modes {
  display: inline-flex;
  margin: 0;
  padding: 3px;
  border: 0;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

legend {
  float: left;
  align-self: center;
  padding: 0 8px 0 6px;
  font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.55;
}

.modes label {
  padding: 5px 10px;
  border-radius: 6px;
  font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
  cursor: pointer;
}

.modes label.on {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  font-weight: 600;
}

.modes label:has(input:focus-visible) {
  outline: 2px solid #42b883;
}

.modes input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.board {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 220px));
  gap: 14px;
  justify-content: center;
  width: 100%;
}

.requests {
  width: min(100%, 454px);
  min-height: 60px;
  margin: 0;
  padding: 8px 12px;
  border-radius: 10px;
  background: color-mix(in srgb, currentColor 5%, transparent);
  font: 11px/1.6 ui-monospace, Menlo, Consolas, monospace;
  list-style: none;
  white-space: pre;
  overflow-x: auto;
}

.requests li + li {
  opacity: 0.55;
}

.requests .status-200 span {
  color: #42b883;
}

.requests .status-503 span {
  color: #e5484d;
}

.requests li.empty {
  color: inherit;
  opacity: 0.45;
}

.carried {
  position: relative;
  width: 100%;
  height: 100%;
}

.saving {
  position: absolute;
  top: 50%;
  right: 10px;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  translate: 0 -50%;
  padding: 4px 8px;
  border-radius: 99px;
  background: #42b883;
  color: #fff;
  font: 600 11px/1 system-ui, sans-serif;
}

.saving::before {
  content: '';
  width: 9px;
  height: 9px;
  border: 2px solid rgb(255 255 255 / 0.4);
  border-top-color: #fff;
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}

@keyframes spin {
  to {
    rotate: 1turn;
  }
}
</style>
Column.vue
const { items } = makeSortableList(list, {
  id: props.id,
  // A getter: the library never writes it. The drop is applied below, once saved.
  items: () => board[props.id]!,
  group: 'sprint',
  onBeforeDrop: async (e) => {
    e.preventDefault();
    try {
      await saveMove(e.keys, e.to);
    } catch {
      return 'reject'; // nothing applied: the card flies back
    }
    for (const [id, tasks] of e.result) board[id] = tasks; // lands in its new place
    return 'accept';
  },
});

Wende e.result an, bevor das Promise sich auflöst, und das Overlay landet in der neuen Reihenfolge. Wenn die Anfrage fehlschlägt, wende nichts an und lehne ab: Die Elemente fliegen zurück, und Monitore hören onDragCancel.

Das Badge kommt aus dem #overlay-Slot des Providers, der das Overlay jedes Drags rendert. Ein DragPreview zeigt die übliche Kopie der Karte, und das Badge sitzt obendrauf, während die Anfrage läuft:

index.vue
<DnDProvider>
  <Board />
  <template #overlay="{ active }">
    <div class="carried">
      <DragPreview :id="active.id" />
      <span v-if="saving" class="saving">Saving…</span>
    </div>
  </template>
</DnDProvider>

Außerhalb aller Ziele ablegen

Ziehe eine App aus dem Dock und lass sie los, in jedem Modus.

Live

Demo wird geladen…

<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider, type DropOutside } from '@vue-dnd-kit/core';
import Dock from './Dock.vue';

const MODES: DropOutside[] = ['placeholder', 'cancel', 'remove'];
const dropOutside = ref<DropOutside>('remove');
</script>

<template>
  <DnDProvider>
    <div class="demo">
      <fieldset class="modes">
        <legend>dropOutside</legend>
        <label v-for="value in MODES" :key="value" :class="{ on: dropOutside === value }">
          <input v-model="dropOutside" type="radio" :value="value" />
          {{ value }}
        </label>
      </fieldset>
      <code class="call">makeSortableList(dock, { strategy: 'horizontal', dropOutside: '{{ dropOutside }}' })</code>
      <Dock :drop-outside="dropOutside" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.modes {
  display: inline-flex;
  margin: 0;
  padding: 3px;
  border: 0;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

legend {
  float: left;
  align-self: center;
  padding: 0 8px 0 6px;
  font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.55;
}

.modes label {
  padding: 5px 10px;
  border-radius: 6px;
  font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
  cursor: pointer;
}

.modes label.on {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  font-weight: 600;
}

.modes label:has(input:focus-visible) {
  outline: 2px solid #42b883;
}

.modes input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.call {
  max-width: 100%;
  padding: 6px 10px;
  overflow-x: auto;
  border-radius: 8px;
  background: color-mix(in srgb, currentColor 6%, transparent);
  font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
  white-space: nowrap;
  color: #42b883;
}
</style>
Dock.vue
const { items } = makeSortableList(dock, {
  items: apps,
  strategy: 'horizontal',
  dropOutside: () => props.dropOutside,
  // Called on the list the items leave, with `to.index` -1, when they are removed.
  onDrop: (e) => {
    if (e.to.index === -1) notice.value = `Removed ${e.items.map((app) => app.name).join(', ')}`;
  },
});
dropOutsideEin Drop außerhalb aller Ziele …
'placeholder'Landet dort, wo der Platzhalter ist: am letzten Platz, den die Liste gezeigt hat. Der Standard.
'cancel'Wird abgebrochen: Die Elemente fliegen zurück. Der Standard bei placeholder: 'indicator'.
'remove'Entfernt die Elemente aus ihrer Liste. Das onBeforeDrop der Liste wird zuerst gefragt, und ihr onDrop bekommt to.index von -1.

Es wird beim Drop gelesen, ein Ref oder ein Getter kann es also umschalten. Eine Papierkorb-Zone macht absichtlich dasselbe: makeDroppable(trash, { onDrop: (e) => e.source?.remove() }). Siehe Drag and Drop.