整个应用一个 provider
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
</script>
<template>
<DnDProvider>
<RouterView />
</DnDProvider>
</template>
在较高的层级放一个 DnDProvider,比如 App.vue 或某个布局里,然后就不用再管它了。每个组合式函数(makeSortableList、makeDraggable、useDnD……)都会查找它所在组件上方最近的 provider。
<DnDProvider> 的组件里调用,会抛出 No <DnDProvider> found above this component:Vue 的 inject 只能看到祖先组件。它掌管什么
| 部分 | provider 做什么 |
|---|---|
| 输入 | 把指针、触摸和键盘输入变成拖拽(sensors、activation),一次一个拖拽。 |
| 查找目标 | 对拖拽下方的放置目标做命中测试(collision),并通知它们。 |
| 浮层 | 渲染跟随指针的盒子,传送到 body,并在放置时让它落位。 |
| 选中集合 | 一个选中 id 的集合,由它之下的所有列表和可拖拽元素共享。 |
| 自动滚动 | 拖拽靠近容器或窗口边缘时滚动它们。 |
| 实时区域 | 向屏幕阅读器播报拿起、移动、放置和取消。 |
除此之外它什么都不渲染:你的标记保持原样。
选项一览
每个选项都有可用的默认值,所以 provider 通常不需要传任何选项。provider 在需要时才读取它的 props:下面的演示会实时切换 collision、activation 和 autoScroll。
| Prop | 默认值 | 设置什么 |
|---|---|---|
collision | pointerFirst | 如何选出拖拽下方的目标。见下文。 |
activation | 鼠标 4 px,触摸 200 ms | 按下何时变成拖拽,按指针类型分别设置。见下文。 |
autoScroll | 开启 | 边缘滚动:{ threshold, maxSpeed, acceleration } 或 false。见下文。 |
haptics | 触摸拿起时短暂振动 | 触摸拖拽的振动,或 false。见下文。 |
copyModifier | 'Alt' | 拖拽时按住该键,移动就变成复制。 |
modifiers | [] | 适用于每次拖拽的移动规则:见约束。 |
animation / dropAnimation | 开启 | 排序时项的滑动 / 浮层落位:见动画。 |
animateChanges | 关闭 | 列表和树的默认值:为非拖拽引起的数据变化添加动画。 |
keyboard | Space / Enter、Escape | 键盘按键和步长;false 关闭键盘拖拽。见无障碍。 |
sensors | 指针 + 键盘 | 把输入变成拖拽的部件:见传感器。 |
overlayTo | 'body' | 浮层传送到哪里。 |
overlayFit | 'slot' | 浮层尺寸:它将落入的位置的尺寸,或源元素的尺寸。见浮层。 |
overlayTransition | 交叉淡入淡出 | 拖拽过程中浮层从一个组件换成另一个组件的方式:false,或 <Transition> 的 props。见改变浮层的目标。 |
overlayClass | — | 浮层内容([data-dnd-overlay-content])的类,它是作为整体添加动画的元素:拖拽拿起时抬起,落位时落定。见浮层:抬起、倾斜、落定。 |
overlayPreviews | 5 | 多项拖拽:拿起时有多少个被拖拽项获得克隆,或 'all'。见默认浮层。 |
announcements | 内置 | 屏幕阅读器文本,或 false。 |
screenReaderInstructions | 内置 | 每个可拖拽元素用 aria-describedby 指向的文本。 |
defaultStyles | true | 为拖拽状态注入零优先级的 CSS:见样式。 |
包含所有类型结构的完整列表见 DnDProvider API。provider 还会触发 drag-start、drag-move、drag-over、drag-end、drag-cancel、drag-resume 和 drag-settle,与 useDnDMonitor 收到的事件相同。
读取拖拽:useDnD()
正在加载演示…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Tasks from './Tasks.vue';
import StatePanel from './StatePanel.vue';
</script>
<template>
<!-- One provider: the list drags, the panel reads the drag. Neither imports the other. -->
<DnDProvider>
<div class="demo">
<Tasks />
<StatePanel />
</div>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 24px;
}
</style>useDnD() 以响应式状态返回当前拖拽,可在 provider 之下的任何组件中使用:
import { useDnD } from '@vue-dnd-kit/core';
const { status, active, over, pointer, activation, isAutoScrolling, selection, cancel } = useDnD();
| 字段 | 内容 |
|---|---|
status | 'idle'、'activating'(按下后等待激活)、'dragging'、'dropping'(没有拖拽在进行,但仍有浮层在落位,或异步的 onBeforeDrop 正在做决定)、'selecting'(框选中)。 |
active | 被拖拽的内容:id、ids(所有被拖拽的 id,抓起的那个在最前)、items、data、type、element、rect、sensor。空闲时为 null。 |
over | 拖拽下方的目标:id、data、element、rect 和 accepted。下方没有目标时为 null。 |
pointer | 指针的视口坐标,已经过修饰器处理。 |
activation.progress | 按下等待激活期间从 0 到 1:可以用它画一个圆环。 |
isAutoScrolling | 自动滚动正在滚动某个元素。 |
selection | provider 的选中集合:ids、has、add、delete、toggle、set、clear。见多项拖拽。 |
cancel() | 取消进行中的拖拽(项飞回原处)、正在蓄力的按下,或框选。 |
<script setup lang="ts">
import { computed } from 'vue';
import { useDnD } from '@vue-dnd-kit/core';
const { status, over } = useDnD();
const refused = computed(() => status.value === 'dragging' && over.value?.accepted === false);
</script>
<template>
<p v-if="refused" role="status">This list doesn't take that.</p>
</template>
放置一旦确定,active、over 和 pointer 就读到 null,即使浮层还在落位(status 读到 'dropping')。由它们派生的 UI 永远不会把旧的拖拽和新数据一起显示。在异步的 onBeforeDrop 等待期间,状态保持松开时的样子。
下一次拖拽不会等待落位:它立即开始,上一个浮层在它周围落位时,status 读到 'dragging'。在正在落位的浮层上按下,就能在半空中接住它:拖拽从浮层所在的位置继续。onDragSettle 告诉你一次拖拽的浮层何时落定。
监听每一次拖拽:useDnDMonitor()
import { useDnDMonitor } from '@vue-dnd-kit/core';
useDnDMonitor({
onDragStart: ({ active }) => {},
onDragMove: ({ pointer, delta }) => {},
onDragOver: ({ over }) => {}, // the target changed
onBeforeDrop: (e) => 'accept', // or 'reject', 'continue', some ids; may be async
onDragEnd: ({ active, over, dropped, accepted, position }) => {},
onDragCancel: ({ active }) => {},
onDragResume: ({ active }) => {}, // onBeforeDrop answered 'continue'
onDragSettle: ({ active, over }) => {}, // its overlay has landed
});
监听器能收到其 provider 之下的每一次拖拽,无论拖拽从哪里开始:上面的面板无需导入列表,就能记录列表的拖拽。调用它的组件卸载时,监听器随之停止。
| 事件 | 触发时机 |
|---|---|
onDragStart | 拖拽开始。 |
onDragMove | 拖拽移动。 |
onDragOver | 拖拽下方的目标改变。 |
onBeforeDrop | 拖拽被松开。它的返回值决定放置:见决定放置。 |
onDragEnd | 放置已完成。可排序列表已经运行了它们的 onDrop 并写入了新顺序。拖拽结束时下方没有目标,则 dropped 为 false。 |
onDragCancel | 按下 Escape、放到拒绝它的目标上,或 onBeforeDrop 返回了 'reject'。 |
onDragResume | onBeforeDrop 返回了 'continue':没有放置,拖拽继续。 |
onDragSettle | 拖拽的浮层已经落位(或已飞回原处,或被下一次拖拽接住),参数是 onDragEnd 或 onDragCancel 收到的那个事件。在此期间可能已有其他拖拽开始。 |
每个事件都带有 active、over、pointer、delta(相对起点的偏移)、position(被拖拽的盒子在 over 中的位置,见拖放),对来自可排序列表的项还带有 source。完整结构见事件 API。
onDragEnd 是做“放置之后”工作的地方:保存、清空选中集合、统计分析。列表自己的 onDrop 在写入之前运行,所以它看到的仍是旧顺序。查找目标
选择一个函数,然后把图钉或卡片拖到各个目标上。徽标显示该函数在这一帧中对它们的排名;绿色的那个就是目标。
正在加载演示…
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue';
import {
DnDProvider,
closestCenter,
closestCorners,
pointerFirst,
pointerWithin,
rectIntersection,
type CollisionDetection,
type DnDEndEvent,
type Key,
} from '@vue-dnd-kit/core';
import Desk from './Desk.vue';
import { magnet } from './magnet';
const DETECTORS: Record<string, CollisionDetection> = {
pointerFirst,
pointerWithin,
rectIntersection,
closestCenter,
closestCorners,
magnet,
};
const names = Object.keys(DETECTORS);
const mode = ref('pointerFirst');
// The ranking of the current frame, best first: a wrapper around the chosen function
// records it, so the targets can show their rank.
const ranked = shallowRef<Key[]>([]);
const collision = computed<CollisionDetection>(() => {
const detect = DETECTORS[mode.value]!;
return (args) => (ranked.value = detect(args));
});
const result = ref('Drag the pin or the card over the targets');
const onEnd = ({ over }: DnDEndEvent) => {
ranked.value = [];
result.value = over ? `Dropped on ${over.id}` : 'Dropped on nothing';
};
const onCancel = () => {
ranked.value = [];
result.value = 'Cancelled';
};
</script>
<template>
<DnDProvider :collision="collision" @drag-end="onEnd" @drag-cancel="onCancel">
<div class="lab">
<div class="modes" role="radiogroup" aria-label="Collision detection">
<label v-for="name in names" :key="name" :class="{ on: mode === name }">
<input v-model="mode" type="radio" :value="name" />
{{ name }}
</label>
</div>
<code class="call"><DnDProvider :collision="{{ mode }}"></code>
<Desk :ranked="ranked" />
<p class="result" aria-live="polite">{{ result }}</p>
</div>
</DnDProvider>
</template>
<style scoped>
.lab {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.modes {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2px;
max-width: 100%;
padding: 3px;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
.modes label {
padding: 5px 9px;
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;
}
.result {
margin: 0;
font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.6;
}
</style>在拖拽的每一帧,provider 把参与其中的放置目标交给 collision,它返回这些目标的 id,最佳的在前。库自带五个函数:
| 函数 | 排名依据 | 适用于 |
|---|---|---|
pointerFirst | 指针下方的目标;没有时,取被拖拽盒子所重叠的目标。 | 默认值:指针下方精确,间隙处宽容。 |
pointerWithin | 只看指针下方的目标,最内层的在前。 | 精确的目标:放在间隙里不会落到任何地方。 |
rectIntersection | 被拖拽盒子重叠的每个目标:先比 priority,再比嵌套深度,再比重叠面积。 | 大的项拖到小的目标上,而指针位于项的边缘。 |
closestCenter | 所有目标,按盒子中心到目标中心的距离。 | 总有一个目标胜出,即使离得很远:网格以及等尺寸项的列表。 |
closestCorners | 所有目标,按四个角之间的距离。 | 并排的列(看板),高度差异很大的目标。 |
在指针下方,命中测试遵循用户所见:重叠的目标中只有最上层的那个会被命中(重叠处是 front,从不是 back),嵌套的目标中最内层的在前(先 subfolder,再 folder)。
自定义函数
import type { CollisionDetection, Coordinates, Rect } from '@vue-dnd-kit/core';
/** How far the point is from the rect: 0 inside it. */
const gap = (rect: Rect, point: Coordinates) =>
Math.hypot(
Math.max(rect.left - point.x, 0, point.x - rect.right),
Math.max(rect.top - point.y, 0, point.y - rect.bottom),
);
/** What is under the pointer; in a gap, the nearest target within 40px. */
export const magnet: CollisionDetection = ({ hits, pointer, candidates }) => {
if (hits.length) return hits;
return candidates
.filter((candidate) => gap(candidate.rect, pointer) <= 40)
.sort((a, b) => gap(a.rect, pointer) - gap(b.rect, pointer))
.map((candidate) => candidate.id);
};
| 参数 | 内容 |
|---|---|
pointer | 指针,已经过修饰器处理。 |
overlayRect | 被拖拽盒子当前显示的样子,没有浮层时为 null。 |
candidates | 参与其中的每个目标:id、rect(它的可见部分,已被滚动容器裁剪)、priority、depth(顶层为 0,嵌套越深越大)。 |
hits | 指针下方的目标,来自命中测试,最佳的在前。 |
返回空数组表示“没有目标”。禁用的目标、位于被拖拽项内部的目标,以及被滚动容器遮住的目标,都不会传给该函数。拒绝该拖拽的目标仍然参与竞争:拖到它上方时,over.accepted 为 false,放置会被拒绝(位于可排序项内部的目标则会让开,让列表在它上方排序)。演示包装了所选的函数来记录它的排名:(args) => (ranked.value = detect(args))。
按下何时变成拖拽
按下不会立刻开始拖拽。activation 按指针类型(mouse、touch、pen)设置要等待的条件:以 px 计的 distance、以 ms 计的 delay(外加一个 tolerance,超出它按下就放弃,让页面可以滚动),或两者中先满足的那个。
<DnDProvider :activation="{ mouse: { distance: 8 }, touch: { delay: 300, tolerance: 8 } }">
| 指针 | 默认值 |
|---|---|
mouse、pen | { distance: 4 }:点击仍然是点击。在 handle 上按下鼠标会立刻开始,不移动的话仍然是点击。 |
touch | { delay: 200, tolerance: 5 }:长按,这样手指下的列表仍能滚动。 |
没有写出的类型保留默认值。可拖拽元素自己的 activation 优先于拖拽区域的,后者又优先于 provider 的。按下等待期间,useDnD().status 为 'activating',activation.progress 从 0 走到 1;按 Escape 或调用 useDnD().cancel() 会放弃它。在传感器中可以实时试验这些约束,并根据进度画出长按圆环。
自动滚动
把一个频道拖向列表的顶部或底部。绿色条带是边缘区域:越深入其中,列表滚动得越快。
正在加载演示…
<script setup lang="ts">
import { computed, ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Channels from './Channels.vue';
const OPTIONS = {
threshold: [0.1, 0.2, 0.4],
maxSpeed: [10, 25, 50],
acceleration: [4, 10, 25],
} as const;
const on = ref(true);
// The defaults: an edge zone of 20% of the container (24 to 96px), 10px a frame at the
// edge, up to 25px a frame past it.
const options = ref({ threshold: 0.2, maxSpeed: 25, acceleration: 10 });
const autoScroll = computed(() => (on.value ? options.value : false));
const call = computed(() =>
on.value
? `:auto-scroll="{ threshold: ${options.value.threshold}, maxSpeed: ${options.value.maxSpeed}, acceleration: ${options.value.acceleration} }"`
: ':auto-scroll="false"',
);
</script>
<template>
<DnDProvider :auto-scroll="autoScroll">
<div class="demo">
<div class="switches">
<label class="switch">
<input v-model="on" type="checkbox" />
autoScroll
</label>
<fieldset v-for="(values, name) in OPTIONS" :key="name" :disabled="!on">
<legend>{{ name }}</legend>
<label v-for="value in values" :key="value" :class="{ on: options[name] === value }">
<input v-model="options[name]" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
</div>
<code class="call">{{ call }}</code>
<Channels :threshold="on ? options.threshold : 0" />
</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 14px;
}
fieldset {
display: inline-flex;
margin: 0;
padding: 3px;
border: 0;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
fieldset:disabled {
opacity: 0.4;
}
legend {
float: left;
align-self: center;
padding: 0 8px 0 6px;
font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
fieldset label {
padding: 5px 9px;
border-radius: 6px;
font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
fieldset label.on {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
font-weight: 600;
}
fieldset label:has(input:focus-visible),
.switch:has(input:focus-visible) {
outline: 2px solid #42b883;
}
fieldset input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.switch {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
.switch input {
accent-color: #42b883;
}
.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>任何 overflow: auto 或 scroll 的元素都会自动滚动,窗口也一样。provider 先滚动指针下方的容器,再滚动它的祖先,最后滚动窗口。
| 选项 | 默认值 | 设置什么 |
|---|---|---|
threshold | 0.2 | 边缘区域,为容器尺寸的比例,介于 24 到 96 px 之间。 |
acceleration | 10 | 在最边缘处的速度,单位为 px/帧。它随深入区域的深度的平方增长,所以起步缓慢而精确。 |
maxSpeed | 25 | 最高速度。越过边缘(指针在容器之外)后,速度继续增长,直到这个值。 |
:auto-scroll="false" 会关闭它。只有当指针已朝某个方向移动后,才会朝那个方向滚动,所以在边缘附近拿起的项不会自己滚动。列表、树和拖拽区域可以为自己的容器设置单独的 autoScroll:
makeSortableList(list, { items: channels, autoScroll: { maxSpeed: 10 } });
键盘拖拽从不自动滚动:它会把项滚动到可见范围内。有内容在滚动时,isAutoScrolling(来自 useDnD() 或拖拽区域)为 true。
触感反馈与复制键
触摸拖拽开始时会短暂振动。:haptics="false" 关闭它;传入对象可以设置每一种振动:
<DnDProvider :haptics="{ start: true, enter: 10, drop: 10, refuse: [20, 40, 20] }">
| 键 | 振动时机 | 默认值 |
|---|---|---|
start | 长按变成拖拽。 | true(10 ms) |
enter | 拖拽进入接受它的目标。 | 关闭 |
drop | 放置完成。 | 关闭 |
refuse | 放置被拒绝,项飞回原处。 | 关闭 |
每一项可以取 true(默认模式)、以 ms 计的时长、一个开/关模式数组,或 false。只有由触摸开始的拖拽才会振动,而 iOS Safari 完全无法振动。
copyModifier 是在列表之间拖拽时按住、把移动变成复制的那个键:默认为 'Alt',可以是任意 KeyboardEvent.key,或 null 表示不用。项所来自的列表需要一个 clone 函数;见共享项的列表。
多个 provider
大多数应用恰好只需要一个。把愿望清单放到它自己的 provider 下,然后试着把一本书移过去:它无法离开自己的 provider,外层的监听器也不再收到愿望清单的拖拽。
正在加载演示…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Shelf from './Shelf.vue';
import EventLog from './EventLog.vue';
const reading = ref([
{ id: 'dune', title: 'Dune', hue: 32 },
{ id: 'emma', title: 'Emma', hue: 330 },
{ id: 'ulysses', title: 'Ulysses', hue: 210 },
]);
const wishlist = ref([
{ id: 'beloved', title: 'Beloved', hue: 140 },
{ id: 'solaris', title: 'Solaris', hue: 260 },
]);
const separate = ref(false);
</script>
<template>
<DnDProvider>
<div class="demo">
<div class="modes" role="radiogroup" aria-label="Wishlist">
<label :class="{ on: !separate }"><input v-model="separate" type="radio" :value="false" />one provider</label>
<label :class="{ on: separate }"><input v-model="separate" type="radio" :value="true" />wishlist in its own</label>
</div>
<div class="columns">
<div class="column">
<Shelf id="reading" v-model="reading" title="Reading" />
<EventLog title="outer monitor" />
</div>
<!-- A provider inside a provider: its items belong to it, and drags never cross. -->
<DnDProvider v-if="separate">
<div class="column island">
<span class="tag"><DnDProvider></span>
<Shelf id="wishlist" v-model="wishlist" title="Wishlist" />
<EventLog title="inner monitor" />
</div>
</DnDProvider>
<div v-else class="column">
<Shelf id="wishlist" v-model="wishlist" title="Wishlist" />
</div>
</div>
</div>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
}
.modes {
display: inline-flex;
padding: 3px;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
.modes label {
padding: 5px 10px;
border-radius: 6px;
font-size: 12px;
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;
}
.columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 230px));
gap: 16px;
max-width: 100%;
}
.column {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.island {
position: relative;
margin: -10px;
padding: 10px;
border: 1.5px dashed color-mix(in srgb, #42b883 60%, transparent);
border-radius: 16px;
}
.tag {
position: absolute;
top: -9px;
right: 14px;
padding: 0 6px;
background: Canvas;
font: 10px/18px ui-monospace, Menlo, Consolas, monospace;
color: #42b883;
}
</style><DnDProvider>
<Shelf v-model="reading" />
<DnDProvider>
<Shelf v-model="wishlist" />
<EventLog /> <!-- hears the inner provider's drags only -->
</DnDProvider>
</DnDProvider>
- provider 之间相互独立:拖拽永远不会从一个跨到另一个。
- 允许嵌套 provider。最近的所有者胜出:项属于它上方最近的 provider,外层 provider 会忽略它。
- 每个 provider 都有自己的选中集合、浮层、实时区域和监听器。
只有当页面的某一部分需要自己的 provider 级设置(碰撞检测、键盘按键、播报),并且其中的项从不离开它时,第二个 provider 才有意义。如果项无法在页面的两部分之间移动,检查它们是否处于两个 provider 之下。
makeDragArea(board, { modifiers: [snapToGrid(20)], activation: { touch: { delay: 400 } } });
挂载在别处的应用,无论是在 shadow root 中还是作为独立的 Vue 应用,都可以加入已有的 provider:在挂载之前把 useDnDContext() 传给 provideDnDContext(app, context)。命中测试和浮层可以跨越 shadow root 工作。
SSR 与 Nuxt
provider 对 SSR 是安全的。列表和项像其他标记一样在服务端渲染。浮层和实时区域是传送出去的,只在客户端挂载,因此永远不会导致激活不匹配。默认 id 来自 Vue 的 useId(),在服务端和客户端之间保持一致。
在 Nuxt 中,模块会全局注册 DnDProvider 并自动导入组合式函数:见安装。