데모 불러오는 중…
<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>리스트와 행
정렬 가능한 리스트에는 컴포넌트 두 개가 필요합니다. 리스트와 그 행입니다.
<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>
<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>
행은 자신이 렌더링된 리스트에 등록됩니다. 한 컴포넌트에서 리스트를 여러 개 만들 수도 있습니다(두 컬럼, 리스트 안의 리스트). 각 행은 자신을 감싼 요소의 리스트에 들어가며, 리스트 안의 리스트에서는 가장 안쪽 리스트에 들어갑니다.
초안
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));
},
});
computed를 넘기세요. 라이브러리는
읽기 전용 소스에 기록하지 않습니다. e.preventDefault()를 호출하고 e.result를 직접 적용하세요.
제어 리스트를 참고하세요.Key
행은 인덱스나 자동 id가 아니라 항상 데이터로 식별합니다. 기본 key는 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를 쓰세요::key="layer.uuid".:key="index"를 쓰면 엉뚱한 행이 애니메이션되고 상태가 행 사이를 오갑니다.- key는 항목을 주고받는 모든 리스트에서 고유해야 합니다.
item과id는 getter로 넘기세요. 그래야 props가 바뀌어도 행이 동기화된 상태를 유지합니다.
핸들
기본적으로 행 전체에서 드래그를 시작할 수 있습니다. 단, 그 안의 버튼, 입력 필드 등 컨트롤은
예외입니다. 레이어의 눈 아이콘과 자물쇠는 일반 버튼으로 남습니다. 한 부분으로만 드래그하려면
handle을 넘기세요.
<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"을 줍니다), 의미 있는 이름이 없습니다.
touch-action: none을, 항목에
activation: { touch: { distance: 4 } }를 주세요.전략
strategy는 리스트가 어떻게 배치되는지 알려 주어, 행이 올바른 방향으로 자리를 비키게 합니다.
strategy | 용도 |
|---|---|
'vertical'(기본값) | 행이 세로로 쌓인 리스트. |
'horizontal' | 한 줄: 탭, 칩, 타임라인. |
'grid' | CSS 그리드와 줄바꿈되는 flex 행. 크기가 다른 항목도 됩니다. |
탭 한 줄은 가로 리스트입니다.
데모 불러오는 중…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Browser from './Browser.vue';
</script>
<template>
<DnDProvider>
<Browser />
</DnDProvider>
</template>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차원 정렬을 합니다.
데모 불러오는 중…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import PhotoGrid from './PhotoGrid.vue';
</script>
<template>
<DnDProvider>
<PhotoGrid />
</DnDProvider>
</template>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는 큰 타일이 움직여도 그리드를 빈틈없이 유지합니다. 이것이 없으면 그리드는
타일이 더 이상 들어가지 않는 곳에 구멍을 남기고 행을 하나 늘립니다.
축 고정
makeSortableList(list, { items: layers, axis: 'y' });
axis: 'y'는 드래그를 세로축에, 'x'는 가로축에 묶어 둡니다. 위의 레이어와 탭이 이를 씁니다.
항목 자체의 axis가 리스트의 것보다 우선합니다. 드래그를 리스트 안에도 가두려면
restrictToParent 모디파이어를 추가하세요. 자세한 내용은 제약에
있습니다.
자리 스타일링
드래그 중인 행은 안착할 자리로서 리스트에 남고, data-dnd-placeholder를 받습니다. 기본 스타일은
명시도 0으로 불투명도를 40%로 낮추므로, 직접 작성한 규칙이 항상 우선합니다.
.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 | 리스트 안에서는 순서를 바꿀 수 없지만, 밖으로는 나갈 수 있습니다. 팔레트가 그렇습니다. 항목을 공유하는 리스트를 참고하세요. |
모두 getter나 ref를 받으며, 다음 드래그부터 적용됩니다.
makeSortableItem(el, { item: () => props.layer, handle: '.grip', disabled: () => props.layer.locked });
다른 행이 무엇을 하든 항상 처음이나 마지막에 있어야 하는 행은 아예 항목이 아닙니다. 리스트 밖에
렌더링하세요. 패널의 Background 레이어는 <ul> 아래에 있습니다.
항목 일부만 보여 주기
검색창이나 태그처럼, filter는 true를 반환한 항목만 보여 주고 드래그는 그 항목들 사이에서
정렬합니다. 나머지 항목은 원본 배열 안의 제자리에 그대로 남습니다.
데모 불러오는 중…
<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>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'를 쓰면 드래그하는 동안 리스트는 그대로 있고, 선이 드롭될 위치를
보여 줍니다. 선은 직접 그립니다.
데모 불러오는 중…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import IssueTable from './IssueTable.vue';
</script>
<template>
<DnDProvider>
<IssueTable />
</DnDProvider>
</template>const { items, indicator } = makeSortableList(body, { items: issues, placeholder: 'indicator' });
<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'을 쓰면 드래그 중인 항목이 그 아래 항목과 자리를 맞바꾸고, 나머지는 그대로
있습니다.
데모 불러오는 중…
<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>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'으로 표시합니다. 선을 그리는 대신 그 항목에 외곽선을 그리세요. 화살표 키는 표시를 항목에서 항목으로 옮기고, 드래그 중인 항목으로 돌아오면 표시가 없습니다.
<li ref="el" :class="{ target: indicator?.side === 'swap' }">…</li>
드롭 이벤트에는 mode: 'swap'과, 자리를 맞바꾼 항목인 partner가 있습니다. partner에는 그
항목의 key, item, 그리고 그 항목 자신의 from과 to가 들어 있습니다. 같은 그룹의 두
리스트가 모두 behavior: 'swap'이면, 두 컬럼처럼 리스트 사이에서도 자리를 맞바꿉니다. 이때
포인터 아래의 항목이 다른 리스트로 옮겨가므로, 나가는 것(group.pull)과 들어오는 것(group.put)이
모두 허용되어야 합니다. 항목을 공유하는 리스트를 참고하세요. 멀티
드래그는 자리를 맞바꾸지 않고 정렬합니다.
키보드
행이나 그 핸들에 포커스를 두고 Space나 Enter를 누르면 집습니다. 리스트 방향의 화살표 키는 한 자리씩 옮기고, 그리드에서 위아래 키는 다음 줄로 가며, 리스트와 교차하는 방향의 화살표 키는 같은 그룹의 다음 리스트로 옮깁니다. 리스트 끝을 지나면 화살표 키는 그 방향의 다음 리스트(위아래로 쌓인 리스트)로 이어서 가고, 없으면 그쪽의 드롭 대상(휴지통 영역)으로 갑니다. 다시 Space나 Enter를 누르면 드롭하고, Escape를 누르면 되돌립니다. 이 페이지의 모든 데모가 그렇게 동작합니다. 스크린 리더 문구는 접근성에 있습니다.
애니메이션
드래그하는 동안 행은 새 자리로 미끄러지듯 이동하고, 드롭한 행은 제자리에 안착합니다. 행이 들어오고
나갈 때 리스트 자체도 새 크기로 커지거나 줄어듭니다(animateSize). 기본으로 켜져 있고 코드가 필요
없습니다. 행을 <TransitionGroup>으로 감싸지 마세요. 이동이 두 번
애니메이션됩니다. 행의 transform에 CSS transition을 걸지도 마세요. 드래그가 아닌
변경(추가, 제거, 섞기)에는 탭처럼 리스트의 animateChanges 옵션을 켜세요.
애니메이션을 참고하세요.
전체 옵션 목록은 makeSortableList와 makeSortableItem API에 있습니다.