Documentation/Vue d'ensemble

Calendrier de la semaine

Un planning de la semaine — des événements déplacés d'un jour et d'un quart d'heure à l'autre, étirés par leurs bords, tracés sur un créneau libre, passés en journée entière, planifiés depuis une liste de tâches, avec annulation.

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 Announcements } from '@vue-dnd-kit/core';
import Calendar from './Calendar.vue';
import { CALENDAR, createCalendar } from './calendar';
import { spokenOf, type DragData } from './placement';
import { toDay } from './time';

// The calendar's state, shared by every component under it.
provide(CALENDAR, createCalendar(toDay(new Date())));

/** The event a drag carries; edges and empty time carry none. */
const eventOf = (data: unknown) => ((data as DragData).kind === 'event' ? (data as Extract<DragData, { kind: 'event' }>).event : null);

// Screen readers hear titles and times, not ids. Where a keyboard drag would land is spoken by
// the week as the arrows move it, and what a drop changed by the toast: the live region
// stays quiet for both.
const announcements: Partial<Announcements> = {
  onDragStart: ({ active }) => {
    const event = eventOf(active.data);
    if (!event) return '';
    const keys =
      active.sensor === 'keyboard'
        ? ' Arrow keys move it by a quarter of an hour and by a day, with Shift by an hour. Space drops it, Escape cancels.'
        : '';
    return `Picked up ${event.title}, ${spokenOf(event, event.duration)}.${keys}`;
  },
  onDragOver: () => '',
  onDragEnd: () => '',
  onDragCancel: ({ active }) => {
    const event = eventOf(active.data);
    return event ? `${event.title} stays where it was.` : '';
  },
};
</script>

<template>
  <DnDProvider :announcements="announcements">
    <Calendar />
  </DnDProvider>
</template>

Ce qu'il faut regarder

  • EventBlock.vue : un événement dans les heures est un élément déplaçable simple dont l'overlay, EventOverlay.vue, dit où il atterrirait : "Wed 10:30 – 11:15". Les événements qui se chevauchent se placent côte à côte (layoutDay dans placement.ts).
  • Edge.vue : les bords haut et bas d'un événement sont des éléments déplaçables à part entière. Faites-en glisser un et l'événement s'étire, d'un quart d'heure à la fois.
  • DayColumn.vue : les heures de chaque jour sont une cible de dépôt, et sous ses événements se trouve aussi un élément déplaçable : appuyez sur un créneau libre et faites glisser pour tracer un nouvel événement, puis tapez son titre.
  • AllDayCell.vue et Tasks.vue : déposez un événement sur la cellule « journée entière » d'un jour et il perd son horaire ; déposez-le sur la liste de tâches et il perd son jour. Les tâches (EventChip.vue) se glissent dans la semaine, et gardent leur durée.
  • Week.vue : la zone de glissement des heures et leur propre modificateur, les déplacements au clavier, la ligne de l'heure actuelle.
  • calendar.ts : les événements et un historique d'annulation. Chaque changement passe par une seule fonction, et le toast propose de l'annuler.

Agrandissez l'exemple : quand il y a la place, un panneau latéral affiche le mois, les calendriers à afficher ou masquer, et les tâches sous forme de liste. Sur un téléphone, la semaine devient trois jours, et un appui long fait glisser.

Une seule réponse pour chaque dépôt

Ce que ferait un dépôt est demandé à quatre endroits : le libellé de l'overlay, le fantôme qu'une tâche montre dans le jour en dessous, ce qu'entendent les lecteurs d'écran, et le dépôt lui-même. Tous appellent placeOf : ils ne se contredisent donc jamais. Chaque cible de dépôt porte ce qu'elle est dans son data, et position est l'endroit où se trouve la boîte glissée à l'intérieur : son haut est l'heure.

placement.ts
export function placeOf(event: CalendarEvent, over: Over | null, position: Coordinates | null): Place | null {
  const target = over?.accepted ? (over.data as Target) : null;
  if (!target) return null;
  if (target.kind === 'tasks') return { day: null, start: null };
  if (target.kind === 'all-day') return { day: target.day, start: null };
  if (!position) return null;
  return { day: target.day, start: clamp(minutesAt(position.y), 0, DAY_MINUTES - event.duration) };
}

L'overlay la lit en direct, depuis useDnD().position, et garde sa dernière réponse pendant qu'il atterrit :

EventOverlay.vue
const { position } = useDnD();
const place = shallowRef(placeOf(event.value, props.over, position.value));
watchEffect(() => {
  if (props.status === 'dragging') place.value = placeOf(event.value, props.over, position.value);
});

Un événement garde son id, son data et son overlay où qu'il soit : un bloc dans les heures, une pastille en journée entière ou parmi les tâches. Déposé ailleurs, il est rendu par un autre composant, et l'overlay atterrit quand même sur le nouvel élément.

Le modificateur propre aux heures

makeDragArea donne aux glissements qui commencent dans les heures (un événement, un bord, un créneau libre) un modificateur à eux. Au-dessus des jours, la boîte se déplace par jours entiers, autant que le pointeur en a traversé : un événement à côté d'un autre garde donc sa place dans le jour ; son haut s'aligne sur les quarts d'heure du jour où il a commencé. containerRect est ce jour, là où il se trouve maintenant : il suit le défilement, et l'alignement tient pendant que les heures défilent automatiquement sous un glissement.

Week.vue
const snapToWeek: Modifier = ({ transform, overlayRect, containerRect, origin }) => {
  const days = grid.value?.getBoundingClientRect();
  if (!days || !containerRect) return transform;
  const width = days.width / props.days.length;
  const column = (x: number) => Math.floor((x - days.left) / width);
  const pointer = origin.x + transform.x;
  const x = pointer < days.left || pointer >= days.right ? transform.x : (column(pointer) - column(origin.x)) * width;
  const top = overlayRect.top - containerRect.top;
  return { x, y: transform.y + Math.round(top / STEP_PX) * STEP_PX - top };
};

makeDragArea(grid, { modifiers: [snapToWeek], autoScroll: { threshold: 0.12 } });

L'autoScroll de la zone rétrécit le bord qui fait défiler les heures, pour chaque glissement au-dessus d'elles : la rangée « journée entière » juste au-dessus reste facile à atteindre. Les tâches et les événements sur la journée entière commencent hors de la zone et se déplacent librement ; le jour en dessous montre un fantôme là où ils atterriraient.

Étirer : un bord est un élément déplaçable

Un bord se trouve à l'intérieur de l'élément de son événement, et un appui fait glisser l'élément déplaçable le plus proche : le bord. Il n'a pas d'overlay, ne bouge que vers le haut et le bas, et reste dans les heures visibles.

Edge.vue
makeDraggable(el, {
  id: () => `${props.event.id}-${props.edge}`,
  type: 'edge',
  data: (): DragData => ({ kind: 'edge', event: props.event, edge: props.edge }),
  overlay: false,
  axis: 'y',
  modifiers: [restrictToFirstScrollableAncestor],
  attributes: false, // no tab stop: Shift + arrows change the end of a focused event
});

Le bord lui-même ne bouge pas. C'est l'événement qui grandit, depuis l'endroit où se trouve le bord dans le jour, lu dans position comme le dépôt le lira :

EventBlock.vue
const time = computed(() =>
  resizing.value && position.value
    ? resizeTo(props.event, resizing.value, position.value)
    : { start: props.event.start!, duration: props.event.duration },
);

Tracer sur un créneau libre

Sous les événements, chaque jour a une piste, et la piste est un élément déplaçable sans overlay. Elle ne bouge jamais : la distance parcourue par sa boîte (position) est la distance parcourue par le pointeur depuis l'appui, alignée sur les quarts d'heure par la zone. Le jour montre le nouvel événement à mesure qu'il est tracé, et son dépôt le crée.

DayColumn.vue
makeDraggable(lane, {
  id: () => `new-${props.day}`,
  type: 'new',
  data: (): DragData => ({ kind: 'new', day: props.day, from: from.value }),
  overlay: false,
  axis: 'y',
  attributes: false, // the New event button is the keyboard's way
});

L'accept du jour est une fonction : il accepte n'importe quel événement, et seulement ses propres bords et pistes. Les cellules « journée entière » et la liste de tâches acceptent 'event', une chaîne : les types edge et new y sont donc refusés sans une ligne de code. Le dépôt tient en un seul gestionnaire :

DayColumn.vue
onDrop: ({ active, over, position }) => {
  const data = active.data as DragData;
  if (!position) return;
  if (data.kind === 'event') {
    const place = placeOf(data.event, over, position);
    if (place) calendar.place(data.event.id, place);
  } else if (data.kind === 'edge') {
    calendar.resize(data.event.id, resizeTo(data.event, data.edge, position));
  } else {
    const { start, duration } = rangeOf(data.from, data.from + position.y);
    calendar.create(props.day, start, duration);
  }
},

Le clavier

Allez sur un événement avec Tab, saisissez-le avec Espace : le moniteur de la semaine répond aux flèches, getKeyboardCoordinates, avec le quart d'heure suivant (avec Maj, une heure) ou le jour suivant. Les glissements au clavier ne défilent pas automatiquement : près d'un bout des heures visibles, la flèche les fait donc défiler à la place. Les tâches et les événements sur la journée entière sautent de cible en cible (keyboardMode: 'droppables') jusqu'à ce qu'ils soient au-dessus des heures.

Week.vue
useDnDMonitor({
  getKeyboardCoordinates: (key, { pointer, direction }) => {
    // …only over the hours
    const dy = direction.y * STEP_PX * (key.shiftKey ? 4 : 1);
    const next = { x: pointer.x + direction.x * (grid.value.offsetWidth / props.days.length), y: pointer.y + dy };
    if (canScroll && (next.y < view.top + HOUR_PX || next.y > view.bottom - HOUR_PX)) {
      hours.scrollTop += dy;
      return { x: next.x, y: pointer.y };
    }
    return next;
  },
});

Les announcements du provider dans index.vue disent ce qui a été saisi et comment le déplacer ; la semaine énonce l'horaire à mesure que les flèches le changent ("Tuesday, 10:30 to 11:15"), et le toast ce qu'un dépôt a changé. Sur un événement qui a le focus, Suppr le supprime, et Ctrl+Z annule n'importe quel changement.

En savoir plus : Votre propre modificateur · Des règles pour une partie de la page · Le laisser là où il a été déposé · Sans overlay · Composants d'overlay · Accessibilité