文档/概览

周日历

一个周计划工具——在各天和各个刻钟之间移动日程,拖动边缘拉伸,在空白时间上画出新日程,改为全天日程,从任务列表安排日程,并且可以撤销。

编辑此页

本页由 AI 翻译,可能存在不准确之处。 阅读英文原文

Live

正在加载演示…

<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 是被拖拽的盒子在目标内部的位置:盒子的顶部就是时间。

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

浮层从 useDnD().position 实时读取它,并在落位期间保留最后一次的答案:

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

日程无论在哪里都保留它的 id、data 和浮层:时间格中的一个块、全天格中或任务中的一个小标签。放到别处时,它由另一个组件渲染,浮层照样落到新元素上。

时间格自己的修饰器

makeDragArea 给从时间格开始的拖拽(日程、边缘、空白时间)一个自己的修饰器。在各天上方,盒子按整天移动,指针越过几天就移动几天,所以与另一个日程并排的日程会保持它在当天中的位置;它的顶部吸附到它开始时那一天的刻钟上。containerRect 就是那一天当前所在的位置:它跟随滚动,所以时间格在拖拽下自动滚动时,吸附依然有效。

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

区域的 autoScroll 让滚动时间格的边缘变窄,对时间格上方的每次拖拽都有效:正上方的全天行仍然容易够到。任务和全天日程从区域之外开始,自由移动;它们下方的那一天会在它们将落位的地方显示虚影。

拉伸:边缘是一个可拖拽元素

边缘位于日程的元素内部,按下时拖动的是最近的可拖拽元素:边缘。它没有浮层,只能上下移动,并且停留在可见的时间格之内。

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

边缘本身不移动。取而代之的是日程从边缘在当天中的位置开始变长,这个位置从 position 读取,与放置时的读取方式相同:

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

在空白时间上画

在日程下方,每一天都有一条通道,这条通道是一个没有浮层的可拖拽元素。它从不移动:它的盒子移动了多远(position),就是指针从按下处移动了多远,并由区域吸附到刻钟。这一天会在画的过程中显示新日程,它的放置会创建这个日程。

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

这一天的 accept 是一个函数:它接收任何日程,但只接收自己的边缘和通道。全天格和任务列表接收字符串 'event',所以 edge 和 new 类型在那里会被拒绝,不需要写一行代码。放置由一个处理函数完成:

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

键盘

用 Tab 移到一个日程上,按 Space 拿起它:这一周的监听器通过 getKeyboardCoordinates 响应方向键,给出下一个刻钟(按住 Shift 时为一小时)或下一天。键盘拖拽不会自动滚动,所以靠近可见时间格的两端时,方向键会改为滚动时间格。任务和全天日程在目标之间跳转(keyboardMode: 'droppables'),直到位于时间格上方。

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

index.vue 中 provider 的 announcements 说明拿起了什么以及如何移动它;方向键改变时间时,这一周会说出时间(“Tuesday, 10:30 to 11:15”),提示消息则说明放置改变了什么。在获得焦点的日程上,Delete 删除它,Ctrl+Z 撤销任何改动。

延伸阅读:自定义修饰器 · 页面某一部分的规则 · 放在哪就留在哪 · 不使用浮层 · 浮层组件 · 无障碍