月間カレンダー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
プロパティ
表は横にスクロールできます
| プロパティ | 型 | 初期値 | 説明 |
|---|---|---|---|
| month | string | Date | - | 表示する月に含まれる日付です。 |
| events | CalendarEvent[] | - | 日付に配置する予定です。id、date、label、tone、ariaLabel を渡します。 |
| today | string | Date | - | 今日として強調する日付です。SSR の決定性のため渡します。 |
| weekStartsOn | 0 | 1 | 0 | 週の開始曜日です。0 は日曜、1 は月曜です。 |
| maxPerDay | number | 3 | 1日に表示する予定チップ数です。超過分は +N で示します。 |
| weekdayLabels | string[] | - | 日曜始まりの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)