フィルタチップFilterChipsBeta
横スクロールできる単一選択チップで、多数のカテゴリを素早く切り替えます。
プレビュー
状態とバリエーション
選択中
選択中のカテゴリは塗りつぶしチップで示し、下の一覧も同じ value で絞り込みます。
無効チップ
disabledReason を渡すと、選べない理由をホバーまたはフォーカスで説明します。
カテゴリが多いとき
入り切らない分は折り返さず、横へ流します。スクロールバーは隠してあるので、矢印キーで隣へ移れることがそのまま操作の担保になります。
折り返さずに横へ流れます。矢印キーで隣のチップへ移り、選んだチップまで自動で寄ります。
プロパティ
表は横にスクロールできます
| プロパティ | 型 | 初期値 | 説明 |
|---|---|---|---|
| items | FilterChip[] | - | 表示するチップです。value、label、icon、count、disabled、disabledReason を渡せます。 |
| value | string | - | 選択中の value です。 |
| onValueChange | (value: string) => void | - | チップ選択時に呼びます。 |
| aria-label | string | "絞り込み" | チップ群のアクセシブル名です。 |
使い方
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 の記事で解説しています。