Combobox

Comboboxは、入力フィールドと候補リストを組み合わせて、ユーザーがテキスト入力または候補の選択によって値を指定できるコンポーネントです。
検索やフィルタリングによって、効率的に目的の項目を探すことができます。

プロパティ

名前 デフォルト値 説明
options ComboboxOption[] 選択肢の配列です。
placeholder string プレースホルダー 入力欄のプレースホルダーです。
disabled boolean false コンボボックスを無効化します。無効化されたコンボボックスは選択できません。
isError boolean false エラー状態を視覚的に示します。バリデーション用など。
multi boolean false true にすると複数選択が可能になります。
open boolean false コンボボックスの候補リストの開閉状態を制御します。
showSelect boolean false 選択した値を表示します。(multiと併用で複数選択表示可能)

ComboboxOptionItem

名前 デフォルト値 説明
label string 選択肢のラベルです。
value string | number 選択肢の値です。

インストールの手順

以下のコンポーネントのコードを、使いたいプロジェクトにコピー&ペーストします。
パスは実際のプロジェクトの構成にあわせて更新します。

atoms/Combobox.svelte
        <!--
@component
## 概要
- 検索入力と選択リストを内包したコンボボックスコンポーネントです。

## 機能
- フォーカス/クリッでリストボックスが開き、上部に検索入力、下部にリストが表示されます
- フラグに応じて複数/単体選択に対応
- キーボード操作対応(Enter 決定 / ↑↓ 移動 / Esc 閉じる)

## Props
- options: 選択肢のリスト
- placeholder: 検索入力のプレースホルダー
- isError: true の場合エラー枠を表示します
- disabled: 指定するとグレーアウトされ、入力・選択不可になります
- multi: 複数選択を許可するかどうか
- emptyView: 候補がない場合に表示する
- open: リストボックスの開閉状態を外部から制御する

## Usage
```svelte
<Combobox {options} placeholder="検索…" bind:value />
```
-->

<script module lang="ts">
  import type { Snippet } from 'svelte';
  import type { ClassValue } from 'svelte/elements';
  import { cva } from 'class-variance-authority';

  const MAX_VISIBLE_OPTIONS = 5;

  export const comboboxListVariants = cva('absolute top-full z-50 w-full px-1 bg-base-container-default border border-base-stroke-default rounded-md shadow-[0px_4px_6px_-1px_rgba(0,_0,_0,_0.10),_0px_2px_4px_-1px_rgba(0,_0,_0,_0.06)] overflow-hidden origin-top mt-1 focus-visible:outline-[0.125rem] focus-visible:outline-offset-[0.125rem] focus-visible:outline-primary');

  export interface ComboboxOption {
    /** 選択肢のラベル */
    label: string;
    /** 選択肢の値 */
    value: any;
  }

  export interface ComboboxProps {
    /** リストボックスとコントロールの関連付けに使用されるDOM ID */
    id?: string;
    /** 現在選択している値(複数選択) */
    value: any[];
    /** 入力欄のプレースホルダー */
    placeholder?: string;
    /** 追加クラス */
    class?: ClassValue;
    /** 表示する選択肢 */
    options: ComboboxOption[];
    /** エラー枠の表示 */
    isError?: boolean;
    /** 入力/選択不可 */
    disabled?: boolean;
    /** 複数選択を許可する場合に true */
    multi?: boolean;
    /** 候補がない場合に表示する */
    emptyView?: Snippet<[]>;
    /** リストボックスの開閉状態を外部から制御する */
    open?: boolean;
    /** 選択した値を表示します(multiと併用で複数選択表示) */
    showSelect?: boolean;
  }
</script>

<script lang="ts">
  import { inputVariants } from '$lib/components/ui/atoms/Input.svelte';
  import { Check, ChevronDown, ChevronUp, Search, X } from '@lucide/svelte';
  import { tick, untrack } from 'svelte';
  import { scale } from 'svelte/transition';

  let { id, value = $bindable<any[]>([]), placeholder = 'プレースホルダー', class: className, isError = false, disabled = false, options = [], multi = false, emptyView, open = $bindable(false), showSelect = false }: ComboboxProps = $props();

  /** 検索クエリ */
  let query = $state('');

  let rootEl = $state<HTMLDivElement | null>(null);
  /** 実DOMの input 参照 */
  let inputEl = $state<HTMLInputElement | null>(null);
  /** リストボックスのコンテナ */
  let listContainer = $state<HTMLDivElement>();
  /** 候補リスト要素 */
  let listEl = $state<HTMLDivElement>();
  /** キーボード操作用のアクティブ項目インデックス */
  let activeIndex = $state<number>(-1);
  /** キーボード操作での移動中は hover の反映を抑える */
  let isKeyboardNavigating = $state(false);

  /** 選択値の高速参照用 Set */
  let selectedSet = $derived(new Set(value));

  /** クエリに応じて候補をフィルタ */
  let filtered = $derived.by(() => {
    const q = query.trim().toLowerCase();
    if (!q) return options;
    return options.filter((o) => o.label.toLowerCase().includes(q) || String(o.value).toLowerCase().includes(q));
  });

  /** リストの最大高さを超えるか */
  let limitedHeight = $derived.by(() => filtered.length > MAX_VISIBLE_OPTIONS);

  // スクロールボタン制御
  let isScrolledToTop = $state(true);
  let isScrolledToBottom = $state(false);
  let showScrollButtons = $derived.by(() => filtered.length > MAX_VISIBLE_OPTIONS);
  let scrollInterval: ReturnType<typeof setInterval> | null = null;

  // options を value で引けるようにする
  let optionByValue = $derived.by(() => new Map(options.map((o) => [o.value, o] as const)));

  // 選択済みオプション(label表示用)
  let selectedOptions = $derived.by(() =>
    value.flatMap((v) => {
      const found = optionByValue.get(v);
      return found ? [found] : [];
    }),
  );

  /** 選択中のlabelを取得 */
  let selectedLabel = $derived.by(() => {
    if (!showSelect || multi || value.length === 0) return '';
    return optionByValue.get(value[0])?.label ?? '';
  });

  let inputVariantsClass = $derived(inputVariants({ isError, disabled }));

  /**
   * リストの自動スクロールを開始。
   * @param direction スクロール方向(上 or 下)
   */
  function startScroll(direction: 'up' | 'down') {
    if (scrollInterval || !listEl) return;
    scrollInterval = setInterval(() => {
      listEl?.scrollBy({ top: direction === 'up' ? -20 : 20, behavior: 'smooth' });
    }, 50);
  }

  /**
   * 自動スクロールを停止。
   */
  function stopScroll() {
    if (scrollInterval) {
      clearInterval(scrollInterval);
      scrollInterval = null;
    }
  }

  /**
   * スクロール端を検知して、端に到達したら自動スクロールを停止。
   */
  function onScrollList() {
    if (!listEl) return;
    isScrolledToTop = listEl.scrollTop === 0;
    isScrolledToBottom = Math.abs(listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight) < 1;
    if (isScrolledToBottom || isScrolledToTop) {
      stopScroll();
    }
  }

  /**
   * リストボックスを開き、inputにフォーカス。
   * @returns DOM 更新待機を含むため非同期
   */
  async function openListbox() {
    if (disabled) {
      open = false;
      return;
    }
    if (open) return;
    open = true;
  }

  /**
   * リストボックスを閉じ、検索クエリとハイライトをリセット。
   */
  function closeListbox() {
    if (!open) return;
    open = false;
  }

  /** option要素をリスト内の表示範囲に納める */
  async function focusActiveOption() {
    await tick();
    const items = listEl?.querySelectorAll('[role="option"]');
    const item = items && items[activeIndex];
    if (item instanceof HTMLElement) {
      item.scrollIntoView({ block: 'nearest' });
    }
  }

  /** リストボックスを開いた直後の初期状態を適用する */
  async function applyOpenState() {
    if (disabled) {
      open = false;
      return;
    }
    await tick();
    inputEl?.focus();
    listEl?.scrollTo({ top: 0 });
    const first_selected_index = filtered.findIndex((o) => selectedSet.has(o.value));
    activeIndex = first_selected_index >= 0 ? first_selected_index : filtered.length > 0 ? 0 : -1;
  }

  /** リストボックスを閉じた時の処理 */
  function applyClosedState() {
    activeIndex = -1;
    isKeyboardNavigating = false;
    stopScroll();
  }

  $effect(() => {
    if (open) {
      untrack(() => {
        applyOpenState();
      });
      return;
    }
    untrack(() => {
      applyClosedState();
    });
  });

  /** 選択した値をクリアする */
  function resetState() {
    value = [];
    query = '';
  }

  /**
   * リスト外のクリックでリストボックスを閉じる。
   * @param e クリックイベント
   */
  function onOutsideClick(e: MouseEvent) {
    const t = e.target;
    if (!(t instanceof Node)) return;
    if (rootEl?.contains(t)) return;
    if (listContainer?.contains(t)) return;
    if (open) closeListbox();
  }

  /**
   * 入力欄のキーハンドリング。
   * @param e キーボードイベント
   * @returns DOM 更新待機を含むため非同期
   */
  async function onInputKeydown(e: KeyboardEvent) {
    if (e.isComposing) {
      return;
    }
    const target = e.currentTarget;
    if (target instanceof HTMLInputElement) {
      inputEl = target;
    }
    if (!open) {
      if (e.key === 'Tab' || e.key === 'Escape') {
        return;
      }
      const open_key = e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown';
      if (open_key) {
        e.preventDefault();
        isKeyboardNavigating = e.key === 'ArrowDown';
        await openListbox();
        return;
      }
      const printable_key = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;
      const edit_key = e.key === 'Backspace' || e.key === 'Delete';
      if (printable_key || edit_key) {
        await openListbox();
      }
    }

    if (e.key === 'Tab' && open) {
      closeListbox();
      return;
    }

    if (e.key === 'ArrowDown') {
      // 次の項目
      e.preventDefault();
      isKeyboardNavigating = true;
      if (filtered.length === 0) return;
      activeIndex = activeIndex < 0 ? 0 : Math.min(activeIndex + 1, filtered.length - 1);
      await focusActiveOption();
    }
    else if (e.key === 'ArrowUp') {
      // 前の項目
      e.preventDefault();
      isKeyboardNavigating = true;
      if (filtered.length === 0) return;
      activeIndex = activeIndex < 0 ? filtered.length - 1 : Math.max(activeIndex - 1, 0);
      await focusActiveOption();
    }
    else if (e.key === 'Enter') {
      // アクティブ項目の選択/解除
      if (activeIndex >= 0 && activeIndex < filtered.length) {
        e.preventDefault();
        onToggle(filtered[activeIndex].value);
      }
    }
    else if (e.key === 'Escape') {
      // クローズ
      e.preventDefault();
      closeListbox();
    }
  }

  /**
   * 項目にフォーカスがある場合のキーハンドリング。
   * @param e キーボードイベント
   * @param optionValue 対象のオプションの値
   */
  function onItemKeydown(e: KeyboardEvent, optionValue: any) {
    if (e.isComposing) {
      return;
    }
    const target = e.currentTarget;
    if (!(target instanceof HTMLElement)) return;
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      onToggle(optionValue);
      activeIndex = -1;
    }
    else if (e.key === 'ArrowDown') {
      e.preventDefault();
      isKeyboardNavigating = true;
      if (target.nextElementSibling instanceof HTMLElement) target.nextElementSibling.focus();
    }
    else if (e.key === 'ArrowUp') {
      e.preventDefault();
      isKeyboardNavigating = true;
      if (target.previousElementSibling instanceof HTMLElement) target.previousElementSibling.focus();
    }
    else if (e.key === 'Escape') {
      e.preventDefault();
      closeListbox();
    }
  }

  /**
   * 値のトグル選択。
   * @param v 選択・解除したい値
   */
  function onToggle(v: any) {
    let selection = new Set(value);

    if (multi) {
      if (selection.has(v)) selection.delete(v);
      else selection.add(v);
      value = Array.from(selection);
      query = '';
      return;
    }

    if (selection.has(v)) {
      selection.delete(v);
      value = Array.from(selection);
    }
    else {
      selection.clear();
      selection.add(v);
      value = Array.from(selection);
    }

    closeListbox();
  }

  /**
   * 項目クリック時の処理(主に multi モード用)
   * @param e クリックイベント
   * @param optionValue 選択した値
   * @param index 項目のインデックス
   */
  function onClickOption(e: MouseEvent, optionValue: any, index: number) {
    e.preventDefault();
    e.stopPropagation();

    onToggle(optionValue);

    if (multi) {
      isKeyboardNavigating = false;
      activeIndex = index;
      inputEl?.focus();
      return;
    }

    closeListbox();
  }
</script>

<svelte:window onclick={onOutsideClick} oncontextmenu={onOutsideClick} />

<div
  class={[
    className,
    inputVariantsClass,
    'relative flex items-center justify-center',
    'focus-within:outline-2 focus-within:outline-primary',
  ]}
  bind:this={rootEl}
  tabindex={0}
  role="combobox"
  aria-controls={id}
  aria-expanded={open}
  onkeydown={onInputKeydown}
  onclick={() => {
    if (!open) openListbox();
  }}
  onfocus={async () => {
    if (!open) await openListbox();
  }}
>
  <div class="flex items-center justify-center w-full gap-1">
    <!-- 左アイコン: 検索 -->
    <Search class={['size-4 my-auto', disabled ? 'text-base-foreground-muted/50' : 'text-base-foreground-muted']} />

    <div class="flex flex-wrap items-center w-full gap-2">
      <!-- 表示される値(multi) -->
      {#if multi && showSelect && selectedOptions.length}
        {#each selectedOptions as selected}
          <div class="flex items-center gap-2 px-3 py-1 bg-base-container-muted rounded-full">
            <div class="text-base-foreground-default text-sm/none">{selected.label}</div>
            <button class="cursor-pointer focus-visible:rounded-sm" type="button" onclick={() => onToggle(selected.value)} aria-label="選択をクリア">
              <X class="size-4 text-base-foreground-muted hover:text-base-foreground-accent" />
            </button>
          </div>
        {/each}
      {/if}

      <!-- 入力欄 -->
      <input
        bind:this={inputEl}
        class="flex-1 max-w-full text-base-foreground-default text-sm outline-none"
        class:cursor-not-allowed={disabled}
        value={showSelect && !multi && value.length > 0 ? selectedLabel : query}
        oninput={(e) => {
          query = (e.target as HTMLInputElement).value;
        }}
        onfocus={async () => {
          if (!open) await openListbox();
        }}
        {placeholder}
        {disabled}
      />
    </div>

    <!-- 右アイコン: ドロップダウン -->
    {#if !multi && showSelect && value.length > 0}
      <button type="button" onclick={resetState}>
        <X class="size-4 text-base-foreground-muted hover:text-base-foreground-accent" />
      </button>
    {:else}
      <ChevronDown class={['size-4', disabled ? 'text-base-foreground-muted/50' : 'text-base-foreground-muted']} />
    {/if}
  </div>

  {#if open}
    <div {id} class={comboboxListVariants()} role="listbox" aria-multiselectable={multi} bind:this={listContainer} transition:scale={{ start: 0.98, opacity: 0, duration: 100 }}>
      <div class={showScrollButtons ? 'py-5' : 'py-1'}>
        {#if showScrollButtons}
          {#if !isScrolledToTop}
            <div class="absolute top-0 left-0 z-50 flex items-center justify-center w-full h-5 py-1 bg-base-container-default rounded-sm text-sm" role="presentation" onmouseenter={() => startScroll('up')} onmouseleave={stopScroll}>
              <ChevronUp class="object-contain text-base-foreground-muted" size="1rem" aria-hidden="true" />
            </div>
          {/if}
          {#if !isScrolledToBottom}
            <div class="absolute bottom-0 left-0 z-50 flex items-center justify-center w-full h-5 py-1 bg-base-container-default text-sm" role="presentation" onmouseenter={() => startScroll('down')} onmouseleave={stopScroll}>
              <ChevronDown class="object-contain text-base-foreground-muted" size="1rem" aria-hidden="true" />
            </div>
          {/if}
        {/if}
        <div
          class="overflow-y-auto hidden-scrollbar"
          class:max-h-48={limitedHeight}
          bind:this={listEl}
          role="presentation"
          onscroll={onScrollList}
          onmousemove={() => (isKeyboardNavigating = false)}
          onmouseleave={() => {
            activeIndex = -1;
            isKeyboardNavigating = false;
          }}
        >
          {#each filtered as option, i}
            <div class="relative flex items-center px-4 py-1.5 rounded-xs text-sm cursor-pointer focus:bg-base-container-accent focus:outline-none" class:hover:bg-base-container-accent={!isKeyboardNavigating} class:bg-base-container-accent={i === activeIndex} tabindex="0" role="option" aria-selected={selectedSet.has(option.value)} onclick={(e) => onClickOption(e, option.value, i)} onkeydown={(e) => onItemKeydown(e, option.value)} onmouseenter={() => { if (!isKeyboardNavigating) activeIndex = i; }} onfocus={() => (activeIndex = i)}>
              {#if selectedSet.has(option.value)}
                <div class="absolute top-2 left-4">
                  <Check class="grow-0 shrink-0 size-4 text-base-foreground-default mr-2" />
                </div>
              {/if}
              <div class="pl-6 text-base-foreground-default">{option.label}</div>
            </div>
          {:else}
            {#if emptyView}
              {@render emptyView()}
            {:else}
              <div class="flex flex-col items-center justify-center gap-2 px-4 py-7">
                <Search class="text-base-foreground-subtle" size="1.5rem" />
                <div class="text-base-foreground-muted text-sm">候補が見つかりませんでした</div>
              </div>
            {/if}
          {/each}
        </div>
      </div>
    </div>
  {/if}
</div>

<style>
  .hidden-scrollbar {
    -ms-overflow-style: none;
    scrollbar-width: none;
  }
  .hidden-scrollbar::-webkit-scrollbar {
    display: none;
  }
</style>

      

依存コンポーネント

Comboboxを使うときは、以下のコンポーネントもダウンロードが必要です。

使い方

        import Combobox from &quot;@/components/atoms/Combobox.svelte&quot;;
      
        &lt;Combobox {options} bind:value /&gt;
      

サンプル

Default

特に操作が行われていない、デフォルトの状態です。

Error

選択内容に問題があり、エラーが表示されている状態です。

Disabled

利用不可の状態です。

Multi

複数選択できます。

Empty

入力内容に該当する項目が存在しない場合、表示されます。

SingleSelect

選択した値を表示させます。

MultiSelect

複数選択した値を表示させます。