---
title: "Construtor de formulários"
description: "Um construtor de formulários feito com Nuxt UI — campos arrastados de uma paleta, colocados lado a lado em linhas, editados em um painel, depois preenchidos em uma prévia ao vivo com validação e exportados como código Vue."
canonical_url: "https://zizigy.github.io/vue-dnd-kit/pt/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="Arraste um campo para o formulário, ao lado de outro para formar colunas" name="form-builder" title="Construtor de formulários">



</example>

Todo controle aqui é um componente do Nuxt UI: os campos do formulário (`UInput`, `USelect`,
`URadioGroup`, `UInputRating`, `UFileUpload`…), os painéis em volta deles, o `UForm` da prévia.
O Vue DnD Kit os move. Expanda a demo para a tela cheia: a paleta, o formulário e as
configurações ficam então lado a lado.

## O que observar

- **PaletteGroup.vue**: uma seção da paleta é uma lista ordenável que só fornece cópias:
`group: { name: 'fields', pull: 'clone', put: false }` e `sort: false`. Os itens dela são campos
como os do formulário, então uma cópia arrastada para uma linha é um campo como os outros;
`clone` dá a ela uma key própria. Um clique (ou <kbd value="Enter">



</kbd>

) adiciona o campo sem
arrastar.
- **FormRow.vue**: uma linha é duas coisas ao mesmo tempo, como uma coluna de kanban: um item da
lista de linhas do formulário, arrastado pelo grip, e uma lista de até três campos no grupo da
paleta. O `put` dela recusa um quarto: sobre uma linha cheia, o campo na mão fica vermelho e diz
por quê, e um drop ali é cancelado.
- **NewRowZone.vue**: solte um campo no vão entre duas linhas, ou abaixo da última, e ele ganha
uma linha própria. Esses lugares também são listas: listas vazias do mesmo grupo, cujos ids
dizem onde a nova linha entra.
- **FieldCard.vue**: as linhas estão no modo de indicador (`placeholder: 'indicator'`), então
nada se move enquanto um campo é arrastado: uma linha ao lado de um campo mostra para onde ele
vai. Em um construtor estreito, os campos de uma linha se empilham, e a linha fica acima ou
abaixo deles.
- **Trash.vue**: enquanto um campo do formulário é arrastado, a paleta vira uma lixeira: um alvo
de drop simples que só aceita os campos do formulário (`accept: 'fields'`, o nome do grupo
deles) e os remove com `event.source.remove()`.
- **OptionList.vue**: as opções de um dropdown ou de uma múltipla escolha são uma lista ordenável
própria no painel de configurações, arrastadas pelos grips para que o texto continue editável.
- **form.ts**: o formulário, e o único código que o muda. As listas o leem e nunca o escrevem,
então Undo e Redo veem toda mudança, drops incluídos.

## Uma linha de campos é uma lista dentro de uma lista

As linhas do formulário e os campos de cada linha são listas de dois grupos, `rows` e `fields`.
Um arraste só entra nas listas do seu próprio grupo: um campo nunca pousa entre as linhas, e uma
linha nunca dentro de outra linha, não importa como estejam aninhadas.

```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,
});
```

Uma nova linha precisa de um lugar em que ser solta. Cada linha tem uma faixa sobre o vão acima
dela, e o espaço abaixo da última linha é mais um lugar assim: uma lista vazia do grupo `fields`,
que nunca mostra um item. Sobre uma faixa, a lista fica `isOver`, e a faixa desenha a sua linha.
As faixas só recebem o ponteiro enquanto algo é arrastado (`html[data-dnd-dragging]`), então nunca
atrapalham um clique:

```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>
```

O teclado também encontra as faixas. Um arraste por teclado vai de lista em lista pelos lugares
delas na página, com as setas no sentido transversal a uma lista: para cima e para baixo para as
linhas, que são horizontais, e para as faixas, horizontais também. Então <kbd value="ArrowUp">



</kbd>

 e
<kbd value="ArrowDown">



</kbd>

 visitam as linhas e os vãos entre elas, um de cada vez.

## Um store, um Undo: listas que nunca escrevem

Toda lista do construtor recebe os seus itens de um getter, então a biblioteca nunca os escreve
(uma [lista controlada](https://zizigy.github.io/docs/guides/shared-lists#controlled-lists)). O `onDrop` delas é
`form.drop`: ele cancela a escrita e entrega ao formulário `event.result`, os novos arrays de toda
lista que o drop altera. Os ids das listas dizem o que cada array é: os campos de uma linha, a
ordem das linhas ou uma linha a criar.

```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` salva um snapshot do formulário para o Undo, faz a mudança e remove as linhas que uma
movimentação deixou vazias, tudo antes de o drop pousar, então o overlay voa para onde o campo
realmente está. A lixeira segue o mesmo caminho: `event.source.remove()` roda o `onDrop` da linha
com o campo retirado (`to.index` é `-1`). Digitar nas configurações também passa por `commit`, e as
teclas em uma mesma configuração compartilham uma key: um rótulo digitado letra por letra é um
único passo de Undo.

## O campo na mão

A paleta e as linhas compartilham um único componente de overlay. Ele mostra a cópia da biblioteca
do que é arrastado (`DragPreview`: um chip vindo da paleta, o campo inteiro vindo do formulário,
incluindo o seu controle do Nuxt UI) em uma moldura, com um badge para o que um drop faria: copiar
(<kbd value="Alt">



</kbd>

), apagar sobre a lixeira, nada sobre uma linha cheia.

```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>
```

Os itens da paleta têm um `type` próprio, `'new-field'`: o overlay os distingue por ele, e a
lixeira, que aceita `'fields'`, não os aceita.

## Com Nuxt UI

A documentação é um app Nuxt com o módulo `@nuxt/ui`, e o auto-import de componentes do Nuxt
chega também aos exemplos: estes arquivos usam `<UInput>`, `<UFormField>` ou `<UTabs>` sem
importá-los, como faz um projeto Nuxt UI. Um app Vue com o plugin Vite do Nuxt UI os importa
automaticamente do mesmo jeito; sem ele, importe cada um:
`import UButton from '@nuxt/ui/components/Button.vue'`. Os tipos vêm de `@nuxt/ui`
(`import type { FormSubmitEvent } from '@nuxt/ui'`).

- **Enquanto você constrói, os campos são figuras.** `FieldView.vue` renderiza um campo do mesmo
jeito no canvas e na prévia: um `UFormField` em volta do seu controle. No canvas ele fica em um
elemento `inert`, então uma pressão em um input inicia um arraste em vez de digitar, e
<kbd value="Tab">



</kbd>

 pula os inputs. A barra de ferramentas do card (um `UFieldGroup` de botões com
tooltips) fica fora da cópia do overlay com `data-dnd-no-clone`.
- **Os estados são variantes do Tailwind.** A biblioteca marca os seus estados com atributos, que
o Tailwind lê como variantes: `data-[dnd-placeholder]:outline-dashed` no campo sendo movido,
`in-data-[dnd-overlay]:bg-default` em uma linha na mão, `in-data-[dnd-dragging]:pointer-events-auto`
nas faixas. As cores são as semânticas do Nuxt UI (`bg-default`, `ring-primary`, `text-error`):
os modos claro e escuro não precisam de mais nada.
- **A prévia é um UForm de verdade.** `schema.ts` cria um schema Zod a partir dos campos, e o
`UForm` valida as respostas com ele: `name` liga cada `UFormField` à sua regra, e as mensagens
aparecem embaixo dos campos. Envie o formulário para ver o que ele submete.
- **A exportação** escreve o mesmo formulário como um componente próprio: controles do Nuxt UI, um
schema Zod, um estado `reactive`, e as linhas como grades que se empilham no celular.
- **O layout acompanha a largura do construtor** (`useElementSize`), não a da janela: três painéis
em tela cheia; na página, as configurações tomam o lugar da paleta; mais estreito, a paleta vira
uma faixa de chips acima do formulário e as configurações uma folha sobre ele. Container queries
(`@lg:`) fazem o resto.

<note>

O botão do StackBlitz abre os arquivos em um projeto Vite configurado para o Nuxt UI, como a
documentação dele descreve para Vue: o plugin `@nuxt/ui/vite`, o Tailwind CSS e o `UApp` em volta
do exemplo. Em um app Nuxt com o módulo `@nuxt/ui`, copie a pasta e renderize `index.vue` em uma
página; o formulário precisa também do `zod`.

</note>

## Teclado e leitores de tela

Chegue a um campo com Tab e pressione <kbd value="Space">



</kbd>

 para pegá-lo. <kbd value="ArrowLeft">



</kbd>

 e
<kbd value="ArrowRight">



</kbd>

 o movem ao longo da linha, <kbd value="ArrowUp">



</kbd>

 e
<kbd value="ArrowDown">



</kbd>

 para as outras linhas e para os vãos entre elas (uma nova linha),
<kbd value="Space">



</kbd>

 o solta, <kbd value="Escape">



</kbd>

 o devolve. O provider usa só Space para arrastes
(`keyboard: { keys: { start: ['Space'], drop: ['Space'] } }`), o que deixa <kbd value="Enter">



</kbd>


livre para abrir as configurações de um campo. <kbd value="Delete">



</kbd>

 remove um campo,
<kbd value="Ctrl">



</kbd>

 <kbd value="D">



</kbd>

 o duplica, <kbd value="Ctrl">



</kbd>

 <kbd value="Z">



</kbd>

 desfaz.

Os `announcements` do provider falam as palavras do construtor: "Work email: row 2, column 2 of
2", "New date field: a new row above row 3". `form.describe` transforma a posição que a biblioteca
dá (um id de lista e um índice) nessas palavras.

Leia mais: [Listas que compartilham itens](https://zizigy.github.io/docs/guides/shared-lists) ·
[Uma linha de inserção](https://zizigy.github.io/docs/guides/sortable-lists#an-insertion-line) ·
[O overlay](https://zizigy.github.io/docs/guides/overlay) ·
[Estilização: Tailwind e UI kits](https://zizigy.github.io/docs/guides/styling#tailwind-css) ·
[Acessibilidade](https://zizigy.github.io/docs/guides/accessibility)

## Sitemap

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