旅程ItineraryExperimental

日別またはフラットな旅程を、時刻、種別アイコン、詳細、操作付きの縦タイムラインで表示します。

プレビュー

1日目

6月27日(土)・東京 → ホノルル

2日目

6月28日(日)・オアフ島

状態とバリエーション

日別グループ

days を渡すと、日ごとの見出しとタイムラインで表示します。

1日目

6月27日(土)・東京 → ホノルル

2日目

6月28日(日)・オアフ島

フラット

items だけを渡すと、日別見出しなしの短い移動予定として使えます。

印の色みのすべて

tone は6種類。色みは種類の目印にすぎないので、印の中には必ずアイコンを置き、時刻は「終日」のような文字も受け取ります。

  1. 09:00
    集合
    第2ターミナル 3階 Fカウンター前
  2. 10:20
    NH852 便
    羽田 → 那覇
  3. 13:40
    離島フェリー
    泊港 3番桟橋
  4. 15:00
    チェックイン
    予約は確定済み
  5. 18:05
    乗り継ぎ 25分
    遅延すると次の便に間に合いません
  6. 終日
    自由行動
    予定は入れていません

プロパティ

表は横にスクロールできます
プロパティ初期値説明
daysItineraryDay[]-日別グループです。各日が見出しと timeline を持ちます。
itemsItineraryItem[]-日別グループなしのフラットな項目です。days がある場合は無視します。
ItineraryItem.timeReactNode-時刻や終日などのラベルです。
ItineraryItem.iconReactNode-種別を示すマーカーアイコンです。
ItineraryItem.tone"default" | "primary" | "info" | "success" | "warning" | "muted""default"マーカーのトーンです。
ItineraryItem.title / descriptionReactNode-主要行と副次行です。
ItineraryItem.contentReactNode-便詳細や地図などのリッチ内容を置くスロットです。
ItineraryItem.trailingReactNode-右側に置く Badge やメタ情報です。
ItineraryItem.onSelect() => void-行を44px以上のクリック可能領域にします。

使い方

import * as React from "react";
import { Badge, Itinerary, Sheet, SheetContent, SheetHeader, SheetTitle, type ItineraryDay } from "@gunjo/ui";
import { IconBuildingPavilion, IconPlane } from "@tabler/icons-react";

export function TripItinerary() {
  const [selectedId, setSelectedId] = React.useState<string | null>(null);
  const [portalContainer, setPortalContainer] = React.useState<HTMLDivElement | null>(null);
  const days: ItineraryDay[] = [
    {
      label: "1日目",
      sublabel: "6月27日(土)・東京 → ホノルル",
      items: [
        {
          id: "flight-out",
          time: "21:55 発 → 10:25 着(現地)",
          icon: <IconPlane className="size-4" />,
          tone: "primary",
          title: "NH182 羽田(HND) → ホノルル(HNL)",
          description: "往路・所要 約7時間30分・座席 32K",
          trailing: <Badge variant="secondary">往路</Badge>,
          onSelect: () => setSelectedId("flight-out"),
        },
        {
          id: "hotel",
          time: "チェックイン 15:00",
          icon: <IconBuildingPavilion className="size-4" />,
          tone: "success",
          title: "ハイアット リージェンシー ワイキキ",
          description: "3泊・予約番号 RZ8K4P",
          onSelect: () => setSelectedId("hotel"),
        },
      ],
    },
  ];
  const itemDetails = {
    "flight-out": {
      status: "確定",
      location: "羽田空港 第3ターミナル",
      reservation: "ANA 予約番号 8K2PQA / 座席 32K",
      next: "出発3時間前までにオンラインチェックインを完了",
    },
    hotel: {
      status: "予約済み",
      location: "2424 Kalakaua Ave, Honolulu",
      reservation: "RZ8K4P / 3泊 / オーシャンビュー",
      next: "到着後にパスポートと予約番号を提示",
    },
  };
  const toneStyles = {
    primary: "border-primary bg-primary/10",
    success: "border-success-border bg-success-subtle/55",
  } as const;
  const visibleItems = days.flatMap((day) => day.items);
  const selectedItem = selectedId ? visibleItems.find((item) => item.id === selectedId) : undefined;
  const selectedDetail = selectedId ? itemDetails[selectedId as keyof typeof itemDetails] : undefined;
  const selectedTone = selectedItem?.tone === "success" ? toneStyles.success : toneStyles.primary;

  return (
    <div ref={setPortalContainer} className="relative flex w-full max-w-xl flex-col gap-4 overflow-hidden rounded-lg border bg-card p-4">
      <Itinerary days={days} />
      <Sheet modal={false} open={selectedItem != null && selectedDetail != null} onOpenChange={(open) => !open && setSelectedId(null)}>
        <SheetContent portalContainer={portalContainer} overlayClassName="rounded-lg" closeLabel="閉じる" className="w-80 max-w-[calc(100%-1rem)] overflow-y-auto p-4">
        {selectedItem && selectedDetail ? (
          <>
          <SheetHeader className="pr-8">
            <SheetTitle>旅程詳細</SheetTitle>
          </SheetHeader>
          <div className={["rounded-lg border border-l-4 p-3 text-sm shadow-sm", selectedTone].join(" ")}>
            <div className="flex items-start justify-between gap-3">
              <div className="min-w-0">
                <p className="mt-1 truncate text-base font-semibold text-foreground">{selectedItem.title}</p>
              </div>
              <Badge variant="outline" className="shrink-0 border-border bg-background text-foreground shadow-sm">
                {selectedDetail.status}
              </Badge>
            </div>
            <dl className="mt-3 grid gap-2 text-xs">
              <div>
                <dt className="text-muted-foreground">予定時刻</dt>
                <dd className="font-medium text-foreground">{selectedItem.time}</dd>
              </div>
              <div>
                <dt className="text-muted-foreground">場所</dt>
                <dd className="font-medium text-foreground">{selectedDetail.location}</dd>
              </div>
              <div>
                <dt className="text-muted-foreground">予約情報</dt>
                <dd className="font-medium text-foreground">{selectedDetail.reservation}</dd>
              </div>
              <div>
                <dt className="text-muted-foreground">次の行動</dt>
                <dd className="font-medium text-foreground">{selectedDetail.next}</dd>
              </div>
            </dl>
          </div>
          </>
        ) : null}
        </SheetContent>
      </Sheet>
    </div>
  );
}

使用コンポーネント