Documentação/Visão geral
Construtor de formulários
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.
Esta página foi traduzida por IA e pode conter imprecisões. Ler o original em inglês
Carregando a 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>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 }esort: 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;clonedá a ela uma key própria. Um clique (ou Enter) 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. Oputdela 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 comevent.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.
// 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:
<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 ArrowUp e ArrowDown 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). 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.
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
(Alt), apagar sobre a lixeira, nada sobre uma linha cheia.
<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.vuerenderiza um campo do mesmo jeito no canvas e na prévia: umUFormFieldem volta do seu controle. No canvas ele fica em um elementoinert, então uma pressão em um input inicia um arraste em vez de digitar, e Tab pula os inputs. A barra de ferramentas do card (umUFieldGroupde botões com tooltips) fica fora da cópia do overlay comdata-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-dashedno campo sendo movido,in-data-[dnd-overlay]:bg-defaultem uma linha na mão,in-data-[dnd-dragging]:pointer-events-autonas 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
UFormde verdade.schema.tscria um schema Zod a partir dos campos, e oUFormvalida as respostas com ele:nameliga cadaUFormFieldà 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.
@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.Teclado e leitores de tela
Chegue a um campo com Tab e pressione Space para pegá-lo. ArrowLeft e
ArrowRight o movem ao longo da linha, ArrowUp e
ArrowDown para as outras linhas e para os vãos entre elas (uma nova linha),
Space o solta, Escape o devolve. O provider usa só Space para arrastes
(keyboard: { keys: { start: ['Space'], drop: ['Space'] } }), o que deixa Enter
livre para abrir as configurações de um campo. Delete remove um campo,
Ctrl D o duplica, Ctrl Z 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 · Uma linha de inserção · O overlay · Estilização: Tailwind e UI kits · Acessibilidade