Sortable List

Sortable Listは、ドラッグアンドドロップによって並び替えが可能なコンポーネントです。

プロパティ

Sortable Listは、以下のプロパティをサポートしています。

名前 デフォルト値 説明
items SortableItem<T>[] 並び替え対象のアイテム配列。
dragHandleSelector string ドラッグ開始を許可するハンドル要素のセレクタを指定します。未指定の場合は行全体でドラッグできます。
children Snippet<[T]> 各アイテムの描画に使用するスニペットを指定します。
emptyContent Snippet Empty状態の描画に使用するスニペットを指定します。
onUpdate (items: SortableItem<T>[], detail: SortableUpdateDetail<T>) => void アイテム順序が更新されるたびに呼び出されます。
onSorted (items: SortableItem<T>[]) => void ドラッグ操作終了時に並び替えが発生していた場合に呼び出されます。

インストールの手順

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

atoms/SortableList.svelte
        <!--
@component
並び替え可能なリストコンポーネント
snippetで任意のアイテムを描画可能

## 機能
- リスト項目を並び替え可能
- カスタマイズ可能な `snippet` による項目描画
- アイテムの更新時に `onUpdate` コールバックで通知
- ソート完了時に `onSorted` コールバックで通知

## Props
- items: 並び替え対象のアイテム配列
- children: アイテムの描画に使うスニペット
- emptyContent: 空のアイテムの表示用スニペット
- onUpdate: アイテムが更新されたときに呼び出されるコールバック
- onSorted: 並び替えが完了したときに呼び出されるコールバック
- class: コンポーネント全体のクラス名
- itemClass: 各アイテム行のクラス名
- dragHandleSelector: ドラッグ開始に使用するハンドル要素のセレクタ

## Usage
```svelte
  <SortableList bind:items dragHandleSelector=".drag-handle">
    {#snippet children(item)}
      <Button class="drag-handle">
      </Button>
    {/snippet}
  </SortableList>
```
-->

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

  const ITEM_ID_KEY = Symbol('SORTABLE_LIST_ITEM_KEY');

  /** アイテム入れ替え時の flip アニメーション速度 */
  const SORT_ANIMATION_MS = 150;

  export interface SortableUpdateDetail<T> {
    item: T;
    oldIndex: number;
    newIndex: number;
  }

  /** アイテムの基本構造 内部で id を参照するため、この型を強制 */
  export type SortableItem<T> = { [ITEM_ID_KEY]: symbol; item: T };

  export interface SortableListProps<T> {
    /** コンポーネントのクラス名 */
    class?: ClassValue;
    /** アイテムのクラス名 */
    itemClass?: ClassValue;
    /** ソート可能なアイテムの配列  */
    items: SortableItem<T>[];
    /** ドラッグ操作を開始できる要素のセレクタ(省略時は行全体がドラッグ対象) */
    dragHandleSelector?: string;
    /** アイテムの表示用スニペット */
    children: Snippet<[T]>;
    /** 空のアイテムの表示用スニペット */
    emptyContent?: Snippet;
    /** アイテムが更新されたときのコールバック */
    onUpdate?: (items: SortableItem<T>[], detail: SortableUpdateDetail<T>) => void;
    /** ソートが完了したときのコールバック */
    onSorted?: (items: SortableItem<T>[]) => void;
  }

  /**
   * 受け取ったオブジェクトを SortableItem に変換する関数
   * すでに SortableItem であればそのまま返し、そうでなければ新たにラップして返す
   */
  export function wrapSortableItem<T>(item: T | SortableItem<T>): SortableItem<T> {
    if (item && typeof item === 'object' && ITEM_ID_KEY in item) {
      return item;
    }
    return { item, [ITEM_ID_KEY]: Symbol('SORTABLE_LIST_ITEM_KEY') };
  }

  /**
   * 受け取った配列を SortableItem の配列に変換する関数
   * すでに SortableItem であればそのまま返し、そうでなければ新たにラップして返す
   */
  export function wrapSortableItems<T>(items: (T | SortableItem<T>)[]): SortableItem<T>[] {
    return items.map(item => wrapSortableItem(item));
  }

  /** SortableItem の配列から生データの配列を抜き出す */
  export function unwrapSortableItems<T>(items: SortableItem<T>[]): T[] {
    return items.map(i => i.item);
  }
</script>

<script lang="ts" generics="T">
  import { tick } from 'svelte';
  import { flip } from 'svelte/animate';

  let { class: className, itemClass, items = $bindable([]), children, emptyContent, onUpdate, onSorted, dragHandleSelector }: SortableListProps<T> = $props();

  /** ソートする要素の配列 */
  let sortableElements: HTMLElement[] = $state([]);
  /** リストボックス本体要素 */
  let listboxElement: HTMLDivElement;

  /** ドラッグ中のアイテムのインデックス */
  let draggingIndex = $state(-1);
  /** ドラッグ中のアイテムのY座標 */
  let ghostY = $state(0);
  /** ドラッグ中のアイテムのX座標 */
  let ghostX = $state(0);
  /** ドラッグ開始地点の要素内オフセット */
  let ghostOffsetX = $state(0);
  /** ドラッグ開始地点の要素内オフセット */
  let ghostOffsetY = $state(0);
  /** ドラッグ中のアイテムの幅 */
  let ghostWidth = $state(0);
  /** 操作中かどうか */
  let isDragging = $state(false);
  /** アイテムが更新されたかどうか */
  let hasUpdated = $state(false);
  /** キーボードで選択中(移動待機中)かどうか */
  let isKeyboardDragging = $state(false);
  /** キーボード操作開始時のアイテム状態のバックアップ */
  let originalItemsCache = $state<SortableItem<T>[]>([]);
  /** キーボード操作開始時のインデックス(キャンセル時のフォーカス復元用) */
  let originalDraggingIndex = $state(-1);

  /**
   * ドラッグ操作を開始する
   * @param index ドラッグ対象アイテムのインデックス
   * @param e ポインターイベント
   */
  function onPointerDown(index: number, e: PointerEvent) {
    // 右クリック(2)や中クリック(1)を無視し、左クリック(0)のみ反応させる
    if (e.button !== 0) return;
    clearKeyboardDragging();
    // dragHandleSelectorが指定されている場合、その要素上での操作のみドラッグ開始
    if (dragHandleSelector) {
      const target = e.target;
      if (!(target instanceof Element) || !target.closest(dragHandleSelector)) {
        return;
      }
    }
    const elm = sortableElements[index];
    if (!elm) return;

    const { left, top } = elm.getBoundingClientRect();
    const current_ghost_width = elm.offsetWidth;

    draggingIndex = index;
    ghostX = e.clientX;
    ghostY = e.clientY;
    ghostOffsetX = e.clientX - left;
    ghostOffsetY = e.clientY - top;
    ghostWidth = current_ghost_width;
    isDragging = true;
  }

  /**
   * ドラッグ中のポインター移動を処理する
   * @param e ポインター移動イベント
   */
  function onPointerMove(e: PointerEvent) {
    if (!isDragging || draggingIndex === -1) return;

    ghostY = e.clientY;
    ghostX = e.clientX;

    const current_y = e.clientY;
    let target_index = draggingIndex;

    // 全ての要素を走査して、現在のポインター位置に基づく挿入先を特定する
    for (let i = 0; i < sortableElements.length; i++) {
      const el = sortableElements[i];
      if (!el) continue;

      const rect = el.getBoundingClientRect();
      const threshold = rect.top + rect.height / 2;

      // 下方向にドラッグ中:ターゲット要素の中央線を越えたら、その後ろに挿入
      if (draggingIndex < i && current_y > threshold) {
        target_index = i;
      }
      // 上方向にドラッグ中:ターゲット要素の中央線を越えたら、その前に挿入
      else if (draggingIndex > i && current_y < threshold) {
        target_index = i;
      }
    }

    if (target_index !== draggingIndex) {
      swapItems(draggingIndex, target_index);
    }
  }

  /**
   * キーボード操作のメインロジック
   * @param e キーボードイベント
   */
  function onKeyDown(e: KeyboardEvent) {
    // マウスドラッグ中は無視
    if (isDragging) return;

    const target = e.target;
    if (!(target instanceof HTMLElement)) return;

    const is_drag_handle = dragHandleSelector ? target.closest(dragHandleSelector) !== null : false;
    if (dragHandleSelector && !is_drag_handle) return;

    const current_index = sortableElements.findIndex((element) => element?.contains(target));
    if (current_index === -1) return;

    let action: (() => void) | undefined;

    let direction = {
      ArrowUp: -1,
      ArrowDown: 1,
    }[e.key];

    if (direction) {
      action = async () => {
        const target_index = current_index + direction;
        if (target_index >= 0 && target_index < items.length) {
          if (isKeyboardDragging) {
            swapItems(current_index, target_index);
          }
          await focusItem(target_index);
        };
      };
    }
    else {
      if (isKeyboardDragging) {
        action = {
          Enter: clearKeyboardDragging,
          Escape: cancelKeyboardDrag,
        }[e.key];
      }
      else {
        action = {
          Enter: () => startKeyboardDrag(current_index),
        }[e.key];
      }
    }

    if (action) {
      e.preventDefault();
      action();
    }
    else {
      clearKeyboardDragging();
    }
  }

  /**
   * キーボードでのドラッグ操作を開始する
   * @param index ドラッグ対象アイテムのインデックス
   */
  function startKeyboardDrag(index: number) {
    draggingIndex = index;
    isKeyboardDragging = true;
    originalItemsCache = [...items];
    originalDraggingIndex = index;
  }

  /** キーボードでのドラッグ操作をキャンセルして元の状態に戻す */
  async function cancelKeyboardDrag() {
    if (!isKeyboardDragging) return;

    if (hasUpdated) {
      items = [...originalItemsCache];
      onUpdate?.(items, {
        item: items[originalDraggingIndex].item,
        oldIndex: draggingIndex,
        newIndex: originalDraggingIndex,
      });
    }

    hasUpdated = false;
    const index_to_focus = originalDraggingIndex;
    clearKeyboardDragging();

    // 元の要素にフォーカスを戻す
    if (index_to_focus !== -1) {
      await focusItem(index_to_focus);
    }
  }

  /**
   * 指定インデックスの要素にフォーカスする
   *  @param index フォーカス対象インデックス
   */
  async function focusItem(index: number) {
    await tick();
    const item_element = sortableElements[index];
    if (!item_element) return;

    const target_element = dragHandleSelector ? item_element.querySelector<HTMLElement>(dragHandleSelector) : item_element;
    target_element?.focus();
  }

  /**
   * 表示中のアイテムの並び順を入れ替える
   * @param fromIndex 移動元インデックス
   * @param toIndex 挿入先インデックス
   */
  function swapItems(fromIndex: number, toIndex: number) {
    const updated = [...items];
    if (fromIndex < 0 || fromIndex >= items.length) return;
    if (toIndex < 0 || toIndex >= items.length) return;
    const [moved] = updated.splice(fromIndex, 1);
    updated.splice(toIndex, 0, moved);
    items = updated;
    hasUpdated = true;
    onUpdate?.(updated, {
      ...moved,
      oldIndex: fromIndex,
      newIndex: toIndex,
    });
    draggingIndex = toIndex;
  }

  /** ドラッグ操作を終了し、必要に応じてソート結果を通知する */
  function onDragEnd() {
    if (!isDragging) return;
    isDragging = false;
    const final_items = [...items];

    draggingIndex = -1;
    ghostOffsetX = 0;
    ghostOffsetY = 0;

    if (hasUpdated) {
      onSorted?.(final_items);
      hasUpdated = false;
    }
  }

  /**
   * キーボード並べ替え状態を解除する
   */
  function clearKeyboardDragging() {
    if (!isKeyboardDragging && draggingIndex === -1) return;

    isKeyboardDragging = false;
    if (hasUpdated) {
      onSorted?.([...items]);
    }
    hasUpdated = false;
    draggingIndex = -1;
    originalItemsCache = [];
    originalDraggingIndex = -1;
  }

  /**
   * リスト外のクリックでキーボード選択状態を解除する
   * @param e ポインターイベント
   */
  function onWindowPointerDown(e: PointerEvent) {
    if (!isKeyboardDragging) return;
    const target = e.target;
    if (!(target instanceof HTMLElement)) return;
    if (listboxElement?.contains(target)) return;
    clearKeyboardDragging();
  }
</script>

<!-- アイテムひとつ分(行全体のラッパー) -->
{#snippet sortableItemWrapper(item: SortableItem<T>, i: number = -1)}
  <div class={[itemClass, 'select-none [-webkit-touch-callout:none]', draggingIndex === i && isDragging && 'opacity-40 pointer-events-none']} onpointerdown={(e) => onPointerDown(i, e)}>
    {@render children(item.item)}
  </div>
{/snippet}

<svelte:window onpointermove={onPointerMove} onpointerup={onDragEnd} onpointercancel={onDragEnd} onpointerdown={onWindowPointerDown} />

<div class={className}>
  <!-- リスト -->
  <div class={[isDragging && 'cursor-grabbing']} onkeydown={onKeyDown} role="listbox" tabindex="-1" bind:this={listboxElement}>
    {#each items as item, i (item[ITEM_ID_KEY])}
      <div class={['w-full aria-selected:**:!cursor-grabbing aria-selected:**:!outline-none', isKeyboardDragging && draggingIndex === i && 'bg-primary/5 rounded-md outline-2 outline-offset-2 outline-primary', !dragHandleSelector && 'cursor-grab']} bind:this={sortableElements[i]} aria-selected={(isDragging || isKeyboardDragging) && draggingIndex === i} tabindex={dragHandleSelector ? -1 : 0} role="option" animate:flip={{ duration: SORT_ANIMATION_MS }}>
        {@render sortableItemWrapper(item, i)}
      </div>
    {:else}
      {#if emptyContent}
        <div class="w-full">
          {@render emptyContent()}
        </div>
      {/if}
    {/each}
    <!-- ドラッグ中のアイテムのゴースト表示 (持って動かしてる風のもの) -->
    {#if isDragging && draggingIndex !== -1 && items[draggingIndex]}
      <div class="fixed z-10 rounded-md opacity-80 shadow-sm cursor-grabbing data-[dragging=true]:**:!cursor-grabbing" data-dragging="true" aria-hidden="true" style="top: {ghostY - ghostOffsetY}px; left: {ghostX - ghostOffsetX}px; width: {ghostWidth}px;">
        {@render sortableItemWrapper(items[draggingIndex], -1)}
      </div>
    {/if}
  </div>
</div>

      

使い方


サンプル

Default

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

DragHandler

ドラッグ操作を開始する要素(ハンドル)を限定する例です。

Empty

Snippet を利用して、Empty 状態の表示を任意にカスタマイズできます。

EventHandlers

各種イベントの例です。