Documentación/Resumen
Constructor de formularios
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.
Esta página la ha traducido una IA y puede contener imprecisiones. Leer el original en inglés
Cargando la demo…
<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>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 }ysort: false. Sus ítems son campos como los del formulario, así que una copia arrastrada a una fila es un campo como los demás;clonele da una clave propia. Un clic (o Enter) 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. Suputrechaza 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 conevent.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.
// 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:
<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í, ArrowUp y ArrowDown 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). 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.
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 (Alt), borrar sobre la papelera, nada sobre una fila llena.
<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.vuerenderiza un campo igual en el lienzo y en la vista previa: unUFormFieldalrededor de su control. En el lienzo está dentro de un elementoinert, así que una pulsación sobre un input empieza un arrastre en lugar de escribir, y Tab se salta los inputs. La barra de herramientas de la tarjeta (unUFieldGroupde botones con tooltips) queda fuera de la copia del overlay condata-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-dasheden el campo que se mueve,in-data-[dnd-overlay]:bg-defaulten una fila en la mano,in-data-[dnd-dragging]:pointer-events-autoen 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
UFormreal.schema.tscrea un esquema de Zod a partir de los campos, yUFormvalida las respuestas con él:nameune cadaUFormFielda 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.
@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.Teclado y lectores de pantalla
Llega a un campo con Tab y pulsa Space para agarrarlo. ArrowLeft y
ArrowRight lo mueven a lo largo de su fila, ArrowUp y
ArrowDown a las otras filas y a los huecos entre ellas (una fila nueva),
Space lo suelta, Escape lo devuelve a su sitio. El provider solo usa
Espacio para los arrastres (keyboard: { keys: { start: ['Space'], drop: ['Space'] } }), lo que
deja Enter para abrir los ajustes de un campo. Delete borra un campo,
Ctrl D lo duplica, Ctrl Z 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 · Una línea de inserción · El overlay · Estilos: Tailwind y kits de UI · Accesibilidad