Select

Selectは、選択肢をドロップダウンメニューで表示するコンポーネントです。

プロパティ

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>

      

使い方


サンプル

Default

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

Error

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

Disabled

利用不可の状態です。

Scroll

選択肢が一定量以上ある場合は、上下にスクロールボタンが表示されます。

OnChange

コールバック関数で値を受け取ることができます。

OptionContent

選択肢の表示内容をカスタマイズできます。

Clearable

clearable に true を指定することで、値をクリアできるようになります。

Sizes

size を指定することで、セレクトボックスのサイズを変更できます。small, medium, large のいずれかを指定してください。