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

Live

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 eine group, 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 mit type: '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 des DnDProvider bereitgestellt, 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.

Column.vue
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.

store.ts
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.

CardOverlay.vue
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.

Board.vue
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.

Trash.vue
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.

store.ts
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.“

index.vue
<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