드래그하는 동안 박스 하나가 포인터를 따라갑니다. 이것이 오버레이입니다. 드래그 중인 요소 자체는 안착할 자리로서 제자리에 남습니다. 드롭하면 오버레이가 그 자리로 날아가 사라집니다.
오버레이는 컴포넌트 밖, body로 텔레포트됩니다. 기본적으로 드래그 중인 요소의 클론을 보여 줍니다.
데모 불러오는 중…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Expenses from './Expenses.vue';
</script>
<template>
<DnDProvider>
<Expenses />
</DnDProvider>
</template>기본 클론
클론은 무엇이든 움직이기 전, 집을 때 만들어집니다. 요소의 깊은 복사본입니다.
- 폼 값과
<canvas>픽셀이 복사됩니다. - 요소가 리스트 밖에서 잃게 될 계산된 스타일은 인라인으로 들어갑니다. 상속된 글꼴과 색상, 조상을
통해 적용되는 스타일(
.list li { … }), 표 셀의 너비 같은 것입니다. 표의 행은 별도의 표로 감싸서 옵니다. id와data-dnd-*속성은 제거되고, 클론은aria-hidden입니다.- UI 키트(Vuetify, Quasar, PrimeVue, Naive UI)의 물결 효과 등 누르기 효과는 빠집니다.
위 표에서 값을 입력한 행은 텍스트, 금액, 체크, 차트와 함께 옮겨지며, 실제 셀의 너비를 유지하는 셀로 된 별도의 표에 담깁니다.
함께 옮겨지면 안 되는 다른 것에는 **data-dnd-no-clone**을 표시하세요. 메뉴 버튼, 툴팁, 선택 표시
같은 것입니다. 각 행의 제거 버튼은 남습니다.
<tr ref="el" class="row">
…
<td><button type="button" aria-label="Remove" data-dnd-no-clone @click="emit('remove')">×</button></td>
</tr>
배경은 추측하지 않습니다
클론은 요소를 있는 그대로 보여 줍니다. 리스트가 배경을 칠해서 자체 배경이 없는 행(흰 <ul> 안의
<li>, v-list-item, 표의 행)은 비쳐 보이는 클론이 됩니다. CSS로 리스트의 배경을 주세요.
여기서는 셀에 줍니다.
<style scoped>
[data-dnd-overlay] .row td {
background: color-mix(in srgb, currentColor 4%, Canvas);
}
</style>
클론은 Vue의 스코프 속성을 포함해 요소의 클래스와 속성을 그대로 유지하므로, 이 규칙은 전역 스타일시트뿐 아니라 행의 scoped 스타일에서도 동작합니다. 다른 방법은 행 자체에 배경을 주는 것입니다. 떠 있도록 만든 아이콘과 타일은 의도적으로 투명하게 남습니다.
박스 스타일링
박스는 data-dnd-overlay가 있는 요소입니다. position: fixed이고, 맨 위에 있으며
(z-index: 9999), pointer-events: none과 inert이고, 속성 값이 지금 무엇을 하는지 알려 줍니다.
data-dnd-overlay | 시점 |
|---|---|
"dragging" | 포인터를 따라갑니다. |
"dropping" | 자리에 안착하거나, 원래 자리로 돌아가거나, 비동기 onBeforeDrop을 기다립니다. |
드래그하는 동안 들어 올린 모습을 주고, 안착하면 사라지게 합니다.
[data-dnd-overlay='dragging'] .row td {
box-shadow: 0 14px 20px -16px rgb(0 0 0 / 0.7);
}
박스는 transform으로 움직이고 크기를 width와 height로 받습니다. 박스가 아니라 그 안의 내용을
스타일링하세요. 클론은 박스를 채우며, 루트에 width: 100%, height: 100%, transform: none이
설정됩니다. 회전과 확대는 rotate와 scale 속성으로 하세요. 트랜지션과 키프레임 모두 이 속성을
애니메이션합니다. 레시피는 애니메이션에 있습니다.
오버레이 컴포넌트
클론이 아닌 다른 것을 보여 주려면 컴포넌트를 넘기세요. 컴포넌트는 박스 안에 렌더링되고 드래그를 props로 받으며, 이 props는 드래그하는 동안 바뀝니다. 드래그가 어디에 있는지, 그곳이 드래그를 받는지, 드롭이 복사인지, 그리고 드롭 자체입니다.
데모 불러오는 중…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';
const columns = ref([
{
id: 'Backlog',
tickets: [
{ id: 't1', title: 'Dark mode toggle', tag: 'UI', approved: false },
{ id: 't2', title: 'Retry failed uploads', tag: 'API', approved: true },
{ id: 't3', title: 'Keyboard shortcuts', tag: 'UI', approved: false },
],
},
{ id: 'Doing', tickets: [{ id: 't4', title: 'Faster search', tag: 'API', approved: true }] },
{ id: 'Done', tickets: [{ id: 't5', title: 'Onboarding tour', tag: 'UI', approved: true }] },
]);
</script>
<template>
<DnDProvider>
<div class="board">
<Column v-for="column in columns" :key="column.id" :column="column" />
</div>
</DnDProvider>
</template>
<style scoped>
.board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px;
width: 100%;
max-width: 600px;
margin: 0 auto;
}
</style><script setup lang="ts">
import { computed } from 'vue';
import type { DnDStatus, DragItem, DragMode, Over } from '@vue-dnd-kit/core';
const props = defineProps<{ items: DragItem[]; over: Over | null; accepted: boolean; status: DnDStatus; mode: DragMode }>();
const ticket = computed(() => props.items[0]?.item as Ticket);
const state = computed(() => {
if (props.status === 'dropping') return props.over ? 'landed' : 'back'; // a cancel flies back
if (!props.over) return 'free';
return props.accepted ? 'ok' : 'refused';
});
</script>
<template>
<!-- Fills the overlay box, which takes the size of the slot. -->
<div class="ticket-overlay" :data-state="state">
{{ ticket.title }}
<span v-if="mode === 'copy'" class="copy">+ copy</span>
</div>
</template>
<style scoped>
.ticket-overlay { width: 100%; height: 100%; }
.ticket-overlay[data-state='refused'] { background: color-mix(in srgb, #ef4444 8%, Canvas); }
</style>
| Prop | 담고 있는 것 |
|---|---|
active | 드래그(id, ids, data, type, …). |
items | 드래그 중인 모든 항목, 집은 항목이 처음: { id, item, data, type }. 정렬 가능한 드래그에서 item은 리스트의 항목입니다. |
over | 드래그 아래의 대상. 드롭이 안착하는 동안에는 안착하는 대상. 아무것도 없는 곳 위에서, 그리고 거부되거나 취소된 드래그가 돌아가는 동안에는 null. 정렬 가능한 리스트의 over.id는 리스트의 id입니다. 위의 컬럼은 자기 이름을 씁니다. |
accepted | over가 드래그를 받는지. 받지 않으면 오버레이를 빨갛게 물들이세요. |
status | 'dragging', 안착하는 동안에는 'dropping'. |
mode | 'move', 'copy'(clone이 있는 리스트에서 Alt를 누르고 드래그하거나 팔레트에서 드래그), 'swap', 'insert'(일반 draggable이 리스트로 들어감). |
to | 지금 드롭하면 항목이 놓일 곳: { list, index }, 트리에서는 parentKey와 depth도. 어떤 리스트에도 놓지 않을 때는 null. 예를 들어 일반 대상 위. 드롭이 갈 곳을 참고하세요. |
props의 타입은 OverlayProps이며, 읽는 것만 선언하면 됩니다. 드래그되는 대상에 컴포넌트를 넘기세요.
import TicketOverlay from './TicketOverlay.vue';
makeSortableList(listRef, { items: tickets, overlay: TicketOverlay }); // every item of the list
makeSortableItem(el, { item: () => props.ticket, overlay: TicketOverlay }); // this item
makeDraggable(el, { id: 'chip', overlay: TicketOverlay }); // a plain draggable
컴포넌트는 그대로 넘기거나, 바꾸려면 shallowRef에 담아 넘기거나(드래그 중에도 됩니다. 오버레이가
바로 바뀝니다), 함수로 넘길 수 있습니다. Vue는 함수를 함수형 컴포넌트로 받아들입니다. TSX에서는
overlay: (props) => <TicketOverlay {...props} compact />처럼 씁니다. 깊은 ref나 reactive에는
절대 넣지 마세요. Vue가 컴포넌트를 반응형으로 만들고 경고합니다.
오버레이 컴포넌트의 루트는 박스를 채워야 합니다(width: 100%; height: 100%). 박스는 자리의 크기를
받고 드롭할 때 그 크기를 애니메이션합니다. 루트에 고정 크기를 주면 박스 안에서 그 크기가 유지됩니다.
프로바이더의 #overlay 슬롯
자체 컴포넌트가 없는 모든 드래그에는 프로바이더의 슬롯이 오버레이를 렌더링합니다. 앱 루트 옆에 한 번 정의하면 앱의 모든 리스트와 draggable이 이를 씁니다. 여기서 메모는 자체 고스트를 받고, 사진은 클론으로 돌아갑니다.
데모 불러오는 중…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Moodboard from './Moodboard.vue';
import NoteGhost from './NoteGhost.vue';
</script>
<template>
<DnDProvider>
<Moodboard />
<!--
The overlay of every drag in the app that has none of its own. Notes get a ghost
of their own; for photos the slot renders nothing, so they fall back to the clone.
-->
<template #overlay="{ items, status }">
<NoteGhost v-if="items[0]?.type.includes('note')" :items="items" :status="status" />
</template>
</DnDProvider>
</template><DnDProvider>
<RouterView />
<template #overlay="{ items, status }">
<NoteGhost v-if="items[0]?.type.includes('note')" :items="items" :status="status" />
</template>
</DnDProvider>
슬롯은 오버레이 컴포넌트와 같은 props를 받습니다. 아무것도 렌더링하지 않으면(위의 v-if) 드래그는
클론을 씁니다. 드래그는 type으로 구별하세요. 정렬 가능한 항목의 타입은 직접 지정하지
않는 한(makeSortableItem(el, { type })) 그룹 이름입니다.
오버레이를 바꾸는 대상
드롭 대상은 자신이 받는 드래그가 위에 있는 동안 오버레이가 무엇을 보여 줄지 정할 수 있습니다.
makeDroppable, 리스트, 트리는 overOverlay를 받습니다(리스트나 트리의 overlay는
자기 항목을 위한 것입니다). 폴더 위에서는 드래그 중인 메시지의 칩이 드롭이 할 일을 보여 주는
모습으로 바뀝니다.
데모 불러오는 중…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Mailbox from './Mailbox.vue';
</script>
<template>
<!-- The mailbox reads the selection with `useDnD()`: it renders inside the provider. -->
<DnDProvider>
<Mailbox />
</DnDProvider>
</template>makeDroppable(el, {
id: () => props.folder.id,
data: () => props.folder,
overOverlay: MovePill,
onDrop: (event) => emit('move', event.active.ids),
});
대상의 컴포넌트는 다른 오버레이 컴포넌트와 같은 props를 받고(over도 그중 하나입니다), 드래그
자체의 오버레이(그 컴포넌트, 프로바이더의 슬롯, 또는 클론)를 기본 슬롯으로 받습니다. MovePill은
슬롯을 빼므로 칩을 대체합니다.
<script setup lang="ts">
import { computed } from 'vue';
import type { DragItem, Over } from '@vue-dnd-kit/core';
const props = defineProps<{ items: DragItem[]; over: Over | null }>();
// The folder under the drag: its `data`.
const folder = computed(() => props.over?.data as Folder | undefined);
</script>
<template>
<!-- Fills the box like any overlay component. -->
<div class="pill-box">
<span v-if="folder" class="pill">Move {{ items.length }} to {{ folder.name }}</span>
</div>
</template>
<slot />을 렌더링하면 드래그 자체의 오버레이를 유지한 채 감쌀 수 있습니다. 위
오버레이 컴포넌트의 보드에서 Done이 하는 일이 바로 그것입니다. Done 위에서
승인된 티켓은 자기 오버레이를 유지하고, Done이 그 위에 도장을 찍으며, 다른 컬럼에서 온 티켓은 드롭될
때 색종이가 터집니다.
makeSortableList(listRef, {
items: tickets,
group: { name: 'release', put },
overlay: TicketOverlay, // the tickets' own, in every column
overOverlay: strict ? DoneOverlay : undefined, // Done's, around it
});
<template>
<div class="done">
<slot /> <!-- TicketOverlay -->
<span class="stamp">Shipped</span>
<span v-if="status === 'dropping' && arrives" class="confetti">…</span>
</div>
</template>
- 대상이 드래그를 받는 동안에만 보이고, 드롭이 그 대상에 안착하는 동안에도 남습니다. 대상을 벗어나면 드래그 자체의 오버레이가 돌아옵니다.
- 바뀔 때는 크로스페이드됩니다. 방식은 프로바이더의
overlayTransition이 정합니다.false는 바로 바꾸고, 객체는 내용을 감싸는<Transition>의 props입니다(name,mode, GSAP이나 motion용 JS 훅).mode: 'out-in'이 없으면 잠깐 둘 다 보입니다..<name>-leave-active에서 떠나는 쪽에position: absolute를 주세요. - 드롭은 색종이를 기다립니다. 안착하는 동안 오버레이 안에서 실행되는 CSS 애니메이션과 트랜지션은 끝까지 재생되며, 최대 2초입니다.
overlay: false인 드래그는 어떤 대상 위에서도 오버레이가 없습니다.
폴더는 또한 필을 자기만의 방식으로, 폴더 아이콘 안으로 안착시킵니다. 대상도 overDropAnimation을
가질 수 있습니다. 애니메이션을 참고하세요.
드롭이 갈 곳: to
to는 지금 드롭하면 항목이 어디에 놓일지를 오버레이에 알려 줍니다. 드롭 이벤트의 to가 알려 줄
것과 같습니다. 리스트와 그 안의 인덱스, 트리에서는 부모와 깊이입니다. 자리가 움직이면 바뀝니다. 이
사이트 메뉴의 페이지는 어느 섹션의 몇 번째 행으로 들어갈지 알려 줍니다.
데모 불러오는 중…
<script setup lang="ts">
import { provide, ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import MenuTree from './MenuTree.vue';
import { MENU, site } from './menu';
// Provided above the provider: its overlay renders under it, so the overlay sees it too.
provide(MENU, ref(site()));
</script>
<template>
<DnDProvider>
<MenuTree />
</DnDProvider>
</template><script setup lang="ts">
import { computed } from 'vue';
import type { DragItem, DropDestination } from '@vue-dnd-kit/core';
const props = defineProps<{ items: DragItem[]; to: DropDestination | null }>();
const page = computed(() => props.items[0]!.item as MenuNode);
// `parentKey` is the section the page would go into, `null` at the top level.
const where = computed(() => (props.to ? pathOf(menu.value, props.to.parentKey) : null));
</script>
<template>
<div class="move">
<div class="row">{{ page.title }}</div>
<p v-if="to && where" class="where">Move to <b>{{ where }}</b>, row {{ to.index + 1 }}</p>
</div>
</template>
모든 대상 밖에서도 to는 여전히 자리의 위치입니다. 기본적으로 그곳에서 드롭하면 항목은 자리가 있는
곳에 남습니다(dropOutside: 'placeholder'). 드롭이 항목을 어떤 리스트에도 놓지 않을 때는 null입니다.
일반 대상 위, 항목을 거부하는 리스트 위, dropOutside: 'cancel'이나 'remove'일 때의 대상 밖이
그렇습니다.
우선순위
각 드래그에는 해당하는 첫 번째 것이 쓰입니다.
- 드롭 대상의
overOverlay(리스트나 트리의 것도 마찬가지). 드래그가 그 위에 있고 대상이 드래그를 받는 동안입니다. 그렇지 않았다면 보였을 것이 그 기본 슬롯이 됩니다. - 항목 자체의
overlay(makeSortableItem,makeDraggable). - 그 리스트나 트리의
overlay. - 프로바이더의
#overlay슬롯. 단, 아무것도 렌더링하지 않으면 제외. - 기본 클론. 멀티 드래그에서는 클론의 열이나 더미입니다. 멀티 드래그를 참고하세요.
드래그에 오버레이가 아예 있는지(overlay: false)는 드래그가 시작될 때 정해집니다. 어떤 컴포넌트를
보여 줄지는 실시간으로 읽습니다. 드래그 중에 바꾼 ref가 컴포넌트를 바꾸고, 자기 오버레이가 있는
대상에 들어가거나 나올 때도 바뀝니다.
DragPreview: 레이아웃 안의 클론
DragPreview는 드래그 중인 항목 하나에 대해 라이브러리가 만든 클론을 렌더링합니다. 오버레이
컴포넌트는 이를 여러 개 배치하거나, 하나에 틀을 씌울 수 있습니다. 책을 클릭해 고른 뒤 하나를
드래그해 보세요. 오버레이가 실제 클론을 부채꼴로 펼칩니다.
데모 불러오는 중…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Shelf from './Shelf.vue';
</script>
<template>
<DnDProvider>
<Shelf />
</DnDProvider>
</template><script setup lang="ts">
import { computed } from 'vue';
import { DragPreview, type DragItem } from '@vue-dnd-kit/core';
const props = defineProps<{ items: DragItem[] }>();
const fan = computed(() =>
props.items.slice(0, 4).map((entry, index) => ({
entry,
angle: index === 0 ? 0 : (index % 2 ? -1 : 1) * Math.ceil(index / 2) * 10,
})),
);
</script>
<template>
<div class="fan">
<div v-for="{ entry, angle } in fan" :key="entry.id" class="card" :style="{ transform: `rotate(${angle}deg)` }">
<DragPreview :id="entry.id" class="face" :style="{ width: '100%', height: '100%' }">
<div class="blank" /> <!-- an item without a clone -->
</DragPreview>
</div>
<span v-if="items.length > 1" class="count">{{ items.length }}</span>
</div>
</template>
- 클론은 집은 항목과, 드롭 순서상 그 옆의 다른 항목들에 대해 프로바이더의
overlayPreviews만큼 존재합니다(기본값 5,'all'이면 전부). 집을 때 렌더링되어 있는 경우에 한합니다. 그 밖의 항목에는DragPreview가 기본 슬롯을 렌더링합니다. - 미리보기는 집을 때의 항목 크기를 가지며, 이 크기는 인라인으로 지정됩니다. 위처럼 그대로
전달되는
style로 크기를 정하세요(클래스로는 덮어쓸 수 없습니다). - 정렬 가능한 멀티 드래그(
multiple: 'group'또는'gaps')에서는 드롭할 때 각 미리보기가 자기 항목의 자리에 안착합니다. 래퍼는rotate속성이 아니라transform으로 회전하세요. 안착하는 동안 원래대로 돌아갑니다. 항목의 모양(모서리, 그림자)은class="face"처럼 미리보기 자체에 주세요. 래퍼는 박스 안에 남습니다.
멀티 드래그 가이드에는 클론 대신 개수와 제목을 보여 주는 오버레이가 있습니다.
크기: overlayFit
크기가 다른 리스트 위에서는 박스가 안착할 자리의 크기를 받습니다. 이는 프로바이더의 overlayFit으로
바꿉니다. DnDProvider 페이지에 좁은 리스트와 넓은 리스트로 된 데모가
있습니다.
overlayFit(프로바이더) | 박스는 |
|---|---|
'slot'(기본값) | 안착할 자리의 크기로 부드럽게 바뀝니다. 넓은 리스트 위에서는 넓어집니다. |
'source' | 드래그 중인 요소의 크기를 유지합니다. |
'slot'에서는 포인터를 중심으로 박스 크기가 바뀌며, 포인터는 박스 안의 같은 비율 위치에 머뭅니다.
내용은 절대 확대·축소되지 않고 새 크기에 맞춰 다시 배치되므로, 그 데모의 작업처럼 flex 행은 어떤 너비에서도
제대로 보입니다. 가는 선처럼 항목보다 훨씬 작게 스타일링한 자리는 그 축 방향으로 박스를 줄이지
않습니다.
드롭은 두 설정 모두에서 같습니다. 박스는 안착할 요소로 날아가 그 요소의 크기로 애니메이션되고, 내용은 그동안 다시 배치됩니다. 항목이 두 리스트에서 다르게 보이면(팔레트의 작은 칩, 보드의 완전한 카드), 원본의 클론이 대상의 크기로 다시 배치되어 그곳에서 찌그러지거나 늘어나 보일 수 있습니다. 이를 바로잡는 방법은 두 가지입니다.
- 위의 작업 행처럼 항목에 두 크기 모두에서 동작하는 레이아웃을 주거나, 안착할 수 있는 모든 리스트에서 같은 크기를 주세요.
- 오버레이 컴포넌트에서 대상의 모양을 렌더링하세요. 컴포넌트는
over를 받으므로 위에 있는 리스트의 모양으로 바꿀 수 있고, 어떤 크기에서도 박스를 채웁니다.
렌더링 위치: overlayTo
프로바이더는 오버레이를 body로 텔레포트합니다. showModal()로 연 모달 <dialog>는 body의 모든
것보다 위인 top layer에 있으므로, 그곳의 오버레이는 다이얼로그 아래에 가려집니다. overlayTo가
다이얼로그 안의 선택자나 요소를 가리키게 하세요.
DnDProvider 페이지에 이 설정이 있을 때와 없을 때를
비교해 볼 수 있는 다이얼로그가 있습니다.
<script setup lang="ts">
const dialog = useTemplateRef<HTMLDialogElement>('dialog');
const open = ref(false);
// While the dialog is open, the overlay renders inside it.
const overlayTo = computed(() => (open.value ? '#columns-dialog' : 'body'));
const show = () => {
dialog.value?.showModal();
open.value = true;
};
</script>
<template>
<DnDProvider :overlay-to="overlayTo">
<button type="button" @click="show">Edit columns</button>
<dialog id="columns-dialog" ref="dialog" @close="open = false">
<ColumnList v-model:columns="columns" />
</dialog>
</DnDProvider>
</template>
- 프로바이더가 마운트된 뒤 오버레이를 렌더링할 때 대상이 페이지에 있어야 합니다. 닫힌
<dialog>도 숨겨져 있을 뿐 페이지에 있습니다. 닫혀 있는 동안에는body로 되돌려 다른 드래그의 오버레이가 보이게 하세요. - 박스는
position: fixed입니다. 대상이나 그 조상에transform,filter,contain이 있으면 뷰포트 대신 그 요소가 박스의 기준이 되므로, 다이얼로그에는 쓰지 마세요. - 클론의 스타일은 표시되는 위치에 맞춰 계산됩니다. 섀도 루트에서는
overlayTo가 그 안을 가리키게 하면 클론이 섀도 루트의 스타일을 유지합니다.
오버레이 없이
makeDraggable(el, { overlay: false })에는 박스가 전혀 없습니다. 반환된 transform으로 요소 자체가
움직입니다.
const { transform } = makeDraggable(el, { overlay: false });
const style = computed(() =>
transform.value ? { translate: `${transform.value.x}px ${transform.value.y}px` } : undefined,
);
드래그 앤 드롭을 참고하세요. 박스가 있는 드래그가
안착하는 방식은 그 dropAnimation이 정합니다. 드롭 대상의 것(overDropAnimation), 항목의 것(또는 그 리스트의 것),
또는 프로바이더의 것입니다.
애니메이션을 참고하세요.