Dokumentation/Übersicht
Formular-Builder
Ein Formular-Builder mit Nuxt UI – Felder, die aus einer Palette gezogen, in Zeilen nebeneinandergesetzt, in einem Panel bearbeitet, dann in einer Live-Vorschau mit Validierung ausgefüllt und als Vue-Code exportiert werden.
Diese Seite wurde von KI übersetzt und kann Ungenauigkeiten enthalten. Englisches Original lesen
Demo wird geladen…
<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>Jedes Bedienelement hier ist eine Nuxt-UI-Komponente: die Felder des Formulars (UInput,
USelect, URadioGroup, UInputRating, UFileUpload…), die Panels drumherum, das UForm der
Vorschau. Vue DnD Kit bewegt sie. Klappe die Demo auf Vollbild auf: Dann stehen die Palette, das
Formular und die Einstellungen nebeneinander.
Worauf du achten solltest
PaletteGroup.vue: Ein Abschnitt der Palette ist eine sortierbare Liste, die nur Kopien hergibt:group: { name: 'fields', pull: 'clone', put: false }undsort: false. Ihre Elemente sind Felder wie die des Formulars, eine in eine Zeile gezogene Kopie ist also ein Feld wie die anderen;clonegibt ihr einen eigenen Schlüssel. Ein Klick (oder Enter) fügt das Feld ohne Drag hinzu.FormRow.vue: Eine Zeile ist zwei Dinge zugleich, wie eine Kanban-Spalte: ein Element der Zeilenliste des Formulars, an seinem Griff gezogen, und eine Liste von bis zu drei Feldern in der Gruppe der Palette. Ihrputlehnt ein viertes ab: Über einer vollen Zeile wird das Feld in der Hand rot und sagt, warum, und ein Drop dort wird abgebrochen.NewRowZone.vue: Lege ein Feld in der Lücke zwischen zwei Zeilen ab oder unter der letzten, und es bekommt eine eigene Zeile. Diese Stellen sind auch Listen: leere Listen derselben Gruppe, deren IDs sagen, wohin die neue Zeile kommt.FieldCard.vue: Die Zeilen sind im Indikator-Modus (placeholder: 'indicator'), also bewegt sich nichts, während ein Feld gezogen wird: Eine Linie neben einem Feld zeigt, wohin es kommt. In einem schmalen Builder stapeln sich die Felder einer Zeile, und die Linie steht über oder unter ihnen.Trash.vue: Während ein Feld des Formulars gezogen wird, wird die Palette zum Papierkorb: ein einfaches Drop-Ziel, das nur die Felder des Formulars annimmt (accept: 'fields', der Name ihrer Gruppe) und sie mitevent.source.remove()entfernt.OptionList.vue: Die Optionen eines Dropdowns oder einer Mehrfachauswahl sind eine eigene sortierbare Liste im Einstellungs-Panel, an ihren Griffen gezogen, damit ihr Text bearbeitbar bleibt.form.ts: das Formular und der einzige Code, der es ändert. Die Listen lesen es und schreiben es nie, also sehen Undo und Redo jede Änderung, Drops eingeschlossen.
Eine Zeile aus Feldern ist eine Liste in einer Liste
Die Zeilen des Formulars und die Felder jeder Zeile sind Listen zweier Gruppen, rows und
fields. Ein Drag betritt immer nur die Listen seiner eigenen Gruppe: Ein Feld landet nie zwischen
den Zeilen und eine Zeile nie in einer anderen Zeile, wie auch immer sie verschachtelt sind.
// 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,
});
Eine neue Zeile braucht einen Ort, an dem sie abgelegt werden kann. Jede Zeile hält ein Band über
der Lücke über ihr, und der Platz unter der letzten Zeile ist ein weiterer solcher Ort: eine leere
Liste der Gruppe fields, die nie ein Element zeigt. Über einem Band ist die Liste isOver, und
das Band zeichnet seine Linie. Die Bänder nehmen den Zeiger nur an, solange etwas gezogen wird
(html[data-dnd-dragging]), sie stehen einem Klick also nie im Weg:
<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>
Auch die Tastatur findet die Bänder. Ein Drag per Tastatur geht von Liste zu Liste nach ihren Plätzen auf der Seite, mit den Pfeilen quer zu einer Liste: hoch und runter für die Zeilen, die horizontal sind, und für die Bänder, ebenfalls horizontal. Also besuchen ArrowUp und ArrowDown abwechselnd die Zeilen und die Lücken dazwischen.
Ein Store, ein Undo: Listen, die nie schreiben
Jede Liste des Builders bekommt ihre Elemente aus einem Getter, die Bibliothek schreibt sie also
nie (eine kontrollierte Liste). Ihr onDrop ist
form.drop: Es bricht das Schreiben ab und gibt event.result, die neuen Arrays jeder Liste, die
der Drop ändert, an das Formular. Die IDs der Listen sagen, was jedes Array ist: die Felder einer
Zeile, die Reihenfolge der Zeilen oder eine Zeile, die angelegt werden soll.
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 sichert einen Schnappschuss des Formulars für Undo, nimmt die Änderung vor und entfernt
die Zeilen, die ein Verschieben leer zurückgelassen hat, alles bevor der Drop landet, sodass das
Overlay dorthin fliegt, wo das Feld wirklich ist. Der Papierkorb nimmt denselben Weg:
event.source.remove() führt das onDrop der Zeile mit dem herausgenommenen Feld aus (to.index
ist -1). Tippen in den Einstellungen geht ebenfalls durch commit, und Tastenanschläge in einer
Einstellung teilen sich einen Schlüssel: Eine Buchstabe für Buchstabe getippte Beschriftung ist ein
einziger Schritt von Undo.
Das Feld in der Hand
Die Palette und die Zeilen teilen sich eine Overlay-Komponente. Sie zeigt die Kopie der
Bibliothek von dem, was gezogen wird (DragPreview: ein Chip aus der Palette, das ganze Feld aus
dem Formular, sein Nuxt-UI-Bedienelement eingeschlossen), in einem Rahmen, mit einem Badge für
das, was ein Drop tun würde: kopieren (Alt), löschen über dem Papierkorb, nichts über
einer vollen Zeile.
<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>
Die Elemente der Palette haben einen eigenen type, 'new-field': Das Overlay unterscheidet sie
daran, und der Papierkorb, der 'fields' annimmt, nimmt sie nicht.
Mit Nuxt UI
Die Dokumentation ist eine Nuxt-App mit dem Modul @nuxt/ui, und der Komponenten-Auto-Import von
Nuxt reicht auch in die Beispiele: Diese Dateien verwenden <UInput>, <UFormField> oder
<UTabs>, ohne sie zu importieren, wie es ein Nuxt-UI-Projekt tut. Eine Vue-App mit dem
Vite-Plugin von Nuxt UI importiert sie genauso automatisch; ohne es importiere jede einzeln:
import UButton from '@nuxt/ui/components/Button.vue'. Die Typen kommen aus @nuxt/ui
(import type { FormSubmitEvent } from '@nuxt/ui').
- Während du baust, sind Felder Bilder.
FieldView.vuerendert ein Feld auf der Leinwand und in der Vorschau gleich: einUFormFieldum sein Bedienelement. Auf der Leinwand sitzt es in eineminert-Element, ein Drücken auf ein Eingabefeld startet also einen Drag statt zu tippen, und Tab überspringt die Eingabefelder. Die Werkzeugleiste der Karte (eineUFieldGroupaus Buttons mit Tooltips) bleibt mitdata-dnd-no-cloneaus der Kopie des Overlays heraus. - Zustände sind Tailwind-Varianten. Die Bibliothek markiert ihre Zustände mit Attributen, die
Tailwind als Varianten liest:
data-[dnd-placeholder]:outline-dashedam Feld, das verschoben wird,in-data-[dnd-overlay]:bg-defaultan einer Zeile in der Hand,in-data-[dnd-dragging]:pointer-events-autoan den Bändern. Die Farben sind die semantischen von Nuxt UI (bg-default,ring-primary,text-error): Heller und dunkler Modus brauchen nichts weiter. - Die Vorschau ist ein echtes
UForm.schema.tserzeugt aus den Feldern ein Zod-Schema, undUFormvalidiert die Antworten damit:nameverbindet jedesUFormFieldmit seiner Regel, und die Meldungen erscheinen unter den Feldern. Sende das Formular ab, um zu sehen, was es übermittelt. - Der Export schreibt dasselbe Formular als eigene Komponente: Nuxt-UI-Bedienelemente, ein
Zod-Schema, ein
reactive-Zustand und die Zeilen als Raster, die sich auf Handys stapeln. - Das Layout folgt der Breite des Builders (
useElementSize), nicht der des Fensters: drei Panels im Vollbild; auf der Seite nehmen die Einstellungen den Platz der Palette ein; schmaler wird die Palette zu einem Streifen aus Chips über dem Formular und die Einstellungen zu einem Blatt darüber. Container Queries (@lg:) erledigen den Rest.
@nuxt/ui/vite, Tailwind CSS und UApp um
das Beispiel. In einer Nuxt-App mit dem Modul @nuxt/ui kopiere den Ordner und rendere
index.vue auf einer Seite; das Formular braucht außerdem zod.Tastatur und Screenreader
Springe mit Tab zu einem Feld und drücke Space, um es aufzunehmen.
ArrowLeft und ArrowRight bewegen es entlang seiner Zeile,
ArrowUp und ArrowDown zu den anderen Zeilen und zu den Lücken
dazwischen (eine neue Zeile), Space legt es ab, Escape stellt es
zurück. Der Provider nimmt für Drags nur Space (keyboard: { keys: { start: ['Space'], drop: ['Space'] } }), was Enter frei lässt, um die Einstellungen eines Feldes zu öffnen.
Delete entfernt ein Feld, Ctrl D dupliziert es,
Ctrl Z macht rückgängig.
Die announcements des Providers sprechen die Worte des Builders: „Work email: row 2, column 2 of
2“, „New date field: a new row above row 3“. form.describe macht aus der Position, die die
Bibliothek liefert (eine Listen-ID und ein Index), diese Worte.
Mehr dazu: Listen mit gemeinsamen Elementen · Eine Einfügelinie · Das Overlay · Styling: Tailwind und UI-Kits · Barrierefreiheit