Dokumentation/Übersicht

Wochenkalender

Ein Wochenplaner – Termine, die über Tage und Viertelstunden verschoben, an ihren Rändern gedehnt, auf freie Zeit gezeichnet, ganztägig gemacht und aus einer Aufgabenliste eingeplant werden, mit Undo.

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

Worauf du achten solltest

  • EventBlock.vue: Ein Termin in den Stunden ist ein einfaches ziehbares Element, dessen Overlay, EventOverlay.vue, sagt, wo er landen würde: „Wed 10:30 – 11:15“. Überlappende Termine stehen nebeneinander (layoutDay in placement.ts).
  • Edge.vue: Der obere und der untere Rand eines Termins sind eigene ziehbare Elemente. Ziehe an einem, und der Termin dehnt sich, eine Viertelstunde nach der anderen.
  • DayColumn.vue: Die Stunden jedes Tages sind ein Drop-Ziel, und unter seinen Terminen liegt ebenfalls ein ziehbares Element: Drücke auf freie Zeit und ziehe, um einen neuen Termin zu zeichnen, und tippe dann seinen Titel.
  • AllDayCell.vue und Tasks.vue: Lege einen Termin in der Ganztags-Zelle eines Tages ab, und er verliert seine Uhrzeit; lege ihn auf der Aufgabenliste ab, und er verliert seinen Tag. Aufgaben (EventChip.vue) lassen sich in die Woche ziehen und behalten ihre Länge.
  • Week.vue: der Drag-Bereich der Stunden und ihr eigener Modifier, die Bewegungen per Tastatur, die Linie der aktuellen Uhrzeit.
  • calendar.ts: die Termine und ein Undo-Verlauf. Jede Änderung läuft durch eine einzige Funktion, und die Meldung bietet an, sie zurückzunehmen.

Klappe das Beispiel auf: Mit genug Platz zeigt ein Seitenpanel den Monat, die Kalender zum Ein- und Ausblenden und die Aufgaben als Liste. Auf einem Handy wird die Woche zu drei Tagen, und ein langes Drücken zieht.

Eine Antwort für jeden Drop

Was ein Drop tun würde, wird an vier Stellen gefragt: die Beschriftung des Overlays, der Geist, den eine Aufgabe im Tag darunter zeigt, was Screenreader hören, und der Drop selbst. Sie rufen alle placeOf auf, widersprechen sich also nie. Jedes Drop-Ziel trägt in seinem data, was es ist, und position ist, wo die gezogene Box darin ist: Ihre Oberkante ist die Uhrzeit.

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

Das Overlay liest sie live, aus useDnD().position, und behält seine letzte Antwort, während es landet:

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

Ein Termin behält seine ID, sein data und sein Overlay, wo immer er ist: ein Block in den Stunden, ein Chip ganztägig oder unter den Aufgaben. Woanders abgelegt, wird er von einer anderen Komponente gerendert, und das Overlay landet trotzdem auf dem neuen Element.

Der eigene Modifier der Stunden

makeDragArea gibt den Drags, die in den Stunden starten (ein Termin, ein Rand, freie Zeit), einen eigenen Modifier. Über den Tagen bewegt sich die Box um ganze Tage, so viele, wie der Zeiger überquert hat, sodass ein Termin neben einem anderen seinen Platz im Tag behält; seine Oberkante rastet auf den Viertelstunden des Tages ein, in dem er gestartet ist. containerRect ist dieser Tag dort, wo er jetzt ist: Es folgt dem Scrollen, und das Einrasten hält, während die Stunden unter einem Drag automatisch scrollen.

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

Das autoScroll des Bereichs verschmälert den Rand, der die Stunden scrollt, für jeden Drag über ihnen: Die Ganztags-Zeile direkt darüber bleibt leicht erreichbar. Aufgaben und ganztägige Termine starten außerhalb des Bereichs und bewegen sich frei; der Tag darunter zeigt einen Geist dort, wo sie landen würden.

Dehnen: Ein Rand ist ein ziehbares Element

Ein Rand liegt im Element seines Termins, und ein Drücken zieht das nächstgelegene ziehbare Element: den Rand. Er hat kein Overlay, bewegt sich nur hoch und runter und bleibt in den sichtbaren Stunden.

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

Der Rand selbst bewegt sich nicht. Stattdessen wächst der Termin, von der Stelle im Tag aus, an der der Rand ist, gelesen aus position, so wie der Drop sie lesen wird:

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

Auf freie Zeit zeichnen

Unter den Terminen hat jeder Tag eine Bahn, und die Bahn ist ein ziehbares Element ohne Overlay. Sie bewegt sich nie: Wie weit ihre Box gegangen ist (position), so weit ist der Zeiger vom Drücken aus gegangen, vom Bereich auf Viertelstunden eingerastet. Der Tag zeigt den neuen Termin, während er gezeichnet wird, und sein Drop erzeugt ihn.

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

Das accept des Tages ist eine Funktion: Es nimmt jeden Termin an und nur seine eigenen Ränder und Bahnen. Die Ganztags-Zellen und die Aufgabenliste nehmen 'event' an, einen String, also werden die Typen edge und new dort ohne eine Zeile Code abgelehnt. Der Drop ist ein einziger Handler:

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

Die Tastatur

Springe mit Tab zu einem Termin und nimm ihn mit Space auf: Der Monitor der Woche beantwortet die Pfeile, getKeyboardCoordinates, mit der nächsten Viertelstunde (mit Shift einer Stunde) oder dem nächsten Tag. Drags per Tastatur scrollen nicht automatisch, also scrollt der Pfeil nahe an einem Ende der sichtbaren Stunden stattdessen diese. Aufgaben und ganztägige Termine springen von Ziel zu Ziel (keyboardMode: 'droppables'), bis sie über den Stunden sind.

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

Die announcements des Providers in index.vue sagen, was aufgenommen wurde und wie man es bewegt; die Woche spricht die Uhrzeit, während die Pfeile sie ändern („Tuesday, 10:30 to 11:15“), und die Meldung, was ein Drop geändert hat. Auf einem fokussierten Termin entfernt Delete ihn, und Strg+Z macht jede Änderung rückgängig.

Mehr dazu: Ein eigener Modifier · Regeln für einen Teil der Seite · Dort lassen, wo es abgelegt wurde · Ohne Overlay · Overlay-Komponenten · Barrierefreiheit