Documentation/Vue d'ensemble

Tableau de projet

Un tableau produit dans l'esprit de Linear et Trello — des colonnes que vous réordonnez, des limites WIP, une question avant Done, la sélection multiple avec une barre de sélection, des filtres à travers lesquels les glissements fonctionnent, une corbeille avec Undo, et un panneau de carte dont les boutons Status jouent de vrais glissements.

Modifier cette page

Cette page a été traduite par IA et peut contenir des inexactitudes. Lire l'original en anglais

Live

Chargement de la démo…

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

Ce qu'il faut regarder

  • Board.vue : les colonnes forment leur propre liste triable horizontale (strategy: 'horizontal', axis: 'x'), et chaque colonne est un item triable qu'on fait glisser par son en-tête (handle). Agrandi, le tableau remplit l'écran ; sur un téléphone, il défile sur le côté.
  • Column.vue : les cartes de toutes les colonnes forment un seul group, avec les règles de la colonne : sa limite WIP, la question avant Done, et les filtres. La jauge de son en-tête allume les cartes qu'un glissement y ferait entrer.
  • Card.vue : un item triable de type: 'card', si bien que la corbeille accepte les cartes et pas les colonnes. Un clic l'ouvre, Ctrl/Cmd-clic la sélectionne (selected) ; au clavier, Espace la saisit, Entrée l'ouvre et X la sélectionne.
  • store.ts : les données et les règles du tableau, sans aucun glisser-déposer, fournies au-dessus du DnDProvider pour que l'overlay les lise aussi.

Les règles d'une colonne

Chaque colonne passe quatre règles à sa liste. put voit toutes les cartes d'un multi-drag à la fois : la colonne les accepte toutes si elles tiennent sous sa limite, ou aucune. Au-dessus d'une colonne pleine, l'emplacement reste là où le glissement a commencé, la colonne devient rouge et les cartes en main disent pourquoi. onBeforeDrop pose une question avant que des cartes n'entrent dans Done, et animateChanges fait glisser les cartes qui arrivent et partent sans glissement : un filtre, 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);
  },
});

Des filtres, et des dépôts que le tableau applique

La recherche et les personnes de la barre d'outils filtrent les cartes, et chaque glissement continue de fonctionner sur ce qui est affiché. Chaque liste lit sa colonne via un getter, filtrée, si bien que la bibliothèque ne l'écrit jamais : onDrop appelle preventDefault() et passe au tableau e.result, les nouvelles cartes filtrées de chaque colonne que le dépôt a modifiée. Le tableau les remet aux places qu'avaient les cartes affichées, et les cartes que les filtres masquent restent où elles étaient.

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

Les cartes en main

CardOverlay.vue est l'overlay des cartes. La carte saisie se soulève et s'incline, le reste d'une sélection est posé dessous, construit à partir de clones DragPreview tournés par le transform de leurs enveloppes, et un compteur est posé par-dessus. Sous les cartes, une indication dit ce que ferait le relâchement, d'après l'over et l'accepted de l'overlay : Move 2 cards to In review, Complete, Delete, ou In review is full · 2 max. Déposé dans une colonne, chaque clone atterrit sur son propre emplacement ; au-dessus de la corbeille, la pile rétrécit, et une fois déposées, les cartes y entrent l'une après l'autre.

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

Une colonne glissée par son en-tête est le clone par défaut, incliné avec la propriété rotate tant que data-dnd-overlay vaut dragging.

Done demande d'abord

Un dépôt dans Done renvoie une promesse depuis onBeforeDrop : les cartes attendent là où elles ont été relâchées pendant que ConfirmDialog.vue pose la question. Move to Done répond 'accept', Cancel et Échap 'reject' (les cartes reviennent à leur place), et Keep dragging'continue' : les cartes suivent de nouveau le pointeur, et le clic suivant les dépose. Une fois les cartes arrivées (onDragSettle), leur coche apparaît dans Done et la progression du sprint avance.

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

Plusieurs cartes, la corbeille et Undo

Les cartes sélectionnées voyagent ensemble : faites-en glisser une, et chaque carte sélectionnée suit, depuis n'importe quelle colonne. La barre de sélection les déplace, les supprime, ou vide la sélection (Échap aussi). Trash.vue est une simple cible de dépôt qui n'accepte que les cartes ; elle les supprime du tableau, comme le font les boutons Delete, pour que la notification puisse remettre chacune là où elle était.

Trash.vue
makeDroppable(el, {
  id: 'trash',
  accept: 'card',
  onDrop: (event) => {
    board.remove(event.active.ids);
    selection.delete(...event.active.ids);
  },
});

Déplacer sans glisser

Le panneau d'une carte a des boutons Status, et la barre de sélection a un bouton par colonne. Ils déplacent les cartes avec @vue-dnd-kit/actions : moveItem joue un vrai glissement au clavier de la carte, et une carte sélectionnée emmène la sélection. Il est annoncé et animé comme les vôtres, et la limite de la colonne et la question de Done s'y appliquent comme à n'importe quel glissement.

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

Clavier et lecteurs d'écran

Seul Espace saisit une carte : Entrée reste libre pour l'ouvrir ; les instructions le disent. Les flèches déplacent une carte d'emplacement en emplacement, et en travers vers la colonne suivante qui l'accepte : une colonne pleine est sautée, et au-delà de la dernière colonne, l'appui suivant emmène la carte jusqu'à la corbeille. Les annonces nomment les cartes et les colonnes au lieu des 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 }"
>

En savoir plus : Listes qui partagent des items · Décider d'un dépôt · Multi-drag · L'overlay · Glissements programmatiques · Accessibilité