座席表SeatMapExperimental

座席や区画を2次元グリッドで表示し、空席、予約済み、確保中、選択状態を扱います。

プレビュー

A
B
C
D
E
F
空席特別席(非常口/足元ゆったり 等)選択中予約済確保中

選択中: 12A

状態とバリエーション

選択可能

空席を選択すると selectedIds が更新されます。特別席、予約済、確保中、選択中は Tooltip で理由を示します。

A
B
C
D
E
F
空席特別席(非常口/足元ゆったり 等)選択中予約済確保中

選択中: 12A

小さい座席表

通路の位置と列数を変えて小型の配置にも対応します。

A
B
C
D
空席特別席(非常口/足元ゆったり 等)選択中予約済確保中

選択中: 12A

凡例なし

周囲に凡例がある画面では hideLegend を使えます。状態の説明は周囲のUIに置く前提です。

A
B
C
D
E
F

選択中: 12A

プロパティ

表は横にスクロールできます
プロパティ初期値説明
columns(string | null)[]-列IDの並びです。null は通路として空けます。
seatsSeat[]-座席データです。id/row/col/state/type/position/fee を持ちます。
Seat.reasonReactNode | function-特別席、予約済、確保中、選択中など、座席状態の理由や補足をホバー/フォーカスで表示します。
selectedIdsstring[]-選択中の座席IDです。
maxSelectablenumber-選択上限です。
onToggle(seatId: string) => void-操作可能な空席を選択/解除した時に呼ばれます。
formatFee(fee: number) => string-アクセシブル名に含める料金表示です。関数propのため Client Component からのみ渡すこと(Server Component から渡すと next build が落ちる)。RSC 安全な整形には feeFormat を使う。
feeFormat"number" | "compact" | "integer" | Intl.NumberFormatOptions-シリアライズ可能な料金フォーマット=formatFee の RSC 安全な代替(例: { style: "currency", currency: "JPY" })。formatFee 指定時は無視。en-US ロケール固定。(#338)
labelsSeatMapLabels-状態、位置、凡例、座席名をローカライズします。
showHeaders / hideLegendboolean-列/行ヘッダーと凡例の表示を制御します。

使い方

import * as React from "react";
import { SeatMap, type Seat } from "@gunjo/ui";

const columns = ["A", "B", "C", null, "D", "E", "F"];
const seats: Seat[] = [
  {
    id: "12A",
    row: 12,
    col: "A",
    position: "window",
    type: "非常口座席",
    fee: 1500,
    reason: (_seat, context) =>
      context.selected
        ? "選択中です。非常口座席のため、緊急時の補助に同意できる利用者だけ選択できます。"
        : "非常口座席のため、緊急時の補助に同意できる利用者だけ選択できます。",
  },
  { id: "12B", row: 12, col: "B", state: "occupied", reason: "すでに予約済みのため選択できません。" },
  { id: "12C", row: 12, col: "C", position: "aisle" },
  { id: "12D", row: 12, col: "D", position: "aisle", type: "足元ゆったり", fee: 1200, reason: "足元ゆったり席です。追加料金がかかります。" },
  { id: "12E", row: 12, col: "E", state: "held", reason: "別の利用者が決済手続き中のため、数分間選択できません。" },
  { id: "12F", row: 12, col: "F", position: "window" },
  { id: "13A", row: 13, col: "A", position: "window" },
  { id: "13B", row: 13, col: "B" },
  { id: "13C", row: 13, col: "C", position: "aisle" },
  { id: "13D", row: 13, col: "D", position: "aisle" },
  { id: "13E", row: 13, col: "E" },
  { id: "13F", row: 13, col: "F", position: "window", state: "occupied", reason: "すでに予約済みのため選択できません。" },
];

export function SeatPicker() {
  const [selectedIds, setSelectedIds] = React.useState(["12A"]);
  const toggle = (seatId: string) =>
    setSelectedIds((current) => current.includes(seatId) ? current.filter((id) => id !== seatId) : [...current, seatId].slice(0, 2));

  return (
    <div className="flex w-full max-w-lg flex-col gap-3 rounded-lg border bg-card p-4">
      <SeatMap
        columns={columns}
        seats={seats}
        selectedIds={selectedIds}
        maxSelectable={2}
        onToggle={toggle}
        label="座席表"
        formatFee={(fee) => "¥" + fee.toLocaleString("ja-JP")}
      />
      <p className="rounded-md border bg-muted/30 px-3 py-2 text-sm text-muted-foreground" aria-live="polite">
        選択中: {selectedIds.join(", ") || "未選択"}
      </p>
    </div>
  );
}

使用コンポーネント