Files
opengroupware/components/calendar/EventDetailDialog.tsx
Ken Yasue 41d65f58bb feat(calendar): replace list view with month/week/day grid + date detail dialog
カレンダー画面を一覧表示から実際のカレンダーグリッドへ変更。

- lib/calendar/grid.ts: 月グリッド/週/時間グリッド/取得範囲(rangeForView)の純粋な日付ヘルパー (+22 unit tests)
- components/calendar/: CalendarView(月/週/日 切替・prev/next/今日)・MonthView(truncateチップ)・WeekView・DayView(時間グリッド+終日)・EventDetailDialog(詳細・フォーカス管理)・sourceColors
- page.tsx: view/date searchParamsから範囲を計算しServer Componentで取得、CalendarViewへ渡す
- e2e: 表示切替・詳細ダイアログ・ナビステップ(週=7日/日=1日)を追加
2026-06-25 08:46:58 +02:00

114 lines
3.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useRef } from 'react';
import type { CalendarEventView } from '@/services/ScheduleService';
import { formatTime } from '@/lib/calendar/grid';
import {
SOURCE_COLORS,
SOURCE_LABELS,
} from '@/components/calendar/sourceColors';
/**
* 指定日のイベント詳細ダイアログ。
* セル内ではtruncateされるタイトルも、ここでは全文と説明を表示する。
* 開閉時にフォーカスをダイアログへ移し、閉じたら元の要素へ戻す。
*/
export function EventDetailDialog({
date,
events,
onClose,
}: {
date: Date;
events: CalendarEventView[];
onClose: () => void;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
dialogRef.current?.focus();
return () => {
previouslyFocused?.focus?.();
};
}, []);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const dateLabel = `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4"
onClick={onClose}
data-testid="calendar-detail-backdrop"
>
<div
ref={dialogRef}
tabIndex={-1}
className="mt-16 w-full max-w-lg rounded-lg bg-white shadow-xl focus:outline-none"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={`${dateLabel}のイベント`}
data-testid="calendar-detail-dialog"
>
<div className="flex items-center justify-between border-b p-4">
<h2 className="text-lg font-bold text-gray-800">{dateLabel}</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="閉じる"
data-testid="calendar-detail-close"
>
×
</button>
</div>
<div className="max-h-[60vh] space-y-3 overflow-y-auto p-4">
{events.length === 0 ? (
<p className="text-sm text-gray-400">
</p>
) : (
events.map((e) => (
<div
key={e.key}
className="rounded border border-gray-200 p-3"
data-testid={`calendar-detail-${e.key}`}
>
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-gray-800 break-words">
{e.title}
</p>
<span
className={`shrink-0 rounded px-2 py-0.5 text-xs ${
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
}`}
>
{SOURCE_LABELS[e.source] ?? e.source}
</span>
</div>
<p className="mt-1 text-xs text-gray-500">
{formatTime(e.startAt)}
{e.endAt ? `${formatTime(e.endAt)}` : ''}
</p>
{e.description && (
<p className="mt-2 whitespace-pre-wrap text-sm text-gray-600">
{e.description}
</p>
)}
</div>
))
)}
</div>
</div>
</div>
);
}