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
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 (layoutDayinplacement.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.vueundTasks.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.
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:
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.
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.
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:
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.
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:
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.
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