正在加载演示…
<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>看点
EventBlock.vue:时间格中的日程是一个普通可拖拽元素,它的浮层EventOverlay.vue会说明它将落在哪里:“Wed 10:30 – 11:15”。重叠的日程并排显示(placement.ts中的layoutDay)。Edge.vue:日程的上下边缘本身就是可拖拽元素。拖动其中一个,日程就会拉伸,每次一刻钟。DayColumn.vue:每一天的时间格是一个放置目标,日程下方还有一个可拖拽元素:在空白时间上按下并拖动即可画出一个新日程,然后输入它的标题。AllDayCell.vue和Tasks.vue:把日程放到某一天的全天格中,它就失去具体时间;放到任务列表中,它就失去日期。任务(EventChip.vue)可以拖进这一周,并保留它们的时长。Week.vue:时间格的拖拽区域和它们自己的修饰器、键盘移动、当前时间线。calendar.ts:日程和撤销历史。每次改动都经过同一个函数,提示消息会提供撤销。
展开这个示例:空间足够时,侧边面板会显示月历、可显示或隐藏的日历,以及列表形式的任务。在手机上,一周变成三天,长按即可拖拽。
每次放置都是同一个答案
放置会做什么,在四个地方被询问:浮层的标签、任务在它下方那一天显示的虚影、屏幕阅读器听到的内容,以及放置本身。它们都调用 placeOf,所以结果永远一致。每个放置目标在它的 data 中说明自己是什么,position 是被拖拽的盒子在目标内部的位置:盒子的顶部就是时间。
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) };
}
浮层从 useDnD().position 实时读取它,并在落位期间保留最后一次的答案:
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);
});
日程无论在哪里都保留它的 id、data 和浮层:时间格中的一个块、全天格中或任务中的一个小标签。放到别处时,它由另一个组件渲染,浮层照样落到新元素上。
时间格自己的修饰器
makeDragArea 给从时间格开始的拖拽(日程、边缘、空白时间)一个自己的修饰器。在各天上方,盒子按整天移动,指针越过几天就移动几天,所以与另一个日程并排的日程会保持它在当天中的位置;它的顶部吸附到它开始时那一天的刻钟上。containerRect 就是那一天当前所在的位置:它跟随滚动,所以时间格在拖拽下自动滚动时,吸附依然有效。
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 } });
区域的 autoScroll 让滚动时间格的边缘变窄,对时间格上方的每次拖拽都有效:正上方的全天行仍然容易够到。任务和全天日程从区域之外开始,自由移动;它们下方的那一天会在它们将落位的地方显示虚影。
拉伸:边缘是一个可拖拽元素
边缘位于日程的元素内部,按下时拖动的是最近的可拖拽元素:边缘。它没有浮层,只能上下移动,并且停留在可见的时间格之内。
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
});
边缘本身不移动。取而代之的是日程从边缘在当天中的位置开始变长,这个位置从 position 读取,与放置时的读取方式相同:
const time = computed(() =>
resizing.value && position.value
? resizeTo(props.event, resizing.value, position.value)
: { start: props.event.start!, duration: props.event.duration },
);
在空白时间上画
在日程下方,每一天都有一条通道,这条通道是一个没有浮层的可拖拽元素。它从不移动:它的盒子移动了多远(position),就是指针从按下处移动了多远,并由区域吸附到刻钟。这一天会在画的过程中显示新日程,它的放置会创建这个日程。
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
});
这一天的 accept 是一个函数:它接收任何日程,但只接收自己的边缘和通道。全天格和任务列表接收字符串 'event',所以 edge 和 new 类型在那里会被拒绝,不需要写一行代码。放置由一个处理函数完成:
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);
}
},
键盘
用 Tab 移到一个日程上,按 Space 拿起它:这一周的监听器通过 getKeyboardCoordinates 响应方向键,给出下一个刻钟(按住 Shift 时为一小时)或下一天。键盘拖拽不会自动滚动,所以靠近可见时间格的两端时,方向键会改为滚动时间格。任务和全天日程在目标之间跳转(keyboardMode: 'droppables'),直到位于时间格上方。
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;
},
});
index.vue 中 provider 的 announcements 说明拿起了什么以及如何移动它;方向键改变时间时,这一周会说出时间(“Tuesday, 10:30 to 11:15”),提示消息则说明放置改变了什么。在获得焦点的日程上,Delete 删除它,Ctrl+Z 撤销任何改动。