Документация/Обзор
Конструктор форм
Конструктор форм на Nuxt UI — поля перетаскиваются из палитры, ставятся рядом в строках, редактируются в панели, затем заполняются в живом превью с валидацией и экспортируются как код Vue.
Страница переведена ИИ и может содержать неточности. Читать английский оригинал
Загружаем демо…
<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даёт ей собственный ключ. Клик (или 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 по очереди посещают строки и промежутки между ними.
Один стор, один Undo: списки, которые никогда не записывают
Каждый список конструктора получает элементы из геттера, поэтому библиотека никогда их не
записывает (управляемый список). Их 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, и нажатия
клавиш в одной настройке делят один ключ: подпись, набранная буква за буквой, — один шаг Undo.
Поле в руке
Палитра и строки делят один компонент оверлея. Он показывает копию перетаскиваемого, сделанную
библиотекой (DragPreview: чип из палитры, поле целиком из формы, включая его элемент управления
Nuxt UI), в рамке, со значком того, что сделает бросок: копирование (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 с модулем @nuxt/ui, и автоимпорт компонентов Nuxt доходит и до
примеров: эти файлы используют <UInput>, <UFormField> или <UTabs> без импорта, как это
делает проект на Nuxt UI. Vue-приложение с Vite-плагином Nuxt UI автоимпортирует их так же; без
него импортируйте каждый: import UButton from '@nuxt/ui/components/Button.vue'. Типы берутся из
@nuxt/ui (import type { FormSubmitEvent } from '@nuxt/ui').
- Пока вы строите, поля — картинки.
FieldView.vueрендерит поле одинаково на холсте и в превью:UFormFieldвокруг его элемента управления. На холсте оно лежит в элементе сinert, так что нажатие на поле ввода начинает драг вместо ввода, а 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, иUFormвалидирует ею ответы:nameсвязывает каждыйUFormFieldс его правилом, а сообщения появляются под полями. Отправьте форму, чтобы увидеть, что она отправляет. - Экспорт записывает ту же форму как отдельный компонент: элементы управления Nuxt UI, схема
Zod, состояние
reactiveи строки как сетки, которые на телефонах идут столбиком. - Раскладка следует за шириной конструктора (
useElementSize), а не окна: три панели на весь экран; на странице настройки занимают место палитры; ещё уже — палитра становится полосой чипов над формой, а настройки — листом поверх неё. Остальное делают container queries (@lg:).
@nuxt/ui/vite, Tailwind CSS и UApp вокруг примера. В
приложении Nuxt с модулем @nuxt/ui скопируйте папку и отрендерите index.vue на странице; форме
нужен ещё zod.Клавиатура и скринридеры
Перейдите табом к полю и нажмите Space, чтобы взять его. ArrowLeft и
ArrowRight двигают его вдоль строки, ArrowUp и
ArrowDown — к другим строкам и к промежуткам между ними (новая строка),
Space бросает его, Escape возвращает на место. Провайдер берёт для
драгов только Space (keyboard: { keys: { start: ['Space'], drop: ['Space'] } }), что оставляет
Enter для открытия настроек поля. Delete удаляет поле,
Ctrl D дублирует его, Ctrl Z отменяет.
announcements провайдера говорят словами конструктора: «Work email: row 2, column 2 of 2»,
«New date field: a new row above row 3». form.describe превращает позицию, которую даёт
библиотека (id списка и индекс), в эти слова.
Подробнее: списки с общими элементами · линия вставки · оверлей · стилизация: Tailwind и UI-киты · доступность