Dokumentation/Übersicht
Projekt-Board
Ein Produkt-Board im Stil von Linear und Trello – Spalten, die du umsortierst, WIP-Limits, eine Frage vor Done, Mehrfachauswahl mit Auswahlleiste, Filter, durch die die Drags hindurch funktionieren, ein Papierkorb mit Undo und ein Karten-Panel, dessen Status-Buttons echte Drags abspielen.
Diese Seite wurde von KI übersetzt und kann Ungenauigkeiten enthalten. Englisches Original lesen
Demo wird geladen…
<script setup lang="ts">
import { provide } from 'vue';
import { DnDProvider, type Active, type AnnouncedPosition, type Announcements } from '@vue-dnd-kit/core';
import Board from './Board.vue';
import { BOARD, createBoard, type Card, type Column } from './store';
// Provided above the provider: the overlay it renders reads the board too.
const board = createBoard();
provide(BOARD, board);
// Screen readers hear cards and columns by name, not by id.
const subject = (active: Active) => {
const item = active.items[0]?.item;
if (active.type.includes('column')) return `the ${(item as Column).title} column`;
if (active.ids.length > 1) return `${active.ids.length} cards`;
return `${(item as Card).id}, ${(item as Card).title}`;
};
/** Where it is: "Todo, position 2 of 6." for a card, "Place 2 of 4." for a column. */
const where = (position: AnnouncedPosition | undefined) => {
if (!position) return '';
const column = board.column(position.list);
if (!column) return `Place ${position.index + 1} of ${position.length}.`;
return `${column.title}, position ${position.index + 1} of ${position.length}.`;
};
const announcements: Partial<Announcements> = {
onDragStart: ({ active }, { position }) => {
const keys = active.sensor === 'keyboard' ? ' Arrow keys move it, Space drops it, Escape cancels.' : '';
return `Picked up ${subject(active)}. ${where(position)}${keys}`;
},
onDragOver: ({ over }, { position }) => {
if (over?.id === 'trash') return 'Over the trash. Drop to delete.';
const column = over ? board.column(over.id) : undefined;
if (column && !over!.accepted) return `${column.title} is full: its limit is ${column.limit} cards.`;
return position ? where(position) : undefined;
},
onDragEnd: ({ active, over }, { position }) =>
over?.id === 'trash' ? `Deleted ${subject(active)}. Undo is in the notice.` : `Dropped ${subject(active)}. ${where(position)}`,
onDragCancel: ({ active }, { position }) => `${subject(active)} went back. ${where(position)}`,
onDragResume: ({ active }) => `Not dropped: still holding ${subject(active)}.`,
};
</script>
<template>
<!--
Space picks a card up and drops it: Enter is left to open the card. Touch drags vibrate
over a column that takes them, and when a drop is refused.
-->
<DnDProvider
:keyboard="{ keys: { start: ['Space'] } }"
screen-reader-instructions="Space picks up a card or a column, the arrow keys move it, Space drops it, Escape cancels. Enter opens a card, X selects it."
:announcements="announcements"
:haptics="{ enter: true, refuse: true }"
>
<Board />
</DnDProvider>
</template>Worauf du achten solltest
Board.vue: Die Spalten sind eine eigene horizontale sortierbare Liste (strategy: 'horizontal',axis: 'x'), und jede Spalte ist ein sortierbares Element, das an seinem Kopf gezogen wird (handle). Aufgeklappt füllt das Board den Bildschirm; auf einem Handy scrollt es seitwärts.Column.vue: Die Karten aller Spalten bilden einegroup, mit den Regeln der Spalte: ihrem WIP-Limit, der Frage vor Done und den Filtern. Die Anzeige in ihrem Kopf hebt die Karten hervor, die ein Drag hereinbringen würde.Card.vue: ein sortierbares Element mittype: 'card', sodass der Papierkorb Karten annimmt und keine Spalten. Ein Klick öffnet die Karte, Strg/Cmd-Klick wählt sie aus (selected); mit der Tastatur nimmt Space sie auf, Enter öffnet sie und X wählt sie aus.store.ts: die Daten und Regeln des Boards, ganz ohne Drag and Drop, oberhalb desDnDProviderbereitgestellt, damit auch das Overlay sie liest.
Die Regeln einer Spalte
Jede Spalte gibt ihrer Liste vier Regeln. put sieht alle Karten eines Multi-Drags auf einmal:
Die Spalte nimmt sie alle, wenn sie unter ihr Limit passen, oder keine. Über einer vollen Spalte
bleibt der Slot dort, wo der Drag gestartet ist, die Spalte wird rot, und die Karten in der Hand
sagen, warum. onBeforeDrop fragt nach, bevor Karten nach Done kommen, und animateChanges lässt die
Karten gleiten, die ohne Drag kommen und gehen: ein Filter, Add card, Undo.
const { items, isOver, isAccepting } = makeSortableList(list, {
id: props.column.id,
items: () => props.column.cards.filter(board.matches),
group: { name: 'cards', put: ({ keys }) => board.fits(props.column, keys) },
overlay: CardOverlay,
animateChanges: true,
onBeforeDrop: (event) => (props.column.confirm && !event.sameList ? board.ask(event.items) : 'accept'),
onDrop: (event) => {
event.preventDefault();
board.apply(event);
},
});
Filter und Drops, die das Board anwendet
Die Suche und die Personen in der Werkzeugleiste filtern die Karten, und jeder Drag arbeitet
weiter auf dem, was gezeigt wird. Jede Liste liest ihre Spalte über einen Getter, gefiltert,
sodass die Bibliothek sie nie schreibt: onDrop ruft preventDefault() auf und gibt e.result,
die neuen gefilterten Karten jeder Spalte, die der Drop geändert hat, an das Board. Das Board
setzt sie zurück auf die Plätze, die die gezeigten Karten hatten, und die Karten, die die Filter
ausblenden, bleiben, wo sie waren.
const merge = (all: Card[], shown: Card[]) => {
const queue = [...shown];
const merged: Card[] = [];
for (const entry of all) {
if (!matches(entry)) merged.push(entry);
else if (queue.length) merged.push(queue.shift()!);
}
// Cards that came in from another column and found no place left: at the end.
return [...merged, ...queue];
};
Die Karten in der Hand
CardOverlay.vue ist das overlay der Karten. Die gegriffene Karte hebt sich und neigt sich,
der Rest einer Auswahl liegt darunter, gebaut aus DragPreview-Klonen, die über das transform
ihrer Wrapper gedreht werden, und obendrauf sitzt eine Anzahl. Unter den Karten sagt ein Hinweis,
was das Loslassen tun würde, aus over und accepted des Overlays: Move 2 cards to In review,
Complete, Delete oder In review is full · 2 max. In eine Spalte abgelegt, landet jeder Klon
auf seinem eigenen Slot; über dem Papierkorb schrumpft der Stapel, und dort abgelegt gehen die
Karten eine nach der anderen hinein.
const hint = computed(() => {
if (props.status !== 'dragging' || !props.over) return null;
const many = props.items.length > 1 ? ` ${props.items.length} cards` : '';
if (props.over.id === 'trash') return { tone: 'danger', icon: 'trash', text: `Delete${many}` };
const column = board.column(props.over.id);
if (!column) return null;
if (!props.accepted) return { tone: 'danger', icon: 'limit', text: `${column.title} is full · ${column.limit} max` };
if (board.find(props.items[0]!.id)?.column.id === column.id) return null;
if (column.confirm) return { tone: 'done', column, text: `Complete${many}` };
return { tone: '', column, text: `Move${many} to ${column.title}` };
});
Eine Spalte, die an ihrem Kopf gezogen wird, ist der Standard-Klon, mit der Eigenschaft rotate
geneigt, solange data-dnd-overlay dragging lautet.
Done fragt zuerst
Ein Drop nach Done gibt aus onBeforeDrop ein Promise zurück: Die Karten warten dort, wo sie
losgelassen wurden, während ConfirmDialog.vue fragt. Move to Done antwortet 'accept',
Cancel und Escape 'reject' (die Karten fliegen zurück), und Keep dragging 'continue': Die
Karten folgen wieder dem Zeiger, und der nächste Klick legt sie ab. Sobald die Karten gelandet
sind (onDragSettle), erscheint ihr Haken in Done, und der Fortschritt des Sprints bewegt sich.
useDnDMonitor({
onDragSettle: (event) => {
const target = event.over ? board.column(event.over.id) : undefined;
if ('dropped' in event && target?.confirm && event.source?.list !== target.id) board.complete(event.accepted);
},
});
Mehrere Karten, der Papierkorb und Undo
Ausgewählte Karten reisen zusammen: Ziehe eine, und jede ausgewählte Karte kommt mit, aus jeder
Spalte. Die Auswahlleiste verschiebt sie, löscht sie oder hebt die Auswahl auf (Escape auch).
Trash.vue ist ein einfaches Drop-Ziel, das nur Karten annimmt; es löscht sie vom Board, wie
es die Delete-Buttons tun, sodass die Meldung jede wieder dorthin zurücklegen kann, wo sie war.
makeDroppable(el, {
id: 'trash',
accept: 'card',
onDrop: (event) => {
board.remove(event.active.ids);
selection.delete(...event.active.ids);
},
});
Verschieben ohne Drag
Das Panel einer Karte hat Status-Buttons, und die Auswahlleiste hat einen Button pro Spalte. Sie
verschieben Karten mit @vue-dnd-kit/actions: moveItem spielt einen echten Drag der Karte per
Tastatur ab, und eine ausgewählte Karte bringt die Auswahl mit. Er wird angesagt und animiert wie
deiner, und das Limit der Spalte und die Frage von Done gelten für ihn wie für jeden Drag.
export function useMove() {
const board = inject(BOARD)!;
const { moveItem } = useDnDActions();
return (ids: Key[], target: Column) => {
if (!board.fits(target, ids)) return board.notify(`${target.title} is full: ${target.limit} cards at most`);
void moveItem(ids[0]!, { list: target.id });
};
}
Tastatur und Screenreader
Nur Space nimmt eine Karte auf, Enter bleibt also frei, um sie zu öffnen; die Anleitung sagt das. Die Pfeile bewegen eine Karte slotweise und quer in die nächste Spalte, die sie annimmt: Eine volle wird übersprungen, und hinter der letzten Spalte trägt der nächste Druck die Karte weiter zum Papierkorb. Die Ansagen nennen Karten und Spalten statt IDs: „Picked up VDK-45, Haptics on Android tablets. Todo, position 6 of 6.“, „In review is full: its limit is 2 cards.“
<DnDProvider
:keyboard="{ keys: { start: ['Space'] } }"
screen-reader-instructions="Space picks up a card or a column, the arrow keys move it, Space drops it, Escape cancels. Enter opens a card, X selects it."
:announcements="announcements"
:haptics="{ enter: true, refuse: true }"
>
Mehr dazu: Listen mit gemeinsamen Elementen · Über einen Drop entscheiden · Multi-Drag · Das Overlay · Programmatisches Ziehen · Barrierefreiheit