shadimage
Shuip
blockcalendarsource: ok · hit

Calendar

npx shadcn add "https://shuip.plvo.dev/r/calendar.json"
free

Where it goes

calendar

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

buttonselect

Files1 · source cached 10/8/2026, 2:12:21 PM

./items/blocks/calendar/component.tsx
'use client';

import {
  DndContext,
  type DragEndEvent,
  PointerSensor,
  useDraggable,
  useDroppable,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import type { Locale } from 'date-fns';
import {
  addDays,
  addMinutes,
  addMonths,
  eachDayOfInterval,
  endOfMonth,
  endOfWeek,
  format,
  isSameDay,
  isSameMonth,
  setHours,
  setMinutes,
  startOfDay,
  startOfMonth,
  startOfWeek,
} from 'date-fns';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';

export type CalendarView = 'month' | 'week' | 'day' | 'agenda';
export type CalendarEventColor = 'primary' | 'secondary' | 'accent' | 'destructive' | 'muted';
export type CalendarSlot = { start: Date; end: Date; allDay: boolean };

export type CalendarMessages = {
  today: string;
  previous: string;
  next: string;
  allDay: string;
  noEvents: string;
  more: (count: number) => string;
  views: Record<CalendarView, string>;
};

export type CalendarMessagesInput = Partial<Omit<CalendarMessages, 'views'>> & {
  views?: Partial<Record<CalendarView, string>>;
};

const defaultMessages: CalendarMessages = {
  today: 'Today',
  previous: 'Previous',
  next: 'Next',
  allDay: 'all-day',
  noEvents: 'No upcoming events',
  more: (count) => `+${count} more`,
  views: { month: 'Month', week: 'Week', day: 'Day', agenda: 'Agenda' },
};

type CalendarEventLike = Record<string, unknown>;

export type CalendarRootProps<T extends CalendarEventLike> = {
  children: React.ReactNode;

  events?: T[];
  defaultEvents?: T[];
  onEventsChange?: (next: T[]) => void;

  idField?: keyof T;
  titleField: keyof T;
  startField: keyof T;
  endField: keyof T;
  allDayField?: keyof T;
  color?: (item: T) => CalendarEventColor | undefined;
  renderEvent?: (item: T) => React.ReactNode;

  view?: CalendarView;
  defaultView?: CalendarView;
  onViewChange?: (v: CalendarView) => void;
  date?: Date;
  defaultDate?: Date;
  onDateChange?: (d: Date) => void;

  views?: CalendarView[];
  weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
  editable?: boolean;
  locale?: Locale;
  messages?: CalendarMessagesInput;

  onEventClick?: (item: T) => void;
  onSlotSelect?: (range: CalendarSlot) => void;
};

type CalendarContextValue<T extends CalendarEventLike = CalendarEventLike> = {
  view: CalendarView;
  setView: (v: CalendarView) => void;
  views: CalendarView[];
  date: Date;
  setDate: (d: Date) => void;
  effectiveView: CalendarView;
  isMobile: boolean;
  weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6;
  editable: boolean;
  locale?: Locale;
  messages: CalendarMessages;
  periodLabel: string;
  goToToday: () => void;
  goToPrevious: () => void;
  goToNext: () => void;

  events: T[];
  getId: (item: T) => string;
  getStart: (item: T) => Date;
  getEnd: (item: T) => Date;
  getTitle: (item: T) => string;
  isAllDay: (item: T) => boolean;
  getColor: (item: T) => CalendarEventColor | undefined;
  renderEvent?: (item: T) => React.ReactNode;
  onEventClick?: (item: T) => void;
  onSlotSelect?: (range: CalendarSlot) => void;
  resizeEvent: (id: string, end: Date) => void;
  draggedRef: React.MutableRefObject<boolean>;
};

const CalendarContext = React.createContext<CalendarContextValue | null>(null);

export function useCalendar<T extends CalendarEventLike = CalendarEventLike>(): CalendarContextValue<T> {
  const ctx = React.use(CalendarContext);
  if (!ctx) throw new Error('useCalendar must be used within <Calendar.Root>');
  return ctx as CalendarContextValue<T>;
}

function useControllableState<V>(
  controlled: V | undefined,
  defaultValue: V,
  onChange?: (v: V) => void,
): [V, (v: V) => void] {
  const [uncontrolled, setUncontrolled] = React.useState<V>(defaultValue);
  const isControlled = controlled !== undefined;
  const value = isControlled ? controlled : uncontrolled;
  const setValue = React.useCallback(
    (next: V) => {
      if (!isControlled) setUncontrolled(next);
      onChange?.(next);
    },
    [isControlled, onChange],
  );
  return [value, setValue];
}

function useIsMobile(breakpoint = 768): boolean {
  const subscribe = React.useCallback(
    (cb: () => void) => {
      const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
      mql.addEventListener('change', cb);
      return () => mql.removeEventListener('change', cb);
    },
    [breakpoint],
  );
  const getSnapshot = React.useCallback(
    () => window.matchMedia(`(max-width: ${breakpoint - 1}px)`).matches,
    [breakpoint],
  );
  return React.useSyncExternalStore(subscribe, getSnapshot, () => false);
}

function CalendarRoot<T extends CalendarEventLike>({
  children,
  events,
  defaultEvents = [] as T[],
  onEventsChange,
  idField = 'id' as keyof T,
  titleField,
  startField,
  endField,
  allDayField,
  color,
  renderEvent,
  view: viewProp,
  defaultView = 'month',
  onViewChange,
  date: dateProp,
  defaultDate,
  onDateChange,
  views = ['month', 'week', 'day', 'agenda'],
  weekStartsOn = 1,
  editable = false,
  locale,
  messages: messagesProp,
  onEventClick,
  onSlotSelect,
}: CalendarRootProps<T>) {
  const messages = React.useMemo<CalendarMessages>(
    () => ({
      ...defaultMessages,
      ...messagesProp,
      views: { ...defaultMessages.views, ...messagesProp?.views },
    }),
    [messagesProp],
  );
  const [view, setView] = useControllableState<CalendarView>(viewProp, defaultView, onViewChange);
  const [date, setDate] = useControllableState<Date>(dateProp, defaultDate ?? startOfDay(new Date()), onDateChange);
  const [items, setItems] = useControllableState<T[]>(events, defaultEvents, onEventsChange);

  const isMobile = useIsMobile();
  const effectiveView = isMobile && view === 'week' ? 'day' : view;
  const draggedRef = React.useRef(false);

  const getStart = React.useCallback((it: T) => it[startField] as unknown as Date, [startField]);
  const getEnd = React.useCallback((it: T) => it[endField] as unknown as Date, [endField]);
  const isAllDay = React.useCallback((it: T) => (allDayField ? Boolean(it[allDayField]) : false), [allDayField]);
  const getId = React.useCallback((it: T) => String(it[idField]), [idField]);
  const getTitle = React.useCallback((it: T) => String(it[titleField] ?? ''), [titleField]);
  const getColor = React.useCallback((it: T) => color?.(it), [color]);

  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));

  const handleDragEnd = React.useCallback(
    (e: DragEndEvent) => {
      const { active, over, delta } = e;
      draggedRef.current = Math.abs(delta.x) > 4 || Math.abs(delta.y) > 4;
      const id = String(active.id);
      const item = items.find((it) => getId(it) === id);
      if (!item) return;
      const oldStart = getStart(item);
      const oldEnd = getEnd(item);
      const duration = oldEnd.getTime() - oldStart.getTime();
      const overDay = (over?.data.current?.day as Date | undefined) ?? oldStart;

      let newStart: Date;
      if (effectiveView === 'month') {
        newStart = setMinutes(setHours(startOfDay(overDay), oldStart.getHours()), oldStart.getMinutes());
      } else {
        const movedMinutes = minutesSinceDayStart(oldStart) + (delta.y / HOUR_HEIGHT) * 60;
        newStart = snapToMinutes(addMinutes(startOfDay(overDay), movedMinutes));
      }
      if (newStart.getTime() === oldStart.getTime()) return;
      const next = items.map((it) =>
        getId(it) === id
          ? ({ ...it, [startField]: newStart, [endField]: new Date(newStart.getTime() + duration) } as T)
          : it,
      );
      setItems(next);
    },
    [items, getId, getStart, getEnd, effectiveView, startField, endField, setItems],
  );

  const resizeEvent = React.useCallback(
    (id: string, newEnd: Date) => {
      setItems(items.map((it) => (getId(it) === id ? ({ ...it, [endField]: newEnd } as T) : it)));
    },
    [items, getId, endField, setItems],
  );

  const periodLabel = React.useMemo(() => {
    switch (effectiveView) {
      case 'month':
        return format(date, 'MMMM yyyy', { locale });
      case 'week': {
        const weekStart = startOfWeek(date, { weekStartsOn });
        const weekEnd = endOfWeek(date, { weekStartsOn });
        return `${format(weekStart, 'MMM d', { locale })} – ${format(weekEnd, 'MMM d, yyyy', { locale })}`;
      }
      case 'day':
        return format(date, 'PPP', { locale });
      case 'agenda':
        return format(date, 'MMMM yyyy', { locale });
    }
  }, [effectiveView, date, weekStartsOn, locale]);

  const shift = React.useCallback(
    (dir: 1 | -1) => {
      switch (effectiveView) {
        case 'month':
          setDate(addMonths(date, dir));
          break;
        case 'week':
          setDate(addDays(date, 7 * dir));
          break;
        default:
          setDate(addDays(date, dir));
      }
    },
    [effectiveView, date, setDate],
  );

  const goToToday = React.useCallback(() => setDate(startOfDay(new Date())), [setDate]);
  const goToPrevious = React.useCallback(() => shift(-1), [shift]);
  const goToNext = React.useCallback(() => shift(1), [shift]);

  const value = React.useMemo<CalendarContextValue<T>>(
    () => ({
      view,
      setView,
      views,
      date,
      setDate,
      effectiveView,
      isMobile,
      weekStartsOn,
      editable,
      locale,
      messages,
      periodLabel,
      goToToday,
      goToPrevious,
      goToNext,
      events: items,
      getId,
      getStart,
      getEnd,
      getTitle,
      isAllDay,
      getColor,
      renderEvent,
      onEventClick,
      onSlotSelect,
      resizeEvent,
      draggedRef,
    }),
    [
      view,
      setView,
      views,
      date,
      setDate,
      effectiveView,
      isMobile,
      weekStartsOn,
      editable,
      locale,
      messages,
      periodLabel,
      goToToday,
      goToPrevious,
      goToNext,
      items,
      getId,
      getStart,
      getEnd,
      getTitle,
      isAllDay,
      getColor,
      renderEvent,
      onEventClick,
      onSlotSelect,
      resizeEvent,
    ],
  );

  return (
    <CalendarContext.Provider value={value as unknown as CalendarContextValue}>
      {editable ? (
        <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
          {children}
        </DndContext>
      ) : (
        children
      )}
    </CalendarContext.Provider>
  );
}

function CalendarNav({ className }: { className?: string }) {
  const { periodLabel, views, view, setView, goToToday, goToPrevious, goToNext, messages } = useCalendar();
  return (
    <div className={cn('flex w-full flex-wrap items-center justify-between gap-2', className)}>
      <div className='flex items-center gap-1'>
        <Button variant='outline' size='sm' onClick={goToToday}>
          {messages.today}
        </Button>
        <Button variant='outline' size='icon' className='size-8' onClick={goToPrevious} aria-label={messages.previous}>
          <ChevronLeft className='size-4' />
        </Button>
        <Button variant='outline' size='icon' className='size-8' onClick={goToNext} aria-label={messages.next}>
          <ChevronRight className='size-4' />
        </Button>
        <span className='ml-2 text-sm font-medium'>{periodLabel}</span>
      </div>
      {views.length > 1 ? (
        <Select value={view} onValueChange={(v) => setView(v as CalendarView)}>
          <SelectTrigger className='w-32' size='sm'>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {views.map((v) => (
              <SelectItem key={v} value={v}>
                {messages.views[v]}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      ) : null}
    </div>
  );
}

function CalendarViewComponent({ className }: { className?: string }) {
  const {
    effectiveView,
    date,
    weekStartsOn,
    events: items,
    getStart,
    getEnd,
    getId,
    getTitle,
    isAllDay,
    getColor,
    renderEvent,
    editable,
    draggedRef,
    resizeEvent,
    onEventClick,
    onSlotSelect,
    locale,
    messages,
  } = useCalendar();

  const weekDays = React.useMemo(
    () => eachDayOfInterval({ start: startOfWeek(date, { weekStartsOn }), end: endOfWeek(date, { weekStartsOn }) }),
    [date, weekStartsOn],
  );

  const agendaDays = React.useMemo(
    () => eachDayOfInterval({ start: startOfDay(date), end: addDays(startOfDay(date), 29) }),
    [date],
  );

  const body = (() => {
    switch (effectiveView) {
      case 'month':
        return (
          <MonthView
            date={date}
            weekStartsOn={weekStartsOn}
            items={items}
            getStart={getStart}
            getId={getId}
            getTitle={getTitle}
            color={getColor}
            renderEvent={renderEvent}
            editable={editable}
            draggedRef={draggedRef}
            onEventClick={onEventClick}
            onSlotSelect={onSlotSelect}
            locale={locale}
            more={messages.more}
          />
        );
      case 'week':
      case 'day':
        return (
          <TimeGridView
            days={effectiveView === 'week' ? weekDays : [date]}
            items={items}
            getStart={getStart}
            getEnd={getEnd}
            getId={getId}
            getTitle={getTitle}
            isAllDay={isAllDay}
            color={getColor}
            renderEvent={renderEvent}
            editable={editable}
            draggedRef={draggedRef}
            onResize={editable ? resizeEvent : undefined}
            onEventClick={onEventClick}
            onSlotSelect={onSlotSelect}
            locale={locale}
            allDayLabel={messages.allDay}
          />
        );
      case 'agenda':
        return (
          <AgendaView
            days={agendaDays}
            items={items}
            getStart={getStart}
            getEnd={getEnd}
            getId={getId}
            getTitle={getTitle}
            color={getColor}
            renderEvent={renderEvent}
            onEventClick={onEventClick}
            locale={locale}
            noEventsLabel={messages.noEvents}
          />
        );
    }
  })();

  return <div className={cn('w-full', className)}>{body}</div>;
}

export const Calendar = {
  Root: CalendarRoot,
  Nav: CalendarNav,
  View: CalendarViewComponent,
};

function monthGrid(date: Date, weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6): Date[] {
  const start = startOfWeek(startOfMonth(date), { weekStartsOn });
  const end = endOfWeek(endOfMonth(date), { weekStartsOn });
  return eachDayOfInterval({ start, end });
}

const colorClassMap: Record<CalendarEventColor, string> = {
  primary: 'bg-primary text-primary-foreground',
  secondary: 'bg-secondary text-secondary-foreground',
  accent: 'bg-accent text-accent-foreground',
  destructive: 'bg-destructive text-destructive-foreground',
  muted: 'bg-muted text-muted-foreground',
};

function colorClasses(c?: CalendarEventColor): string {
  return c ? colorClassMap[c] : colorClassMap.primary;
}

const HOUR_HEIGHT = 48;
const SNAP_MINUTES = 15;

function snapToMinutes(date: Date, minutes = SNAP_MINUTES): Date {
  const ms = minutes * 60_000;
  return new Date(Math.round(date.getTime() / ms) * ms);
}

function minutesSinceDayStart(date: Date): number {
  return date.getHours() * 60 + date.getMinutes();
}
function eventTop(start: Date): number {
  return (minutesSinceDayStart(start) / 60) * HOUR_HEIGHT;
}
function eventHeight(start: Date, end: Date): number {
  const mins = Math.max(15, (end.getTime() - start.getTime()) / 60_000);
  return (mins / 60) * HOUR_HEIGHT;
}

function offsetToMinutes(clientY: number, columnEl: HTMLElement): number {
  const rect = columnEl.getBoundingClientRect();
  return ((clientY - rect.top) / HOUR_HEIGHT) * 60;
}

type Creating = { dayIndex: number; startMin: number; endMin: number };

type LaidOut<T> = { item: T; col: number; cols: number };

function layoutDay<T>(dayEvents: T[], getStart: (t: T) => Date, getEnd: (t: T) => Date): LaidOut<T>[] {
  const sorted = [...dayEvents].sort((a, b) => getStart(a).getTime() - getStart(b).getTime());
  const colEnds: number[] = [];
  const placed = sorted.map((item) => {
    const s = getStart(item).getTime();
    let col = colEnds.findIndex((end) => end <= s);
    if (col === -1) {
      col = colEnds.length;
      colEnds.push(0);
    }
    colEnds[col] = getEnd(item).getTime();
    return { item, col };
  });
  const cols = Math.max(1, colEnds.length);
  return placed.map((p) => ({ ...p, cols }));
}

function eventBlockStyle(start: Date, end: Date, laidOut: { col: number; cols: number }): React.CSSProperties {
  const widthPct = 100 / laidOut.cols;
  return {
    top: eventTop(start),
    height: eventHeight(start, end),
    left: `${laidOut.col * widthPct}%`,
    width: `calc(${widthPct}% - 2px)`,
  };
}

function EventBlockContent({
  title,
  start,
  end,
  label,
}: {
  title: string;
  start: Date;
  end: Date;
  label?: React.ReactNode;
}) {
  if (label !== undefined) return <>{label}</>;
  return (
    <>
      <span className='block truncate font-medium'>{title}</span>
      <span className='block truncate opacity-80'>
        {format(start, 'HH:mm')} – {format(end, 'HH:mm')}
      </span>
    </>
  );
}

const eventBlockClass = 'absolute z-10 overflow-hidden rounded px-1 py-0.5 text-left text-xs';

function EventBlock({
  title,
  start,
  end,
  label,
  laidOut,
  color,
  onClick,
}: {
  title: string;
  start: Date;
  end: Date;
  label?: React.ReactNode;
  laidOut: { col: number; cols: number };
  color?: CalendarEventColor;
  onClick?: () => void;
}) {
  return (
    <button
      type='button'
      data-event=''
      onClick={onClick}
      style={eventBlockStyle(start, end, laidOut)}
      className={cn(eventBlockClass, colorClasses(color))}
    >
      <EventBlockContent title={title} start={start} end={end} label={label} />
    </button>
  );
}

function DraggableEventBlock({
  id,
  title,
  start,
  end,
  realEnd,
  label,
  laidOut,
  color,
  draggedRef,
  onClick,
  onResize,
  onPreview,
}: {
  id: string;
  title: string;
  start: Date;
  end: Date;
  realEnd: Date;
  label?: React.ReactNode;
  laidOut: { col: number; cols: number };
  color?: CalendarEventColor;
  draggedRef: React.MutableRefObject<boolean>;
  onClick?: () => void;
  onResize?: (id: string, end: Date) => void;
  onPreview: (end: Date | null) => void;
}) {
  const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id });
  return (
    <button
      type='button'
      data-event=''
      ref={setNodeRef}
      {...attributes}
      {...listeners}
      onClick={() => {
        if (draggedRef.current) {
          draggedRef.current = false;
          return;
        }
        onClick?.();
      }}
      style={{
        ...eventBlockStyle(start, end, laidOut),
        transform: CSS.Translate.toString(transform),
        zIndex: isDragging ? 40 : undefined,
        touchAction: 'none',
      }}
      className={cn(eventBlockClass, colorClasses(color))}
    >
      <EventBlockContent title={title} start={start} end={end} label={label} />
      <ResizeHandle id={id} start={start} end={realEnd} onResize={onResize} onPreview={onPreview} />
    </button>
  );
}

function ResizeHandle({
  id,
  start,
  end,
  onResize,
  onPreview,
}: {
  id: string;
  start: Date;
  end: Date;
  onResize?: (id: string, end: Date) => void;
  onPreview: (end: Date | null) => void;
}) {
  const handlePointerDown = (e: React.PointerEvent<HTMLSpanElement>) => {
    e.preventDefault();
    e.stopPropagation();
    const target = e.target as HTMLElement;
    target.setPointerCapture(e.pointerId);

    const startY = e.clientY;
    const originalEnd = end;
    const minEnd = new Date(start.getTime() + SNAP_MINUTES * 60_000);
    let finalEnd = originalEnd;

    const compute = (clientY: number) => {
      const deltaMin = ((clientY - startY) / HOUR_HEIGHT) * 60;
      let proposed = snapToMinutes(new Date(originalEnd.getTime() + deltaMin * 60_000));
      if (proposed.getTime() - start.getTime() < SNAP_MINUTES * 60_000) proposed = minEnd;
      return proposed;
    };

    const onMove = (moveE: PointerEvent) => {
      finalEnd = compute(moveE.clientY);
      onPreview(finalEnd);
    };

    const onUp = (upE: PointerEvent) => {
      finalEnd = compute(upE.clientY);
      target.releasePointerCapture(e.pointerId);
      target.removeEventListener('pointermove', onMove);
      target.removeEventListener('pointerup', onUp);
      onResize?.(id, finalEnd);
      onPreview(null);
    };

    target.addEventListener('pointermove', onMove);
    target.addEventListener('pointerup', onUp);
  };

  return (
    <span
      data-event=''
      className='absolute inset-x-0 bottom-0 z-20 h-1.5 cursor-ns-resize'
      onPointerDown={handlePointerDown}
      onClick={(e) => e.stopPropagation()}
    />
  );
}

const HOURS = Array.from({ length: 24 }, (_, h) => h);
const DAY_MS = 24 * 60 * 60 * 1000;

function CreatingOverlay({ startMin, endMin }: { startMin: number; endMin: number }) {
  const lo = Math.min(startMin, endMin);
  const span = Math.max(15, Math.abs(endMin - startMin));
  return (
    <div
      className='pointer-events-none absolute inset-x-0 z-30 rounded border border-primary bg-primary/30'
      style={{ top: (lo / 60) * HOUR_HEIGHT, height: (span / 60) * HOUR_HEIGHT }}
    />
  );
}

function DayColumnContent<T extends Record<string, unknown>>({
  laidOut,
  getStart,
  getEnd,
  getId,
  getTitle,
  color,
  renderEvent,
  editable,
  draggedRef,
  resizing,
  creatingRange,
  onResize,
  onPreview,
  onEventClick,
}: {
  laidOut: LaidOut<T>[];
  getStart: (item: T) => Date;
  getEnd: (item: T) => Date;
  getId: (item: T) => string;
  getTitle: (item: T) => string;
  color?: (item: T) => CalendarEventColor | undefined;
  renderEvent?: (item: T) => React.ReactNode;
  editable: boolean;
  draggedRef: React.MutableRefObject<boolean>;
  resizing: { id: string; end: Date } | null;
  creatingRange?: { startMin: number; endMin: number } | null;
  onResize?: (id: string, end: Date) => void;
  onPreview: (preview: { id: string; end: Date } | null) => void;
  onEventClick?: (item: T) => void;
}) {
  return (
    <>
      {HOURS.map((h) => (
        <div key={h} className='h-12 border-t first:border-t-0' />
      ))}
      {creatingRange ? <CreatingOverlay startMin={creatingRange.startMin} endMin={creatingRange.endMin} /> : null}
      {laidOut.map((entry) => {
        const id = getId(entry.item);
        const start = getStart(entry.item);
        const realEnd = getEnd(entry.item);
        const shownEnd = resizing?.id === id ? resizing.end : realEnd;
        const label = renderEvent ? renderEvent(entry.item) : undefined;
        return editable ? (
          <DraggableEventBlock
            key={id}
            id={id}
            title={getTitle(entry.item)}
            start={start}
            end={shownEnd}
            realEnd={realEnd}
            label={label}
            laidOut={entry}
            color={color?.(entry.item)}
            draggedRef={draggedRef}
            onClick={() => onEventClick?.(entry.item)}
            onResize={onResize}
            onPreview={(end) => onPreview(end ? { id, end } : null)}
          />
        ) : (
          <EventBlock
            key={id}
            title={getTitle(entry.item)}
            start={start}
            end={realEnd}
            label={label}
            laidOut={entry}
            color={color?.(entry.item)}
            onClick={() => onEventClick?.(entry.item)}
          />
        );
      })}
    </>
  );
}

const dayColumnClass = 'relative flex-1 border-l first:border-l-0';

function DroppableDayColumn<T extends Record<string, unknown>>({
  day,
  dayIndex,
  creating,
  onCreatingChange,
  onSlotSelect,
  ...content
}: {
  day: Date;
  dayIndex: number;
  creating: Creating | null;
  onCreatingChange: (next: Creating | null) => void;
  onSlotSelect?: (range: CalendarSlot) => void;
  laidOut: LaidOut<T>[];
  getStart: (item: T) => Date;
  getEnd: (item: T) => Date;
  getId: (item: T) => string;
  getTitle: (item: T) => string;
  color?: (item: T) => CalendarEventColor | undefined;
  renderEvent?: (item: T) => React.ReactNode;
  editable: boolean;
  draggedRef: React.MutableRefObject<boolean>;
  resizing: { id: string; end: Date } | null;
  onResize?: (id: string, end: Date) => void;
  onPreview: (preview: { id: string; end: Date } | null) => void;
  onEventClick?: (item: T) => void;
}) {
  const { setNodeRef } = useDroppable({ id: `col-${day.toISOString()}`, data: { day } });
  const columnRef = React.useRef<HTMLDivElement | null>(null);
  const startMinRef = React.useRef(0);
  const canCreate = Boolean(onSlotSelect);

  const mergedRef = React.useCallback(
    (node: HTMLDivElement | null) => {
      setNodeRef(node);
      columnRef.current = node;
    },
    [setNodeRef],
  );

  const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!canCreate) return;
    if ((e.target as HTMLElement).closest('[data-event]')) return;
    const columnEl = columnRef.current;
    if (!columnEl) return;
    e.preventDefault();
    columnEl.setPointerCapture(e.pointerId);
    const startMin = offsetToMinutes(e.clientY, columnEl);
    startMinRef.current = startMin;
    onCreatingChange({ dayIndex, startMin, endMin: startMin });
  };

  const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!creating || creating.dayIndex !== dayIndex) return;
    const columnEl = columnRef.current;
    if (!columnEl) return;
    onCreatingChange({ dayIndex, startMin: startMinRef.current, endMin: offsetToMinutes(e.clientY, columnEl) });
  };

  const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!creating || creating.dayIndex !== dayIndex) return;
    const columnEl = columnRef.current;
    if (columnEl?.hasPointerCapture(e.pointerId)) columnEl.releasePointerCapture(e.pointerId);
    let lo = Math.min(creating.startMin, creating.endMin);
    let hi = Math.max(creating.startMin, creating.endMin);
    if (hi - lo < 5) {
      lo = creating.startMin;
      hi = creating.startMin + 60;
    }
    const base = startOfDay(day);
    const start = snapToMinutes(addMinutes(base, lo));
    let end = snapToMinutes(addMinutes(base, hi));
    if (end.getTime() - start.getTime() < SNAP_MINUTES * 60_000) {
      end = new Date(start.getTime() + SNAP_MINUTES * 60_000);
    }
    onCreatingChange(null);
    onSlotSelect?.({ start, end, allDay: false });
  };

  const creatingRange = creating?.dayIndex === dayIndex ? creating : null;

  return (
    <div
      ref={mergedRef}
      className={cn(dayColumnClass, 'cursor-pointer')}
      style={{ height: 24 * HOUR_HEIGHT }}
      onPointerDown={canCreate ? handlePointerDown : undefined}
      onPointerMove={canCreate ? handlePointerMove : undefined}
      onPointerUp={canCreate ? handlePointerUp : undefined}
    >
      <DayColumnContent {...content} creatingRange={creatingRange} />
    </div>
  );
}

function PlainDayColumn<T extends Record<string, unknown>>(content: {
  laidOut: LaidOut<T>[];
  getStart: (item: T) => Date;
  getEnd: (item: T) => Date;
  getId: (item: T) => string;
  getTitle: (item: T) => string;
  color?: (item: T) => CalendarEventColor | undefined;
  renderEvent?: (item: T) => React.ReactNode;
  editable: boolean;
  draggedRef: React.MutableRefObject<boolean>;
  resizing: { id: string; end: Date } | null;
  onResize?: (id: string, end: Date) => void;
  onPreview: (preview: { id: string; end: Date } | null) => void;
  onEventClick?: (item: T) => void;
}) {
  return (
    <div className={dayColumnClass} style={{ height: 24 * HOUR_HEIGHT }}>
      <DayColumnContent {...content} />
    </div>
  );
}

function TimeGridView<T extends Record<string, unknown>>({
  days,
  items,
  getStart,
  getEnd,
  getId,
  getTitle,
  isAllDay,
  color,
  renderEvent,
  editable,
  draggedRef,
  onResize,
  onEventClick,
  onSlotSelect: selectSlot,
  locale,
  allDayLabel,
}: {
  days: Date[];
  items: T[];
  getStart: (item: T) => Date;
  getEnd: (item: T) => Date;
  getId: (item: T) => string;
  getTitle: (item: T) => string;
  isAllDay: (item: T) => boolean;
  color?: (item: T) => CalendarEventColor | undefined;
  renderEvent?: (item: T) => React.ReactNode;
  editable: boolean;
  draggedRef: React.MutableRefObject<boolean>;
  onResize?: (id: string, end: Date) => void;
  onEventClick?: (item: T) => void;
  onSlotSelect?: (range: CalendarSlot) => void;
  locale?: Locale;
  allDayLabel: string;
}) {
  const [resizing, setResizing] = React.useState<{ id: string; end: Date } | null>(null);
  const [creating, setCreating] = React.useState<Creating | null>(null);

  const spansAllDay = React.useCallback(
    (item: T) => isAllDay(item) || getEnd(item).getTime() - getStart(item).getTime() >= DAY_MS,
    [isAllDay, getEnd, getStart],
  );

  const canCreateAllDay = editable && Boolean(selectSlot);

  return (
    <div className='w-full overflow-hidden rounded-md border'>
      <div className='max-h-[600px] overflow-y-auto'>
        <div className='sticky top-0 z-30 bg-background'>
          <div className='flex border-b'>
            <div className='w-14 shrink-0 border-r' />
            {days.map((day) => (
              <div
                key={day.toISOString()}
                className='flex-1 border-l p-2 text-center text-xs font-medium first:border-l-0'
              >
                {format(day, 'EEE d', { locale })}
              </div>
            ))}
          </div>

          <div className='flex border-b'>
            <div className='flex w-14 shrink-0 items-center justify-center border-r py-1 text-xs text-muted-foreground'>
              {allDayLabel}
            </div>
            {days.map((day) => {
              const allDayEvents = items.filter((item) => spansAllDay(item) && isSameDay(getStart(item), day));
              return (
                <div
                  key={day.toISOString()}
                  className={cn(
                    'flex min-h-7 flex-1 flex-col gap-1 border-l p-1 first:border-l-0',
                    canCreateAllDay && 'cursor-pointer',
                  )}
                  onClick={
                    canCreateAllDay
                      ? () => selectSlot?.({ start: startOfDay(day), end: addDays(startOfDay(day), 1), allDay: true })
                      : undefined
                  }
                >
                  {allDayEvents.map((item) => (
                    <button
                      type='button'
                      key={getId(item)}
                      onClick={(e) => {
                        e.stopPropagation();
                        onEventClick?.(item);
                      }}
                      className={cn(
                        'w-full truncate rounded px-1 py-0.5 text-left text-xs',
                        colorClasses(color?.(item)),
                      )}
                    >
                      {renderEvent ? renderEvent(item) : getTitle(item)}
                    </button>
                  ))}
                </div>
              );
            })}
          </div>
        </div>

        <div className='flex'>
          <div className='w-14 shrink-0 border-r'>
            {HOURS.map((h) => (
              <div key={h} className='h-12 pr-1 text-right text-xs text-muted-foreground'>
                {`${String(h).padStart(2, '0')}:00`}
              </div>
            ))}
          </div>
          {days.map((day, dayIndex) => {
            const timed = items.filter((item) => !spansAllDay(item) && isSameDay(getStart(item), day));
            const laidOut = layoutDay(timed, getStart, getEnd);
            const columnProps = {
              laidOut,
              getStart,
              getEnd,
              getId,
              getTitle,
              color,
              renderEvent,
              editable,
              draggedRef,
              resizing,
              onResize,
              onPreview: setResizing,
              onEventClick,
            };
            return editable ? (
              <DroppableDayColumn
                key={day.toISOString()}
                day={day}
                dayIndex={dayIndex}
                creating={creating}
                onCreatingChange={setCreating}
                onSlotSelect={selectSlot}
                {...columnProps}
              />
            ) : (
              <PlainDayColumn key={day.toISOString()} {...columnProps} />
            );
          })}
        </div>
      </div>
    </div>
  );
}

const monthPillClass = 'w-full truncate rounded px-1 py-0.5 text-left text-xs';

function MonthEventPill<T>({
  item,
  id,
  title,
  label,
  color,
  draggedRef,
  onEventClick,
}: {
  item: T;
  id: string;
  title: string;
  label?: React.ReactNode;
  color?: CalendarEventColor;
  draggedRef: React.MutableRefObject<boolean>;
  onEventClick?: (item: T) => void;
}) {
  const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id });
  return (
    <button
      type='button'
      ref={setNodeRef}
      {...attributes}
      {...listeners}
      onClick={(e) => {
        e.stopPropagation();
        if (draggedRef.current) {
          draggedRef.current = false;
          return;
        }
        onEventClick?.(item);
      }}
      style={{ transform: CSS.Translate.toString(transform), zIndex: isDragging ? 40 : undefined, touchAction: 'none' }}
      className={cn(monthPillClass, colorClasses(color))}
    >
      {label ?? title}
    </button>
  );
}

function MonthCell<T extends Record<string, unknown>>({
  day,
  date,
  visible,
  overflow,
  getId,
  getTitle,
  color,
  renderEvent,
  draggedRef,
  onEventClick,
  onSlotSelect,
  more,
}: {
  day: Date;
  date: Date;
  visible: T[];
  overflow: number;
  getId: (item: T) => string;
  getTitle: (item: T) => string;
  color?: (item: T) => CalendarEventColor | undefined;
  renderEvent?: (item: T) => React.ReactNode;
  draggedRef: React.MutableRefObject<boolean>;
  onEventClick?: (item: T) => void;
  onSlotSelect?: (range: CalendarSlot) => void;
  more: (count: number) => string;
}) {
  const { setNodeRef } = useDroppable({ id: `cell-${day.toISOString()}`, data: { day } });
  return (
    <div
      ref={setNodeRef}
      className='flex min-h-24 cursor-pointer flex-col gap-1 border-b border-l p-1 hover:bg-accent/50 [&:nth-child(7n+1)]:border-l-0'
      onClick={() => onSlotSelect?.({ start: startOfDay(day), end: addDays(startOfDay(day), 1), allDay: true })}
    >
      <span className={cn('text-xs', !isSameMonth(day, date) && 'text-muted-foreground')}>{format(day, 'd')}</span>
      {visible.map((item) => (
        <MonthEventPill
          key={getId(item)}
          item={item}
          id={getId(item)}
          title={getTitle(item)}
          label={renderEvent ? renderEvent(item) : undefined}
          color={color?.(item)}
          draggedRef={draggedRef}
          onEventClick={onEventClick}
        />
      ))}
      {overflow > 0 ? <span className='px-1 text-xs text-muted-foreground'>{more(overflow)}</span> : null}
    </div>
  );
}

function PlainMonthCell<T extends Record<string, unknown>>({
  day,
  date,
  visible,
  overflow,
  getId,
  getTitle,
  color,
  renderEvent,
  onEventClick,
  more,
}: {
  day: Date;
  date: Date;
  visible: T[];
  overflow: number;
  getId: (item: T) => string;
  getTitle: (item: T) => string;
  color?: (item: T) => CalendarEventColor | undefined;
  renderEvent?: (item: T) => React.ReactNode;
  onEventClick?: (item: T) => void;
  more: (count: number) => string;
}) {
  return (
    <div className='flex min-h-24 flex-col gap-1 border-b border-l p-1 [&:nth-child(7n+1)]:border-l-0'>
      <span className={cn('text-xs', !isSameMonth(day, date) && 'text-muted-foreground')}>{format(day, 'd')}</span>
      {visible.map((item) => (
        <button
          type='button'
          key={getId(item)}
          className={cn(monthPillClass, colorClasses(color?.(item)))}
          onClick={() => onEventClick?.(item)}
        >
          {renderEvent ? renderEvent(item) : getTitle(item)}
        </button>
      ))}
      {overflow > 0 ? <span className='px-1 text-xs text-muted-foreground'>{more(overflow)}</span> : null}
    </div>
  );
}

function MonthView<T extends Record<string, unknown>>({
  date,
  weekStartsOn,
  items,
  getStart,
  getId,
  getTitle,
  color,
  renderEvent,
  editable,
  draggedRef,
  onEventClick,
  onSlotSelect,
  locale,
  more,
}: {
  date: Date;
  weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6;
  items: T[];
  getStart: (item: T) => Date;
  getId: (item: T) => string;
  getTitle: (item: T) => string;
  color?: (item: T) => CalendarEventColor | undefined;
  renderEvent?: (item: T) => React.ReactNode;
  editable: boolean;
  draggedRef: React.MutableRefObject<boolean>;
  onEventClick?: (item: T) => void;
  onSlotSelect?: (range: CalendarSlot) => void;
  locale?: Locale;
  more: (count: number) => string;
}) {
  const days = monthGrid(date, weekStartsOn);
  const weekdayLabels = days.slice(0, 7).map((day) => format(day, 'EEE', { locale }));

  return (
    <div className='w-full overflow-hidden rounded-md border'>
      <div className='grid grid-cols-7'>
        {weekdayLabels.map((label) => (
          <div
            key={label}
            className='border-b border-l p-2 text-center text-xs font-medium text-muted-foreground first:border-l-0'
          >
            {label}
          </div>
        ))}
        {days.map((day) => {
          const dayEvents = items
            .filter((item) => isSameDay(getStart(item), day))
            .sort((a, b) => getStart(a).getTime() - getStart(b).getTime());
          const visible = dayEvents.slice(0, 3);
          const overflow = dayEvents.length - visible.length;

          return editable ? (
            <MonthCell
              key={day.toISOString()}
              day={day}
              date={date}
              visible={visible}
              overflow={overflow}
              getId={getId}
              getTitle={getTitle}
              color={color}
              renderEvent={renderEvent}
              draggedRef={draggedRef}
              onEventClick={onEventClick}
              onSlotSelect={onSlotSelect}
              more={more}
            />
          ) : (
            <PlainMonthCell
              key={day.toISOString()}
              day={day}
              date={date}
              visible={visible}
              overflow={overflow}
              getId={getId}
              getTitle={getTitle}
              color={color}
              renderEvent={renderEvent}
              onEventClick={onEventClick}
              more={more}
            />
          );
        })}
      </div>
    </div>
  );
}

function AgendaView<T extends Record<string, unknown>>({
  days,
  items,
  getStart,
  getEnd,
  getId,
  getTitle,
  color,
  renderEvent,
  onEventClick,
  locale,
  noEventsLabel,
}: {
  days: Date[];
  items: T[];
  getStart: (item: T) => Date;
  getEnd: (item: T) => Date;
  getId: (item: T) => string;
  getTitle: (item: T) => string;
  color?: (item: T) => CalendarEventColor | undefined;
  renderEvent?: (item: T) => React.ReactNode;
  onEventClick?: (item: T) => void;
  locale?: Locale;
  noEventsLabel: string;
}) {
  const groups = days
    .map((day) => ({
      day,
      events: items
        .filter((item) => isSameDay(getStart(item), day))
        .sort((a, b) => getStart(a).getTime() - getStart(b).getTime()),
    }))
    .filter((group) => group.events.length > 0);

  if (groups.length === 0) {
    return <p className='w-full text-sm text-muted-foreground'>{noEventsLabel}</p>;
  }

  return (
    <div className='flex max-h-[600px] w-full flex-col gap-4 overflow-y-auto'>
      {groups.map((group) => (
        <div key={group.day.toISOString()} className='flex flex-col gap-1'>
          <div className='text-sm font-medium'>{format(group.day, 'EEEE, MMM d', { locale })}</div>
          {group.events.map((item) => (
            <button
              type='button'
              key={getId(item)}
              onClick={() => onEventClick?.(item)}
              className='flex w-full items-center gap-2 text-left text-sm'
            >
              <span className={cn('size-2 rounded-full', colorClasses(color?.(item)))} />
              <span className='text-muted-foreground'>
                {format(getStart(item), 'HH:mm')} – {format(getEnd(item), 'HH:mm')}
              </span>
              <span>{renderEvent ? renderEvent(item) : getTitle(item)}</span>
            </button>
          ))}
        </div>
      ))}
    </div>
  );
}

More from Shuip

Browse all components in this library →