正在加载演示…
<script setup lang="ts">
import { provide } from 'vue';
import { DnDProvider, type Active, type Announcements } from '@vue-dnd-kit/core';
import Builder from './Builder.vue';
import { FORM, KINDS, TRASH, createForm, type Field, type Row } from './form';
// One form for the whole builder: its lists, its panels and the announcements read it.
const form = createForm();
provide(FORM, form);
// Space picks a field up and drops it; Enter is left to open a field's settings.
const KEYBOARD = { keys: { start: ['Space'], drop: ['Space'] } };
// Screen readers hear what moves and where it would go, in words: "Email: row 2, column 1 of 2".
const nameOf = ({ items: [first] }: Active) => {
const item = first?.item as Field | Row | undefined;
if (!item) return 'Item';
if ('fields' in item) return `Row ${form.state.rows.findIndex((row) => row.id === item.id) + 1}`;
return first!.type.includes('new-field') ? `New ${KINDS[item.kind].name.toLowerCase()} field` : item.label || 'Untitled field';
};
const announcements: Partial<Announcements> = {
onDragStart: ({ active }) => `${nameOf(active)} picked up. Arrows move it, Space drops it, Escape cancels.`,
onDragOver: ({ active }, { position }) => (position ? `${nameOf(active)}: ${form.describe(position)}.` : undefined),
onDragEnd: ({ active, over }) => (over?.id === TRASH ? `${nameOf(active)} deleted.` : `${nameOf(active)} dropped.`),
onDragCancel: ({ active }) => `${nameOf(active)} put back.`,
};
</script>
<template>
<DnDProvider :keyboard="KEYBOARD" :announcements="announcements">
<Builder />
</DnDProvider>
</template>这里的每个控件都是 Nuxt UI 组件:表单的字段(UInput、USelect、URadioGroup、UInputRating、UFileUpload……)、它们周围的面板、预览中的 UForm。Vue DnD Kit 负责移动它们。把演示展开到全屏:面板、表单和设置会并排显示。
看点
PaletteGroup.vue:面板中的一个分区是一个只往外复制的可排序列表:group: { name: 'fields', pull: 'clone', put: false }加上sort: false。它的项和表单中的字段一样,所以拖进某一行的副本就是一个普通字段;clone给它一个自己的 key。单击(或 Enter)无需拖拽即可添加字段。FormRow.vue:一行同时是两样东西,就像看板的一列:它是表单行列表中的一项,通过抓手拖动;它也是面板分组中最多容纳三个字段的列表。它的put拒绝第四个:在已满的行上方,手中的字段变红并说明原因,在那里放置会被取消。NewRowZone.vue:把字段放到两行之间的空隙中,或放到最后一行下方,它就会获得自己的一行。这些地方也是列表:同一分组的空列表,它们的 id 说明新行插在哪里。FieldCard.vue:各行使用指示器模式(placeholder: 'indicator'),所以拖动字段时没有东西移动:字段旁边的一条线显示它将去往哪里。在较窄的构建器中,一行的字段会堆叠起来,线显示在它们上方或下方。Trash.vue:拖动表单中的字段时,面板会变成垃圾桶:一个只接收表单字段的普通放置目标(accept: 'fields',即它们分组的名称),并用event.source.remove()删除它们。OptionList.vue:下拉框或多选题的选项本身是设置面板中的一个可排序列表,通过抓手拖动,所以它们的文本仍可编辑。form.ts:表单,以及唯一会改变它的代码。各个列表读取它但从不写入,所以 Undo 和 Redo 能看到每一次改动,包括放置。
一行字段是列表中的列表
表单的行和每一行的字段是两个分组的列表:rows 和 fields。拖拽只会进入它自己分组的列表:无论如何嵌套,字段永远不会落到行之间,行也永远不会落进另一行里面。
// The row is an item of the form's rows, dragged by its grip...
makeSortableItem(el, { item: () => props.row, handle: grip });
// ...and a list of up to three fields side by side, in the group of the palette.
const { items, isOver, isAccepting } = makeSortableList(list, {
id: rowList(props.row.id),
items: () => props.row.fields,
group: { name: 'fields', put: ({ keys }) => props.row.fields.length + keys.length <= MAX_COLUMNS },
strategy: () => (props.stacked ? 'vertical' : 'horizontal'),
placeholder: 'indicator',
overlay: FieldOverlay,
clone: cloneField, // Alt-drag copies a field
animateChanges: true,
onDrop: form.drop,
});
新行需要一个可以放置的地方。每一行在它上方的空隙上都有一条带状区域,最后一行下方的空间也是这样一个地方:fields 分组的一个空列表,从不显示任何项。在带状区域上方时,列表处于 isOver,带状区域画出它的线。带状区域只在有东西被拖拽时才接收指针(html[data-dnd-dragging]),所以从不妨碍单击:
<script setup lang="ts">
const NONE: readonly Field[] = [];
const { isOver, isAccepting } = makeSortableList(el, {
id: newRowList(props.before), // 'new:<row id>', or 'new:end'
items: () => NONE,
group: 'fields',
placeholder: 'indicator',
// A band across the page: up and down are the keys that cross it, to the next list.
strategy: 'horizontal',
onDrop: form.drop,
});
</script>
<template>
<div
ref="el"
class="pointer-events-none absolute inset-x-0 -top-[18px] z-10 h-6 in-data-[dnd-dragging]:pointer-events-auto"
>
<span class="… bg-primary" :class="isOver ? 'opacity-100' : 'opacity-0'" />
</div>
</template>
键盘也能找到这些带状区域。键盘拖拽按列表在页面上的位置,用垂直于列表方向的方向键从一个列表移到另一个列表:对横向的行来说是上下键,对同样是横向的带状区域也是如此。所以 ArrowUp 和 ArrowDown 会依次经过各行以及行之间的空隙。
一个 store,一个 Undo:从不写入的列表
构建器的每个列表都从 getter 获取它的项,所以库从不写入它们(受控列表)。它们的 onDrop 是 form.drop:它取消写入,把 event.result(这次放置改变的每个列表的新数组)交给表单。列表的 id 说明每个数组是什么:某一行的字段、各行的顺序,或要创建的一行。
function apply(result: ReadonlyMap<Key, unknown[]>) {
commit(() => {
const created: [before: string, fields: Field[]][] = [];
for (const [list, items] of result) {
const [type, id] = String(list).split(':') as [string, string];
if (list === ROWS) state.rows = items as Row[];
else if (type === 'row') state.rows.find((row) => row.id === id)!.fields = items as Field[];
else if (type === 'new' && items.length) created.push([id, items as Field[]]);
}
for (const [before, items] of created) {
const index = state.rows.findIndex((row) => row.id === before);
state.rows.splice(index < 0 ? state.rows.length : index, 0, row(...items));
}
});
}
/** `onDrop` of every list: applies the drop, and selects a field that was just added. */
function drop(event: SortableDropEvent<unknown>) {
event.preventDefault();
apply(event.result);
if (event.mode === 'copy') selectedId.value = (event.items[0] as Field).id;
forgetGone();
}
commit 为 Undo 保存表单的快照,做出改动,并删除移动后留空的行,这一切都发生在放置落位之前,所以浮层会飞到字段真正所在的地方。垃圾桶走的是同一条路:event.source.remove() 以移除该字段后的结果运行这一行的 onDrop(to.index 为 -1)。在设置中输入也经过 commit,同一个设置项中的按键共用一个 key:逐字输入的标签只算一步 Undo。
手中的字段
面板和各行共用一个浮层组件。它在一个框中显示库对被拖拽内容的副本(DragPreview:从面板拖出的是一个小标签,从表单拖出的是整个字段,包括它的 Nuxt UI 控件),并用一个徽章说明放置会做什么:复制(:kbd{value="Alt"})、在垃圾桶上方时删除、在已满的行上方时什么都不做。
<script setup lang="ts">
const props = defineProps<{ items: DragItem[]; over: Over | null; accepted: boolean; status: DnDStatus; mode: DragMode }>();
const entry = computed(() => props.items[0]!);
const state = computed(() => {
if (props.status === 'dropping') return 'landing';
if (props.over?.id === TRASH) return 'delete';
// A row that refuses the field: it is full.
if (props.over && !props.accepted) return 'full';
// Palette fields are copies too, but they say nothing: adding is what they do.
if (props.mode === 'copy' && !entry.value.type.includes('new-field')) return 'copy';
return 'move';
});
</script>
<template>
<!-- LOOKS: lifted and tilted, red when refused, smaller over the trash, flat as it lands. -->
<div class="relative size-full rounded-md bg-default ring-2 …" :class="LOOKS[state]">
<DragPreview :id="entry.id" class="rounded-md" :style="{ width: '100%', height: '100%' }" />
<UBadge v-if="badge" :label="badge.label" :icon="badge.icon" :color="badge.color" size="sm" />
</div>
</template>
面板中的项有自己的 type,即 'new-field':浮层据此区分它们,而接收 'fields' 的垃圾桶不会接收它们。
搭配 Nuxt UI
文档是一个带有 @nuxt/ui 模块的 Nuxt 应用,Nuxt 的组件自动导入也作用于这些示例:这些文件直接使用 <UInput>、<UFormField> 或 <UTabs> 而不导入它们,就像 Nuxt UI 项目那样。使用 Nuxt UI Vite 插件的 Vue 应用也会以同样的方式自动导入它们;没有该插件时,逐个导入:import UButton from '@nuxt/ui/components/Button.vue'。类型来自 @nuxt/ui(import type { FormSubmitEvent } from '@nuxt/ui')。
- 构建期间,字段只是图示。
FieldView.vue在画布和预览中以同样的方式渲染字段:一个UFormField包裹它的控件。在画布上,它位于一个inert元素中,所以在输入框上按下会开始拖拽而不是输入,:kbd{value="Tab"} 也会跳过这些输入框。卡片的工具栏(一个由带提示的按钮组成的UFieldGroup)通过data-dnd-no-clone不进入浮层的副本。 - 状态就是 Tailwind 变体。 库用属性标记它的状态,Tailwind 把这些属性当作变体读取:被移动的字段上的
data-[dnd-placeholder]:outline-dashed、手中的行上的in-data-[dnd-overlay]:bg-default、带状区域上的in-data-[dnd-dragging]:pointer-events-auto。颜色使用 Nuxt UI 的语义色(bg-default、ring-primary、text-error):浅色和深色模式无需额外处理。 - 预览是一个真正的
UForm。schema.ts根据字段生成一个 Zod schema,UForm用它校验答案:name把每个UFormField和它的规则关联起来,错误信息显示在字段下方。提交表单就能看到它提交了什么。 - 导出会把同一个表单写成一个独立组件:Nuxt UI 控件、一个 Zod schema、一个
reactive状态,以及在手机上会堆叠的网格行。 - 布局跟随构建器的宽度(
useElementSize),而不是窗口的宽度:全屏时是三个面板;在页面中,设置取代面板的位置;再窄一些时,面板变成表单上方的一排小标签,设置变成覆盖在表单上的抽屉。其余的由容器查询(@lg:)完成。
@nuxt/ui/vite 插件、Tailwind CSS,以及包裹示例的 UApp。在带有 @nuxt/ui 模块的 Nuxt 应用中,复制文件夹并在某个页面上渲染 index.vue 即可;表单还需要 zod。键盘和屏幕阅读器
用 Tab 移到一个字段上,按 Space 拿起它。:kbd{value="ArrowLeft"} 和 ArrowRight 沿它所在的行移动它,:kbd{value="ArrowUp"} 和 ArrowDown 把它移到其他行以及行之间的空隙(新的一行),:kbd{value="Space"} 放下它,:kbd{value="Escape"} 把它放回原处。provider 只用 Space 进行拖拽(keyboard: { keys: { start: ['Space'], drop: ['Space'] } }),这样 Enter 就可以用来打开字段的设置。:kbd{value="Delete"} 删除字段,:kbd{value="Ctrl"} D 复制它,:kbd{value="Ctrl"} Z 撤销。
provider 的 announcements 使用构建器自己的说法:“Work email: row 2, column 2 of 2”“New date field: a new row above row 3”。form.describe 把库给出的位置(列表 id 和索引)转换成这些说法。
延伸阅读:共享项的列表 · 插入线 · 浮层 · 样式:Tailwind 和 UI 库 · 无障碍