Select
Selectは、選択肢をドロップダウンメニューで表示するコンポーネントです。
<script lang="ts">
import Select from '$lib/components/ui/atoms/Select.svelte';
let value = $state('');
let options = [
{ label: '選択肢1', value: 'item1' },
{ label: '選択肢2', value: 'item2' },
{ label: '選択肢3', value: 'item3' },
];
</script>
<div class="w-full flex flex-col gap-4">
<Select options={options} placeholder="選択してください" bind:value={value} />
</div>
プロパティ
Selectは、以下のプロパティをサポートしています。
| 名前 | 型 | デフォルト値 | 説明 |
|---|---|---|---|
options |
SelectOptionItem[] |
選択肢の配列です。 | |
size |
string |
medium |
セレクトボックスの高さを指定できます。small, medium, large のいずれかを指定できます。 |
placeholder |
string |
セレクトボックスが空のときに表示されるプレースホルダーテキストです。 | |
disabled |
boolean |
false |
セレクトボックスを無効化します。無効化されたセレクトボックスは選択できません。 |
isError |
boolean |
false |
エラー状態を視覚的に示します。バリデーション用など。 |
clearable |
boolean |
false |
現在選択されている値をクリアできるかどうかを指定します。true に設定すると、選択された値をクリアできるようになります。 |
onChange |
(value: string) => void |
選択されたときのコールバック関数。 |
SelectOptionItem
SelectOptionItemは、各選択肢の情報を表すオブジェクトです。
| 名前 | 型 | デフォルト値 | 説明 |
|---|---|---|---|
label |
string |
選択肢のラベルです。 | |
value |
string |
選択肢の値です。 |
インストールの手順
以下のコンポーネントのコードを、使いたいプロジェクトにコピー&ペーストします。
パスは実際のプロジェクトの構成にあわせて更新します。
atoms/Select.svelte
<!--
@component
## 概要
- selectタグのように、選択肢の中から値を1つ選べる汎用的なセレクトボックスコンポーネントです
## 機能
- selectタグのように、選択肢の中から1つを選択できます
- プレースホルダーの表示、選択解除ボタン、フォーカスやキーボード操作への対応
- 画面の表示領域に応じて、ドロップダウンの表示方向(上/下)を自動調整
- エラー状態や操作状態を制御するためのPropsが追加されています(詳細はPropsセクションを参照)
## Props
- id: コントロールとリストボックスの関連付けに使用するID
- value: 現在選択中の値(双方向バインディング可)
- size: サイズを指定します
- options: 選択肢のリスト(SelectOptionItem型の配列)
- placeholder: 未選択時に表示するテキスト
- isError: true の場合エラー時のスタイルを適用します
- clearable: デフォルト値は false。true に設定すると、選択された値をクリアできるようになります
- disabled: 指定するとグレーアウトされ、クリック不可になります
## Usage
```svelte
<Select options={options} placeholder="選択してください" bind:value={value} />
```
-->
<script module lang="ts">
import type { ClassValue } from 'svelte/elements';
import { cva, type VariantProps } from 'class-variance-authority';
const MAX_VISIBLE_OPTIONS = 5;
export const selectVariants = cva('flex items-center justify-between w-full py-0.5 pr-3 pl-1 border border-base-stroke-default rounded-md text-sm transition hover:bg-base-container-accent focus-visible:outline-[0.125rem] focus-visible:outline-offset-[0.125rem] focus-visible:outline-primary focus:transition-none', {
variants: {
/** セレクトのサイズ */
size: {
small: ['min-h-9'],
medium: ['min-h-10'],
large: ['min-h-11'],
},
/** 操作できるかどうか */
disabled: {
true: ['opacity-50 pointer-events-none'],
false: ['cursor-pointer'],
},
/** エラーかどうか */
isError: {
true: ['border-destructive'],
false: [],
},
},
defaultVariants: {
size: 'medium',
disabled: false,
},
});
export const selectBoxVariants = cva('absolute top-[calc(100%+2px)] z-50 w-full p-1 bg-base-container-default border border-base-stroke-default rounded-md shadow-xs overflow-hidden origin-top focus-visible:outline-[0.125rem] focus-visible:outline-offset-[0.125rem] focus-visible:outline-primary');
export type SelectVariants = VariantProps<typeof selectVariants>;
export interface SelectOptionItem {
/** 選択肢のラベル */
label: string;
/** 選択肢の値 */
value: string;
}
export interface SelectProps extends SelectVariants {
/** リストボックスとコントロールの関連付けに使用されるDOM ID */
id?: string;
/** 現在選択されている値 */
value?: string;
/** 未選択時に表示するテキスト */
placeholder?: string;
/** クラス */
class?: ClassValue;
/** 現在選択されている値をクリアできるかどうか */
clearable?: boolean;
/** オプションのリスト */
options: SelectOptionItem[];
/** 選択されたときのコールバック関数 */
onChange?: (value: string) => void;
/** option 1件ぶんをカスタム描画したいときの snippet */
optionContent?: Snippet<[any]>;
}
</script>
<script lang="ts">
import { Check, ChevronDown, ChevronUp, X } from '@lucide/svelte';
import { type Snippet, tick } from 'svelte';
import { scale } from 'svelte/transition';
let { id, value = $bindable(''), placeholder = '', class: className, size, isError = false, disabled = false, clearable = false, options = [], onChange, optionContent }: SelectProps = $props();
let selectBox: HTMLDivElement;
let selectBoxListContainer = $state<HTMLDivElement>();
let selectBoxListElement = $state<HTMLDivElement>();
let isOpen = $state(false);
// スクロール位置が一番上にあるかどうか(リストを開いた時は一番上になるので初期値 true)
let isScrolledToTop = $state(true);
// スクロール位置が一番下にあるかどうか
let isScrolledToBottom = $state(false);
let showScrollButtons = $derived.by(() => {
return options.length > MAX_VISIBLE_OPTIONS;
});
let scrollInterval: ReturnType<typeof setInterval> | null = null;
let selectedOption = $derived.by(() => {
return options.find((option) => option.value === value) ?? null;
});
let selectVariantClass = $derived(selectVariants({ size, isError, disabled }));
function onSelect(e: Event) {
e.preventDefault();
isOpen = !isOpen;
if (isOpen) {
tick().then(() => {
// 選択済みだった場合はそれをフォーカスしておく
if (selectBoxListElement && value) {
const selected = Array.from(selectBoxListElement.children).find(
(el) => el.getAttribute('data-value') === value,
) as HTMLElement;
selected?.focus();
}
});
}
}
function onKeydownSelect(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(e);
}
else if (e.key === 'ArrowDown') {
e.preventDefault();
if (!isOpen) {
isOpen = true;
}
else {
const first_item = selectBoxListContainer?.querySelector('[tabindex="0"]') as HTMLElement;
first_item?.focus();
}
}
}
function listboxClose(e: Event) {
if (selectBox && selectBox.contains(e.target as Node)) return;
if (selectBoxListContainer && selectBoxListContainer.contains(e.target as Node)) return;
isOpen = false;
}
function onClear(e: Event) {
e.preventDefault();
e.stopPropagation();
value = '';
}
function startScroll(direction: 'up' | 'down') {
if (scrollInterval || !selectBoxListElement) return;
scrollInterval = setInterval(() => {
selectBoxListElement?.scrollBy({
top: direction === 'up' ? -20 : 20,
behavior: 'smooth',
});
}, 50);
}
function stopScroll() {
if (scrollInterval) {
clearInterval(scrollInterval);
scrollInterval = null;
}
}
function onScrollSelectBox() {
if (!selectBoxListElement) return;
isScrolledToTop = selectBoxListElement.scrollTop === 0;
isScrolledToBottom = Math.abs(selectBoxListElement.scrollHeight - selectBoxListElement.scrollTop - selectBoxListElement.clientHeight) < 1;
// 要素が消えてしまうと setinterval を clear できないので考慮
if (isScrolledToBottom || isScrolledToTop) {
stopScroll();
}
}
function listItemSelectClose() {
isOpen = false;
}
function listItemSelectKeyDownClose(e: KeyboardEvent) {
if (e.key === 'Enter') {
isOpen = false;
}
}
function onSelectValue(v: string) {
value = v;
isOpen = false;
onChange?.(v);
selectBox?.focus();
}
function onKeydown(e: KeyboardEvent, optionValue: string) {
const target = e.currentTarget as HTMLElement;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectValue(optionValue);
}
else if (e.key === 'ArrowDown') {
e.preventDefault();
const next = target.nextElementSibling as HTMLElement;
next?.focus();
}
else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = target.previousElementSibling as HTMLElement;
prev?.focus();
}
}
function onMouseenterFocus(e: MouseEvent) {
const target = e.currentTarget as HTMLElement;
target?.focus();
}
function onMouseleaveBlur(e: MouseEvent) {
const target = e.currentTarget as HTMLElement;
target?.blur();
}
</script>
<svelte:window onclick={listboxClose} />
<div class={['relative', className]} class:cursor-not-allowed={disabled}>
<div class={selectVariantClass} onclick={(e) => onSelect(e)} role="combobox" tabindex={disabled ? -1 : 0} onkeydown={(e) => onKeydownSelect(e)} aria-controls={id} aria-expanded={isOpen} bind:this={selectBox}>
{#if optionContent}
{@render optionContent({ option: selectedOption, value, index: -1 })}
{:else}
<div class="px-2">
{#if selectedOption}
<span class="text-base-foreground-default">{selectedOption.label}</span>
{:else}
<span class="text-base-foreground-muted">{placeholder}</span>
{/if}
</div>
{/if}
{#if value && clearable}
<button class="px-0.5 py-1.5 cursor-pointer select-none" onclick={(e) => onClear(e)} tabindex="-1">
<X class="size-4 text-base-foreground-subtle transition-colors hover:text-base-foreground-accent" />
</button>
{:else}
<div class="px-0.5 py-1.5">
<ChevronDown class="size-4 text-base-foreground-muted" />
</div>
{/if}
</div>
{#if isOpen}
<div class={[selectBoxVariants(), showScrollButtons && 'py-6']} role="listbox" bind:this={selectBoxListContainer} transition:scale={{ start: 0.98, opacity: 0, duration: 100 }}>
{#if showScrollButtons}
<!-- 上スクロール -->
{#if !isScrolledToTop}
<button class="absolute top-0 left-0 z-50 flex items-center justify-center w-full h-6 py-1 bg-base-container-default rounded-sm text-sm" type="button" onmouseenter={() => startScroll('up')} onmouseleave={stopScroll}>
<ChevronUp class="object-contain text-base-foreground-muted" size="1rem" />
</button>
{/if}
<!-- 下スクロール -->
{#if !isScrolledToBottom}
<button class="absolute bottom-0 left-0 z-50 flex items-center justify-center w-full h-6 py-1 bg-base-container-default text-sm" type="button" onmouseenter={() => startScroll('down')} onmouseleave={stopScroll}>
<ChevronDown class="object-contain text-base-foreground-muted" size="1rem" />
</button>
{/if}
{/if}
<div class="max-h-40 overflow-y-scroll hidden-scrollbar" onclick={listItemSelectClose} onkeydown={listItemSelectKeyDownClose} onscroll={onScrollSelectBox} role="presentation" bind:this={selectBoxListElement}>
{#each options as option, index}
<div class="relative flex items-center h-full rounded-xs text-sm/normal focus:bg-base-container-accent focus:outline-none select-none" onclick={() => onSelectValue(option.value)} onkeydown={(e) => onKeydown(e, option.value)} onmouseenter={(e) => onMouseenterFocus(e)} onmouseleave={(e) => onMouseleaveBlur(e)} tabindex="0" role="option" aria-selected={option.value === value} data-value={option.value}>
{#if optionContent}
{@render optionContent({ option, value, index })}
{:else}
<div class="px-4 py-1.5">
{#if option.value === value}
<div class="absolute inset-y-0 left-4 flex items-center">
<Check class="size-4 text-base-foreground-default" />
</div>
{/if}
<div class="pl-6 text-base-foreground-default">
{option.label}
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
.hidden-scrollbar {
-ms-overflow-style: none;
/* IE, Edge 対応 */
scrollbar-width: none;
/* Firefox 対応 */
}
.hidden-scrollbar::-webkit-scrollbar {
/* Chrome, Safari 対応 */
display: none;
}
</style>
使い方
<script lang="ts">
import Select from '$lib/components/ui/atoms/Select.svelte';
let value = $state('');
let options = [
{ label: '選択肢1', value: 'item1' },
{ label: '選択肢2', value: 'item2' },
{ label: '選択肢3', value: 'item3' },
];
</script>
<div class="w-full flex flex-col gap-4">
<Select options={options} placeholder="選択してください" bind:value={value} />
</div>
サンプル
Default
特に操作が行われていない、デフォルトの状態です。
<script lang="ts">
import Select from '$lib/components/ui/atoms/Select.svelte';
let value = $state('');
let options = [
{ label: '選択肢1', value: 'item1' },
{ label: '選択肢2', value: 'item2' },
{ label: '選択肢3', value: 'item3' },
];
</script>
<div class="w-full flex flex-col gap-4">
<Select options={options} placeholder="選択してください" bind:value={value} />
</div>
Error
選択内容に問題があり、エラーが表示されている状態です。
<script lang="ts">
import Select from '$lib/components/ui/atoms/Select.svelte';
let value = $state('');
let isError = $derived(value === '');
let options = [
{ label: '選択肢1', value: 'item1' },
{ label: '選択肢2', value: 'item2' },
{ label: '選択肢3', value: 'item3' },
];
</script>
<div class="flex flex-col w-full gap-2">
<Select {options} placeholder="選択してください" bind:value {isError} />
{#if isError}
<p class="text-destructive text-sm">ここにエラーメッセージが入ります。</p>
{/if}
</div>
Disabled
利用不可の状態です。
<script lang="ts">
import Select from '$lib/components/ui/atoms/Select.svelte';
let value = $state('');
let options = [
{ label: '選択肢1', value: 'item1' },
{ label: '選択肢2', value: 'item2' },
{ label: '選択肢3', value: 'item3' },
];
</script>
<div class="w-full flex flex-col gap-4">
<Select options={options} placeholder="選択してください" bind:value={value} disabled />
</div>
Scroll
選択肢が一定量以上ある場合は、上下にスクロールボタンが表示されます。
<script lang="ts">
import Select from '$lib/components/ui/atoms/Select.svelte';
let value = $state('');
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' },
{ label: '選択肢9', value: 'item9' },
{ label: '選択肢10', value: 'item10' },
];
</script>
<div class="flex flex-col w-full gap-4">
<Select {options} placeholder="選択してください" bind:value />
</div>
OnChange
コールバック関数で値を受け取ることができます。
<script lang="ts">
import DebugConsole from '$lib/components/ui/atoms/DebugConsole.svelte';
import Select from '$lib/components/ui/atoms/Select.svelte';
let selectedValue = $state('');
let options = [
{ label: '選択肢1', value: 'item1' },
{ label: '選択肢2', value: 'item2' },
{ label: '選択肢3', value: 'item3' },
];
/**
* セレクトボックスの選択値が変更されたときに呼び出されるコールバック関数。
* @param value - 選択されたオプションの値
*/
function onChange(value: string) {
selectedValue = value;
}
</script>
<div class="flex flex-col items-center w-full gap-2">
<Select class="flex flex-col w-full gap-4" {onChange} {options} placeholder="選択してください" />
<div class="w-full mt-4">
<DebugConsole data={selectedValue} />
</div>
</div>
OptionContent
選択肢の表示内容をカスタマイズできます。
<script lang="ts">
import DebugConsole from '$lib/components/ui/atoms/DebugConsole.svelte';
import Select from '$lib/components/ui/atoms/Select.svelte';
import { CircleAlert, CircleCheck, CirclePlay } from '@lucide/svelte';
let options = [
{ label: '未対応', value: 'pending', icon: CircleAlert, class: 'text-destructive' },
{ label: '対応中', value: 'in_progress', icon: CirclePlay, class: 'text-primary' },
{ label: '解決済み', value: 'resolved', icon: CircleCheck, class: 'text-base-foreground-subtle' },
];
let value = $state(options[0].value);
</script>
<div class="flex flex-col items-center w-full gap-2">
<Select class="flex flex-col w-full gap-4" {options} bind:value>
{#snippet optionContent({ option })}
{#if option}
<div class="flex gap-2 items-center px-2 py-1.5 {option.class}">
<option.icon size="1rem" />
<span class="text-sm/normal">{option.label}</span>
</div>
{:else}
<div class="px-2 py-1.5 text-base-foreground-muted text-sm/normal">選択してください</div>
{/if}
{/snippet}
</Select>
<div class="w-full mt-4">
<DebugConsole data={value} />
</div>
</div>
Clearable
clearable に true を指定することで、値をクリアできるようになります。
<script lang="ts">
import DebugConsole from '$lib/components/ui/atoms/DebugConsole.svelte';
import Select from '$lib/components/ui/atoms/Select.svelte';
import { Check } from '@lucide/svelte';
let options = [
{ label: 'ステータス1', value: 'status_1' },
{ label: 'ステータス2', value: 'status_2' },
{ label: 'ステータス3', value: 'status_3' },
{ label: 'ステータス4', value: 'status_4' },
];
let value = $state(options[0].value);
</script>
<div class="flex flex-col items-center w-full gap-2">
<Select class="flex flex-col w-full gap-4" {options} placeholder="選択してください" bind:value clearable>
{#snippet optionContent({ option, value })}
{#if option}
<div class={['flex items-center p-2', option.class]}>
<div class="size-4 mr-2">
{#if option.value === value}
<Check size="1rem" />
{/if}
</div>
<span>{option.label}</span>
</div>
{:else}
<div class="p-2 text-base-foreground-muted">未選択</div>
{/if}
{/snippet}
</Select>
<div class="w-full mt-4">
<DebugConsole data={value} />
</div>
</div>
Sizes
size を指定することで、セレクトボックスのサイズを変更できます。small, medium, large のいずれかを指定してください。
<script lang="ts">
import Select from '$lib/components/ui/atoms/Select.svelte';
let smallValue = $state('');
let mediumValue = $state('');
let largeValue = $state('');
let options = [
{ label: '選択肢1', value: 'item1' },
{ label: '選択肢2', value: 'item2' },
{ label: '選択肢3', value: 'item3' },
];
</script>
<div class="flex items-center w-full gap-4 max-lg:flex-col">
<Select class="w-full" options={options} placeholder="選択してください" bind:value={smallValue} size="small" />
<Select class="w-full" options={options} placeholder="選択してください" bind:value={mediumValue} size="medium" />
<Select class="w-full" options={options} placeholder="選択してください" bind:value={largeValue} size="large" />
</div>