nuxt-formkit-webform
v0.0.1
Published
Render Drupal Webforms as FormKit forms.
Readme
nuxt-formkit-webform
A Nuxt module that renders
Drupal Webforms as
FormKit forms, sourcing the form definition from the
graphql_webform Drupal
module via GraphQL and submitting back through the same schema's mutation.
Requirements
On the Drupal side, the backend must expose a GraphQL endpoint with the
webform and graphql_webform modules enabled.
On the Nuxt side, this module builds on two peer modules that you install and configure yourself:
nuxt-graphql-middleware— proxies GraphQL through your Nuxt server. This module registers its own query + mutation documents into it.@formkit/nuxt— renders the forms.
Install
npx nuxt module add nuxt-formkit-webform
npm install nuxt-graphql-middleware @formkit/nuxtSetup
1. nuxt.config.ts
export default defineNuxtConfig({
modules: [
// MUST come before nuxt-graphql-middleware — see below.
'nuxt-formkit-webform',
'nuxt-graphql-middleware',
'@formkit/nuxt',
],
graphqlMiddleware: {
graphqlEndpoint: 'https://example.com/graphql',
},
formkitWebform: {
// All options are optional — see "Module options".
},
})Module order is load-bearing.
nuxt-formkit-webformmust be listed beforenuxt-graphql-middleware: it registers its GraphQL documents via thenuxt-graphql-middleware:inithook, which has already fired if the middleware boots first. The module throws at build time if you get this wrong, so you won't debug it silently.
2. formkit.config.ts
withWebformConfig registers everything the module needs in one call — the
custom input types, validation rules, locale messages (EN/DE/FR/IT), and the
plugins behind Drupal's label/description affordances:
import { defineFormKitConfig } from '@formkit/vue'
import { withWebformConfig } from 'nuxt-formkit-webform/formkit'
import { rootClasses } from './formkit.theme'
export default defineFormKitConfig(() =>
withWebformConfig({
config: { rootClasses },
// Your own plugins / rules / messages go here. They win on key
// conflicts; whole locale buckets are deep-merged, not clobbered.
}),
)New module features (input types, rules, locales) arrive by version bump without
your config moving. Per-piece opt-out lives in nuxt.config (see "FormKit
integration options"), and every constituent piece is exported individually if
you'd rather assemble by hand.
FormKit integration options
Opt-outs go in nuxt.config, not into withWebformConfig — they're baked into
the generated config as literal consts, so turning one off drops its code from
the bundle:
formkitWebform: {
formkit: {
helpTooltip: false, // `?` affordance (Drupal `#help`)
more: false, // embedded `<details>` disclosure (Drupal `#more`)
asterisk: false, // required-marker (`*`)
counter: false, // live character/word count (Drupal `#counter_*`)
},
}All four are presentational: asterisk: false keeps aria-required,
counter: false keeps Drupal's server-side limit. more: false only drops the
EMBEDDED disclosure — the standalone WebformElementWebformMore element renders
its own; exclude it via supportedElements.
3. Theme and CSS
The module ships default Tailwind classes for its custom inputs
(webformMessage, webformSection, webformFieldset, webformContainer,
webformDetails, webformFlexbox, webformItem, webformDatelist,
webformLikert) and for the shared affordances (required asterisk, ? help
tooltip, "more" disclosure). Two exports:
| Export | Keyed by | Merge into |
| ---------------- | --------------------------- | ---------------------- |
| webformClasses | `${type}__${section}` | your theme's classes |
| webformGlobals | bare section name | your theme's globals |
If you use stock Tailwind: import them
Merge both maps into the theme file you already have (e.g. a
formkit theme --theme=regenesis generated formkit.theme.ts). Keep them in
separate Object.assign calls so a theme regen doesn't trample them:
// formkit.theme.ts
import { webformClasses, webformGlobals } from 'nuxt-formkit-webform/formkit'
const classes: Record<string, Record<string, boolean>> = {
/* generated */
}
Object.assign(classes, webformClasses)
const globals: Record<string, Record<string, boolean>> = {
/* generated */
}
Object.assign(globals, webformGlobals)Tailwind will not emit these classes unless you tell it to scan the shipped
file. Tailwind scans file text, not imported values, and it ignores
node_modules by default — so without this, the classes exist in the DOM but no
CSS is generated for them:
/* app/assets/css/main.css */
@import 'tailwindcss';
@source "../../../node_modules/nuxt-formkit-webform/dist/formkit/theme.mjs";(Adjust the relative path to your CSS file's location.)
If you use your own class names: copy it
The theme is a plain map of section keys to class objects, so there's nothing to
subclass — copy src/formkit/theme.ts into your repo,
swap the Tailwind classes for your own, and merge your copy instead of the
import. The section keys are the contract; the classes are yours. Each input's
source file documents the full section list it exposes.
Stylesheets
A handful of behaviours can't be expressed as utility classes. Import only the ones you need:
// nuxt.config.ts
css: [
// Drupal `#input_hide` parity (value masked until focus).
'nuxt-formkit-webform/css/input-hide.css',
// Layout for the range element's `#output` affordance.
'nuxt-formkit-webform/css/range-output.css',
// Spacing fix for sortable tableselect.
'nuxt-formkit-webform/css/webform_tableselect_sort.css',
// Classes Drupal itself may emit (`.visually-hidden`, …).
'nuxt-formkit-webform/css/webform-classes.css',
]Wizard forms (Drupal's webform_wizard_page) render as FormKit's multi-step
input. Its stylesheet is FormKit's own — import it straight from
@formkit/addons, which you already have as a transitive dependency:
css: ['@formkit/addons/css/multistep']Rendering a form
<NuxtWebform> takes the fetched webform payload, not an id — so you own
the query, and caching / SSR / reactivity stay in your hands. The module
registers a webformById query into nuxt-graphql-middleware, so it's already
available to useAsyncGraphqlQuery:
<template>
<NuxtWebform
v-if="webform"
:webform="webform"
@success="onSuccess"
@error="onError"
/>
</template>
<script setup lang="ts">
const { data: webform } = await useAsyncGraphqlQuery(
'webformById',
{ id: 'contact' },
// Unwrap the GraphQL envelope. The native `Webform` shape is passed
// through as-is — <NuxtWebform> reads `form.elements` itself.
{ transform: (data) => data.data.webformById },
)
function onSuccess(payload) {
// payload: { submissionId, submissionToken, confirmation, submission }
}
function onError(errors: readonly string[]) {}
</script>Because the variables are reactive, making sourceEntityId a ref reloads the
form against the new context automatically.
Props
| Prop | Type | Description |
| ------------------ | ----------------------- | -------------------------------------------------------------------------------------------------------------------- |
| webform | WebformPayload | Required. The webformById result, passed through unreshaped. |
| sourceEntityType | string | Source entity the submission is attached to. Pass the same value you used in the query. |
| sourceEntityId | string | ↑ |
| submitLabel | string | Overrides the submit button label. |
| sectionsSchema | WebformSectionsSchema | Per-element FormKit section overrides, keyed by element key. Lets you replace any section's markup without a mapper. |
| debug | boolean | Dev aid — renders a <details> block with the generated schema + live submission state. |
Events
success—{ submissionId, submissionToken, confirmation, submission }error—readonly string[]of form-level errors. Element-level validation errors are pushed back onto the corresponding inputs automatically; you don't need to route them.
Slots
unavailable— replaces the default banner when the form can't be filled in (closed, submission limit reached, source entity required/mismatched…). Receives theunavailablepayload.<NuxtWebform>renders the form or this — never both.- default — rendered after the form.
Query variables worth knowing
webformById accepts sourceEntityType / sourceEntityId (context) and
prepopulate — a URL-encoded query string in Drupal's own convention
(customer_name=Alice&colors[]=red) that pre-fills element values server-side.
Module options
formkitWebform: {
// Extra directories to scan for mappers, on top of the convention
// directory `~/webform/mappers/` (app root + every layer).
mapperDirs: [],
// Restrict which element typenames are supported. Pass EITHER include
// (allow-list) OR exclude (deny-list) — the type forbids both.
supportedElements: { exclude: ['WebformElementManagedFile'] },
// Element typename with no mapper: 'warn' (default) | 'skip' | 'throw'.
onUnknownElement: 'warn',
// How deep the generated master fragment unrolls container recursion.
containerRecursionDepth: 4,
// Mapper discovery / generated fragment logging. Auto-on when Nuxt's
// `debug` is on.
debug: false,
// See "FormKit integration options".
formkit: {
pro: { enabled: false },
helpTooltip: true,
more: true,
asterisk: true,
counter: true,
},
// Max options at which the time element renders a <select> instead of
// <input type="time">. 0 disables the auto-selectify behaviour.
timeSelectThreshold: 24,
}Elements outside the configured supportedElements set are skipped silently at
runtime — they don't trigger onUnknownElement.
FormKit Pro
Drupal's '#multiple': true elements render through FormKit Pro's repeater.
This module does not bundle FormKit Pro. To enable it:
- Register
@formkit/proand therepeaterinput in your ownformkit.config.ts. - Set
formkitWebform.formkit.pro.enabled = true.
When pro.enabled is false (the default), multi-value elements fall back to
single-value rendering with a one-time console warning per element.
Custom elements
To support a Drupal element this module doesn't ship a mapper for — or to
override a bundled one — drop a folder into ~/webform/mappers/<Typename>/
(i.e. app/webform/mappers/ in a default Nuxt 4 app). The folder name must
match the GraphQL __typename:
app/webform/mappers/WebformElementMyThing/
├── index.ts # default export: defineWebformElementMapper(...)
└── fragment.graphql # the fields your mapper needs// index.ts — defineWebformElementMapper is auto-imported.
export default defineWebformElementMapper(
'WebformElementMyThing',
(element) => {
const meta = element.metadata
if (!meta) return null
const node = { $formkit: 'text' }
applyCommonMetadata(node, meta) // #nuxt-formkit-webform/helpers/*
return node
},
)The fragment is composed into the module's master query automatically, and
element is typed from it — no hand-written types. A userland mapper whose
typename matches a bundled one overrides it.
Development
This module is developed alongside a DDEV-managed Drupal backend in the
graphql-webform-integration repository. See the repo's top-level README.md
and CONTRIBUTING.md for the contributor workflow.
