フィルタチップFilterChipsBeta

横スクロールできる単一選択チップで、多数のカテゴリを素早く切り替えます。

プレビュー

状態とバリエーション

選択中

選択中のカテゴリは塗りつぶしチップで示し、下の一覧も同じ value で絞り込みます。

無効チップ

disabledReason を渡すと、選べない理由をホバーまたはフォーカスで説明します。

カテゴリが多いとき

入り切らない分は折り返さず、横へ流します。スクロールバーは隠してあるので、矢印キーで隣へ移れることがそのまま操作の担保になります。

折り返さずに横へ流れます。矢印キーで隣のチップへ移り、選んだチップまで自動で寄ります。

プロパティ

表は横にスクロールできます
プロパティ初期値説明
itemsFilterChip[]-表示するチップです。value、label、icon、count、disabled、disabledReason を渡せます。
valuestring-選択中の value です。
onValueChange(value: string) => void-チップ選択時に呼びます。
aria-labelstring"絞り込み"チップ群のアクセシブル名です。

使い方

import * as React from "react";
import { createPortal } from "react-dom";
import {
  Badge,
  FilterChips,
  ListCard,
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  type FilterChip,
} from "@gunjo/ui";
import {
  IconArmchair2,
  IconCup,
  IconDoorEnter,
  IconPlaneDeparture,
  IconToolsKitchen2,
  IconWifi,
} from "@tabler/icons-react";

const categories: FilterChip[] = [
  {
    value: "all",
    label: "すべて",
    count: 24,
    icon: <IconPlaneDeparture className="h-4 w-4" />,
  },
  { value: "gate", label: "搭乗口", count: 8, icon: <IconDoorEnter className="h-4 w-4" /> },
  {
    value: "lounge",
    label: "ラウンジ",
    count: 3,
    icon: <IconArmchair2 className="h-4 w-4" />,
  },
  {
    value: "food",
    label: "飲食",
    count: 6,
    icon: <IconToolsKitchen2 className="h-4 w-4" />,
  },
  { value: "cafe", label: "カフェ", count: 4, icon: <IconCup className="h-4 w-4" /> },
  { value: "wifi", label: "Wi-Fi", count: 2, icon: <IconWifi className="h-4 w-4" /> },
  {
    value: "smoking",
    label: "喫煙所",
    count: 0,
    disabled: true,
    disabledReason: "喫煙所は改修中のため、このフロアでは選択できません。",
  },
];

const facilities = [
  {
    id: "gate-12",
    category: "gate",
    title: "12番搭乗口",
    description: "国内線・保安検査場から徒歩4分",
    status: "搭乗中",
    meta: "4分",
  },
  {
    id: "sakura-lounge",
    category: "lounge",
    title: "サクララウンジ",
    description: "対象カード・上級会員向け",
    status: "空席あり",
    meta: "3F",
  },
  {
    id: "ramen",
    category: "food",
    title: "空港食堂 そら",
    description: "定食・ラーメン・朝食営業",
    status: "営業中",
    meta: "2F",
  },
  {
    id: "coffee",
    category: "cafe",
    title: "Blue Coffee Stand",
    description: "テイクアウト・電源席あり",
    status: "混雑",
    meta: "1F",
  },
  {
    id: "wifi-counter",
    category: "wifi",
    title: "Wi-Fi カウンター",
    description: "接続サポート・充電ケーブル貸出",
    status: "受付中",
    meta: "B1F",
  },
];

type PreviewSelection = { type: "facility"; facility: (typeof facilities)[number] };

export function FacilityFinder() {
  const rootRef = React.useRef<HTMLDivElement>(null);
  const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
  const [category, setCategory] = React.useState("all");
  const [selection, setSelection] = React.useState<PreviewSelection | null>(null);
  const visibleFacilities = category === "all" ? facilities : facilities.filter((facility) => facility.category === category);

  React.useEffect(() => {
    setPortalContainer(rootRef.current?.closest<HTMLElement>("[data-doc-component-preview-surface]") ?? rootRef.current);
  }, []);

  return (
    <div ref={rootRef} className="relative flex min-h-[360px] w-full max-w-2xl flex-col gap-4">
      <FilterChips
        items={categories}
        value={category}
        onValueChange={(nextValue) => {
          setCategory(nextValue);
          setSelection(null);
        }}
        aria-label="施設カテゴリ"
      />
      <div className="grid gap-2">
        {visibleFacilities.map((facility) => (
          <ListCard
            key={facility.id}
            title={facility.title}
            description={facility.description}
            status={<Badge variant="secondary">{facility.status}</Badge>}
            meta={facility.meta}
            selected={selection?.type === "facility" && selection.facility.id === facility.id}
            onSelect={() => setSelection({ type: "facility", facility })}
          />
        ))}
      </div>
      {selection && portalContainer
        ? createPortal(
          <div
            aria-hidden="true"
            className="absolute inset-0 z-40 rounded-md bg-overlay/60"
            onPointerDown={(event) => {
              event.preventDefault();
              setSelection(null);
            }}
          />,
          portalContainer
        )
        : null}
      <Sheet modal={false} 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.facility.title}
                </p>
                <p className="mt-1 text-xs text-muted-foreground">
                  {selection.facility.meta}
                </p>
              </div>
              <div className="rounded-md border bg-muted/30 px-3 py-2">
                <div className="flex items-center justify-between gap-2">
                  <p className="font-medium text-foreground">{selection.facility.title}</p>
                  <Badge variant="secondary">{selection.facility.status}</Badge>
                </div>
                <p className="mt-1 text-muted-foreground">{selection.facility.description}</p>
              </div>
            </div>
          ) : null}
        </SheetContent>
      </Sheet>
    </div>
  );
}

使用コンポーネント

設計の判断(UIXHERO)

「いつ・なぜ使うか」の判断は、姉妹サイト UIXHERO の記事で解説しています。