HextaUI

Combobox

チップ、グループ、非同期の結果に対応した、絞り込み可能なセレクトです。入力に合わせてポップアップのサイズが変わります。

"use client"

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"

const fruits = [
  "Apple",
  "Apricot",
  "Banana",
  "Blackberry",
  "Blueberry",
  "Cherry",
  "Grape",
  "Grapefruit",
  "Kiwi",
  "Lychee",
  "Mango",
  "Orange",
  "Papaya",
  "Peach",
  "Pear",
  "Pineapple",
  "Plum",
  "Raspberry",
  "Strawberry",
  "Watermelon",
]

export function ComboboxDemo() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <Label htmlFor="combobox-demo">Fruit</Label>
      <Combobox items={fruits}>
        <ComboboxInput id="combobox-demo" placeholder="Select a fruit" />
        <ComboboxContent>
          <ComboboxEmpty>No fruit found.</ComboboxEmpty>
          <ComboboxList>
            {(item: string) => (
              <ComboboxItem key={item} value={item}>
                {item}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/combobox.json

コンポーネント、HextaUIのテーマトークン、依存するHextaUIコンポーネントを追加します。

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"
const fruits = ["Apple", "Banana", "Cherry"]

<Combobox items={fruits}>
  <ComboboxInput placeholder="Select a fruit" />
  <ComboboxContent>
    <ComboboxEmpty>No fruit found.</ComboboxEmpty>
    <ComboboxList>
      {(item: string) => (
        <ComboboxItem key={item} value={item}>
          {item}
        </ComboboxItem>
      )}
    </ComboboxList>
  </ComboboxContent>
</Combobox>

選択肢を items に渡し、<ComboboxList /> の内側の関数でそれぞれをレンダリングします。combobox は入力に合わせてフィルタリングし、一致したものだけをレンダリングします。オブジェクトも使え、その label が入力欄に表示され、value が送信されます。

フィールドに入力して、リストを絞り込みます。

Combobox
├── ComboboxInput
└── ComboboxContent
    ├── ComboboxEmpty
    ├── ComboboxStatus
    └── ComboboxList
        ├── ComboboxItem
        ├── ComboboxGroup
        │   ├── ComboboxLabel
        │   └── ComboboxCollection
        │       └── ComboboxItem
        └── ComboboxSeparator

ボタンが値を表示し、検索フィールドはポップアップ内に移動します。

Combobox
├── ComboboxTrigger
│   └── ComboboxValue
└── ComboboxContent
    ├── ComboboxInput
    ├── ComboboxEmpty
    └── ComboboxList
        └── ComboboxItem

multiple を指定すると、選択された各項目が入力欄の前にチップとして表示されます。

Combobox
├── ComboboxChips
│   └── ComboboxValue
│       ├── ComboboxChip
│       └── ComboboxChipsInput
└── ComboboxContent
    ├── ComboboxEmpty
    └── ComboboxList
        └── ComboboxItem

クリアボタン

showClear は、値がある間、山形アイコンの位置に入れ替わるクリアボタンを追加するため、フィールドが大きくなることはありません。

"use client"

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"

const fruits = [
  "Apple",
  "Apricot",
  "Banana",
  "Blackberry",
  "Blueberry",
  "Cherry",
  "Grape",
  "Grapefruit",
  "Kiwi",
  "Lychee",
  "Mango",
  "Orange",
  "Papaya",
  "Peach",
  "Pear",
  "Pineapple",
  "Plum",
  "Raspberry",
  "Strawberry",
  "Watermelon",
]

export function ComboboxWithClear() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <Label htmlFor="combobox-clear">Fruit</Label>
      <Combobox items={fruits} defaultValue="Mango">
        <ComboboxInput
          id="combobox-clear"
          placeholder="Select a fruit"
          showClear
        />
        <ComboboxContent>
          <ComboboxEmpty>No fruit found.</ComboboxEmpty>
          <ComboboxList>
            {(item: string) => (
              <ComboboxItem key={item} value={item}>
                {item}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
    </div>
  )
}

アイコンつき

項目内のアイコンは、サイズと色味が自動で調整されます。autoHighlight は入力中に最初の一致をハイライトするため、Enter でそれを選択できます。

"use client"

import type * as React from "react"
import {
  IconBrandAngular,
  IconBrandNextjs,
  IconBrandReact,
  IconBrandSvelte,
  IconBrandVue,
} from "@tabler/icons-react"

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"

type Framework = {
  value: string
  label: string
  icon: React.ComponentType<{ className?: string }>
}

const frameworks: Framework[] = [
  { value: "next", label: "Next.js", icon: IconBrandNextjs },
  { value: "react", label: "React", icon: IconBrandReact },
  { value: "vue", label: "Vue", icon: IconBrandVue },
  { value: "svelte", label: "Svelte", icon: IconBrandSvelte },
  { value: "angular", label: "Angular", icon: IconBrandAngular },
]

export function ComboboxWithIcons() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <Label htmlFor="combobox-icons">Framework</Label>
      <Combobox items={frameworks} autoHighlight>
        <ComboboxInput id="combobox-icons" placeholder="Select a framework" />
        <ComboboxContent>
          <ComboboxEmpty>No framework found.</ComboboxEmpty>
          <ComboboxList>
            {(item: Framework) => (
              <ComboboxItem key={item.value} value={item}>
                <item.icon />
                {item.label}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
    </div>
  )
}

グループとセパレーター

{ value, items } の形のグループを渡し、それぞれを <ComboboxGroup />、<ComboboxLabel />、<ComboboxCollection /> でレンダリングします。空のグループはフィルタリング中は非表示になります。

"use client"

import * as React from "react"

import {
  Combobox,
  ComboboxCollection,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxLabel,
  ComboboxList,
  ComboboxSeparator,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"

type Timezone = { value: string; label: string }
type TimezoneGroup = { value: string; items: Timezone[] }

const timezones: TimezoneGroup[] = [
  {
    value: "Americas",
    items: [
      { value: "America/New_York", label: "New York (GMT-4)" },
      { value: "America/Chicago", label: "Chicago (GMT-5)" },
      { value: "America/Los_Angeles", label: "Los Angeles (GMT-7)" },
      { value: "America/Sao_Paulo", label: "São Paulo (GMT-3)" },
    ],
  },
  {
    value: "Europe",
    items: [
      { value: "Europe/London", label: "London (GMT+1)" },
      { value: "Europe/Paris", label: "Paris (GMT+2)" },
      { value: "Europe/Berlin", label: "Berlin (GMT+2)" },
    ],
  },
  {
    value: "Asia",
    items: [
      { value: "Asia/Kolkata", label: "Kolkata (GMT+5:30)" },
      { value: "Asia/Tokyo", label: "Tokyo (GMT+9)" },
      { value: "Asia/Singapore", label: "Singapore (GMT+8)" },
    ],
  },
]

export function ComboboxGroups() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <Label htmlFor="combobox-groups">Timezone</Label>
      <Combobox items={timezones} autoHighlight>
        <ComboboxInput id="combobox-groups" placeholder="Select a timezone" />
        <ComboboxContent>
          <ComboboxEmpty>No timezone found.</ComboboxEmpty>
          <ComboboxList>
            {(group: TimezoneGroup, index: number) => (
              <React.Fragment key={group.value}>
                {index > 0 ? <ComboboxSeparator /> : null}
                <ComboboxGroup items={group.items}>
                  <ComboboxLabel>{group.value}</ComboboxLabel>
                  <ComboboxCollection>
                    {(item: Timezone) => (
                      <ComboboxItem key={item.value} value={item}>
                        {item.label}
                      </ComboboxItem>
                    )}
                  </ComboboxCollection>
                </ComboboxGroup>
              </React.Fragment>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
    </div>
  )
}

複数

multiple を指定すると、選択内容は <ComboboxChips /> の中でチップになります。選択している間もポップアップは開いたままで、空の入力欄で Backspace を押すと最後のチップが削除され、矢印キーでチップ間を移動できます。

"use client"

import type * as React from "react"
import {
  IconBrandAngular,
  IconBrandNextjs,
  IconBrandReact,
  IconBrandSvelte,
  IconBrandVue,
} from "@tabler/icons-react"

import {
  Combobox,
  ComboboxChip,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxItem,
  ComboboxList,
  ComboboxValue,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"

type Framework = {
  value: string
  label: string
  icon: React.ComponentType<{ className?: string }>
}

const frameworks: Framework[] = [
  { value: "next", label: "Next.js", icon: IconBrandNextjs },
  { value: "react", label: "React", icon: IconBrandReact },
  { value: "vue", label: "Vue", icon: IconBrandVue },
  { value: "svelte", label: "Svelte", icon: IconBrandSvelte },
  { value: "angular", label: "Angular", icon: IconBrandAngular },
]

export function ComboboxMultiple() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <Label htmlFor="combobox-multiple">Frameworks</Label>
      <Combobox
        items={frameworks}
        multiple
        autoHighlight
        defaultValue={[frameworks[0], frameworks[1]]}
      >
        <ComboboxChips>
          <ComboboxValue>
            {(values: Framework[]) => (
              <>
                {values.map((value) => (
                  <ComboboxChip key={value.value}>{value.label}</ComboboxChip>
                ))}
                <ComboboxChipsInput
                  id="combobox-multiple"
                  placeholder={values.length > 0 ? "" : "Add frameworks"}
                />
              </>
            )}
          </ComboboxValue>
        </ComboboxChips>
        <ComboboxContent>
          <ComboboxEmpty>No framework found.</ComboboxEmpty>
          <ComboboxList>
            {(item: Framework) => (
              <ComboboxItem key={item.value} value={item}>
                <item.icon />
                {item.label}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
    </div>
  )
}

ポップアップ内で検索

select のようなフィールドには <ComboboxTrigger /> を使います。入力欄を <ComboboxContent /> の内側に置くと、アイコン付きの検索ボックスになり、ポップアップは少なくとも 15rem の幅に広がります。

"use client"

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
  ComboboxValue,
} from "@/components/ui/combobox"

const countries = [
  { value: "ar", label: "Argentina" },
  { value: "au", label: "Australia" },
  { value: "br", label: "Brazil" },
  { value: "ca", label: "Canada" },
  { value: "de", label: "Germany" },
  { value: "fr", label: "France" },
  { value: "in", label: "India" },
  { value: "jp", label: "Japan" },
  { value: "mx", label: "Mexico" },
  { value: "nl", label: "Netherlands" },
  { value: "uk", label: "United Kingdom" },
  { value: "us", label: "United States" },
]

export function ComboboxPopupSearch() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <Combobox items={countries}>
        <ComboboxTrigger aria-label="Country">
          <ComboboxValue placeholder="Select a country" />
        </ComboboxTrigger>
        <ComboboxContent>
          <ComboboxInput placeholder="Search countries" />
          <ComboboxEmpty>No country found.</ComboboxEmpty>
          <ComboboxList>
            {(item: (typeof countries)[number]) => (
              <ComboboxItem key={item.value} value={item}>
                {item.label}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
    </div>
  )
}

Button としてレンダリングされたトリガー

トリガーに render を渡すと、任意のボタンを使えます。ポップアップはそれにアンカーされ、少なくともその幅を保ちます。

"use client"

import { Button } from "@/components/ui/button"
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
  ComboboxValue,
} from "@/components/ui/combobox"

const countries = [
  { value: "ar", label: "Argentina" },
  { value: "au", label: "Australia" },
  { value: "br", label: "Brazil" },
  { value: "ca", label: "Canada" },
  { value: "de", label: "Germany" },
  { value: "fr", label: "France" },
  { value: "in", label: "India" },
  { value: "jp", label: "Japan" },
  { value: "mx", label: "Mexico" },
  { value: "nl", label: "Netherlands" },
  { value: "uk", label: "United Kingdom" },
  { value: "us", label: "United States" },
]

export function ComboboxButtonTrigger() {
  return (
    <Combobox items={countries} defaultValue={countries[6]}>
      <ComboboxTrigger
        aria-label="Country"
        render={<Button variant="outline" />}
      >
        <ComboboxValue />
      </ComboboxTrigger>
      <ComboboxContent>
        <ComboboxInput placeholder="Search countries" />
        <ComboboxEmpty>No country found.</ComboboxEmpty>
        <ComboboxList>
          {(item: (typeof countries)[number]) => (
            <ComboboxItem key={item.value} value={item}>
              {item.label}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

制御

選択は value と onValueChange、ポップアップは open と onOpenChange で制御します。クリアすると値は null になります。

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"

const fruits = [
  "Apple",
  "Apricot",
  "Banana",
  "Blackberry",
  "Blueberry",
  "Cherry",
  "Grape",
  "Grapefruit",
  "Kiwi",
  "Lychee",
  "Mango",
  "Orange",
  "Papaya",
  "Peach",
  "Pear",
  "Pineapple",
  "Plum",
  "Raspberry",
  "Strawberry",
  "Watermelon",
]

export function ComboboxControlled() {
  const [value, setValue] = React.useState<string | null>("Peach")
  const [open, setOpen] = React.useState(false)

  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <Label htmlFor="combobox-controlled">Fruit</Label>
      <Combobox
        items={fruits}
        value={value}
        onValueChange={setValue}
        open={open}
        onOpenChange={setOpen}
      >
        <ComboboxInput
          id="combobox-controlled"
          placeholder="Select a fruit"
          showClear
        />
        <ComboboxContent>
          <ComboboxEmpty>No fruit found.</ComboboxEmpty>
          <ComboboxList>
            {(item: string) => (
              <ComboboxItem key={item} value={item}>
                {item}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
      <div className="flex items-center gap-2">
        <Button size="sm" variant="outline" onClick={() => setValue("Kiwi")}>
          Pick Kiwi
        </Button>
        <Button size="sm" variant="outline" onClick={() => setOpen(!open)}>
          {open ? "Close" : "Open"}
        </Button>
      </div>
      <p className="text-sm text-muted-foreground">Value: {value ?? "none"}</p>
    </div>
  )
}

無効、不正、無効な項目

ルートの disabled はフィールドとそのボタンを暗くします。入力欄の aria-invalid はエラーのリングを描画します。無効な項目は、矢印キーでスキップされます。

"use client"

import {
  Combobox,
  ComboboxContent,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"

const fruits = [
  "Apple",
  "Apricot",
  "Banana",
  "Blackberry",
  "Blueberry",
  "Cherry",
  "Grape",
  "Grapefruit",
  "Kiwi",
  "Lychee",
  "Mango",
  "Orange",
  "Papaya",
  "Peach",
  "Pear",
  "Pineapple",
  "Plum",
  "Raspberry",
  "Strawberry",
  "Watermelon",
]

export function ComboboxStates() {
  return (
    <div className="grid w-full max-w-xl gap-6 sm:grid-cols-2">
      <div className="flex flex-col gap-2">
        <Label htmlFor="combobox-disabled">Disabled</Label>
        <Combobox items={fruits} disabled defaultValue="Apple">
          <ComboboxInput id="combobox-disabled" showClear />
          <ComboboxContent>
            <ComboboxList>
              {(item: string) => (
                <ComboboxItem key={item} value={item}>
                  {item}
                </ComboboxItem>
              )}
            </ComboboxList>
          </ComboboxContent>
        </Combobox>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="combobox-invalid">Invalid</Label>
        <Combobox items={fruits}>
          <ComboboxInput
            id="combobox-invalid"
            aria-invalid
            placeholder="Required"
          />
          <ComboboxContent>
            <ComboboxList>
              {(item: string) => (
                <ComboboxItem
                  key={item}
                  value={item}
                  disabled={item.startsWith("B")}
                >
                  {item}
                </ComboboxItem>
              )}
            </ComboboxList>
          </ComboboxContent>
        </Combobox>
        <p className="text-sm text-muted-foreground">
          Items starting with B are disabled.
        </p>
      </div>
    </div>
  )
}

長いコンテンツと大きなリスト

長いラベルや区切りのないラベルは、ポップアップを広げずに折り返されます。limit はレンダリングする一致の数を制限するため、500 件のリストも高速に保たれます。

"use client"

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"

const longItems = [
  "A very long option label that wraps onto a second line instead of pushing the popup wider than its input",
  "supercalifragilisticexpialidocious-unbroken-string-without-any-spaces-at-all-anywhere",
  "olivia.martin+newsletter-subscriptions@a-very-long-company-domain.example.com",
  "👩‍👩‍👧‍👦 Family 🧑🏽‍💻 Developer 🏳️‍🌈",
  "東京都千代田区丸の内一丁目",
  "",
  "Short",
]

const manyItems = Array.from({ length: 500 }, (_, index) => `Item ${index + 1}`)

export function ComboboxLongContent() {
  return (
    <div className="grid w-full max-w-xl gap-6 sm:grid-cols-2">
      <div className="flex w-full max-w-60 flex-col gap-2">
        <Label htmlFor="combobox-long">Long labels</Label>
        <Combobox items={longItems} defaultValue={longItems[1]}>
          <ComboboxInput id="combobox-long" placeholder="Pick one" showClear />
          <ComboboxContent>
            <ComboboxEmpty>
              Nothing matches this unusually long query, try something else.
            </ComboboxEmpty>
            <ComboboxList>
              {(item: string) => (
                <ComboboxItem key={item} value={item}>
                  {item || "(empty)"}
                </ComboboxItem>
              )}
            </ComboboxList>
          </ComboboxContent>
        </Combobox>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="combobox-many">500 items</Label>
        <Combobox items={manyItems} limit={100}>
          <ComboboxInput id="combobox-many" placeholder="Search items" />
          <ComboboxContent>
            <ComboboxEmpty>No item found.</ComboboxEmpty>
            <ComboboxList>
              {(item: string) => (
                <ComboboxItem key={item} value={item}>
                  {item}
                </ComboboxItem>
              )}
            </ComboboxList>
          </ComboboxContent>
        </Combobox>
        <p className="text-sm text-muted-foreground">
          Shows the first 100 matches.
        </p>
      </div>
    </div>
  )
}

filter={null} で組み込みのフィルタリングをオフにし、onInputValueChange で取得を行い、進行状況は <ComboboxStatus /> で表示します。これはスクリーンリーダーにも通知されます。結果が変わると、ポップアップの高さがアニメーションします。

"use client"

import * as React from "react"
import { IconLoader2 } from "@tabler/icons-react"

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxStatus,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"

const countries = [
  { value: "ar", label: "Argentina" },
  { value: "au", label: "Australia" },
  { value: "br", label: "Brazil" },
  { value: "ca", label: "Canada" },
  { value: "de", label: "Germany" },
  { value: "fr", label: "France" },
  { value: "in", label: "India" },
  { value: "jp", label: "Japan" },
  { value: "mx", label: "Mexico" },
  { value: "nl", label: "Netherlands" },
  { value: "es", label: "Spain" },
  { value: "se", label: "Sweden" },
  { value: "ch", label: "Switzerland" },
  { value: "za", label: "South Africa" },
  { value: "kr", label: "South Korea" },
  { value: "uk", label: "United Kingdom" },
  { value: "us", label: "United States" },
]

export function ComboboxAsync() {
  const [query, setQuery] = React.useState("")
  const [results, setResults] = React.useState(countries.slice(0, 5))
  const [loading, setLoading] = React.useState(false)
  const runRef = React.useRef(0)

  React.useEffect(() => {
    const run = ++runRef.current
    const timer = setTimeout(() => {
      setLoading(true)
      setTimeout(() => {
        if (run !== runRef.current) {
          return
        }
        const needle = query.trim().toLowerCase()
        setResults(
          countries.filter((country) =>
            country.label.toLowerCase().includes(needle)
          )
        )
        setLoading(false)
      }, 600)
    }, 150)
    return () => {
      clearTimeout(timer)
      runRef.current += 1
    }
  }, [query])

  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <Label htmlFor="combobox-async">Country</Label>
      <Combobox
        items={results}
        filter={null}
        inputValue={query}
        onInputValueChange={setQuery}
      >
        <ComboboxInput id="combobox-async" placeholder="Search countries" />
        <ComboboxContent>
          <ComboboxStatus>
            {loading ? (
              <>
                <IconLoader2 className="animate-spin" />
                Searching…
              </>
            ) : null}
          </ComboboxStatus>
          {loading ? null : (
            <ComboboxEmpty>No country matches “{query}”.</ComboboxEmpty>
          )}
          <ComboboxList>
            {(item: (typeof countries)[number]) => (
              <ComboboxItem key={item.value} value={item}>
                {item.label}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
    </div>
  )
}

シートの中

ポップアップはシートの上に重なり、Escape はシートより先にポップアップを閉じます。

"use client"

import { Button } from "@/components/ui/button"
import {
  Combobox,
  ComboboxCollection,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxLabel,
  ComboboxList,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"
import {
  Sheet,
  SheetBody,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

type Timezone = { value: string; label: string }
type TimezoneGroup = { value: string; items: Timezone[] }

const timezones: TimezoneGroup[] = [
  {
    value: "Americas",
    items: [
      { value: "America/New_York", label: "New York (GMT-4)" },
      { value: "America/Chicago", label: "Chicago (GMT-5)" },
      { value: "America/Los_Angeles", label: "Los Angeles (GMT-7)" },
      { value: "America/Sao_Paulo", label: "São Paulo (GMT-3)" },
    ],
  },
  {
    value: "Europe",
    items: [
      { value: "Europe/London", label: "London (GMT+1)" },
      { value: "Europe/Paris", label: "Paris (GMT+2)" },
      { value: "Europe/Berlin", label: "Berlin (GMT+2)" },
    ],
  },
  {
    value: "Asia",
    items: [
      { value: "Asia/Kolkata", label: "Kolkata (GMT+5:30)" },
      { value: "Asia/Tokyo", label: "Tokyo (GMT+9)" },
      { value: "Asia/Singapore", label: "Singapore (GMT+8)" },
    ],
  },
]

export function ComboboxInSheet() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>
        Open sheet
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>Preferences</SheetTitle>
          <SheetDescription>
            Choose the timezone for your reports.
          </SheetDescription>
        </SheetHeader>
        <SheetBody>
          <div className="flex flex-col gap-2">
            <Label htmlFor="combobox-sheet">Timezone</Label>
            <Combobox items={timezones} autoHighlight>
              <ComboboxInput
                id="combobox-sheet"
                placeholder="Select a timezone"
              />
              <ComboboxContent>
                <ComboboxEmpty>No timezone found.</ComboboxEmpty>
                <ComboboxList>
                  {(group: TimezoneGroup) => (
                    <ComboboxGroup key={group.value} items={group.items}>
                      <ComboboxLabel>{group.value}</ComboboxLabel>
                      <ComboboxCollection>
                        {(item: Timezone) => (
                          <ComboboxItem key={item.value} value={item}>
                            {item.label}
                          </ComboboxItem>
                        )}
                      </ComboboxCollection>
                    </ComboboxGroup>
                  )}
                </ComboboxList>
              </ComboboxContent>
            </Combobox>
          </div>
        </SheetBody>
      </SheetContent>
    </Sheet>
  )
}

右から左

ポップアップはフィールドの方向を引き継ぐため、クリアボタン、チップ、項目は、追加の props なしで反転します。

"use client"

import { DirectionProvider } from "@base-ui/react/direction-provider"

import {
  Combobox,
  ComboboxChip,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxValue,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"

const cities = ["القاهرة", "الرياض", "دبي", "بيروت", "عمّان", "الدوحة"]

export function ComboboxRtl() {
  return (
    <DirectionProvider direction="rtl">
      <div dir="rtl" className="flex w-full max-w-xs flex-col gap-4">
        <div className="flex flex-col gap-2">
          <Label htmlFor="combobox-rtl">المدينة</Label>
          <Combobox items={cities} defaultValue={cities[2]}>
            <ComboboxInput
              id="combobox-rtl"
              placeholder="اختر مدينة"
              showClear
            />
            <ComboboxContent>
              <ComboboxEmpty>لا توجد نتائج.</ComboboxEmpty>
              <ComboboxList>
                {(item: string) => (
                  <ComboboxItem key={item} value={item}>
                    {item}
                  </ComboboxItem>
                )}
              </ComboboxList>
            </ComboboxContent>
          </Combobox>
        </div>
        <div className="flex flex-col gap-2">
          <Label htmlFor="combobox-rtl-chips">المدن</Label>
          <Combobox items={cities} multiple defaultValue={[cities[0]]}>
            <ComboboxChips>
              <ComboboxValue>
                {(values: string[]) => (
                  <>
                    {values.map((value) => (
                      <ComboboxChip key={value}>{value}</ComboboxChip>
                    ))}
                    <ComboboxChipsInput id="combobox-rtl-chips" />
                  </>
                )}
              </ComboboxValue>
            </ComboboxChips>
            <ComboboxContent>
              <ComboboxList>
                {(item: string) => (
                  <ComboboxItem key={item} value={item}>
                    {item}
                  </ComboboxItem>
                )}
              </ComboboxList>
            </ComboboxContent>
          </Combobox>
        </div>
      </div>
    </DirectionProvider>
  )
}
キーアクション
↓↑ポップアップを開き、ハイライトを一致の間で移動します。無効な項目はスキップされます。
Enterハイライトされた項目を選択します。何もハイライトされていない場合は、ポップアップを閉じてフォームを送信できる状態にします。
Escapeポップアップを閉じます。すでに閉じている場合は、値と入力内容をクリアします。
HomeEndテキストカーソルを入力欄の先頭または末尾に移動します。
Backspace空のチップ入力欄では、最後のチップを削除します。フォーカス中のチップでは、そのチップを削除します。
←→チップがある場合、フォーカスをチップ間で、また入力欄へ戻る方向に移動します。右から左のレイアウトでは反転します。
Tabポップアップを閉じ、フォーカスを次へ移動します。
  • id と htmlFor で入力欄に見える <label> を付けるか、aria-label を付けてください。表示テキストのない <ComboboxTrigger /> にも aria-label が必要です。
  • 山形アイコンのボタンには「Show options」、クリアボタンには「Clear selection」、各チップの削除ボタンには「Remove」というラベルが付きます。
  • ハイライトは aria-activedescendant で移動するため、閲覧中もフォーカスは入力欄に残ります。
  • タッチスクリーンでは、iOS でズームされないよう入力欄に 16px のフォントを使い、項目は 44px のタップターゲットに拡大されます。

Base UI の combobox をベースにしています。各パーツは、ラップしているプリミティブの props を受け付けます。表には、よく使うものを掲載しています。

プロパティ型デフォルト
items選択肢。入力に合わせてフィルタリングされ、リストの render 関数に渡されます。
Item[] | Group[]–
multiple複数の値を選択し、チップとして表示します。
booleanfalse
value
Value | Value[] | null–
defaultValue
Value | Value[] | null–
onValueChange
(value, details) => void–
open
boolean–
defaultOpen
booleanfalse
onOpenChange
(open: boolean, details) => void–
inputValue
string–
defaultInputValue
string–
onInputValueChange
(inputValue: string, details) => void–
filterカスタムの一致判定。null を指定すると、サーバー側検索向けにフィルタリングをオフにします。
((item, query, itemToString) => boolean) | null–
limitレンダリングする一致の最大数。-1 はすべてを意味します。
number-1
autoHighlight入力中に最初の一致をハイライトします。
booleanfalse
highlightItemOnHover
booleantrue
openOnInputClick
booleantrue
loopFocusハイライトを最後の項目から最初の項目へ回り込ませます。
booleantrue
itemToStringLabelオブジェクトの項目について、入力欄に表示されるテキスト。
(item) => string–
itemToStringValueオブジェクトの項目について、フォームと一緒に送信される値。
(item) => string–
isItemEqualToValue
(item, value) => boolean–
name
string–
required
booleanfalse
disabled
booleanfalse
readOnly
booleanfalse
modal開いている間、ページのスクロールと外側のクリックをロックします。
booleanfalse
virtualized仮想化ライブラリで項目をレンダリングするときに設定します。
booleanfalse
locale一致判定に使われるロケール。
Intl.LocalesArgument–

ポップアップの外側では完全なフィールドをレンダリングします。<ComboboxContent /> の内側では、コンパクトな検索ボックスになります。

プロパティ型デフォルト
showTrigger山形アイコンのボタンを表示します。ポップアップ内では、設定しない限り常にオフです。
booleantrue outside the popup
showClear値がある間、山形アイコンの位置にクリアボタンを表示します。
booleanfalse
className入力欄を囲む input group に適用されます。
string–
disabled
booleanfalse
placeholder
string–
属性説明
data-slot="combobox-input-group"入力欄を囲むフィールド。
data-slot="combobox-input"テキスト入力欄。
data-slot="combobox-input-actions"山形アイコンとクリアボタンを、重ねた 1 つのセルに収めます。
data-popup-openポップアップが開いている間、入力欄に付与されます。
data-popup-sideポップアップが開いた側。
data-list-empty一致するものがないときに付与されます。
data-disabled無効のときに存在します。
data-invalidBase UI の Field 内で無効なときに付与されます。
プロパティ型デフォルト
children通常は <ComboboxValue /> です。山形アイコンはその後ろに追加されます。
ReactNode–
render設定すると、組み込みのフィールドスタイルがスキップされます。
ReactElement | (props, state) => ReactElement<button>
属性説明
data-slot="combobox-trigger"トリガーボタン。
data-slot="combobox-trigger-value"省略された値を包みます。
data-slot="combobox-trigger-icon"山形アイコン。開いている間は反転します。
data-popup-openポップアップが開いている間、付与されます。
data-placeholder値が選択されていない間、付与されます。
プロパティ型デフォルト
children選択された値を、たとえばチップとして、自分でレンダリングします。
ReactNode | (value) => ReactNode–
placeholder何も選択されていない間に表示されます。
ReactNode–
プロパティ型デフォルト
side
"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end""bottom"
align
"start" | "center" | "end""start"
sideOffset
number6
alignOffset
number0
anchor別の要素を基準に配置します。デフォルトはフィールドです。useComboboxAnchor を参照してください。
Element | RefObject<Element | null> | VirtualElement | (() => Element | VirtualElement | null) | null–
dirデフォルトは、フィールドの方向です。
"ltr" | "rtl"–
属性説明
data-slot="combobox-positioner"ポップアップの位置を決めます。
data-slot="combobox-content"ポップアップの面。
data-slot="combobox-content-sizer"一致の変化に合わせてポップアップの高さをアニメーションさせるために計測されます。
data-open開いている間存在します。
data-side開いた側。
data-alignその配置。
data-empty一致するものがないときに付与されます。
data-starting-style表示アニメーション中に付与されます。
data-ending-style非表示アニメーション中に付与されます。
--combobox-item-radius項目の角丸の半径。ポップアップの角丸からパディングを引いた値です。
プロパティ型デフォルト
childrenitems の一致ごとに呼ばれます。
ReactNode | (item, index) => ReactNode–
属性説明
data-slot="combobox-list"スクロールするリスト。
プロパティ型デフォルト
valueこの行が表す項目。
Item–
disabled
booleanfalse
render
ReactElement | (props, state) => ReactElement<div>
属性説明
data-slot="combobox-item"選択肢。
data-slot="combobox-item-indicator"選択時にスケールインして表示されるチェックマーク。
data-highlightedハイライトされている間存在します。
data-selected選択されているときに付与されます。
data-disabled無効のときに存在します。
プロパティ型デフォルト
itemsComboboxGroup では、グループ自身の項目。
Item[]–
childrenComboboxCollection では、各一致をレンダリングします。
(item, index) => ReactNode–
属性説明
data-slot="combobox-group"項目のグループ。
data-slot="combobox-label"グループの見出し。

<ComboboxEmpty /> は、一致するものがないときだけ子要素を表示します。<ComboboxStatus /> は、読み込み中や結果のメッセージ用のライブリージョンです。どちらも空のときは何も表示されません。

属性説明
data-slot="combobox-empty"結果なしのメッセージ。
data-slot="combobox-status"ライブのステータスメッセージ。
data-slot="combobox-separator"グループ間の区切り線。
プロパティ型デフォルト
children
ReactNode<IconX />
属性説明
data-slot="combobox-clear"「Clear selection」というラベルが付きます。
data-visibleクリアできるものがある間、付与されます。
プロパティ型デフォルト
classNameチップを囲むフィールドに適用されます。
string–
属性説明
data-slot="combobox-chips"チップと入力欄を保持するフィールド。
プロパティ型デフォルト
showRemove削除ボタンを表示します。
booleantrue
属性説明
data-slot="combobox-chip"選択された値。
data-slot="combobox-chip-label"省略されたラベル。
data-slot="combobox-chip-remove"「Remove」というラベルが付きます。

チップの後ろに置かれるテキスト入力欄。Base UI の input と同じ props を受け付けます。

属性説明
data-slot="combobox-chips-input"チップの入力欄。
  • useComboboxAnchor() は、要素と、コンテンツの anchor に渡す ref を返します。
  • useComboboxFilter() は、filter 向けに、ロケールに対応した contains、startsWith、endsWith のマッチャーを返します。
  • useComboboxFilteredItems() は、件数表示や仮想化リスト向けに、現在の一致を読み取ります。
  • createComboboxItems(data, { getValue }) は、選択値がオブジェクト全体ではなく、データベースのキーのようなプリミティブな id になる項目コレクションを作ります。
  • comboboxFieldVariants は、カスタムのフィールドを作るためのフィールドスタイルを公開します。