---
title: "Constructor de formularios"
description: "Un constructor de formularios hecho con Nuxt UI — campos arrastrados desde una paleta, colocados uno al lado del otro en filas, editados en un panel, y luego rellenados en una vista previa en vivo con validación y exportados como código Vue."
canonical_url: "https://zizigy.github.io/vue-dnd-kit/es/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="Arrastra un campo al formulario, junto a otro para hacer columnas" name="form-builder" title="Constructor de formularios">



</example>

Cada control de aquí es un componente de Nuxt UI: los campos del formulario (`UInput`, `USelect`,
`URadioGroup`, `UInputRating`, `UFileUpload`…), los paneles que los rodean, el `UForm` de la vista
previa. Vue DnD Kit los mueve. Pon la demo a pantalla completa: la paleta, el formulario y los
ajustes se colocan entonces uno al lado del otro.

## Qué observar

- **PaletteGroup.vue**: una sección de la paleta es una lista ordenable que solo da copias:
`group: { name: 'fields', pull: 'clone', put: false }` y `sort: false`. Sus ítems son campos como
los del formulario, así que una copia arrastrada a una fila es un campo como los demás; `clone`
le da una clave propia. Un clic (o <kbd value="Enter">



</kbd>

) añade el campo sin arrastre.
- **FormRow.vue**: una fila es dos cosas a la vez, como una columna de kanban: un ítem de la
lista de filas del formulario, arrastrado por su asa, y una lista de hasta tres campos en el
grupo de la paleta. Su `put` rechaza un cuarto: sobre una fila llena, el campo en la mano se pone
rojo y dice por qué, y un drop ahí se cancela.
- **NewRowZone.vue**: suelta un campo en el hueco entre dos filas, o debajo de la última, y
obtiene una fila propia. Esos sitios también son listas: listas vacías del mismo grupo, cuyos
ids dicen dónde va la fila nueva.
- **FieldCard.vue**: las filas están en modo indicador (`placeholder: 'indicator'`), así que nada
se mueve mientras se arrastra un campo: una línea junto a un campo muestra adónde irá. En un
constructor estrecho, los campos de una fila se apilan, y la línea va encima o debajo de ellos.
- **Trash.vue**: mientras se arrastra un campo del formulario, la paleta se convierte en una
papelera: un destino de drop simple que solo acepta los campos del formulario
(`accept: 'fields'`, el nombre de su grupo) y los quita con `event.source.remove()`.
- **OptionList.vue**: las opciones de un desplegable o de una elección múltiple son una lista
ordenable propia en el panel de ajustes, arrastradas por sus asas para que su texto siga siendo
editable.
- **form.ts**: el formulario, y el único código que lo cambia. Las listas lo leen y nunca lo
escriben, así que Undo y Redo ven cada cambio, drops incluidos.

## Una fila de campos es una lista dentro de una lista

Las filas del formulario y los campos de cada fila son listas de dos grupos, `rows` y `fields`. Un
arrastre solo entra en las listas de su propio grupo: un campo nunca aterriza entre las filas, ni
una fila dentro de otra fila, por mucho que estén anidadas.

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

Una fila nueva necesita un sitio donde soltarse. Cada fila tiene una banda sobre el hueco de
encima, y el espacio debajo de la última fila es un sitio más de ese tipo: una lista vacía del
grupo `fields`, que nunca muestra ningún ítem. Sobre una banda, la lista está `isOver`, y la banda
dibuja su línea. Las bandas solo reciben el puntero mientras se arrastra algo
(`html[data-dnd-dragging]`), así que nunca estorban a un clic:

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

El teclado también encuentra las bandas. Un arrastre con teclado va de lista en lista según su
sitio en la página, con las flechas transversales a una lista: arriba y abajo para las filas, que
son horizontales, y para las bandas, horizontales también. Así, <kbd value="ArrowUp">



</kbd>

 y
<kbd value="ArrowDown">



</kbd>

 recorren por turnos las filas y los huecos entre ellas.

## Un store, un Undo: listas que nunca escriben

Cada lista del constructor recibe sus ítems de un getter, así que la biblioteca nunca los escribe
(una [lista controlada](https://zizigy.github.io/docs/guides/shared-lists#controlled-lists)). Su `onDrop` es `form.drop`:
cancela la escritura y le pasa al formulario `event.result`, los nuevos arrays de cada lista que
cambia el drop. Los ids de las listas dicen qué es cada array: los campos de una fila, el orden de
las filas o una fila que crear.

```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` guarda una instantánea del formulario para Undo, hace el cambio y quita las filas que un
movimiento dejó vacías, todo antes de que el drop aterrice, así que el overlay vuela adonde está
de verdad el campo. La papelera sigue el mismo camino: `event.source.remove()` ejecuta el `onDrop`
de la fila sin el campo (`to.index` es `-1`). Lo que se escribe en los ajustes también pasa por
`commit`, y las pulsaciones en un mismo ajuste comparten una clave: una etiqueta escrita letra a
letra es un solo paso de Undo.

## El campo en la mano

La paleta y las filas comparten un mismo componente de overlay. Muestra la copia que hace la
biblioteca de lo que se arrastra (`DragPreview`: un chip desde la paleta, el campo entero desde el
formulario, con su control de Nuxt UI) en un marco, con una insignia para lo que haría un drop:
copiar (<kbd value="Alt">



</kbd>

), borrar sobre la papelera, nada sobre una fila llena.

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

Los ítems de la paleta tienen un `type` propio, `'new-field'`: el overlay los distingue por él, y
la papelera, que acepta `'fields'`, no los acepta.

## Con Nuxt UI

La documentación es una app de Nuxt con el módulo `@nuxt/ui`, y la importación automática de
componentes de Nuxt también llega a los ejemplos: estos archivos usan `<UInput>`, `<UFormField>` o
`<UTabs>` sin importarlos, como hace un proyecto de Nuxt UI. Una app de Vue con el plugin de Vite
de Nuxt UI los importa automáticamente igual; sin él, importa cada uno:
`import UButton from '@nuxt/ui/components/Button.vue'`. Los tipos vienen de `@nuxt/ui`
(`import type { FormSubmitEvent } from '@nuxt/ui'`).

- **Mientras construyes, los campos son imágenes.** `FieldView.vue` renderiza un campo igual en el
lienzo y en la vista previa: un `UFormField` alrededor de su control. En el lienzo está dentro de
un elemento `inert`, así que una pulsación sobre un input empieza un arrastre en lugar de
escribir, y <kbd value="Tab">



</kbd>

 se salta los inputs. La barra de herramientas de la tarjeta (un
`UFieldGroup` de botones con tooltips) queda fuera de la copia del overlay con
`data-dnd-no-clone`.
- **Los estados son variantes de Tailwind.** La biblioteca marca sus estados con atributos, que
Tailwind lee como variantes: `data-[dnd-placeholder]:outline-dashed` en el campo que se mueve,
`in-data-[dnd-overlay]:bg-default` en una fila en la mano,
`in-data-[dnd-dragging]:pointer-events-auto` en las bandas. Los colores son los semánticos de
Nuxt UI (`bg-default`, `ring-primary`, `text-error`): los modos claro y oscuro no necesitan nada
más.
- **La vista previa es un UForm real.** `schema.ts` crea un esquema de Zod a partir de los
campos, y `UForm` valida las respuestas con él: `name` une cada `UFormField` a su regla, y los
mensajes aparecen bajo los campos. Envía el formulario para ver lo que manda.
- **La exportación** escribe el mismo formulario como un componente propio: controles de Nuxt UI,
un esquema de Zod, un estado `reactive`, y las filas como cuadrículas que se apilan en los
teléfonos.
- **El diseño sigue el ancho del constructor** (`useElementSize`), no el de la ventana: tres
paneles a pantalla completa; en la página, los ajustes ocupan el sitio de la paleta; más
estrecho, la paleta se convierte en una tira de chips sobre el formulario y los ajustes en una
hoja encima de él. Las container queries (`@lg:`) hacen el resto.

<note>

El botón de StackBlitz abre los archivos en un proyecto de Vite preparado para Nuxt UI, tal como
lo describe su documentación para Vue: el plugin `@nuxt/ui/vite`, Tailwind CSS y `UApp` alrededor
del ejemplo. En una app de Nuxt con el módulo `@nuxt/ui`, copia la carpeta y renderiza
`index.vue` en una página; el formulario necesita además `zod`.

</note>

## Teclado y lectores de pantalla

Llega a un campo con Tab y pulsa <kbd value="Space">



</kbd>

 para agarrarlo. <kbd value="ArrowLeft">



</kbd>

 y
<kbd value="ArrowRight">



</kbd>

 lo mueven a lo largo de su fila, <kbd value="ArrowUp">



</kbd>

 y
<kbd value="ArrowDown">



</kbd>

 a las otras filas y a los huecos entre ellas (una fila nueva),
<kbd value="Space">



</kbd>

 lo suelta, <kbd value="Escape">



</kbd>

 lo devuelve a su sitio. El provider solo usa
Espacio para los arrastres (`keyboard: { keys: { start: ['Space'], drop: ['Space'] } }`), lo que
deja <kbd value="Enter">



</kbd>

 para abrir los ajustes de un campo. <kbd value="Delete">



</kbd>

 borra un campo,
<kbd value="Ctrl">



</kbd>

 <kbd value="D">



</kbd>

 lo duplica, <kbd value="Ctrl">



</kbd>

 <kbd value="Z">



</kbd>

 deshace.

Los `announcements` del provider hablan con las palabras del constructor: "Work email: row 2,
column 2 of 2", "New date field: a new row above row 3". `form.describe` convierte la posición que
da la biblioteca (un id de lista y un índice) en esas palabras.

Más información: [Listas que comparten ítems](https://zizigy.github.io/docs/guides/shared-lists) ·
[Una línea de inserción](https://zizigy.github.io/docs/guides/sortable-lists#an-insertion-line) ·
[El overlay](https://zizigy.github.io/docs/guides/overlay) ·
[Estilos: Tailwind y kits de UI](https://zizigy.github.io/docs/guides/styling#tailwind-css) ·
[Accesibilidad](https://zizigy.github.io/docs/guides/accessibility)

## Sitemap

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