Documentação/Visão geral

Calendário semanal

Um planejador semanal — eventos movidos entre dias e quartos de hora, esticados pelas bordas, desenhados no tempo vazio, transformados em eventos de dia inteiro, planejados a partir de uma lista de tarefas, com desfazer.

Editar esta página

Esta página foi traduzida por IA e pode conter imprecisões. Ler o original em inglês

Live

Carregando a demo…

<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>

O que observar

  • EventBlock.vue: um evento nas horas é um draggable simples cujo overlay, EventOverlay.vue, diz onde ele pousaria: "Wed 10:30 – 11:15". Eventos sobrepostos ficam lado a lado (layoutDay em placement.ts).
  • Edge.vue: as bordas de cima e de baixo de um evento são draggables próprios. Arraste uma e o evento se estica, um quarto de hora por vez.
  • DayColumn.vue: as horas de cada dia são um alvo de drop, e embaixo dos eventos também há um draggable: pressione o tempo vazio e arraste para desenhar um novo evento, depois digite o título.
  • AllDayCell.vue e Tasks.vue: solte um evento na célula de dia inteiro de um dia e ele perde o horário; solte-o na lista de tarefas e ele perde o dia. As tarefas (EventChip.vue) são arrastadas para a semana e mantêm a duração.
  • Week.vue: a área de arraste das horas e o modificador delas, os movimentos por teclado, a linha da hora atual.
  • calendar.ts: os eventos e um histórico de desfazer. Toda mudança passa por uma única função, e o toast oferece desfazê-la.

Expanda o exemplo: com espaço para isso, um painel lateral mostra o mês, os calendários a mostrar ou esconder, e as tarefas como uma lista. No celular, a semana vira três dias, e um toque longo arrasta.

Uma resposta para todo drop

O que um drop faria é perguntado em quatro lugares: o rótulo do overlay, o fantasma que uma tarefa mostra no dia sob ela, o que os leitores de tela ouvem e o próprio drop. Todos chamam placeOf, então nunca discordam. Cada alvo de drop carrega o que ele é no seu data, e position é onde a caixa arrastada está dentro dele: o topo dela é o horário.

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

O overlay o lê ao vivo, de useDnD().position, e mantém a última resposta enquanto pousa:

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

Um evento mantém o seu id, o seu data e o seu overlay onde quer que esteja: um bloco nas horas, um chip de dia inteiro ou entre as tarefas. Solto em outro lugar, ele é renderizado por outro componente, e o overlay pousa no novo elemento do mesmo jeito.

O modificador das horas

makeDragArea dá aos arrastes que começam nas horas (um evento, uma borda, o tempo vazio) um modificador próprio. Sobre os dias, a caixa se move por dias inteiros, tantos quanto o ponteiro cruzou, então um evento ao lado de outro mantém o seu lugar no dia; o topo dele encaixa nos quartos de hora do dia em que começou. containerRect é esse dia onde ele está agora: ele acompanha a rolagem, e o encaixe se mantém enquanto as horas rolam automaticamente sob um arraste.

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

O autoScroll da área estreita a borda que rola as horas, para todo arraste sobre elas: a linha de dia inteiro logo acima continua fácil de alcançar. Tarefas e eventos de dia inteiro começam fora da área e se movem livremente; o dia sob eles mostra um fantasma onde pousariam.

Esticar: uma borda é um draggable

Uma borda fica dentro do elemento do seu evento, e uma pressão arrasta o draggable mais próximo: a borda. Ela não tem overlay, só se move para cima e para baixo, e fica dentro das horas visíveis.

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

A borda em si não se move. Em vez disso, o evento cresce a partir de onde a borda está no dia, lido de position como o drop vai lê-lo:

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

Desenhar no tempo vazio

Embaixo dos eventos, cada dia tem uma faixa, e a faixa é um draggable sem overlay. Ela nunca se move: quanto a caixa dela andou (position) é quanto o ponteiro andou desde a pressão, encaixado em quartos de hora pela área. O dia mostra o novo evento enquanto ele é desenhado, e o drop dela o cria.

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

O accept do dia é uma função: ele aceita qualquer evento, e só as próprias bordas e faixas. As células de dia inteiro e a lista de tarefas aceitam 'event', uma string, então os tipos edge e new são recusados ali sem uma linha de código. O drop é um único handler:

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

O teclado

Chegue a um evento com Tab, pegue-o com Space: o monitor da semana responde às setas, getKeyboardCoordinates, com o próximo quarto de hora (com Shift, uma hora) ou o próximo dia. Arrastes por teclado não rolam automaticamente, então perto de uma ponta das horas visíveis a seta as rola em vez disso. Tarefas e eventos de dia inteiro pulam de alvo em alvo (keyboardMode: 'droppables') até estarem sobre as horas.

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

Os announcements do provider em index.vue dizem o que foi pego e como movê-lo; a semana fala o horário conforme as setas o mudam ("Tuesday, 10:30 to 11:15"), e o toast, o que um drop mudou. Em um evento focado, Delete o remove, e Ctrl+Z desfaz qualquer mudança.

Leia mais: Um modificador seu · Regras para uma parte da página · Deixar onde foi solto · Sem overlay · Componentes de overlay · Acessibilidade