スケジュールグリッド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 / columnsScheduleAxisItem[]-行軸と列軸です。label/sublabel/ariaLabel を持ちます。
cellsScheduleCell[]-rowId と colId でセルを配置します。
ScheduleCell.contentReactNode-セル本文です。
ScheduleCell.toneScheduleCellTone-セルの状態トーンです。destructive は競合リングを持ちます。
ScheduleCell.onSelect() => void-セルをクリック/Enter/Spaceで操作可能にします。
ScheduleCell.selected / cellClassNameboolean / string-selected は編集中セル等をリング+aria-selected で強調(フォーカスとは別)。cellClassName でセル個別に追加クラスを注入できます。(#299)
unavailable / unavailableLabelboolean / string-利用不可スロットとその表示/読み上げ名です。
unavailableReasonReactNode-利用不可セルにホバー/フォーカスした時に表示する理由です。
emptyLabel / renderEmptystring / function-空セルの読み上げ名と表示を指定します。空き枠を操作対象にする場合は onSelect 付きのセルとして渡します。関数propのため Client Component からのみ渡すこと(Server Component から渡すと next build が落ちる)。JSX を返すため serializable な代替は無く、RSC からは "use client" ラッパーで包む。(#338)
minColumnWidth / rowHeaderWidthnumber-横スクロール前の列幅と固定行ヘッダー幅です。

使い方

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>
  );
}

使用コンポーネント