スケジュールグリッドScheduleGridBeta
行と列で構成された予定表や割当表を、sticky header とキーボード操作付きのグリッドで表示します。
プレビュー
時限
月
火
水
1限8:50
数学
佐藤 / 2-A
英語
田中 / 3-B
空き
授業を追加
2限9:50
利用不可
数学
佐藤 / 2-A
競合空き
授業を追加
3限10:50
空き
授業を追加
空き
授業を追加
理科
山本 / 実験室
状態とバリエーション
予定表
予定セルは詳細 Sheet、空きセルは予定追加 Sheet を開きます。
時限
月
火
水
1限8:50
数学
佐藤 / 2-A
英語
田中 / 3-B
空き
授業を追加
2限9:50
利用不可
数学
佐藤 / 2-A
競合空き
授業を追加
3限10:50
空き
授業を追加
空き
授業を追加
理科
山本 / 実験室
競合と利用不可
競合セルは詳細で調整内容を確認し、利用不可セルは理由を Tooltip で示します。
時限
月
火
水
1限8:50
空き
授業を追加
空き
授業を追加
空き
授業を追加
2限9:50
利用不可
数学
佐藤 / 2-A
競合空き
授業を追加
3限10:50
空き
授業を追加
空き
授業を追加
空き
授業を追加
列数を絞る
狭い画面では列数や minColumnWidth を調整します。
時限
月
火
1限8:50
数学
佐藤 / 2-A
英語
田中 / 3-B
2限9:50
利用不可
数学
佐藤 / 2-A
競合3限10:50
空き
授業を追加
空き
授業を追加
プロパティ
表は横にスクロールできます
| プロパティ | 型 | 初期値 | 説明 |
|---|---|---|---|
| rows / columns | ScheduleAxisItem[] | - | 行軸と列軸です。label/sublabel/ariaLabel を持ちます。 |
| cells | ScheduleCell[] | - | rowId と colId でセルを配置します。 |
| ScheduleCell.content | ReactNode | - | セル本文です。 |
| ScheduleCell.tone | ScheduleCellTone | - | セルの状態トーンです。destructive は競合リングを持ちます。 |
| ScheduleCell.onSelect | () => void | - | セルをクリック/Enter/Spaceで操作可能にします。 |
| ScheduleCell.selected / cellClassName | boolean / string | - | selected は編集中セル等をリング+aria-selected で強調(フォーカスとは別)。cellClassName でセル個別に追加クラスを注入できます。(#299) |
| unavailable / unavailableLabel | boolean / string | - | 利用不可スロットとその表示/読み上げ名です。 |
| unavailableReason | ReactNode | - | 利用不可セルにホバー/フォーカスした時に表示する理由です。 |
| emptyLabel / renderEmpty | string / function | - | 空セルの読み上げ名と表示を指定します。空き枠を操作対象にする場合は onSelect 付きのセルとして渡します。関数propのため Client Component からのみ渡すこと(Server Component から渡すと next build が落ちる)。JSX を返すため serializable な代替は無く、RSC からは "use client" ラッパーで包む。(#338) |
| minColumnWidth / rowHeaderWidth | number | - | 横スクロール前の列幅と固定行ヘッダー幅です。 |
使い方
import * as React from "react";
import {
Button,
Input,
ScheduleGrid,
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
type ScheduleAxisItem,
type ScheduleCell,
} from "@gunjo/ui";
const rows: ScheduleAxisItem[] = [
{ id: "p1", label: "1限", sublabel: "8:50", ariaLabel: "1限" },
{ id: "p2", label: "2限", sublabel: "9:50", ariaLabel: "2限" },
{ id: "p3", label: "3限", sublabel: "10:50", ariaLabel: "3限" },
];
const columns: ScheduleAxisItem[] = [
{ id: "mon", label: "月", ariaLabel: "月曜" },
{ id: "tue", label: "火", ariaLabel: "火曜" },
{ id: "wed", label: "水", ariaLabel: "水曜" },
];
export function WeeklyTimetable() {
const rootRef = React.useRef<HTMLDivElement | null>(null);
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
const [selected, setSelected] = React.useState<"math" | "english" | "conflict" | "science" | "open" | null>(null);
const [isLargeViewport, setIsLargeViewport] = React.useState(false);
React.useEffect(() => {
setPortalContainer(rootRef.current?.closest<HTMLElement>("[data-doc-component-preview-surface]") ?? rootRef.current);
}, []);
React.useEffect(() => {
const node = rootRef.current;
if (!node) return;
const update = () => {
const surface = node.parentElement ?? node;
setIsLargeViewport(surface.getBoundingClientRect().width >= 900);
};
update();
if (typeof ResizeObserver === "undefined") {
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, []);
const cells: ScheduleCell[] = [
{ rowId: "p1", colId: "mon", content: <span>数学 / 佐藤</span>, description: "数学、佐藤、2-A", onSelect: () => setSelected("math") },
{ rowId: "p1", colId: "tue", tone: "info", content: <span>英語 / 田中</span>, description: "英語、田中、3-B", onSelect: () => setSelected("english") },
{ rowId: "p1", colId: "wed", tone: "muted", content: <span>空き / 授業を追加</span>, description: "空き、授業を追加できます", onSelect: () => setSelected("open") },
{ rowId: "p2", colId: "mon", unavailable: true, unavailableReason: "全校集会のため、この時限には授業を登録できません。" },
{ rowId: "p2", colId: "tue", tone: "destructive", content: <span>数学 / 競合</span>, description: "数学、競合あり", onSelect: () => setSelected("conflict") },
{ rowId: "p3", colId: "wed", tone: "success", content: <span>理科 / 山本</span>, description: "理科、山本、実験室", onSelect: () => setSelected("science") },
];
return (
<div ref={rootRef} className="relative flex w-full max-w-3xl flex-col gap-3 overflow-hidden rounded-lg border bg-card p-4">
<ScheduleGrid label="2年A組 週間時間割" cornerLabel="時限" rows={rows} columns={columns} cells={cells} emptyLabel="空き" unavailableLabel="利用不可" />
<Sheet modal={false} open={selected != null} onOpenChange={(open) => !open && setSelected(null)}>
<SheetContent side={isLargeViewport ? "right" : "bottom"} portalContainer={portalContainer} className="overflow-y-auto p-4">
<SheetHeader className="pr-8">
<SheetTitle>{selected === "open" ? "授業を追加" : "予定の詳細"}</SheetTitle>
<SheetDescription>{selected === "open" ? "この空き枠に登録する授業を入力します。" : "選択した授業の担当と教室を確認します。"}</SheetDescription>
</SheetHeader>
{selected === "open" ? (
<div className="grid gap-4">
<Input placeholder="例: 国語" />
<Input placeholder="例: 鈴木 / 1-A" />
<Button type="button" size="sm">授業を追加</Button>
</div>
) : (
<div className="grid gap-3 text-sm">
<p className="font-semibold">数学 / 佐藤</p>
<p className="text-muted-foreground">2-A</p>
<Button type="button" size="sm">予定を編集</Button>
</div>
)}
</SheetContent>
</Sheet>
</div>
);
}