Create custom components and configuration panels
QForm Builder can be extended at three independent levels:
- a catalog element, which adds a tool to the left drawer;
- a custom FormKit type, which defines the Vue/Quasar component rendered at runtime;
- 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.
// 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-]+$/',
},
}),
]<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
<!-- 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:
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
// 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.
// 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
// 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.
<!-- 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:
<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:
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
$formkitidentifier 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-htmlwithout a dedicated HTML sanitizer.
5. Recommended tests
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:checkTo stabilize Playwright screenshots:
- use a fixed schema and anonymized values;
- clear
localStoragebefore every scenario; - disable animations;
- target stable
data-*attributes instead of Quasar internal classes.
See also Custom configuration panels and Community catalog.