---
title: "Конструктор форм"
description: "Конструктор форм на Nuxt UI — поля перетаскиваются из палитры, ставятся рядом в строках, редактируются в панели, затем заполняются в живом превью с валидацией и экспортируются как код Vue."
canonical_url: "https://zizigy.github.io/vue-dnd-kit/ru/docs/examples/form-builder"
---

> Index of every page on this site: [llms.txt](https://zizigy.github.io/vue-dnd-kit/llms.txt)

<example height="44rem" hint="Перетащите поле в форму, рядом с другим — для колонок" name="form-builder" title="Конструктор форм">



</example>

Каждый элемент управления здесь — компонент Nuxt UI: поля формы (`UInput`, `USelect`,
`URadioGroup`, `UInputRating`, `UFileUpload`…), панели вокруг них, `UForm` превью. Vue DnD Kit их
двигает. Разверните демо на весь экран: тогда палитра, форма и настройки встанут рядом.

## На что обратить внимание

- **PaletteGroup.vue**: раздел палитры — сортируемый список, который только отдаёт копии:
`group: { name: 'fields', pull: 'clone', put: false }` и `sort: false`. Его элементы — такие же
поля, как в форме, так что копия, перетащенная в строку, — поле, как остальные; `clone` даёт ей
собственный ключ. Клик (или <kbd value="Enter">



</kbd>

) добавляет поле без драга.
- **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`. Драг входит только в
списки своей группы: поле никогда не приземлится среди строк, а строка — внутри другой строки,
как бы они ни были вложены.

```ts [FormRow.vue]
// 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]`),
поэтому никогда не мешают клику:

```vue [NewRowZone.vue]
<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>
```

Клавиатура тоже находит полосы. Перетаскивание с клавиатуры переходит от списка к списку по их
местам на странице, стрелками поперёк списка: вверх и вниз для строк, которые горизонтальны, и для
полос, тоже горизонтальных. Поэтому <kbd value="ArrowUp">



</kbd>

 и <kbd value="ArrowDown">



</kbd>

 по очереди
посещают строки и промежутки между ними.

## Один стор, один Undo: списки, которые никогда не записывают

Каждый список конструктора получает элементы из геттера, поэтому библиотека никогда их не
записывает ([управляемый список](https://zizigy.github.io/docs/guides/shared-lists#controlled-lists)). Их `onDrop` — это
`form.drop`: он отменяет запись и передаёт форме `event.result` — новые массивы каждого списка,
который меняет бросок. Id списков говорят, что такое каждый массив: поля строки, порядок строк или
строка, которую нужно создать.

```ts [form.ts]
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), в рамке, со значком того, что сделает бросок: копирование (<kbd value="Alt">



</kbd>

), удаление
над корзиной, ничего над заполненной строкой.

```vue [FieldOverlay.vue]
<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`,
так что нажатие на поле ввода начинает драг вместо ввода, а <kbd value="Tab">



</kbd>

 пропускает поля
ввода. Панель инструментов карточки (`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:`).

<note>

Кнопка StackBlitz открывает файлы в Vite-проекте, настроенном под Nuxt UI так, как это описано в
его документации для Vue: плагин `@nuxt/ui/vite`, Tailwind CSS и `UApp` вокруг примера. В
приложении Nuxt с модулем `@nuxt/ui` скопируйте папку и отрендерите `index.vue` на странице; форме
нужен ещё `zod`.

</note>

## Клавиатура и скринридеры

Перейдите табом к полю и нажмите <kbd value="Space">



</kbd>

, чтобы взять его. <kbd value="ArrowLeft">



</kbd>

 и
<kbd value="ArrowRight">



</kbd>

 двигают его вдоль строки, <kbd value="ArrowUp">



</kbd>

 и
<kbd value="ArrowDown">



</kbd>

 — к другим строкам и к промежуткам между ними (новая строка),
<kbd value="Space">



</kbd>

 бросает его, <kbd value="Escape">



</kbd>

 возвращает на место. Провайдер берёт для
драгов только Space (`keyboard: { keys: { start: ['Space'], drop: ['Space'] } }`), что оставляет
<kbd value="Enter">



</kbd>

 для открытия настроек поля. <kbd value="Delete">



</kbd>

 удаляет поле,
<kbd value="Ctrl">



</kbd>

 <kbd value="D">



</kbd>

 дублирует его, <kbd value="Ctrl">



</kbd>

 <kbd value="Z">



</kbd>

 отменяет.

`announcements` провайдера говорят словами конструктора: «Work email: row 2, column 2 of 2»,
«New date field: a new row above row 3». `form.describe` превращает позицию, которую даёт
библиотека (id списка и индекс), в эти слова.

Подробнее: [списки с общими элементами](https://zizigy.github.io/docs/guides/shared-lists) ·
[линия вставки](https://zizigy.github.io/docs/guides/sortable-lists#an-insertion-line) ·
[оверлей](https://zizigy.github.io/docs/guides/overlay) ·
[стилизация: Tailwind и UI-киты](https://zizigy.github.io/docs/guides/styling#tailwind-css) ·
[доступность](https://zizigy.github.io/docs/guides/accessibility)

## Sitemap

See the full [sitemap](https://zizigy.github.io/vue-dnd-kit/sitemap.md) for all pages.
