Documentation/Guides
Décider d'un dépôt
Accepter, refuser ou différer un dépôt — des règles pendant le glissement ou au relâchement, une partie d'un multi-drag, une boîte de dialogue, un serveur qui doit d'abord être d'accord, des moniteurs qui opposent leur veto, et les dépôts en dehors de toute cible.
Cette page a été traduite par IA et peut contenir des inexactitudes. Lire l'original en anglais
Faites glisser une carte dans Done. Le dépôt attend votre réponse : déplacer la carte, continuer à la faire glisser ailleurs, ou annuler.
Chargement de la démo…
<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'),
});
Les listes, les arbres, les cibles de dépôt (makeDroppable) et les moniteurs (useDnDMonitor)
acceptent un onBeforeDrop. Il s'exécute quand le glissement est relâché, avant que quoi que ce
soit ne change, et sa réponse décide :
| Réponse | Ce qui se passe |
|---|---|
'accept', true ou rien | Le dépôt aboutit. |
'reject' ou false | Pas de dépôt : les items reviennent à leur place. Une erreur levée refuse aussi. |
| Un tableau de clés | Seuls ces items sont déposés ; les autres reviennent à leur place. |
'continue' | Pas de dépôt, et le glissement continue. |
| Une promesse de l'une de ces réponses | L'overlay attend là où il a été relâché jusqu'à ce que la promesse soit réglée. |
Les listes et les arbres passent un SortableDropEvent : keys, items, from, to, mode,
sameList et result, les nouveaux tableaux de chaque liste que le dépôt modifie. Les cibles
de dépôt et les moniteurs reçoivent le DnDEvent générique : active, over, position et
source. Voir les événements.
Une règle pendant le glissement, ou au relâchement
Email et Chat acceptent tous deux des pièces jointes de 10 Mo au plus. Faites glisser demo.mov au-dessus de chacun : Email la refuse pendant le survol, Chat l'accepte et la renvoie quand vous relâchez.
Chargement de la démo…
<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>// 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 (ou l'accept d'une cible) | onBeforeDrop | |
|---|---|---|
| Interrogé | Pendant le glissement, dès que le glissement atteint la liste. | Une fois, quand le glissement est relâché. |
| L'utilisateur voit | La liste refuse tout de suite : aucun emplacement ne s'ouvre, isAccepting vaut false, over.accepted vaut false. | L'emplacement s'ouvre comme d'habitude ; les items reviennent à leur place au relâchement. |
| Connaît | Ce qui est glissé : keys, items, types, from, to. | Tout le dépôt : to.index, sameList, mode, result. |
| Peut | Dire oui ou non. | Attendre une promesse, accepter certaines clés, prolonger le glissement. |
Utilisez group.put pour ce qui se sait à partir des items seuls (un type, un indicateur, un
nombre) : l'utilisateur l'apprend avant de relâcher. Utilisez onBeforeDrop pour ce qui dépend
de l'endroit, de la réponse de l'utilisateur ou de celle du serveur. Les règles propres à la
cible passent en premier : au-dessus d'une liste ou d'une cible qui refuse le glissement, le
dépôt est annulé et son onBeforeDrop n'est pas interrogé.
Accepter une partie des items
Certaines règles ne peuvent être vérifiées qu'au relâchement, auprès d'un serveur. Les places de cet atelier sont aussi réservées par d'autres : au relâchement, la liste demande au serveur combien il en reste, et accepte autant de personnes parmi celles qui ont été glissées. Cochez-en quelques-unes, et faites-les glisser ensemble.
Chargement de la démo…
<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>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`;
},
});
Dans un multi-drag, les clés que vous renvoyez atterrissent ensemble
et les autres reviennent à leur place. onDrop les voit ensuite dans e.accepted et
e.rejected, et son e.result est le résultat final. Un tableau vide n'accepte rien : le dépôt
est refusé.
Continuer à glisser : 'continue'
'continue' annule le dépôt, pas le glissement. Répondez Keep dragging dans la boîte de
dialogue ci-dessus : l'item reste en main depuis l'endroit où il était, la page repasse en mode
glissement, et l'utilisateur continue.
- À la souris ou au stylet, l'item suit le pointeur sans bouton enfoncé. Le relâchement suivant — un clic — le dépose, et ce clic n'atteint jamais la page en dessous.
- Au toucher, l'item est repris là où le doigt se pose ; lever le doigt le dépose.
- Un glissement au clavier continue : les flèches déplacent l'item, Space ou Enter le déposent.
- Escape l'annule, comme n'importe quel glissement.
- Le dépôt suivant interroge de nouveau
onBeforeDrop.
Les moniteurs entendent onDragResume, et les lecteurs d'écran annoncent que l'item n'a pas été
déposé et qu'il est toujours en mouvement (remplacez le texte avec
announcements.onDragResume du provider).
useDnDMonitor({
onDragResume() {
hint.value = 'Still dragging: click to drop, Esc to cancel';
},
});
Demander à l'utilisateur
onBeforeDrop peut renvoyer une promesse. Ouvrez une boîte de dialogue, et résolvez la promesse
avec le bouton que choisit l'utilisateur :
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'),
});
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 };
});
Tant que la promesse est en attente :
- l'overlay attend là où il a été relâché, marqué
data-dnd-overlay="dropping"; useDnD()garde l'état qu'il avait au relâchement, etstatusvaut'dropping';- la page est sortie du mode glissement : le curseur et la sélection de texte sont revenus, et une boîte de dialogue fonctionne donc normalement ;
- aucun autre glissement ne peut commencer.
<dialog> modal ouvert avec showModal() se trouve dans la top layer, au-dessus de l'overlay
en attente. Transformez son événement cancel (Escape) en 'reject', comme le
fait l'exemple.L'ordre des hooks
Réordonnez les notes, déposez-en une dans la corbeille, puis changez une réponse et recommencez. Le panneau liste les hooks du dernier dépôt dans l'ordre où ils se sont exécutés.
Chargement de la démo…
<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>- L'
onBeforeDropde chaqueuseDnDMonitor, tour à tour. Un'reject'ou un'continue'met fin aux questions ; des clés restreignent le dépôt. - L'
onBeforeDropde la cible : la liste ou l'arbre où vont les items, ou la cible de dépôt. - L'
onDropde la cible uniquement : une liste que des items quittent pour une autre n'est pas interrogée, et son nouveau tableau se trouve dans l'e.resultde la cible. Quand des items sont retirés (une corbeille,dropOutside: 'remove'), l'onDropde leur liste s'exécute, avec unto.indexde-1. - L'écriture par défaut : chaque liste que le dépôt modifie reçoit son nouveau tableau.
- L'
onDragEnddu provider (@drag-end) et des moniteurs. Puis l'overlay atterrit, etonDragSettle(@drag-settle) l'annonce.
Un dépôt refusé passe directement à onDragCancel (@drag-cancel), et les items reviennent à
leur place. Un 'continue' met aussi fin aux questions, avec onDragResume : le glissement
continue, et son prochain dépôt pose de nouveau les questions.
Les moniteurs décident avant la cible : un veto ne vient donc jamais après qu'une liste a déjà appliqué un dépôt de son côté. Cela fait d'un moniteur l'endroit idéal pour des règles portant sur chaque dépôt de l'application :
const { syncing } = useSync();
// Read-only while syncing: every drop under the provider flies back.
useDnDMonitor({
onBeforeDrop: () => (syncing.value ? 'reject' : 'accept'),
});
onDrop : prévenu avant l'écriture
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));
},
});
L'onDrop d'une liste s'exécute une fois le dépôt accepté, de façon synchrone, juste avant que
la liste ne l'écrive. Pour agir après l'écriture, utilisez le @drag-end du provider ou
useDnDMonitor({ onDragEnd }). Sur une simple cible de dépôt, onDrop est l'endroit où vous
modifiez vos données : la bibliothèque ne déplace rien pour elle.
Stores et serveurs : listes contrôlées
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);
},
});
La bibliothèque n'écrit un dépôt que dans une ref modifiable. Un getter, un computed ou une
ref readonly ne sont jamais écrits : appelez e.preventDefault() et appliquez e.result
vous-même — les nouveaux tableaux de chaque liste que le dépôt modifie, par id de liste.
preventDefault() saute l'écriture dans toutes ces listes, car n'en écrire que certaines
perdrait ou dupliquerait des items. Les builds de développement avertissent quand le dépôt d'une
liste en lecture seule est laissé à la bibliothèque.
Enregistrer d'abord sur un serveur
Déplacez une carte avec le serveur flaky (une requête sur deux échoue) ou down. La carte attend avec un badge pendant que la requête s'exécute, puis atterrit, ou revient à sa place si elle a échoué.
Chargement de la démo…
<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>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';
},
});
Appliquez e.result avant que la promesse ne se résolve, et l'overlay atterrit dans le nouvel
ordre. Quand la requête échoue, n'appliquez rien et refusez : les items reviennent à leur place,
et les moniteurs entendent onDragCancel.
Le badge vient du slot #overlay du provider, qui rend l'overlay de chaque glissement. Un
DragPreview affiche la copie habituelle de la carte, et le badge se place par-dessus pendant
que la requête s'exécute :
<DnDProvider>
<Board />
<template #overlay="{ active }">
<div class="carried">
<DragPreview :id="active.id" />
<span v-if="saving" class="saving">Saving…</span>
</div>
</template>
</DnDProvider>
Déposer en dehors de toute cible
Faites glisser une application hors du dock et relâchez, dans chaque mode.
Chargement de la démo…
<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>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(', ')}`;
},
});
dropOutside | Un dépôt en dehors de toute cible… |
|---|---|
'placeholder' | Atterrit là où se trouve le placeholder : la dernière place que la liste a montrée. La valeur par défaut. |
'cancel' | Est annulé : les items reviennent à leur place. La valeur par défaut avec placeholder: 'indicator'. |
'remove' | Retire les items de leur liste. L'onBeforeDrop de la liste est interrogé d'abord, et son onDrop reçoit un to.index de -1. |
Il est lu au dépôt : une ref ou un getter peut donc le changer. Une corbeille fait la même chose
volontairement : makeDroppable(trash, { onDrop: (e) => e.source?.remove() }). Voir le
glisser-déposer.