月間カレンダーEventCalendarExperimental

日付ごとの予定を月間グリッドに配置し、日付選択と予定選択を扱うカレンダーです。

プレビュー

2026年6月
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
1
2
3
4
5
6
7
8
9
10
11

状態とバリエーション

予定の超過表示

同じ日に予定が多い場合、maxPerDay を超えた分を +N で表示します。

2026年6月
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
1
2
3
4
5
6
7
8
9
10
11

月曜始まり

weekStartsOn={1} と曜日ラベルで業務カレンダーの並びに合わせます。

2026年6月
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12

予定チップの差し替え

renderEvent で予定チップの見た目を差し替えられます。

renderEvent 使用
2026年6月
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
1
2
3
4
5
6
7
8
9
10
11

プロパティ

表は横にスクロールできます
プロパティ初期値説明
monthstring | Date-表示する月に含まれる日付です。
eventsCalendarEvent[]-日付に配置する予定です。id、date、label、tone、ariaLabel を渡します。
todaystring | Date-今日として強調する日付です。SSR の決定性のため渡します。
weekStartsOn0 | 10週の開始曜日です。0 は日曜、1 は月曜です。
maxPerDaynumber31日に表示する予定チップ数です。超過分は +N で示します。
weekdayLabelsstring[]-日曜始まりの7つの曜日ラベルです。
renderEvent(event: CalendarEvent) => ReactNode-予定チップの描画を差し替えます。関数propのため Client Component からのみ渡すこと(Server Component から渡すと next build が落ちる)。JSX を返すため serializable な代替は無く、RSC からは "use client" ラッパーで包む。(#338)
onSelectDate / onSelectEvent(value) => void-日付または予定を選択した時に呼びます。
onMonthChange(month: Date) => void-渡すと月見出しと前後移動ボタンを表示します。
formatMonthTitle(monthDate: Date) => string-月見出し(とグリッドのアクセシブル名)の文字列を組み立てます。既定は YYYY年M月 です。インスタンス単位のローカライズ用。
formatDayLabel(date: Date, ctx: { isToday: boolean; events: CalendarEvent[] }) => string-日セルのアクセシブル名を日付・今日判定・予定から組み立てます。既定は日本語の合成(M月D日、今日、N件: … / …、予定なし)です。

使い方

import * as React from "react";
import {
  EventCalendar,
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  type CalendarEvent,
} from "@gunjo/ui";

type CalendarSelection =
  | { type: "date"; iso: string; events: CalendarEvent[] }
  | { type: "event"; iso: string; event: CalendarEvent };

function eventDateIso(date: CalendarEvent["date"]) {
  if (date instanceof Date) {
    const year = String(date.getFullYear());
    const month = String(date.getMonth() + 1).padStart(2, "0");
    const day = String(date.getDate()).padStart(2, "0");
    return year + "-" + month + "-" + day;
  }
  return date;
}

const events: CalendarEvent[] = [
  { id: "a1", date: "2026-06-03", label: "特集: 夏の旅", tone: "info", ariaLabel: "特集 夏の旅" },
  {
    id: "a2",
    date: "2026-06-10",
    label: "撮影: 商品A",
    tone: "success",
    ariaLabel: "撮影 商品A",
  },
  { id: "a3", date: "2026-06-15", label: "編集会議", tone: "muted", ariaLabel: "編集会議" },
  {
    id: "a4",
    date: "2026-06-15",
    label: "入稿締切: 連載#12",
    tone: "destructive",
    ariaLabel: "入稿締切 連載12",
  },
  { id: "a5", date: "2026-06-15", label: "校了確認", tone: "warning", ariaLabel: "校了確認" },
  { id: "a6", date: "2026-06-15", label: "公開予約", tone: "primary", ariaLabel: "公開予約" },
  {
    id: "a7",
    date: "2026-06-24",
    label: "公開: GunjoUI 解説",
    tone: "primary",
    ariaLabel: "公開 GunjoUI 解説",
  },
];

export function EditorialCalendar() {
  const rootRef = React.useRef<HTMLDivElement>(null);
  const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
  const [month, setMonth] = React.useState(new Date(2026, 5, 1));
  const [selection, setSelection] = React.useState<CalendarSelection | null>(null);

  React.useEffect(() => {
    setPortalContainer(rootRef.current?.closest<HTMLElement>("[data-doc-component-preview-surface]") ?? rootRef.current);
  }, []);

  return (
    <div ref={rootRef} className="relative flex w-full max-w-3xl flex-col gap-4">
      <EventCalendar
        month={month}
        events={events}
        today="2026-06-24"
        label="編集カレンダー"
        weekdayLabels={["日", "月", "火", "水", "木", "金", "土"]}
        maxPerDay={3}
        onMonthChange={setMonth}
        onSelectDate={(iso) => setSelection({ type: "date", iso, events: events.filter((event) => event.date === iso) })}
        onSelectEvent={(event) => setSelection({ type: "event", iso: eventDateIso(event.date), event })}
      />
      <Sheet open={selection != null} onOpenChange={(open) => !open && setSelection(null)}>
        <SheetContent
          portalContainer={portalContainer}
          overlayClassName="rounded-md"
          closeLabel="閉じる"
        >
          <SheetHeader>
            <SheetTitle asChild>
              <p>プレビュー</p>
            </SheetTitle>
            <SheetDescription>選択した日付または予定の詳細を確認します。</SheetDescription>
          </SheetHeader>
          {selection ? (
            <div className="mt-4 grid gap-4 text-sm">
              <div className="rounded-lg border bg-card p-3">
                <p className="font-medium text-foreground">
                  {selection.type === "date"
                    ? selection.iso + " の予定"
                    : "予定「" + String(selection.event.label) + "」"}
                </p>
                <p className="mt-1 text-xs text-muted-foreground">{selection.iso}</p>
              </div>
              {selection.type === "date" ? (
                <div className="grid gap-2">
                  {selection.events.length > 0 ? (
                    selection.events.map((event) => (
                      <div key={event.id} className="rounded-md border bg-background px-3 py-2">
                        <p className="font-medium text-foreground">{event.label}</p>
                        <p className="mt-1 text-xs text-muted-foreground">{event.ariaLabel}</p>
                      </div>
                    ))
                  ) : (
                    <p className="rounded-md border bg-muted/30 px-3 py-2 text-muted-foreground">この日に登録された予定はありません。</p>
                  )}
                </div>
              ) : (
                <div className="rounded-md border bg-muted/30 px-3 py-2">
                  <p className="font-medium text-foreground">{selection.event.label}</p>
                  <p className="mt-1 text-muted-foreground">{selection.event.ariaLabel}</p>
                </div>
              )}
            </div>
          ) : null}
        </SheetContent>
      </Sheet>
    </div>
  );
}

設計の判断

  • 矢印キーで日を移せるようにした。資料はカレンダーの核を「キーボードで日付を移動できること」に置いています。左右で1日ずつ(週をまたぐ)、上下で1週ずつ、Home と End でその週の端まで動き、Enter と Space で選びます。フォーカスを持つ升目は常に1つだけ(roving tabindex)なので、Tab を31回押させません。
  • role="grid" の骨組みを持たせた。全体が role="grid"、曜日が role="columnheader"、日が role="gridcell" で、升目の読み上げ名は「5月12日、今日、2件: ○○、△△」のように日付・今日かどうか・その日の予定を1つの文にまとめてあります。月の見出しには aria-live="polite" を付け、月を送ったことが読み上げられるようにしました。
  • 「今日」は外から渡す。一方で、選べない日はまだ持っていません。today は props で、渡さなければどの日にも印を付けません。サーバーで描いた HTML とブラウザで描き直した HTML がずれないようにするためです。資料が挙げている「選べない日を aria-disabled にする」はまだ書いていません。前後の月の日は薄く出しますが選べるままで、休業日や過去日を止める口はありません。
    一般のカレンダーの設計は UIXHERO の「カレンダー」にあります。 UIXHERO: カレンダー(Calendar)

使用コンポーネント