テーマカラーの定義を更新しました

最新のCSSを確認

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;
  /** タッチ操作でドラッグ開始とみなす長押し時間 */
  const TOUCH_DRAG_START_DELAY_MS = 128;
  /** 長押し待機中にこの距離を超えて動いたらドラッグ開始をキャンセルする */
  const TOUCH_DRAG_CANCEL_DISTANCE_SQUARED = 8 ** 2;
  /** 画面端から何px以内に近づいたらスクロール開始するか */
  const AUTO_SCROLL_MARGIN = 60;
  /** スクロール速度の最大値 */
  const AUTO_SCROLL_SPEED = 10;

  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);
  /** タッチ操作で長押しドラッグ開始を待機するタイマー */
  let dragStartTimerId: ReturnType<typeof setTimeout> | null = null;
  /** ポインター押下時の開始位置。長押しドラッグのキャンセル判定に使う */
  let startPos = { x: 0, y: 0 };
  /** 長押し待機中の最新ポインター位置。ドラッグ開始時の基準に使う */
  let currentPos = { x: 0, y: 0 };
  /** オートスクロールループの requestAnimationFrame ID */
  let autoScrollAnimationId: number | null = null;
  /** オートスクロールの対象となる親要素 */
  let scrollParent: HTMLElement | Window | null = null;
  /** 現在操作中のポインターID(マルチタッチによる誤操作を防ぐために使用) */
  let activePointerId: number | null = null;

  $effect(() => {
    if (!isDragging) return;

    const prevent_scroll = (event: TouchEvent) => {
      if (isDragging) {
        // ドラッグが開始されている場合のみ、画面のスクロールを止める
        event.preventDefault();
      }
    };
    window.addEventListener('touchmove', prevent_scroll, { passive: false });

    return () => {
      window.removeEventListener('touchmove', prevent_scroll);
    };
  });

  $effect(() => {
    // コンポーネントが破棄される時のクリーンアップ処理
    return () => {
      cancelDragTimer();
      stopAutoScroll();
    };
  });

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

    activePointerId = event.pointerId;

    startPos = { x: event.clientX, y: event.clientY };
    currentPos = { ...startPos };

    if (event.pointerType === 'touch') {
      dragStartTimerId = setTimeout(() => {
        // タッチ長押し待機状態を解除してからドラッグを開始する
        dragStartTimerId = null;
        startDragging(index, currentPos.x, currentPos.y);
      }, TOUCH_DRAG_START_DELAY_MS);
      elm.setPointerCapture(event.pointerId);
    }
    else {
      event.preventDefault();
      startDragging(index, event.clientX, event.clientY);
    }
  }

  /** 長押し待機中のタイマーを解除する */
  function cancelDragTimer() {
    if (dragStartTimerId !== null) {
      clearTimeout(dragStartTimerId);
      dragStartTimerId = null;
    }
  }

  /**
   * 指定アイテムのドラッグ状態を初期化して開始する
   * @param index ドラッグ対象アイテムのインデックス
   * @param clientX ポインターのX座標
   * @param clientY ポインターのY座標
   */
  function startDragging(index: number, clientX: number, clientY: number) {
    const elm = sortableElements[index];
    if (!elm) return;

    const rect = elm.getBoundingClientRect();
    draggingIndex = index;
    ghostX = clientX;
    ghostY = clientY;
    ghostOffsetX = clientX - rect.left;
    ghostOffsetY = clientY - rect.top;
    ghostWidth = elm.offsetWidth;

    isDragging = true;
    scrollParent = getScrollParent(listboxElement);
    updateAutoScroll();
  }

  /**
   * ドラッグ中のポインター移動を処理する
   * @param event ポインター移動イベント
   */
  function handlePointerMove(event: PointerEvent) {
    if (activePointerId !== null && activePointerId !== event.pointerId) return;
    if (dragStartTimerId !== null) {
      currentPos = { x: event.clientX, y: event.clientY };
      // 長押し待機中に大きく動いたら、通常スクロールを優先してドラッグ開始をやめる
      const dist_square = (event.clientX - startPos.x) ** 2 + (event.clientY - startPos.y) ** 2;
      if (dist_square > TOUCH_DRAG_CANCEL_DISTANCE_SQUARED) {
        cancelDragTimer();
      }
      return;
    }

    if (!isDragging || draggingIndex === -1) return;

    if (event.pointerType === 'touch' && event.cancelable) {
      event.preventDefault();
    }

    ghostY = event.clientY;
    ghostX = event.clientX;

    if (autoScrollAnimationId === null) {
      updateAutoScroll();
    }

    const current_y = event.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 event キーボードイベント
   */
  function handleKeyDown(event: KeyboardEvent) {
    // マウスドラッグ中は無視
    if (isDragging) return;

    const target = event.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,
    }[event.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,
        }[event.key];
      }
      else {
        action = {
          Enter: () => startKeyboardDrag(current_index),
        }[event.key];
      }
    }

    if (action) {
      event.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;
  }

  /**
   * ドラッグ操作を終了し、必要に応じてソート結果を通知する
   * @param event ポインター終了イベント
   */
  function handleDragEnd(event: PointerEvent) {
    if (activePointerId !== null && event.pointerId !== activePointerId) return;
    cancelDragTimer();
    if (event.target instanceof HTMLElement && event.target.hasPointerCapture(event.pointerId)) {
      event.target.releasePointerCapture(event.pointerId);
    }

    stopAutoScroll();

    activePointerId = null;

    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 event ポインターイベント
   */
  function handleWindowPointerDown(event: PointerEvent) {
    if (!isKeyboardDragging) return;
    const target = event.target;
    if (!(target instanceof HTMLElement)) return;
    if (listboxElement?.contains(target)) return;
    clearKeyboardDragging();
  }

  /**
   * ポインターの位置に基づいて、オートスクロールの移動量を計算する
   * @param pointerY 現在のポインターのY座標
   * @param container スクロール対象のコンテナ要素
   * @returns スクロール量
   */
  function calculateScrollSpeed(pointerY: number, container: HTMLElement | Window): number {
    let top_bound = 0;
    let bottom_bound = 0;

    if (container instanceof HTMLElement) {
      const rect = container.getBoundingClientRect();
      top_bound = rect.top;
      bottom_bound = rect.bottom;
    }
    else {
      top_bound = 0;
      bottom_bound = window.innerHeight;
    }

    if (pointerY > bottom_bound - AUTO_SCROLL_MARGIN) {
      const intensity = Math.min(1, (pointerY - (bottom_bound - AUTO_SCROLL_MARGIN)) / AUTO_SCROLL_MARGIN);
      return AUTO_SCROLL_SPEED * intensity;
    }
    else if (pointerY < top_bound + AUTO_SCROLL_MARGIN) {
      const intensity = Math.min(1, ((top_bound + AUTO_SCROLL_MARGIN) - pointerY) / AUTO_SCROLL_MARGIN);
      return -(AUTO_SCROLL_SPEED * intensity);
    }

    return 0;
  }

  /** オートスクロールを更新し、必要に応じて次フレームを予約する */
  function updateAutoScroll() {
    if (!isDragging || !scrollParent) {
      stopAutoScroll();
      return;
    }

    const scroll_y = calculateScrollSpeed(ghostY, scrollParent);

    if (scroll_y !== 0) {
      scrollParent.scrollBy(0, scroll_y);
      autoScrollAnimationId = requestAnimationFrame(updateAutoScroll);
    }
    else {
      stopAutoScroll();
    }
  }

  /** オートスクロールを停止する */
  function stopAutoScroll() {
    if (autoScrollAnimationId !== null) {
      cancelAnimationFrame(autoScrollAnimationId);
      autoScrollAnimationId = null;
    }
  }

  /**
   * 最も近いスクロール可能な親要素を取得する
   * @param node 起点となる要素
   * @returns 最も近いスクロール可能な親要素。見つからない場合は `window`
   */
  function getScrollParent(node: HTMLElement | null): HTMLElement | Window {
    if (!node) return window;
    if (node === document.body || node === document.documentElement) return window;

    const style = window.getComputedStyle(node);
    const overflow_y = style.overflowY;
    const is_scrollable = (overflow_y === 'auto' || overflow_y === 'scroll') && node.scrollHeight > node.clientHeight;

    if (is_scrollable) return node;
    return getScrollParent(node.parentElement);
  }
</script>

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

<svelte:window onpointermove={handlePointerMove} onpointerup={handleDragEnd} onpointercancel={handleDragEnd} onpointerdown={handleWindowPointerDown} />

<div class={className} data-rabee-ui="sortable-list">
  <!-- リスト -->
  <div class={[isDragging && 'cursor-grabbing']} onkeydown={handleKeyDown} 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 outline-ring outline-offset-2 focus-visible:outline-2 focus-visible:rounded-md', isKeyboardDragging && draggingIndex === i && 'bg-primary/5', !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

各種イベントの例です。