문서/개요

주간 캘린더

주간 일정표. 일정을 요일과 15분 단위로 옮기고, 가장자리로 늘리고, 빈 시간에 그리고, 종일 일정으로 만들고, 작업 목록에서 계획하며, 실행 취소할 수 있습니다.

이 페이지 편집

이 페이지는 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: 시간대에 있는 일정은 일반 draggable이며, 그 오버레이인 **EventOverlay.vue**가 안착할 곳을 알려 줍니다. "Wed 10:30 – 11:15". 겹치는 일정은 나란히 놓입니다(placement.ts의 layoutDay).
  • Edge.vue: 일정의 위쪽과 아래쪽 가장자리는 각각 별도의 draggable입니다. 하나를 드래그하면 일정이 15분씩 늘어납니다.
  • DayColumn.vue: 각 요일의 시간대는 드롭 대상이고, 일정 아래에도 draggable이 있습니다. 빈 시간을 누르고 드래그하면 새 일정이 그려지고, 이어서 제목을 입력합니다.
  • AllDayCell.vue와 Tasks.vue: 일정을 요일의 종일 칸에 드롭하면 시간이 없어지고, 작업 목록에 드롭하면 요일이 없어집니다. 작업(EventChip.vue)은 주간으로 드래그할 수 있으며 길이를 유지합니다.
  • Week.vue: 시간대의 드래그 영역과 그 자체 모디파이어, 키보드 이동, 현재 시간 선.
  • calendar.ts: 일정과 실행 취소 기록. 모든 변경은 함수 하나를 거치고, 토스트가 되돌릴지 묻습니다.

예제를 펼쳐 보세요. 공간이 충분하면 사이드 패널에 월 달력, 보이거나 숨길 캘린더, 목록 형태의 작업이 나타납니다. 휴대폰에서는 주간이 3일이 되고, 길게 눌러 드래그합니다.

모든 드롭에 하나의 답

드롭이 무엇을 할지는 네 곳에서 묻습니다. 오버레이의 레이블, 작업이 그 아래 요일에 보여 주는 고스트, 스크린 리더가 듣는 내용, 그리고 드롭 자체입니다. 모두 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는 시간대에서 시작하는 드래그(일정, 가장자리, 빈 시간)에 자체 모디파이어를 줍니다. 요일 위에서 박스는 포인터가 지나간 만큼 하루 단위로 움직이므로, 다른 일정 옆의 일정도 그날 안에서 자기 위치를 유지합니다. 박스의 위쪽은 드래그가 시작된 요일의 15분 단위에 스냅됩니다. 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은 시간대 위를 지나는 모든 드래그에 대해, 시간대를 스크롤하는 가장자리 구역을 좁힙니다. 바로 위의 종일 행에 쉽게 닿도록 하기 위해서입니다. 작업과 종일 일정은 영역 밖에서 시작하므로 자유롭게 움직이고, 그 아래 요일에는 안착할 곳에 고스트가 보입니다.

늘리기: 가장자리는 draggable입니다

가장자리는 일정 요소 안에 있고, 누르면 가장 가까운 draggable, 즉 가장자리가 드래그됩니다. 가장자리에는 오버레이가 없고, 위아래로만 움직이며, 보이는 시간대 안에 머뭅니다.

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

빈 시간에 그리기

일정 아래에는 요일마다 레인이 있고, 레인은 오버레이가 없는 draggable입니다. 레인은 절대 움직이지 않습니다. 그 박스가 간 거리(position)가 곧 누른 곳에서 포인터가 간 거리이며, 영역이 이를 15분 단위로 스냅합니다. 요일은 그려지는 새 일정을 보여 주고, 드롭하면 일정이 만들어집니다.

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로 답하며, 다음 15분(Shift를 누르면 1시간)이나 다음 요일을 돌려줍니다. 키보드 드래그는 자동 스크롤하지 않으므로, 보이는 시간대의 끝 근처에서는 화살표 키가 대신 시간대를 스크롤합니다. 작업과 종일 일정은 시간대 위에 올 때까지 대상에서 대상으로 건너뜁니다(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에 있는 프로바이더의 announcements는 무엇을 집었는지와 어떻게 옮기는지 알려 줍니다. 주간은 화살표 키로 바뀐 시간을 읽어 주고("Tuesday, 10:30 to 11:15"), 토스트는 드롭이 무엇을 바꿨는지 알려 줍니다. 포커스된 일정에서 Delete를 누르면 삭제되고, Ctrl+Z는 어떤 변경이든 되돌립니다.

더 읽어 보기: 직접 만든 모디파이어 · 페이지 일부를 위한 규칙 · 놓은 자리에 그대로 두기 · 오버레이 없이 · 오버레이 컴포넌트 · 접근성