Combobox
Comboboxは、入力フィールドと候補リストを組み合わせて、ユーザーがテキスト入力または候補の選択によって値を指定できるコンポーネントです。
検索やフィルタリングによって、効率的に目的の項目を探すことができます。
<script lang="ts">
import Combobox from '$lib/components/ui/atoms/Combobox.svelte';
import DebugConsole from '$lib/components/ui/atoms/DebugConsole.svelte';
let value = $state<string[]>([]);
let options = [
{ label: 'アイテム1', value: 'item1' },
{ label: 'アイテム2', value: 'item2' },
{ label: 'アイテム3', value: 'item3' },
{ label: 'アイテム4', value: 'item4' },
{ label: 'アイテム5', value: 'item5' },
{ label: 'アイテム6', value: 'item6' },
{ label: 'アイテム7', value: 'item7' },
{ label: 'アイテム8', value: 'item8' },
];
</script>
<div class="flex flex-col w-full gap-4">
<Combobox {options} placeholder="プレースホルダー" bind:value />
<DebugConsole class="mt-4" data={{ value }} />
</div>
プロパティ
| 名前 | 型 | デフォルト値 | 説明 |
|---|---|---|---|
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 "@/components/atoms/Combobox.svelte";
<Combobox {options} bind:value />
サンプル
Default
特に操作が行われていない、デフォルトの状態です。
<script lang="ts">
import Combobox from '$lib/components/ui/atoms/Combobox.svelte';
import DebugConsole from '$lib/components/ui/atoms/DebugConsole.svelte';
let value = $state<string[]>([]);
let options = [
{ label: 'アイテム1', value: 'item1' },
{ label: 'アイテム2', value: 'item2' },
{ label: 'アイテム3', value: 'item3' },
{ label: 'アイテム4', value: 'item4' },
{ label: 'アイテム5', value: 'item5' },
{ label: 'アイテム6', value: 'item6' },
{ label: 'アイテム7', value: 'item7' },
{ label: 'アイテム8', value: 'item8' },
];
</script>
<div class="flex flex-col w-full gap-4">
<Combobox {options} placeholder="プレースホルダー" bind:value />
<DebugConsole class="mt-4" data={{ value }} />
</div>
Error
選択内容に問題があり、エラーが表示されている状態です。
<script lang="ts">
import Combobox from '$lib/components/ui/atoms/Combobox.svelte';
import DebugConsole from '$lib/components/ui/atoms/DebugConsole.svelte';
let value = $state<string[]>([]);
let isError = $derived(value.length === 0);
let options = [
{ label: 'アイテム1', value: 'item1' },
{ label: 'アイテム2', value: 'item2' },
{ label: 'アイテム3', value: 'item3' },
{ label: 'アイテム4', value: 'item4' },
{ label: 'アイテム5', value: 'item5' },
{ label: 'アイテム6', value: 'item6' },
{ label: 'アイテム7', value: 'item7' },
{ label: 'アイテム8', value: 'item8' },
];
</script>
<div class="flex flex-col w-full">
<Combobox {options} placeholder="プレースホルダー" bind:value {isError} />
{#if isError}
<p class="text-destructive text-sm mt-2">ここにエラーメッセージが入ります。</p>
{/if}
<DebugConsole class="mt-4" data={{ value }} />
</div>
Disabled
利用不可の状態です。
<script lang="ts">
import Combobox from '$lib/components/ui/atoms/Combobox.svelte';
let value = $state<string[]>([]);
let options = [
{ label: 'アイテム1', value: 'item1' },
{ label: 'アイテム2', value: 'item2' },
{ label: 'アイテム3', value: 'item3' },
{ label: 'アイテム4', value: 'item4' },
{ label: 'アイテム5', value: 'item5' },
{ label: 'アイテム6', value: 'item6' },
{ label: 'アイテム7', value: 'item7' },
{ label: 'アイテム8', value: 'item8' },
];
</script>
<div class="flex flex-col w-full gap-4">
<Combobox {options} placeholder="プレースホルダー" bind:value disabled />
</div>
Multi
複数選択できます。
<script lang="ts">
import Combobox from '$lib/components/ui/atoms/Combobox.svelte';
import DebugConsole from '$lib/components/ui/atoms/DebugConsole.svelte';
let value = $state<string[]>([]);
let options = [
{ label: 'アイテム1', value: 'item1' },
{ label: 'アイテム2', value: 'item2' },
{ label: 'アイテム3', value: 'item3' },
{ label: 'アイテム4', value: 'item4' },
{ label: 'アイテム5', value: 'item5' },
{ label: 'アイテム6', value: 'item6' },
{ label: 'アイテム7', value: 'item7' },
{ label: 'アイテム8', value: 'item8' },
];
</script>
<div class="flex flex-col w-full gap-4">
<Combobox {options} placeholder="プレースホルダー" bind:value multi />
<DebugConsole class="mt-4" data={{ value }} />
</div>
Empty
入力内容に該当する項目が存在しない場合、表示されます。
<script lang="ts">
import Combobox from '$lib/components/ui/atoms/Combobox.svelte';
import { Search } from '@lucide/svelte';
let value = $state<string[]>([]);
const options: { label: string; value: string }[] = [];
</script>
<div class="flex flex-col w-full gap-4">
<Combobox {options} placeholder="プレースホルダー" bind:value>
{#snippet emptyView()}
<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>
{/snippet}
</Combobox>
</div>
SingleSelect
選択した値を表示させます。
<script lang="ts">
import Combobox from '$lib/components/ui/atoms/Combobox.svelte';
let value = $state<string[]>([]);
let options = [
{ label: 'アイテム1', value: 'アイテム1' },
{ label: 'アイテム2', value: 'アイテム2' },
{ label: 'アイテム3', value: 'アイテム3' },
{ label: 'アイテム4', value: 'アイテム4' },
{ label: 'アイテム5', value: 'アイテム5' },
{ label: 'アイテム6', value: 'アイテム6' },
{ label: 'アイテム7', value: 'アイテム7' },
{ label: 'アイテム8', value: 'アイテム8' },
];
</script>
<div class="flex flex-col w-full gap-4">
<Combobox {options} placeholder="プレースホルダー" bind:value showSelect />
</div>
MultiSelect
複数選択した値を表示させます。
<script lang="ts">
import Combobox from '$lib/components/ui/atoms/Combobox.svelte';
let value = $state<string[]>([]);
let options = [
{ label: 'アイテム1', value: 'アイテム1' },
{ label: 'アイテム2', value: 'アイテム2' },
{ label: 'アイテム3', value: 'アイテム3' },
{ label: 'アイテム4', value: 'アイテム4' },
{ label: 'アイテム5', value: 'アイテム5' },
{ label: 'アイテム6', value: 'アイテム6' },
{ label: 'アイテム7', value: 'アイテム7' },
{ label: 'アイテム8', value: 'アイテム8' },
];
</script>
<div class="flex flex-col w-full gap-4">
<Combobox {options} placeholder="プレースホルダー" bind:value showSelect multi />
</div>