Documentation/Vue d'ensemble

Constructeur de formulaires

Un constructeur de formulaires fait avec Nuxt UI — des champs glissés depuis une palette, placés côte à côte en rangées, modifiés dans un panneau, puis remplis dans un aperçu en direct avec validation et exportés en code Vue.

Modifier cette page

Cette page a été traduite par IA et peut contenir des inexactitudes. Lire l'original en anglais

Live

Chargement de la démo…

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

Chaque contrôle ici est un composant Nuxt UI : les champs du formulaire (UInput, USelect, URadioGroup, UInputRating, UFileUpload…), les panneaux qui les entourent, le UForm de l'aperçu. Vue DnD Kit les déplace. Passez la démo en plein écran : la palette, le formulaire et les réglages se placent alors côte à côte.

Ce qu'il faut regarder

  • PaletteGroup.vue : une section de la palette est une liste triable qui ne donne que des copies : group: { name: 'fields', pull: 'clone', put: false } et sort: false. Ses items sont des champs comme ceux du formulaire : une copie glissée dans une rangée est donc un champ comme les autres ; clone lui donne une clé à elle. Un clic (ou Enter) ajoute le champ sans glissement.
  • FormRow.vue : une rangée est deux choses à la fois, comme une colonne de kanban : un item de la liste des rangées du formulaire, glissé par sa poignée, et une liste de trois champs au plus dans le groupe de la palette. Son put refuse un quatrième : au-dessus d'une rangée pleine, le champ en main devient rouge et dit pourquoi, et un dépôt à cet endroit est annulé.
  • NewRowZone.vue : déposez un champ dans l'espace entre deux rangées, ou sous la dernière, et il obtient une rangée à lui. Ces endroits sont aussi des listes : des listes vides du même groupe, dont les ids disent où va la nouvelle rangée.
  • FieldCard.vue : les rangées sont en mode indicateur (placeholder: 'indicator') : rien ne bouge pendant qu'un champ est glissé ; une ligne à côté d'un champ montre où il ira. Dans un constructeur étroit, les champs d'une rangée s'empilent, et la ligne va au-dessus ou en dessous.
  • Trash.vue : pendant qu'un champ du formulaire est glissé, la palette devient une corbeille : une simple cible de dépôt qui n'accepte que les champs du formulaire (accept: 'fields', le nom de leur groupe) et les retire avec event.source.remove().
  • OptionList.vue : les options d'une liste déroulante ou d'un choix multiple sont une liste triable à part entière dans le panneau des réglages, glissées par leurs poignées pour que leur texte reste modifiable.
  • form.ts : le formulaire, et le seul code qui le modifie. Les listes le lisent et ne l'écrivent jamais : Undo et Redo voient donc chaque changement, dépôts compris.

Une rangée de champs est une liste dans une liste

Les rangées du formulaire et les champs de chaque rangée sont des listes de deux groupes, rows et fields. Un glissement n'entre jamais que dans les listes de son propre groupe : un champ n'atterrit jamais parmi les rangées, ni une rangée dans une autre rangée, quelle que soit leur imbrication.

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

Une nouvelle rangée a besoin d'un endroit où être déposée. Chaque rangée porte une bande sur l'espace au-dessus d'elle, et l'espace sous la dernière rangée est un endroit de plus de ce genre : une liste vide du groupe fields, qui n'affiche jamais d'item. Au-dessus d'une bande, la liste est isOver, et la bande dessine sa ligne. Les bandes ne prennent le pointeur que pendant qu'un glissement est en cours (html[data-dnd-dragging]) : elles ne gênent donc jamais un clic :

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>

Le clavier trouve aussi les bandes. Un glissement au clavier passe de liste en liste selon leur place sur la page, avec les flèches en travers d'une liste : haut et bas pour les rangées, qui sont horizontales, et pour les bandes, horizontales elles aussi. ArrowUp et ArrowDown visitent donc tour à tour les rangées et les espaces entre elles.

Un store, un Undo : des listes qui n'écrivent jamais

Chaque liste du constructeur reçoit ses items d'un getter : la bibliothèque ne les écrit donc jamais (une liste contrôlée). Leur onDrop est form.drop : il annule l'écriture et passe au formulaire event.result, les nouveaux tableaux de chaque liste que le dépôt modifie. Les ids des listes disent ce qu'est chaque tableau : les champs d'une rangée, l'ordre des rangées, ou une rangée à créer.

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 sauvegarde un instantané du formulaire pour Undo, fait le changement, et retire les rangées qu'un déplacement a laissées vides, tout cela avant que le dépôt n'atterrisse : l'overlay vole donc là où le champ se trouve vraiment. La corbeille prend le même chemin : event.source.remove() exécute l'onDrop de la rangée sans le champ (to.index vaut -1). La saisie dans les réglages passe aussi par commit, et les frappes dans un même réglage partagent une clé : un libellé tapé lettre par lettre est une seule étape d'Undo.

Le champ en main

La palette et les rangées partagent un même composant d'overlay. Il affiche la copie que fait la bibliothèque de ce qui est glissé (DragPreview : une pastille depuis la palette, le champ entier depuis le formulaire, son contrôle Nuxt UI compris) dans un cadre, avec un badge pour ce que ferait un dépôt : copier (Alt), supprimer au-dessus de la corbeille, rien au-dessus d'une rangée pleine.

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>

Les items de la palette ont un type à eux, 'new-field' : l'overlay les distingue grâce à lui, et la corbeille, qui accepte 'fields', ne les prend pas.

Avec Nuxt UI

La documentation est une application Nuxt avec le module @nuxt/ui, et l'auto-import des composants de Nuxt atteint aussi les exemples : ces fichiers utilisent <UInput>, <UFormField> ou <UTabs> sans les importer, comme le fait un projet Nuxt UI. Une application Vue avec le plugin Vite de Nuxt UI les auto-importe de la même façon ; sans lui, importez chacun : import UButton from '@nuxt/ui/components/Button.vue'. Les types viennent de @nuxt/ui (import type { FormSubmitEvent } from '@nuxt/ui').

  • Pendant la construction, les champs sont des images. FieldView.vue rend un champ de la même façon sur le canevas et dans l'aperçu : un UFormField autour de son contrôle. Sur le canevas, il se trouve dans un élément inert : un appui sur un champ de saisie démarre donc un glissement au lieu de la saisie, et Tab saute les champs de saisie. La barre d'outils de la carte (un UFieldGroup de boutons avec infobulles) reste hors de la copie de l'overlay grâce à data-dnd-no-clone.
  • Les états sont des variantes Tailwind. La bibliothèque marque ses états avec des attributs, que Tailwind lit comme des variantes : data-[dnd-placeholder]:outline-dashed sur le champ en cours de déplacement, in-data-[dnd-overlay]:bg-default sur une rangée en main, in-data-[dnd-dragging]:pointer-events-auto sur les bandes. Les couleurs sont les couleurs sémantiques de Nuxt UI (bg-default, ring-primary, text-error) : les modes clair et sombre n'ont besoin de rien de plus.
  • L'aperçu est un vrai UForm. schema.ts crée un schéma Zod à partir des champs, et UForm valide les réponses avec : name relie chaque UFormField à sa règle, et les messages s'affichent sous les champs. Envoyez le formulaire pour voir ce qu'il soumet.
  • L'export écrit le même formulaire sous forme d'un composant à part : des contrôles Nuxt UI, un schéma Zod, un état reactive, et les rangées sous forme de grilles qui s'empilent sur téléphone.
  • La mise en page suit la largeur du constructeur (useElementSize), pas celle de la fenêtre : trois panneaux en plein écran ; sur la page, les réglages prennent la place de la palette ; plus étroit, la palette devient une bande de pastilles au-dessus du formulaire et les réglages un panneau par-dessus. Les container queries (@lg:) font le reste.
Le bouton StackBlitz ouvre les fichiers dans un projet Vite configuré pour Nuxt UI, comme sa documentation le décrit pour Vue : le plugin @nuxt/ui/vite, Tailwind CSS et UApp autour de l'exemple. Dans une application Nuxt avec le module @nuxt/ui, copiez le dossier et rendez index.vue dans une page ; le formulaire a aussi besoin de zod.

Clavier et lecteurs d'écran

Allez sur un champ avec Tab et appuyez sur Space pour le saisir. ArrowLeft et ArrowRight le déplacent le long de sa rangée, ArrowUp et ArrowDown vers les autres rangées et vers les espaces entre elles (une nouvelle rangée), Space le dépose, Escape le remet en place. Le provider ne prend que Espace pour les glissements (keyboard: { keys: { start: ['Space'], drop: ['Space'] } }), ce qui laisse Enter pour ouvrir les réglages d'un champ. Delete supprime un champ, Ctrl D le duplique, Ctrl Z annule.

Les announcements du provider parlent avec les mots du constructeur : "Work email: row 2, column 2 of 2", "New date field: a new row above row 3". form.describe transforme la position que donne la bibliothèque (un id de liste et un index) en ces mots.

En savoir plus : Listes qui partagent des items · Une ligne d'insertion · L'overlay · Style : Tailwind et kits d'UI · Accessibilité