ドキュメント/ガイド

並べ替え可能なリスト

ドラッグ中に並べ替わるリスト、タブバー、グリッド——makeSortableList がドラフトを描画し、makeSortableItem が行を作り、ドロップで新しい順序が ref に書き込まれます。ハンドル、挿入ライン、入れ替えも。

このページを編集

このページは AI によって翻訳されており、不正確な箇所が含まれている可能性があります。 英語の原文を読む

Live

デモを読み込み中…

<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import LayersPanel from './LayersPanel.vue';

// Front to back, as a design tool lists them.
const layers = ref([
  { id: 'title', name: 'Title', kind: 'text' as const, visible: true, locked: false },
  { id: 'badge', name: 'Badge', kind: 'shape' as const, visible: true, locked: false },
  { id: 'photo', name: 'Photo', kind: 'image' as const, visible: true, locked: false },
  { id: 'card', name: 'Card', kind: 'shape' as const, visible: true, locked: true },
]);
</script>

<template>
  <DnDProvider>
    <div class="editor">
      <!-- The artboard reads `layers`: it restacks when a drop writes the new order. -->
      <div class="artboard" aria-hidden="true">
        <template v-for="(layer, index) in layers" :key="layer.id">
          <div v-show="layer.visible" class="shape" :class="layer.id" :style="{ zIndex: layers.length - index }">
            <template v-if="layer.id === 'title'">Summer sale</template>
            <template v-if="layer.id === 'badge'">-30%</template>
          </div>
        </template>
      </div>
      <LayersPanel v-model="layers" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.editor {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 24px;
}

.artboard {
  position: relative;
  overflow: hidden;
  width: 240px;
  height: 170px;
  border-radius: 4px;
  background: #fff;
  box-shadow: 0 10px 30px -12px rgb(0 0 0 / 0.5);
}

.shape {
  position: absolute;
}

.card {
  inset: 18px;
  border-radius: 12px;
  background: #1e3a8a;
}

.photo {
  top: 34px;
  left: 66px;
  width: 120px;
  height: 84px;
  border-radius: 8px;
  background:
    radial-gradient(circle at 75% 30%, #fde68a 0 12px, transparent 13px),
    linear-gradient(170deg, #67e8f9, #0e7490);
}

.badge {
  top: 20px;
  left: 160px;
  display: grid;
  place-items: center;
  width: 52px;
  height: 52px;
  border-radius: 50%;
  background: #facc15;
  color: #1e3a8a;
  font: 800 13px/1 system-ui, sans-serif;
}

.title {
  top: 104px;
  left: 30px;
  color: #fff;
  font: 800 24px/1 system-ui, sans-serif;
  letter-spacing: -0.02em;
  text-shadow: 0 2px 6px rgb(0 0 0 / 0.35);
}
</style>

リストとその行

並べ替え可能なリストには 2 つのコンポーネントが必要です:リストと、その行です。

LayersPanel.vue
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableList } from '@vue-dnd-kit/core';
import LayerRow from './LayerRow.vue';

const layers = defineModel<Layer[]>({ required: true });
const list = useTemplateRef<HTMLElement>('list');
const { items } = makeSortableList(list, { items: layers });
</script>

<template>
  <ul ref="list">
    <LayerRow v-for="layer in items" :key="layer.id" :layer="layer" />
  </ul>
</template>
LayerRow.vue
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableItem } from '@vue-dnd-kit/core';

const props = defineProps<{ layer: Layer }>();
const el = useTemplateRef<HTMLElement>('el');
makeSortableItem(el, { item: () => props.layer });
</script>

<template>
  <li ref="el">{{ layer.name }}</li>
</template>

行は、それが描画されているリストに登録されます。1 つのコンポーネントで複数のリストを作ることもできます(2 つの列、リストの中のリスト):各行は自分を含む要素のリストに加わり、リストの中のリストでは最も内側のリストに加わります。

ドラフト

makeSortableList は items を返します。これは配列のドラフトです。ドラッグ中、ドラフトはその場で並べ替わります:ドラッグ中の行が新しい枠に入り、ほかの行は道を空けるようにスライドします。配列自体はドロップまで変わりません。ドロップ時に、ライブラリが新しい順序を書き込みます。

上のレイヤーパネルは両方を示しています。パネルは items を描画していて、ドラッグ中に動きます。アートボードは配列そのものである layers を読み取っていて、離したときにだけ重なり順が変わります。

  • 配列ではなく items を描画してください。配列を描画すると、ドロップまで何も動きません。
  • 書き込みはリストの onDrop の後に行われます。onDrop の中では、配列はまだ古い順序で、e.result が新しい順序です。
  • layers は普通の ref です(ここでは defineModel で、書き込みで update:modelValue が発行されます)。そのため、アプリの他の部分もほかの変更と同じようにこの変更を受け取ります。
makeSortableList(list, {
  items: layers,
  onDrop: (e) => {
    console.log(e.from.index, '→', e.to.index, e.result.get(e.to.list));
  },
});
ストア、サーバーの状態、イミュータブルなデータを使う場合は、ref の代わりにゲッターか computed を渡します。ライブラリは読み取り専用のソースには書き込みません。e.preventDefault() を呼び、e.result を自分で適用してください。制御されたリストを参照。

キー

行はデータでキー付けされ、インデックスや自動生成の id は使いません。デフォルトのキーは item.id で、文字列や数値の場合はアイテムそのものです。

// LayersPanel.vue: your items have another key
makeSortableList(list, { items: layers, getKey: (layer) => layer.uuid });

// LayerRow.vue: `item` is keyed with the list's getKey…
makeSortableItem(el, { item: () => props.layer });
// …or pass the key yourself
makeSortableItem(el, { id: () => props.layer.uuid });
  • v-for でも同じキーを使ってください::key="layer.uuid"。:key="index" では違う行がアニメーションし、行の状態がほかの行へ移ってしまいます。
  • キーは、アイテムをやり取りするすべてのリストの中で一意である必要があります。
  • item と id はゲッターとして渡してください。props が変わっても行が同期した状態を保てます。

ハンドル

デフォルトでは行全体でドラッグを開始できますが、行の中のボタン、入力欄、その他のコントロールは除きます。レイヤーの目とロックのアイコンは普通のボタンのままです。一部分だけでドラッグするには handle を渡します。

LayerRow.vue
<script setup lang="ts">
makeSortableItem(el, { item: () => props.layer, handle: '.grip' });
</script>

<template>
  <li ref="el">
    <button type="button" class="grip" :aria-label="`Move ${layer.name}`">⠿</button>
    {{ layer.name }}
  </li>
</template>
  • handle はアイテム内のセレクター、または要素の ref です。
  • マウスのドラッグはハンドルからすぐに始まり、通常の 4 px を待ちません。動かさない押下はクリックのままです:ハンドルは click でメニューを開くこともできます。
  • ハンドルにはドラッグの属性(role、tabindex、aria-*)が付き、キーボードドラッグもそこから始まります。

ハンドルは aria-label 付きの <button type="button"> にしてください。そのままでフォーカスでき、スクリーンリーダーがラベルを読み上げます。アイコンだけの <span> でも動作します(ライブラリが tabindex="0" と role="button" を付けます)が、意味のある名前がありません。

タッチドラッグは 200 ms の長押しの後に始まるので、ページはスクロールできたままです。ハンドルからすぐにドラッグするには、ハンドルに touch-action: none を、アイテムに activation: { touch: { distance: 4 } } を指定します。

ストラテジー

strategy はリストのレイアウトを伝え、行が正しい方向に場所を空けるようにします。

strategy用途
'vertical'(デフォルト)行の縦並び。
'horizontal'1 行の横並び:タブ、チップ、タイムライン。
'grid'CSS グリッドや折り返す flex の行。サイズの異なるアイテムにも対応します。

タブの並びは横方向のリストです。

Live

デモを読み込み中…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Browser from './Browser.vue';
</script>

<template>
  <DnDProvider>
    <Browser />
  </DnDProvider>
</template>
Browser.vue
const { items } = makeSortableList(strip, {
  items: tabs,
  strategy: 'horizontal',
  axis: 'x',        // the tabs stay on their row
  animateChanges: true, // the buttons open and close tabs: slide the others then too
});

タブの閉じるボタンはドラッグ可能なタブの中でも動作し、ドラッグを終えるクリックがタブを選択することはありません。ドラッグ中、行は自動でスライドします。animateChanges はドラッグ以外による変化、ここではボタンによる変化にも同じ動きを加えます(アニメーションを参照)。

グリッドは 2 次元で並べ替わり、タイルのサイズは自由です。

Live

デモを読み込み中…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import PhotoGrid from './PhotoGrid.vue';
</script>

<template>
  <DnDProvider>
    <PhotoGrid />
  </DnDProvider>
</template>
PhotoGrid.vue
const { items } = makeSortableList(grid, { items: photos, strategy: 'grid' });
.grid { display: grid; grid-template-columns: repeat(4, 76px); grid-auto-rows: 76px; grid-auto-flow: dense; }
.wide { grid-column: span 2; }
.tall { grid-row: span 2; }

grid-auto-flow: dense は、大きなタイルが動いてもグリッドを詰まった状態に保ちます。これがないと、タイルが収まらなくなった場所に穴が空き、グリッドが 1 行伸びます。

軸を固定する

makeSortableList(list, { items: layers, axis: 'y' });

axis: 'y' はドラッグを縦軸に、'x' は横軸に固定します。上のレイヤーとタブで使っています。アイテム自身の axis はリストのものより優先されます。ドラッグをリストの中に収めるには、restrictToParent モディファイアも追加します。詳しくは制約を参照してください。

枠のスタイル

ドラッグ中の行は、着地する枠としてリストの中に残り、data-dnd-placeholder が付きます。デフォルトのスタイルは不透明度を 40% にするもので、詳細度がゼロなので、あなたのどんなルールも優先されます。

LayerRow.vue
.layer[data-dnd-placeholder] {
  opacity: 1;
  box-shadow: inset 0 0 0 1.5px #42b883;
}

.layer[data-dnd-placeholder] > * {
  visibility: hidden;   /* an empty slot of the row's exact size */
}

makeSortableItem は同じ状態を ref として返し、さらにいくつか追加します。

戻り値内容
isPlaceholderこの行が、ドラッグ中のアイテムが着地する枠です。
isDraggingこの行がドラッグされています(マルチドラッグでは、ドラッグ中のすべての行)。
indexドラフト内でのインデックス:ドラッグ中に変わります。
selected書き込み可能で、プロバイダーの選択にバインドされています。
indicatorインジケーターモードで、ラインが入る位置。

リストは自身の強調表示用に isOver と isAccepting を返し、さらに activeKey と activeIndex(ドラッグ中のアイテムと、このリストのドラフト内での位置)も返します。すべての状態とそのライブデモはスタイリングにあります。

機能をオフにする

オプション止めるもの
アイテムの disabledこのアイテムはドラッグできません。ほかのアイテムはその横を通って動けます。上のレイヤーをロックして、別のレイヤーをその上へドラッグしてみましょう。
リストの disabledそこからドラッグできず、そこへドロップもできません。
リストの sort: falseアイテムはリスト内で並べ替えられませんが、リストから出ることはできます:パレット。アイテムを共有するリストを参照。

いずれもゲッターまたは ref を受け取り、次のドラッグから適用されます。

LayerRow.vue
makeSortableItem(el, { item: () => props.layer, handle: '.grip', disabled: () => props.layer.locked });

ほかがどうであれ必ず先頭や末尾にある行は、そもそもアイテムではありません。リストの外に描画してください。パネルの Background レイヤーは <ul> の下にあります。

アイテムの一部を表示する

検索ボックスやタグなど:filter は true を返したアイテムを表示し、ドラッグはその中で並べ替えます。ほかのアイテムは配列の中に、元の位置のまま残ります。

Live

デモを読み込み中…

<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Backlog from './Backlog.vue';
import type { Task } from './TaskRow.vue';

// In priority order: the array is the backlog.
const tasks = ref<Task[]>([
  { id: 1, title: 'Login fails on Safari', tag: 'bug' },
  { id: 2, title: 'Dark mode for settings', tag: 'feature' },
  { id: 3, title: 'Document the export API', tag: 'docs' },
  { id: 4, title: 'Crash when the list is empty', tag: 'bug' },
  { id: 5, title: 'Keyboard shortcuts', tag: 'feature' },
  { id: 6, title: 'Typo in the pricing page', tag: 'docs' },
  { id: 7, title: 'Slow search with 10k rows', tag: 'bug' },
  { id: 8, title: 'Share a board by link', tag: 'feature' },
]);
</script>

<template>
  <DnDProvider>
    <Backlog v-model="tasks" />
  </DnDProvider>
</template>
Backlog.vue
const query = ref('');
const tag = ref<Tag | null>(null);

const { items } = makeSortableList(list, {
  items: tasks,
  filter: (task) => (!tag.value || task.tag === tag.value) && task.title.includes(query.value),
});
  • items には条件に合うアイテムが入ります:いつもどおり描画してください。フィルターはリアクティブです:検索ボックスに入力すると、リストはすぐに更新されます。
  • ドロップは配列全体を書き込みます。隠れた各アイテムは、直前にあった表示中のアイテムのすぐ後ろに残ります。そのアイテムが移動したアイテムだった場合は、その前にある移動していない最も近いアイテムの後ろに残ります。
  • e.to.index とリストの activeIndex は、表示中のアイテムで数えます。e.result には、隠れたアイテムも含めた配列全体が入ります。
配列をフィルターする computed を items として渡すこともできますが、それは読み取り専用です:e.result を自分で適用し、隠れたアイテムをどこに置くかを自分で計算することになります。filter はその両方を行います。

挿入ライン

placeholder: 'indicator' では、ドラッグ中もリストは動かず、ドロップ先をラインが示します。ラインはあなたが描きます。

Live

デモを読み込み中…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import IssueTable from './IssueTable.vue';
</script>

<template>
  <DnDProvider>
    <IssueTable />
  </DnDProvider>
</template>
IssueTable.vue
const { items, indicator } = makeSortableList(body, { items: issues, placeholder: 'indicator' });
IssueRow.vue
<script setup lang="ts">
const { indicator } = makeSortableItem(el, { item: () => props.issue });
</script>

<template>
  <div ref="el" class="row" :class="indicator && `drop-${indicator.side}`">…</div>
</template>

<style scoped>
.row.drop-before::before { content: ''; position: absolute; top: -1.5px; left: 6px; right: 6px; height: 2px; background: #42b883; }
.row.drop-after::before { content: ''; position: absolute; bottom: -1.5px; left: 6px; right: 6px; height: 2px; background: #42b883; }
</style>
indicator のフィールド内容
listドロップ先のリスト。
index描画されているリストでの挿入インデックス。
keyラインが付くアイテム。空のリストでは null(そのときはリストに描きます)。
sideそのアイテムの 'before' または 'after'。入れ替えのリストでは 'swap'。ツリーでは 'inside' が加わります。
depth、parentKeyツリーのみ:ドロップの着地先。ツリーを参照。
  • リストの indicator はラインがそのリストにある間(テーブルがそれを表示しています)、アイテムの indicator はラインがそのアイテムに付いている間、値を持ちます。
  • ドラッグ中の行は元の位置にとどまり、プレースホルダーとしてマークされます。薄く表示してください。
  • キーボードでもラインが動きます。
  • このモードでは、すべてのターゲットの外へのドロップはキャンセルになります(dropOutside: 'cancel')。リストは別の順序を一度も表示していないためです。
  • オーバーレイはその下の行の一部を覆います。少し透けるようにすると、ラインが透けて見えます:[data-dnd-overlay] .row { opacity: 0.85 }。

ずらす代わりに入れ替える

behavior: 'swap' では、ドラッグ中のアイテムがその下のアイテムと場所を入れ替え、ほかのアイテムは動きません。

Live

デモを読み込み中…

<script setup lang="ts">
import { reactive, ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Rotation from './Rotation.vue';

const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const columns = reactive([
  {
    id: 'primary',
    title: 'Primary',
    people: [
      { id: 'ava', name: 'Ava', hue: 160 },
      { id: 'ben', name: 'Ben', hue: 20 },
      { id: 'cleo', name: 'Cleo', hue: 280 },
      { id: 'dev', name: 'Dev', hue: 210 },
      { id: 'eli', name: 'Eli', hue: 45 },
    ],
  },
  {
    id: 'backup',
    title: 'Backup',
    people: [
      { id: 'fay', name: 'Fay', hue: 330 },
      { id: 'gus', name: 'Gus', hue: 100 },
      { id: 'hana', name: 'Hana', hue: 190 },
      { id: 'ivo', name: 'Ivo', hue: 0 },
      { id: 'jun', name: 'Jun', hue: 250 },
    ],
  },
]);

/** Live: the swap shows while you drag. On drop: nothing moves, the partner is marked. */
const placeholder = ref<'item' | 'indicator'>('item');
const last = ref('');
</script>

<template>
  <DnDProvider>
    <div class="schedule">
      <div class="modes" role="group" aria-label="Swap preview">
        <button type="button" :aria-pressed="placeholder === 'item'" @click="placeholder = 'item'">Live</button>
        <button type="button" :aria-pressed="placeholder === 'indicator'" @click="placeholder = 'indicator'">On drop</button>
      </div>
      <div class="week">
        <ol class="days">
          <li v-for="day in DAYS" :key="day">{{ day }}</li>
        </ol>
        <Rotation v-for="column in columns" :key="column.id" :column="column" :placeholder="placeholder" @swap="last = $event" />
      </div>
      <p class="last">{{ last ? `Swapped ${last}` : 'Drag a person onto another' }}</p>
    </div>
  </DnDProvider>
</template>

<style scoped>
.schedule {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.modes {
  display: flex;
  padding: 3px;
  border-radius: 10px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

.modes button {
  padding: 6px 12px;
  border: 0;
  border-radius: 7px;
  background: none;
  color: inherit;
  font: inherit;
  font-size: 12px;
  font-weight: 500;
  opacity: 0.6;
  cursor: pointer;
}

.modes button[aria-pressed='true'] {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  opacity: 1;
}

.week {
  display: flex;
  gap: 10px;
  width: 400px;
  max-width: 100%;
}

.days {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin: 21px 0 0;
  padding: 0;
  list-style: none;
}

.days li {
  height: 36px;
  font: 11px/36px ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.55;
}

.last {
  margin: 0;
  font: 12px/1 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.6;
}
</style>
Rotation.vue
const { items } = makeSortableList(list, {
  id: props.column.id,
  items: toRef(props.column, 'people'),
  group: 'rotation',
  behavior: 'swap',
  placeholder: () => props.placeholder,   // 'item' (live) or 'indicator' (on drop)
  onDrop: (e) => {
    if (e.partner) emit('swap', `${e.items[0]?.name} ⇄ ${e.partner.item.name}`);
  },
});
  • ライブ(placeholder: 'item'、デフォルト):ドラッグ中に入れ替えが表示されます。戻したり、リストの外に出たりすると元に戻ります。
  • ドロップ時(placeholder: 'indicator'):ドロップまで何も動きません。インジケーターはドロップで入れ替わるアイテムを side: 'swap' で示します。ラインを描く代わりに、そのアイテムを枠線で囲みましょう。矢印キーでその印がアイテムからアイテムへ移り、ドラッグ中のアイテムに戻ると印はなくなります。
PersonCell.vue
<li ref="el" :class="{ target: indicator?.side === 'swap' }">…</li>

ドロップイベントは mode: 'swap' と、入れ替わった相手のアイテムである partner(その key、item、自身の from と to)を持ちます。同じグループの 2 つのリストは、どちらも behavior: 'swap' であれば、2 つの列のように互いの間で入れ替えができます。このときホバーされたアイテムはもう一方のリストへ移るので、出ること(group.pull)と入ること(group.put)が許可されている必要があります:アイテムを共有するリストを参照。マルチドラッグは入れ替えではなく並べ替えになります。

キーボード

行またはそのハンドルにフォーカスし、:kbd{value="Space"} か Enter を押して持ち上げます。リストに沿った矢印キーで 1 枠ずつ動き、グリッドでは上下キーで次の行へ、リストを横切る方向の矢印キーで同じグループの次のリストへ移ります。リストの端を越えると、矢印キーはその方向にある次のリストへ進み(上下に積み重なったリスト)、なければそこにあるドロップターゲット(ゴミ箱ゾーン)へ進みます。もう一度 Space か Enter を押すとドロップし、:kbd{value="Escape"} で元に戻します。このページのすべてのデモがこのように動作します。スクリーンリーダー向けのテキストはアクセシビリティにあります。

アニメーション

ドラッグ中、行は新しい位置へスライドし、ドロップした行は自分の枠に着地します。行が入ったり出たりすると、リスト自体も新しいサイズへと大きくなったり小さくなったりします(animateSize)。これはデフォルトでオンで、コードは不要です。行を <TransitionGroup> で囲まないでください。移動が二重にアニメーションします。行の transform に CSS の transition を付けるのも避けてください。ドラッグ以外による変化(追加、削除、シャッフル)には、タブのようにリストの animateChanges オプションをオンにします。アニメーションを参照してください。

オプションの完全な一覧は makeSortableList と makeSortableItem の API にあります。