Skip to content

Create custom components and configuration panels

QForm Builder can be extended at three independent levels:

  1. a catalog element, which adds a tool to the left drawer;
  2. a custom FormKit type, which defines the Vue/Quasar component rendered at runtime;
  3. a custom configuration panel, which replaces the right drawer panel.

A catalog element may reuse an existing type (q-input, q-select, and so on). A genuinely new business component must also be registered in FormKit.

1. Simple case: reuse an existing component

The public defineQFormElement() helper adds a typed catalog entry without modifying the layer.

ts
// app/forms/catalog.ts
import {
  createCommunityCatalog,
  defineQFormElement,
} from '@vevedh/qform-builder-layer/catalog'
import type { QFormBuilderCatalog } from '@vevedh/qform-builder-layer/types'

export const businessCatalog: QFormBuilderCatalog = [
  ...createCommunityCatalog(),
  defineQFormElement({
    key: 'employee-code',
    category: 'fields',
    icon: 'badge',
    title: 'Employee code',
    description: 'Internal identifier using uppercase characters.',
    schema: {
      $formkit: 'q-input',
      name: 'employee_code',
      label: 'Employee code',
      inputType: 'text',
      maxlength: 20,
      validation: 'required|matches:/^[A-Z0-9-]+$/',
    },
  }),
]
vue
<script setup lang="ts">
import { businessCatalog } from '~/forms/catalog'
</script>

<template>
  <FormBuilder
    builder-id="business-form"
    :catalog="businessCatalog"
    catalog-mode="replace"
  />
</template>

This is sufficient when the Quasar renderer already exists.

2. Create a real FormKit/Quasar type

The following example creates a q-business-code field. It uses QInput, normalizes its value and never forwards arbitrary schema attributes to the DOM.

2.1 Runtime component

vue
<!-- app/components/forms/BusinessCodeInput.vue -->
<script setup lang="ts">
import type { FormKitFrameworkContext } from '@formkit/core'

interface BusinessCodeAttrs {
  prefix?: string
  maxlength?: number | string
  placeholder?: string
  description?: string
  disable?: boolean
  readonly?: boolean
}

const props = defineProps<{
  context: FormKitFrameworkContext & { attrs: BusinessCodeAttrs }
}>()

const prefix = computed(() => {
  const value = String(props.context.attrs.prefix || '').trim().toUpperCase()
  return value.replace(/[^A-Z0-9-]/g, '').slice(0, 10)
})

const maximumLength = computed(() => {
  const value = Number(props.context.attrs.maxlength)
  return Number.isInteger(value) && value >= 1 && value <= 64 ? value : 20
})

const model = computed(() => String(props.context.value || ''))
const hasError = computed(() => props.context.state?.valid === false)

function updateValue(value: string | number | null) {
  const normalized = String(value || '')
    .toUpperCase()
    .replace(/[^A-Z0-9-]/g, '')
    .slice(0, maximumLength.value)

  props.context.node.input(normalized)
}
</script>

<template>
  <q-input
    :model-value="model"
    :label="context.label"
    :hint="context.help || context.attrs.description"
    :placeholder="context.attrs.placeholder"
    :prefix="prefix || undefined"
    :maxlength="maximumLength"
    :disable="context.attrs.disable === true"
    :readonly="context.attrs.readonly === true"
    :error="hasError"
    filled
    clearable
    @update:model-value="updateValue"
  />
</template>

Important points:

  • the value is normalized before node.input();
  • only explicitly allowed props are forwarded to Quasar;
  • no v-html, on* attribute or arbitrary object is accepted;
  • the component remains compatible with FormKit runtime and FormViewer.

2.1.1 Type a controlled Quasar component

Some Quasar interfaces require modelValue. In a FormKit adapter, however, the model is supplied separately from context.value. Serializable attributes should therefore make Quasar props optional and omit modelValue:

ts
import type { QSelectProps } from 'quasar'

type BusinessSelectAttrs = Record<string, unknown>
  & Partial<Omit<QSelectProps, 'modelValue'>>
  & {
    description?: string
  }

This shape avoids inventing a fake modelValue inside context.attrs, remains compatible with dictionary-based normalization and preserves known prop types. Keep passing the controlled value with :model-value, then validate it before node.input().

2.2 Host application FormKit plugin

ts
// formkit.config.ts
import type { FormKitNode } from '@formkit/core'
import { en, fr } from '@formkit/i18n'
import { defineFormKitConfig } from '@formkit/vue'
import { qformBuilderQuasarPlugin } from '@vevedh/qform-builder-layer/formkit.config'
import BusinessCodeInput from './app/components/forms/BusinessCodeInput.vue'

function businessInputsPlugin() {}

businessInputsPlugin.library = (node: FormKitNode) => {
  if (node.props.type !== 'q-business-code') return

  node.define({
    type: 'input',
    props: ['columns', 'prefix', 'maxlength'],
    component: BusinessCodeInput,
  })
}

export default defineFormKitConfig({
  plugins: [
    qformBuilderQuasarPlugin,
    businessInputsPlugin,
  ],
  locales: { fr, en },
  locale: 'en',
})

When the host overrides the layer FormKit configuration file, it must register qformBuilderQuasarPlugin again. Otherwise Community types such as q-input and q-columns become unknown.

ts
// nuxt.config.ts
import { fileURLToPath } from 'node:url'

const formkitConfigFile = fileURLToPath(
  new URL('./formkit.config.ts', import.meta.url),
)

export default defineNuxtConfig({
  extends: ['@vevedh/qform-builder-layer'],
  formkit: {
    configFile: formkitConfigFile,
    defaultConfig: true,
    autoImport: false,
  },
})

2.3 Catalog element for the component

ts
// app/forms/catalog.ts
import { defineQFormElement } from '@vevedh/qform-builder-layer/catalog'
import type { QFormBuilderCatalog } from '@vevedh/qform-builder-layer/types'

export const businessCatalog: QFormBuilderCatalog = [
  defineQFormElement({
    key: 'business-code',
    category: 'fields',
    icon: 'badge',
    title: 'Business code',
    description: 'Normalized and prefixed code.',
    schema: {
      $formkit: 'q-business-code',
      name: 'business_code',
      label: 'Business code',
      prefix: 'ORG-',
      maxlength: 20,
      validation: 'required|length:3,20',
    },
  }),
]

The schema remains declarative and serializable. Vue components and functions must never be stored in exported JSON.

3. Create the matching configuration panel

The panel receives the selected field and an updateField() function. Use this function to keep history, the controlled schema and autosave synchronized.

vue
<!-- app/components/forms/BusinessCodePanel.vue -->
<script setup lang="ts">
import type { QFormBuilderConfigPanelProps } from '@vevedh/qform-builder-layer/types'

const props = defineProps<QFormBuilderConfigPanelProps>()

const label = computed({
  get: () => String(props.field.label || ''),
  set: value => props.updateField({ label: value.slice(0, 120) }),
})

const prefix = computed({
  get: () => String(props.field.prefix || ''),
  set: (value) => {
    const normalized = value
      .toUpperCase()
      .replace(/[^A-Z0-9-]/g, '')
      .slice(0, 10)
    props.updateField({ prefix: normalized || undefined })
  },
})

const maxlength = computed({
  get: () => Number(props.field.maxlength || 20),
  set: (value) => {
    const normalized = Math.min(64, Math.max(1, Math.round(Number(value) || 20)))
    props.updateField({ maxlength: normalized })
  },
})
</script>

<template>
  <q-list separator>
    <q-item-label header>Business code</q-item-label>
    <q-item>
      <q-item-section class="q-gutter-md">
        <q-input v-model="label" filled dense label="Label" />
        <q-input v-model="prefix" filled dense label="Prefix" maxlength="10" />
        <q-input
          v-model.number="maxlength"
          filled
          dense
          type="number"
          min="1"
          max="64"
          label="Maximum length"
        />
      </q-item-section>
    </q-item>
    <q-item>
      <q-item-section side>
        <q-btn flat no-caps label="Close" @click="props.close" />
      </q-item-section>
    </q-item>
  </q-list>
</template>

Register the panel:

vue
<script setup lang="ts">
import { markRaw } from 'vue'
import type {
  QFormBuilderCatalog,
  QFormBuilderConfigPanelRegistry,
} from '@vevedh/qform-builder-layer/types'
import BusinessCodePanel from '~/components/forms/BusinessCodePanel.vue'
import { businessCatalog } from '~/forms/catalog'

const catalog: QFormBuilderCatalog = businessCatalog

const configPanels: QFormBuilderConfigPanelRegistry = [
  {
    key: 'business-code-panel',
    component: markRaw(BusinessCodePanel),
    match: 'q-business-code',
    priority: 100,
  },
]
</script>

<template>
  <FormBuilder
    builder-id="business-form"
    :catalog="catalog"
    catalog-mode="append"
    :config-panels="configPanels"
  />
</template>

The matcher may also be a predicate:

ts
match: field => field.$formkit === 'q-input' && field.qformKind === 'employee-id'

4. Import/export and security

QForm Builder JSON import deliberately rejects dynamic components such as $cmp, functions, on* event handlers, innerHTML, outerHTML, srcdoc and prototype-pollution keys.

For a custom component:

  • register the type in trusted application code;
  • export only its $formkit identifier and serializable props;
  • apply a prop allowlist inside the adapter;
  • validate the schema again on the server before persistence;
  • never render a schema string with v-html without a dedicated HTML sanitizer.
bash
bun run typecheck
bun run formkit:check
bun run catalog:check
bun run e2e:visual:update   # after an intentional visual change
bun run e2e:visual          # compare against approved references
bun run runtime:build:check

To stabilize Playwright screenshots:

  • use a fixed schema and anonymized values;
  • clear localStorage before every scenario;
  • disable animations;
  • target stable data-* attributes instead of Quasar internal classes.

See also Custom configuration panels and Community catalog.

QForm Builder — Nuxt 4, Quasar and FormKit layer for dynamic forms.