데모 불러오는 중…
<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은 드래그 중인 박스가 대상 안의
어디에 있는지 나타냅니다. 박스의 위쪽이 시간입니다.
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는 시간대에서 시작하는 드래그(일정, 가장자리, 빈 시간)에 자체 모디파이어를 줍니다.
요일 위에서 박스는 포인터가 지나간 만큼 하루 단위로 움직이므로, 다른 일정 옆의 일정도 그날 안에서
자기 위치를 유지합니다. 박스의 위쪽은 드래그가 시작된 요일의 15분 단위에 스냅됩니다.
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은 시간대 위를 지나는 모든 드래그에 대해, 시간대를 스크롤하는 가장자리 구역을
좁힙니다. 바로 위의 종일 행에 쉽게 닿도록 하기 위해서입니다. 작업과 종일 일정은 영역 밖에서 시작하므로
자유롭게 움직이고, 그 아래 요일에는 안착할 곳에 고스트가 보입니다.
늘리기: 가장자리는 draggable입니다
가장자리는 일정 요소 안에 있고, 누르면 가장 가까운 draggable, 즉 가장자리가 드래그됩니다. 가장자리에는 오버레이가 없고, 위아래로만 움직이며, 보이는 시간대 안에 머뭅니다.
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 },
);
빈 시간에 그리기
일정 아래에는 요일마다 레인이 있고, 레인은 오버레이가 없는 draggable입니다. 레인은 절대 움직이지
않습니다. 그 박스가 간 거리(position)가 곧 누른 곳에서 포인터가 간 거리이며, 영역이 이를 15분
단위로 스냅합니다. 요일은 그려지는 새 일정을 보여 주고, 드롭하면 일정이 만들어집니다.
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로 답하며,
다음 15분(Shift를 누르면 1시간)이나 다음 요일을 돌려줍니다. 키보드 드래그는 자동 스크롤하지 않으므로,
보이는 시간대의 끝 근처에서는 화살표 키가 대신 시간대를 스크롤합니다. 작업과 종일 일정은 시간대 위에
올 때까지 대상에서 대상으로 건너뜁니다(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에 있는 프로바이더의 announcements는 무엇을 집었는지와 어떻게 옮기는지 알려 줍니다.
주간은 화살표 키로 바뀐 시간을 읽어 주고("Tuesday, 10:30 to 11:15"), 토스트는 드롭이 무엇을
바꿨는지 알려 줍니다. 포커스된 일정에서 Delete를 누르면 삭제되고, Ctrl+Z는 어떤 변경이든 되돌립니다.
더 읽어 보기: 직접 만든 모디파이어 · 페이지 일부를 위한 규칙 · 놓은 자리에 그대로 두기 · 오버레이 없이 · 오버레이 컴포넌트 · 접근성