SCALE — Build Lab
UI部品 · REACT COMPONENT

カレンダー週次ビュー

CATEGORYUI部品 TYPEReact Component EFFORT240〜600分 DIFFICULTY
PRIMARY CODE
tsx
'use client';
import { useMemo } from 'react';

type Event = { id: string; title: string; start: Date; end: Date; color?: string };

// 週次ビュー(時間帯 6:00 - 24:00)
export function WeekCalendar({
  weekStart, events, onClickEvent, onClickSlot,
}: {
  weekStart: Date;  // 月曜0:00
  events: Event[];
  onClickEvent?: (e: Event) => void;
  onClickSlot?: (date: Date) => void;
}) {
  const days = useMemo(() => Array.from({ length: 7 }, (_, i) => {
    const d = new Date(weekStart);
    d.setDate(d.getDate() + i);
    return d;
  }), [weekStart]);

  const HOUR_HEIGHT = 48;
  const START_HOUR = 6;
  const END_HOUR = 24;
  const HOURS = Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i);

  const eventsByDay = useMemo(() => {
    const map = new Map<string, Event[]>();
    for (const ev of events) {
      const key = ev.start.toDateString();
      const arr = map.get(key) ?? [];
      arr.push(ev);
      map.set(key, arr);
    }
    return map;
  }, [events]);

  const eventStyle = (ev: Event) => {
    const startMin = (ev.start.getHours() - START_HOUR) * 60 + ev.start.getMinutes();
    const durMin = (ev.end.getTime() - ev.start.getTime()) / 60000;
    return { top: `${(startMin / 60) * HOUR_HEIGHT}px`, height: `${(durMin / 60) * HOUR_HEIGHT}px` };
  };

  return (
    <div className="bg-zinc-900 rounded-2xl border border-zinc-800 overflow-hidden">
      <div className="grid grid-cols-[60px_repeat(7,1fr)] border-b border-zinc-800">
        <div />
        {days.map((d) => (
          <div key={d.toISOString()} className="text-center py-2 text-xs text-zinc-400">
            <div>{['月','火','水','木','金','土','日'][d.getDay() === 0 ? 6 : d.getDay() - 1]}</div>
            <div className="text-base text-zinc-100">{d.getDate()}</div>
          </div>
        ))}
      </div>
      <div className="grid grid-cols-[60px_repeat(7,1fr)] relative">
        <div>
          {HOURS.map((h) => (
            <div key={h} className="text-[10px] text-zinc-500 text-right pr-2" style={{ height: `${HOUR_HEIGHT}px` }}>{h}:00</div>
          ))}
        </div>
        {days.map((d) => (
          <div key={d.toISOString()} className="relative border-l border-zinc-800" style={{ height: `${HOURS.length * HOUR_HEIGHT}px` }}
            onClick={(e) => {
              const rect = e.currentTarget.getBoundingClientRect();
              const y = e.clientY - rect.top;
              const hour = START_HOUR + Math.floor(y / HOUR_HEIGHT);
              const date = new Date(d); date.setHours(hour, 0, 0, 0);
              onClickSlot?.(date);
            }}>
            {(eventsByDay.get(d.toDateString()) ?? []).map((ev) => (
              <div key={ev.id}
                onClick={(e) => { e.stopPropagation(); onClickEvent?.(ev); }}
                className="absolute left-1 right-1 rounded px-1.5 py-1 text-[11px] text-white cursor-pointer overflow-hidden"
                style={{ ...eventStyle(ev), background: ev.color ?? '#6366f1' }}>
                {ev.title}
              </div>
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}
前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • スケジュール管理
  • 予定管理

カレンダー週次ビュー

:LiTarget: 用途

Google Calendar 風の週次ビュー。イベント表示・追加対応。

:LiSparkle: 特徴

  • 週次表示
  • イベント追加
  • D&D配置
  • 色分け

:LiCode: コード(コピペ用)

'use client';
import { useMemo } from 'react';

type Event = { id: string; title: string; start: Date; end: Date; color?: string };

// 週次ビュー(時間帯 6:00 - 24:00)
export function WeekCalendar({
  weekStart, events, onClickEvent, onClickSlot,
}: {
  weekStart: Date;  // 月曜0:00
  events: Event[];
  onClickEvent?: (e: Event) => void;
  onClickSlot?: (date: Date) => void;
}) {
  const days = useMemo(() => Array.from({ length: 7 }, (_, i) => {
    const d = new Date(weekStart);
    d.setDate(d.getDate() + i);
    return d;
  }), [weekStart]);

  const HOUR_HEIGHT = 48;
  const START_HOUR = 6;
  const END_HOUR = 24;
  const HOURS = Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i);

  const eventsByDay = useMemo(() => {
    const map = new Map<string, Event[]>();
    for (const ev of events) {
      const key = ev.start.toDateString();
      const arr = map.get(key) ?? [];
      arr.push(ev);
      map.set(key, arr);
    }
    return map;
  }, [events]);

  const eventStyle = (ev: Event) => {
    const startMin = (ev.start.getHours() - START_HOUR) * 60 + ev.start.getMinutes();
    const durMin = (ev.end.getTime() - ev.start.getTime()) / 60000;
    return { top: `${(startMin / 60) * HOUR_HEIGHT}px`, height: `${(durMin / 60) * HOUR_HEIGHT}px` };
  };

  return (
    <div className="bg-zinc-900 rounded-2xl border border-zinc-800 overflow-hidden">
      <div className="grid grid-cols-[60px_repeat(7,1fr)] border-b border-zinc-800">
        <div />
        {days.map((d) => (
          <div key={d.toISOString()} className="text-center py-2 text-xs text-zinc-400">
            <div>{['月','火','水','木','金','土','日'][d.getDay() === 0 ? 6 : d.getDay() - 1]}</div>
            <div className="text-base text-zinc-100">{d.getDate()}</div>
          </div>
        ))}
      </div>
      <div className="grid grid-cols-[60px_repeat(7,1fr)] relative">
        <div>
          {HOURS.map((h) => (
            <div key={h} className="text-[10px] text-zinc-500 text-right pr-2" style={{ height: `${HOUR_HEIGHT}px` }}>{h}:00</div>
          ))}
        </div>
        {days.map((d) => (
          <div key={d.toISOString()} className="relative border-l border-zinc-800" style={{ height: `${HOURS.length * HOUR_HEIGHT}px` }}
            onClick={(e) => {
              const rect = e.currentTarget.getBoundingClientRect();
              const y = e.clientY - rect.top;
              const hour = START_HOUR + Math.floor(y / HOUR_HEIGHT);
              const date = new Date(d); date.setHours(hour, 0, 0, 0);
              onClickSlot?.(date);
            }}>
            {(eventsByDay.get(d.toDateString()) ?? []).map((ev) => (
              <div key={ev.id}
                onClick={(e) => { e.stopPropagation(); onClickEvent?.(ev); }}
                className="absolute left-1 right-1 rounded px-1.5 py-1 text-[11px] text-white cursor-pointer overflow-hidden"
                style={{ ...eventStyle(ev), background: ev.color ?? '#6366f1' }}>
                {ev.title}
              </div>
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

  • 依存パッケージを忘れず追加