문서/개요

폼 빌더

Nuxt UI로 만든 폼 빌더. 팔레트에서 드래그한 필드를 행 안에 나란히 놓고, 패널에서 편집하고, 검증이 있는 라이브 미리보기에서 입력한 뒤, Vue 코드로 내보냅니다.

이 페이지 편집

이 페이지는 AI가 번역했으며 부정확한 내용이 있을 수 있습니다. 영어 원문 읽기

Live

데모 불러오는 중…

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

여기 있는 모든 컨트롤은 Nuxt UI 컴포넌트입니다. 폼의 필드(UInput, USelect, URadioGroup, UInputRating, UFileUpload…), 그 주변의 패널, 미리보기의 UForm. 이들을 옮기는 것은 Vue DnD Kit입니다. 데모를 전체 화면으로 펼치면 팔레트, 폼, 설정이 나란히 놓입니다.

살펴볼 점

  • PaletteGroup.vue: 팔레트의 각 섹션은 복사본만 내주는 정렬 가능한 리스트입니다. group: { name: 'fields', pull: 'clone', put: false }와 sort: false. 그 항목은 폼의 것과 같은 필드이므로, 행으로 드래그한 복사본은 다른 것과 똑같은 필드가 됩니다. clone이 복사본에 고유한 key를 줍니다. 클릭(또는 Enter)하면 드래그 없이 필드를 추가합니다.
  • FormRow.vue: 행은 칸반 컬럼처럼 두 가지 역할을 동시에 합니다. 그립으로 드래그하는 폼의 행 리스트의 항목이면서, 팔레트와 같은 그룹에 속한 최대 세 개 필드의 리스트입니다. 그 put은 네 번째 필드를 거부합니다. 꽉 찬 행 위에서는 손에 든 필드가 빨갛게 변하며 이유를 알려 주고, 그곳에 드롭하면 취소됩니다.
  • NewRowZone.vue: 두 행 사이의 틈이나 마지막 행 아래에 필드를 드롭하면, 그 필드는 자기만의 행을 얻습니다. 그런 자리도 리스트입니다. 같은 그룹의 빈 리스트이며, 그 id가 새 행이 들어갈 곳을 알려 줍니다.
  • FieldCard.vue: 행은 인디케이터 모드(placeholder: 'indicator')이므로 필드를 드래그하는 동안 아무것도 움직이지 않습니다. 필드 옆의 선이 어디로 갈지 보여 줍니다. 좁은 빌더에서는 행의 필드가 세로로 쌓이고, 선은 그 위나 아래에 그려집니다.
  • Trash.vue: 폼의 필드를 드래그하는 동안 팔레트는 휴지통이 됩니다. 폼의 필드만 받고 (accept: 'fields', 그 그룹 이름) event.source.remove()로 제거하는 일반 드롭 대상입니다.
  • OptionList.vue: 드롭다운이나 객관식의 선택지는 설정 패널 안에서 그 자체로 정렬 가능한 리스트이며, 텍스트를 계속 편집할 수 있도록 그립으로 드래그합니다.
  • form.ts: 폼과, 폼을 바꾸는 유일한 코드. 리스트는 폼을 읽기만 하고 기록하지 않으므로, 실행 취소와 다시 실행이 드롭을 포함한 모든 변경을 봅니다.

필드 행은 리스트 안의 리스트입니다

폼의 행과 각 행의 필드는 rows와 fields라는 두 그룹의 리스트입니다. 드래그는 자기 그룹의 리스트에만 들어갑니다. 어떻게 중첩되어 있든 필드가 행 사이에 안착하는 일은 없고, 행이 다른 행 안에 들어가는 일도 없습니다.

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

새 행에는 드롭할 자리가 필요합니다. 각 행은 자기 위의 틈에 띠를 하나 두고, 마지막 행 아래의 공간도 그런 자리 하나입니다. 항목을 절대 보여 주지 않는 fields 그룹의 빈 리스트입니다. 띠 위에서는 리스트가 isOver가 되고, 띠가 선을 그립니다. 띠는 무언가를 드래그하는 동안에만 (html[data-dnd-dragging]) 포인터를 받으므로 클릭을 방해하지 않습니다.

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>

키보드도 띠를 찾습니다. 키보드 드래그는 리스트와 교차하는 방향의 화살표 키로, 페이지에서의 위치에 따라 리스트에서 리스트로 갑니다. 가로 방향인 행에는 위아래 키로, 역시 가로 방향인 띠에도 위아래 키로 갑니다. 그래서 ArrowUp과 ArrowDown은 행과 그 사이의 틈을 번갈아 방문합니다.

스토어 하나, 실행 취소 하나: 기록하지 않는 리스트

빌더의 모든 리스트는 getter에서 항목을 받으므로 라이브러리가 기록하지 않습니다 (제어 리스트). 그 onDrop은 form.drop입니다. 기록을 취소하고 event.result(드롭이 바꾸는 모든 리스트의 새 배열)를 폼에 넘깁니다. 리스트 id가 각 배열이 무엇인지 알려 줍니다. 행의 필드, 행의 순서, 또는 만들 행입니다.

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은 실행 취소를 위해 폼의 스냅숏을 저장하고, 변경을 적용하고, 이동으로 비게 된 행을 제거합니다. 이 모든 것이 드롭이 안착하기 전에 일어나므로, 오버레이는 필드가 실제로 있는 곳으로 날아갑니다. 휴지통도 같은 길을 갑니다. event.source.remove()는 필드를 뺀 상태로 행의 onDrop을 실행합니다(to.index는 -1). 설정에서의 입력도 commit을 거치며, 한 설정에 대한 키 입력은 key를 공유합니다. 한 글자씩 입력한 레이블은 실행 취소의 한 단계입니다.

손에 든 필드

팔레트와 행은 오버레이 컴포넌트 하나를 공유합니다. 이 컴포넌트는 드래그 중인 것의 라이브러리 복사본(DragPreview: 팔레트에서는 칩, 폼에서는 Nuxt UI 컨트롤을 포함한 필드 전체)을 틀 안에 보여 주고, 드롭이 할 일을 배지로 알려 줍니다. 복사(Alt), 휴지통 위에서는 삭제, 꽉 찬 행 위에서는 아무 일도 없음.

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>

팔레트의 항목에는 고유한 type인 'new-field'가 있습니다. 오버레이는 이것으로 항목을 구별하고, 'fields'를 받는 휴지통은 이 항목을 받지 않습니다.

Nuxt UI와 함께

이 문서는 @nuxt/ui 모듈을 쓰는 Nuxt 앱이며, Nuxt의 컴포넌트 자동 임포트는 예제에도 적용됩니다. 이 파일들은 Nuxt UI 프로젝트처럼 <UInput>, <UFormField>, <UTabs>를 임포트 없이 씁니다. Nuxt UI의 Vite 플러그인을 쓰는 Vue 앱도 똑같이 자동 임포트합니다. 플러그인이 없다면 각각 임포트하세요. import UButton from '@nuxt/ui/components/Button.vue'. 타입은 @nuxt/ui에서 가져옵니다 (import type { FormSubmitEvent } from '@nuxt/ui').

  • 만드는 동안 필드는 그림입니다. FieldView.vue는 캔버스와 미리보기에서 필드를 똑같이 렌더링합니다. 컨트롤을 UFormField로 감싼 모습입니다. 캔버스에서는 inert 요소 안에 있으므로 입력 필드를 누르면 입력 대신 드래그가 시작되고, Tab은 입력 필드를 건너뜁니다. 카드의 툴바(툴팁이 달린 버튼의 UFieldGroup)는 data-dnd-no-clone으로 오버레이 복사본에서 빠집니다.
  • 상태는 Tailwind 변형입니다. 라이브러리는 상태를 속성으로 표시하고, Tailwind는 이를 변형으로 읽습니다. 옮기는 중인 필드에는 data-[dnd-placeholder]:outline-dashed, 손에 든 행에는 in-data-[dnd-overlay]:bg-default, 띠에는 in-data-[dnd-dragging]:pointer-events-auto. 색상은 Nuxt UI의 시맨틱 색상(bg-default, ring-primary, text-error)이므로 라이트 모드와 다크 모드에 더 필요한 것이 없습니다.
  • 미리보기는 실제 UForm입니다. schema.ts가 필드로 Zod 스키마를 만들고, UForm이 그 스키마로 답을 검증합니다. name이 각 UFormField를 그 규칙에 연결하고, 메시지는 필드 아래에 나타납니다. 폼을 제출하면 무엇이 전송되는지 볼 수 있습니다.
  • 내보내기는 같은 폼을 별도의 컴포넌트로 작성합니다. Nuxt UI 컨트롤, Zod 스키마, reactive 상태, 그리고 휴대폰에서는 세로로 쌓이는 그리드로서의 행입니다.
  • 레이아웃은 창이 아니라 빌더의 너비를 따릅니다(useElementSize). 전체 화면에서는 패널 세 개, 페이지에서는 설정이 팔레트 자리를 차지하고, 더 좁으면 팔레트는 폼 위의 칩 줄이 되고 설정은 폼 위에 겹치는 시트가 됩니다. 나머지는 컨테이너 쿼리(@lg:)가 처리합니다.
StackBlitz 버튼은 Nuxt UI 문서가 Vue용으로 설명하는 대로 Nuxt UI를 설정한 Vite 프로젝트에서 파일을 엽니다. @nuxt/ui/vite 플러그인, Tailwind CSS, 예제를 감싸는 UApp이 들어 있습니다. @nuxt/ui 모듈이 있는 Nuxt 앱에서는 폴더를 복사하고 페이지에서 index.vue를 렌더링하세요. 폼에는 zod도 필요합니다.

키보드와 스크린 리더

Tab으로 필드에 가서 Space를 눌러 집으세요. ArrowLeft와 ArrowRight는 행을 따라 옮기고, ArrowUp과 ArrowDown은 다른 행과 그 사이의 틈(새 행)으로 옮기며, Space는 드롭하고, Escape는 되돌립니다. 프로바이더는 드래그에 Space만 쓰므로 (keyboard: { keys: { start: ['Space'], drop: ['Space'] } }), Enter는 필드의 설정을 여는 데 쓸 수 있습니다. Delete는 필드를 제거하고, Ctrl D는 복제하고, Ctrl Z는 실행 취소합니다.

프로바이더의 announcements는 빌더의 말로 읽어 줍니다. "Work email: row 2, column 2 of 2", "New date field: a new row above row 3". form.describe가 라이브러리가 주는 위치(리스트 id와 인덱스)를 그런 말로 바꿉니다.

더 읽어 보기: 항목을 공유하는 리스트 · 삽입선 · 오버레이 · 스타일링: Tailwind와 UI 키트 · 접근성