카드를 Done으로 드래그해 보세요. 드롭이 답을 기다립니다. 카드를 옮기거나, 다른 곳으로 계속 드래그하거나, 취소할 수 있습니다.
데모 불러오는 중…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Board from './Board.vue';
</script>
<template>
<DnDProvider>
<Board />
</DnDProvider>
</template>onBeforeDrop
makeSortableList(listRef, {
items: tasks,
onBeforeDrop: (e) => (e.items.some((task) => task.locked) ? 'reject' : 'accept'),
});
리스트, 트리, 드롭 대상(makeDroppable), 모니터(useDnDMonitor)는 onBeforeDrop을 받습니다. 드래그를
놓을 때, 무엇이든 바뀌기 전에 실행되며, 그 답이 결과를 정합니다.
| 답 | 일어나는 일 |
|---|---|
'accept', true, 또는 반환값 없음 | 드롭이 완료됩니다. |
'reject' 또는 false | 드롭하지 않습니다. 항목은 원래 자리로 돌아갑니다. 오류를 던져도 거부됩니다. |
| key 배열 | 그 항목만 드롭되고, 나머지는 원래 자리로 돌아갑니다. |
'continue' | 드롭하지 않고, 드래그가 계속됩니다. |
| 위 값 중 하나의 promise | promise가 끝날 때까지 오버레이는 놓은 자리에서 기다립니다. |
리스트와 트리는 SortableDropEvent를 넘깁니다. keys, items, from, to, mode, sameList,
그리고 드롭이 바꾸는 모든 리스트의 새 배열인 result입니다. 드롭 대상과 모니터는 범용 DnDEvent를
받습니다. active, over, position, source입니다. 이벤트를 참고하세요.
드래그 중의 규칙, 놓을 때의 규칙
Email과 Chat은 둘 다 최대 10 MB의 첨부 파일을 받습니다. demo.mov를 각각의 위로 드래그해 보세요. Email은 위에 머무는 동안 거부하고, Chat은 받았다가 놓으면 돌려보냅니다.
데모 불러오는 중…
<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';
const columns = reactive([
{
id: 'desktop',
title: 'Desktop',
files: [
{ id: 'notes', name: 'notes.pdf', size: 2 },
{ id: 'demo', name: 'demo.mov', size: 48 },
{ id: 'logo', name: 'logo.png', size: 1 },
{ id: 'backup', name: 'backup.zip', size: 120 },
],
},
// Both take files of 10 MB at most, and say so at different times.
{ id: 'email', title: 'Email', rule: 'put' as const, files: [] },
{ id: 'chat', title: 'Chat', rule: 'onBeforeDrop' as const, files: [] },
]);
</script>
<template>
<DnDProvider>
<div class="board">
<Column v-for="column in columns" :key="column.id" :column="column" />
</div>
</DnDProvider>
</template>
<style scoped>
.board {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.board > * {
flex: 0 1 170px;
min-width: 150px;
}
</style>// Asked while dragging: a refused drag never enters the list.
group: { name: 'attachments', put: ({ items }) => fits(items as Attachment[]) },
// Asked on release: the drag enters the list, then flies back.
onBeforeDrop: (e) => (fits(e.items) ? 'accept' : 'reject'),
group.put(또는 대상의 accept) | onBeforeDrop | |
|---|---|---|
| 묻는 시점 | 드래그 중, 드래그가 리스트에 닿자마자. | 드래그를 놓을 때 한 번. |
| 사용자가 보는 것 | 리스트가 즉시 거부합니다. 자리가 열리지 않고, isAccepting이 false, over.accepted가 false입니다. | 자리는 평소처럼 열리고, 놓으면 항목이 돌아갑니다. |
| 아는 것 | 무엇을 드래그하는지: keys, items, types, from, to. | 드롭 전체: to.index, sameList, mode, result. |
| 할 수 있는 것 | 예, 아니요로 답하기. | promise 기다리기, 일부 key만 받기, 드래그 계속하기. |
항목만 보고 알 수 있는 것(타입, 플래그, 개수)에는 group.put을 쓰세요. 사용자가 놓기 전에 알 수
있습니다. 위치, 사용자의 답, 서버의 답에 달린 것에는 onBeforeDrop을 쓰세요. 대상 자체의 규칙이
먼저입니다. 드래그를 거부하는 리스트나 대상 위에서는 드롭이 취소되고 그 onBeforeDrop은 호출되지
않습니다.
일부 항목만 받기
어떤 규칙은 놓을 때 서버에 물어봐야만 확인할 수 있습니다. 이 워크숍의 좌석은 다른 사람들도 예약합니다. 놓으면 리스트가 서버에 남은 좌석 수를 묻고, 끌어온 사람 중 그 수만큼만 받습니다. 몇 명을 체크하고 함께 드래그해 보세요.
데모 불러오는 중…
<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';
const columns = reactive([
{
id: 'waiting',
title: 'Waiting list',
people: [
{ id: 'ada', name: 'Ada', hue: 200 },
{ id: 'bo', name: 'Bo', hue: 30 },
{ id: 'cy', name: 'Cyrus', hue: 280 },
{ id: 'dee', name: 'Dee', hue: 140 },
],
},
{
id: 'workshop',
title: 'Workshop',
seats: true,
people: [
{ id: 'eli', name: 'Eli', hue: 340 },
{ id: 'fay', name: 'Fay', hue: 90 },
],
},
]);
</script>
<template>
<DnDProvider>
<div class="demo">
<p class="tip">Tick a few people, then drag one of them into the workshop</p>
<div class="board">
<Column v-for="column in columns" :key="column.id" :column="column" />
</div>
</div>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.tip {
margin: 0;
font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
.board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 230px));
justify-content: center;
gap: 14px;
width: 100%;
}
</style>makeSortableList(list, {
items: toRef(props.column, 'people'),
group: 'workshop',
// Some keys: those land, the others fly back to where they came from.
onBeforeDrop: async (e) => {
const { free } = await seatsLeft(props.column.people.length); // the overlay waits
return e.keys.slice(0, free);
},
onDrop: (e) => {
selection.delete(...e.accepted); // the ones sent back stay selected
notice.value = `${e.accepted.length} booked, ${e.rejected.length} back to the waiting list`;
},
});
멀티 드래그에서는 반환한 key의 항목이 함께 안착하고, 나머지는 원래 자리로
돌아갑니다. 그다음 onDrop은 이를 e.accepted와 e.rejected로 보고, e.result는 최종 결과입니다.
빈 배열은 아무것도 받지 않으므로 드롭이 거부됩니다.
계속 드래그하기: 'continue'
'continue'는 드래그가 아니라 드롭을 취소합니다. 위 다이얼로그에서 Keep dragging을 고르면, 항목은
있던 자리에서 계속 손에 들려 있고, 페이지는 다시 드래그 모드로 돌아가며, 사용자는 하던 일을 계속합니다.
- 마우스나 펜에서는 버튼을 누르지 않아도 항목이 포인터를 따라갑니다. 다음 놓기, 즉 클릭이 항목을 드롭하며, 그 클릭은 아래 페이지에 전달되지 않습니다.
- 터치에서는 손가락이 닿는 곳에서 항목을 다시 잡고, 손가락을 떼면 드롭합니다.
- 키보드 드래그는 계속됩니다. 화살표 키로 항목을 움직이고, Space나 Enter로 드롭합니다.
- Escape는 다른 드래그처럼 취소합니다.
- 다음 드롭에서
onBeforeDrop을 다시 묻습니다.
모니터는 onDragResume을 듣고, 스크린 리더는 항목이 드롭되지 않고 여전히 움직이는 중이라고
안내합니다(문구는 프로바이더의 announcements.onDragResume으로 바꿉니다).
useDnDMonitor({
onDragResume() {
hint.value = 'Still dragging: click to drop, Esc to cancel';
},
});
사용자에게 묻기
onBeforeDrop은 promise를 반환할 수 있습니다. 다이얼로그를 열고, 사용자가 고른 버튼으로 promise를
resolve하세요.
const { items } = makeSortableList(list, {
id: props.column.id,
items: toRef(props.column, 'tasks'),
group: 'release',
onBeforeDrop: (e) => (props.column.confirm && !e.sameList ? props.ask(e) : 'accept'),
});
type Answer = 'accept' | 'continue' | 'reject';
const question = shallowRef<{ title: string; resolve: (answer: Answer) => void } | null>(null);
/** Opens the dialog: `onBeforeDrop` waits for the button the user picks. */
const ask = (e: SortableDropEvent<Task>) =>
new Promise<Answer>((resolve) => {
question.value = { title: e.items[0]!.title, resolve };
});
promise가 대기 중인 동안:
- 오버레이는 놓은 자리에서
data-dnd-overlay="dropping"으로 표시된 채 기다립니다. useDnD()는 놓은 순간의 상태를 유지하고,status는'dropping'입니다.- 페이지는 드래그 모드에서 벗어납니다. 커서와 텍스트 선택이 돌아오므로 다이얼로그가 평소처럼 동작합니다.
- 다른 드래그는 시작할 수 없습니다.
showModal()로 연 모달 <dialog>는 top layer에 있어 기다리는 오버레이보다 위에 있습니다. 예제처럼
다이얼로그의 cancel 이벤트(Escape)를 'reject'로 바꾸세요.훅의 순서
메모의 순서를 바꾸고, 하나를 휴지통에 드롭한 뒤, 답을 바꿔서 다시 해 보세요. 패널에 마지막 드롭의 훅이 실행된 순서대로 나열됩니다.
데모 불러오는 중…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Monitor from './Monitor.vue';
import Notes from './Notes.vue';
import { ANSWERS, type Answer } from './types';
const monitorAnswer = ref<Answer>('accept');
const targetAnswer = ref<Answer>('accept');
// Every hook of the last drop, in the order they ran.
const steps = ref<{ who: string; what: string }[]>([]);
const trace = (who: string, what: string) => steps.value.push({ who, what });
</script>
<template>
<DnDProvider
@drag-start="steps = []"
@drag-end="trace('provider', '@drag-end')"
@drag-cancel="trace('provider', '@drag-cancel')"
@drag-settle="trace('provider', '@drag-settle: landed')"
>
<div class="demo">
<div class="switches">
<fieldset>
<legend>monitor answers</legend>
<label v-for="value in ANSWERS" :key="value" :class="{ on: monitorAnswer === value }">
<input v-model="monitorAnswer" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
<fieldset>
<legend>target answers</legend>
<label v-for="value in ANSWERS" :key="value" :class="{ on: targetAnswer === value }">
<input v-model="targetAnswer" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
</div>
<div class="layout">
<Notes :answer="targetAnswer" :trace="trace" />
<ol class="steps" aria-label="Hooks of the last drop">
<li v-for="(step, index) in steps" :key="index">
<span class="who">{{ step.who }}</span>{{ step.what }}
</li>
<li v-if="!steps.length" class="empty">Move a note, or drop one in the trash</li>
</ol>
</div>
<Monitor :answer="monitorAnswer" :trace="trace" />
</div>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.switches {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px 16px;
}
fieldset {
display: inline-flex;
margin: 0;
padding: 3px;
border: 0;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
legend {
float: left;
align-self: center;
padding: 0 8px 0 6px;
font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
label {
padding: 5px 10px;
border-radius: 6px;
font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
label.on {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
font-weight: 600;
}
label:has(input:focus-visible) {
outline: 2px solid #42b883;
}
input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.layout {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 16px;
}
.steps {
box-sizing: border-box;
min-width: 220px;
min-height: 150px;
margin: 0;
padding: 10px 14px 10px 32px;
border-radius: 10px;
background: color-mix(in srgb, currentColor 5%, transparent);
font: 11px/1.8 ui-monospace, Menlo, Consolas, monospace;
white-space: nowrap;
list-style: decimal;
}
.steps li::marker {
color: color-mix(in srgb, currentColor 45%, transparent);
}
.who {
display: inline-block;
min-width: 60px;
color: #42b883;
}
.steps li.empty {
margin-left: -18px;
list-style: none;
opacity: 0.45;
}
</style>- 각
useDnDMonitor의onBeforeDrop이 차례로 실행됩니다.'reject'나'continue'가 나오면 묻기가 끝나고, key는 드롭 범위를 좁힙니다. - 대상의
onBeforeDrop: 항목이 들어가는 리스트나 트리, 또는 드롭 대상. - 대상의
onDrop만 실행됩니다. 항목이 다른 리스트로 떠나는 리스트는 호출되지 않으며, 그 리스트의 새 배열은 대상의e.result에 있습니다. 항목이 제거될 때(휴지통 영역,dropOutside: 'remove')는 그 리스트의onDrop이to.index가-1인 채로 실행됩니다. - 기본 기록: 드롭이 바꾸는 각 리스트가 새 배열을 받습니다.
- 프로바이더(
@drag-end)와 모니터의onDragEnd. 그다음 오버레이가 안착하고,onDragSettle(@drag-settle)이 안착했음을 알립니다.
거부된 드롭은 대신 onDragCancel(@drag-cancel)로 건너뛰고, 항목은 원래 자리로 돌아갑니다.
'continue'도 묻기를 끝내며 onDragResume이 호출됩니다. 드래그는 계속되고, 다음 드롭에서 다시
묻습니다.
모니터가 대상보다 먼저 결정하므로, 리스트가 이미 자체 드롭을 적용한 뒤에 거부되는 일은 없습니다. 그래서 앱의 모든 드롭에 관한 규칙은 모니터에 두면 됩니다.
const { syncing } = useSync();
// Read-only while syncing: every drop under the provider flies back.
useDnDMonitor({
onBeforeDrop: () => (syncing.value ? 'reject' : 'accept'),
});
onDrop: 기록 전에 알림
makeSortableList(listRef, {
items: tasks,
onDrop: (e) => {
// `tasks` still holds the old order, `e.result` the new one.
console.log(e.keys, e.from, e.to, e.result.get(e.to.list));
},
});
리스트의 onDrop은 드롭이 받아들여지면 리스트가 기록하기 직전에 동기적으로 실행됩니다. 기록 뒤에
무언가를 하려면 프로바이더의 @drag-end나 useDnDMonitor({ onDragEnd })를 쓰세요. 일반 드롭
대상에서는 onDrop에서 직접 데이터를 바꿉니다. 라이브러리는 대신 아무것도 옮기지 않습니다.
스토어와 서버: 제어 리스트
const store = useBoardStore();
const { items } = makeSortableList(listRef, {
id: 'todo',
group: 'board',
items: () => store.lists.todo, // a getter: read-only
onDrop: (e) => {
e.preventDefault();
for (const [listId, next] of e.result) store.setList(listId, next);
},
});
라이브러리는 쓰기 가능한 ref에만 드롭을 기록합니다. getter, computed, readonly ref에는 절대
기록하지 않습니다. e.preventDefault()를 호출하고 e.result, 즉 드롭이 바꾸는 모든 리스트의 새
배열을 리스트 id별로 직접 적용하세요. 일부에만 기록하면 항목이 사라지거나 중복되므로,
preventDefault()는 모든 리스트로의 기록을 건너뜁니다. 개발 빌드에서는 읽기 전용 리스트의 드롭을
라이브러리에 맡기면 경고합니다.
먼저 서버에 저장하기
서버를 flaky(요청이 한 번 걸러 한 번 실패)나 down으로 두고 카드를 옮겨 보세요. 요청이 진행되는 동안 카드는 배지를 달고 기다렸다가, 안착하거나 실패하면 원래 자리로 돌아갑니다.
데모 불러오는 중…
<script setup lang="ts">
import { DnDProvider, DragPreview } from '@vue-dnd-kit/core';
import Column from './Column.vue';
import { mode, requests, saving, type ServerMode } from './server';
const MODES: ServerMode[] = ['reliable', 'flaky', 'down'];
</script>
<template>
<DnDProvider>
<div class="demo">
<fieldset class="modes">
<legend>server</legend>
<label v-for="value in MODES" :key="value" :class="{ on: mode === value }">
<input v-model="mode" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
<div class="board">
<Column id="backlog" title="Backlog" />
<Column id="sprint" title="Sprint" />
</div>
<ol class="requests" aria-label="Requests">
<li v-for="request in requests" :key="request.id" :class="`status-${request.status}`">
{{ request.text }} <span>{{ request.status === 'pending' ? '…' : request.status }}</span>
</li>
<li v-if="!requests.length" class="empty">No requests yet</li>
</ol>
</div>
<!-- The overlay of every drag: the card's copy, and a badge while the server answers. -->
<template #overlay="{ active }">
<div class="carried">
<DragPreview :id="active.id" />
<span v-if="saving" class="saving">Saving…</span>
</div>
</template>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.modes {
display: inline-flex;
margin: 0;
padding: 3px;
border: 0;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
legend {
float: left;
align-self: center;
padding: 0 8px 0 6px;
font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
.modes label {
padding: 5px 10px;
border-radius: 6px;
font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
.modes label.on {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
font-weight: 600;
}
.modes label:has(input:focus-visible) {
outline: 2px solid #42b883;
}
.modes input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.board {
display: grid;
grid-template-columns: repeat(2, minmax(0, 220px));
gap: 14px;
justify-content: center;
width: 100%;
}
.requests {
width: min(100%, 454px);
min-height: 60px;
margin: 0;
padding: 8px 12px;
border-radius: 10px;
background: color-mix(in srgb, currentColor 5%, transparent);
font: 11px/1.6 ui-monospace, Menlo, Consolas, monospace;
list-style: none;
white-space: pre;
overflow-x: auto;
}
.requests li + li {
opacity: 0.55;
}
.requests .status-200 span {
color: #42b883;
}
.requests .status-503 span {
color: #e5484d;
}
.requests li.empty {
color: inherit;
opacity: 0.45;
}
.carried {
position: relative;
width: 100%;
height: 100%;
}
.saving {
position: absolute;
top: 50%;
right: 10px;
display: inline-flex;
align-items: center;
gap: 6px;
translate: 0 -50%;
padding: 4px 8px;
border-radius: 99px;
background: #42b883;
color: #fff;
font: 600 11px/1 system-ui, sans-serif;
}
.saving::before {
content: '';
width: 9px;
height: 9px;
border: 2px solid rgb(255 255 255 / 0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
rotate: 1turn;
}
}
</style>const { items } = makeSortableList(list, {
id: props.id,
// A getter: the library never writes it. The drop is applied below, once saved.
items: () => board[props.id]!,
group: 'sprint',
onBeforeDrop: async (e) => {
e.preventDefault();
try {
await saveMove(e.keys, e.to);
} catch {
return 'reject'; // nothing applied: the card flies back
}
for (const [id, tasks] of e.result) board[id] = tasks; // lands in its new place
return 'accept';
},
});
promise가 resolve되기 전에 e.result를 적용하면 오버레이가 새 순서에 안착합니다. 요청이 실패하면
아무것도 적용하지 말고 거부하세요. 항목은 원래 자리로 돌아가고, 모니터는 onDragCancel을 듣습니다.
배지는 모든 드래그의 오버레이를 렌더링하는 프로바이더의 #overlay 슬롯에서 옵니다. DragPreview가
평소의 카드 복사본을 보여 주고, 요청이 진행되는 동안 배지가 그 위에 표시됩니다.
<DnDProvider>
<Board />
<template #overlay="{ active }">
<div class="carried">
<DragPreview :id="active.id" />
<span v-if="saving" class="saving">Saving…</span>
</div>
</template>
</DnDProvider>
모든 대상 밖에 드롭하기
모드마다 앱을 독 밖으로 드래그해서 놓아 보세요.
데모 불러오는 중…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider, type DropOutside } from '@vue-dnd-kit/core';
import Dock from './Dock.vue';
const MODES: DropOutside[] = ['placeholder', 'cancel', 'remove'];
const dropOutside = ref<DropOutside>('remove');
</script>
<template>
<DnDProvider>
<div class="demo">
<fieldset class="modes">
<legend>dropOutside</legend>
<label v-for="value in MODES" :key="value" :class="{ on: dropOutside === value }">
<input v-model="dropOutside" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
<code class="call">makeSortableList(dock, { strategy: 'horizontal', dropOutside: '{{ dropOutside }}' })</code>
<Dock :drop-outside="dropOutside" />
</div>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.modes {
display: inline-flex;
margin: 0;
padding: 3px;
border: 0;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
legend {
float: left;
align-self: center;
padding: 0 8px 0 6px;
font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
.modes label {
padding: 5px 10px;
border-radius: 6px;
font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
.modes label.on {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
font-weight: 600;
}
.modes label:has(input:focus-visible) {
outline: 2px solid #42b883;
}
.modes input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.call {
max-width: 100%;
padding: 6px 10px;
overflow-x: auto;
border-radius: 8px;
background: color-mix(in srgb, currentColor 6%, transparent);
font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
white-space: nowrap;
color: #42b883;
}
</style>const { items } = makeSortableList(dock, {
items: apps,
strategy: 'horizontal',
dropOutside: () => props.dropOutside,
// Called on the list the items leave, with `to.index` -1, when they are removed.
onDrop: (e) => {
if (e.to.index === -1) notice.value = `Removed ${e.items.map((app) => app.name).join(', ')}`;
},
});
dropOutside | 모든 대상 밖의 드롭은… |
|---|---|
'placeholder' | 플레이스홀더가 있는 곳, 즉 리스트가 마지막으로 보여 준 자리에 안착합니다. 기본값입니다. |
'cancel' | 취소됩니다. 항목은 원래 자리로 돌아갑니다. placeholder: 'indicator'일 때의 기본값입니다. |
'remove' | 항목을 리스트에서 제거합니다. 먼저 리스트의 onBeforeDrop에 묻고, 리스트의 onDrop은 to.index로 -1을 받습니다. |
드롭할 때 읽으므로 ref나 getter로 바꿀 수 있습니다. 휴지통 영역은 같은 일을 의도적으로 합니다.
makeDroppable(trash, { onDrop: (e) => e.source?.remove() }).
드래그 앤 드롭을 참고하세요.