Form Using Headless Components
The debugging messages next to some fields are deliberately placed there
The source code below the form contains comments about some of the decisions being made
Source code
vue
<template>
<HeadlessForm
class="grid grid-cols-2 gap-4"
ref="$form"
:data="data"
:validator="validator"
:submit-handler="submitHandler"
>
<template #default="formCtrl">
<div class="col-start-1 col-end-3">
<AppFormField name="name" label="Name">
<template #default="{ attrs, events, id }">
<div class="flex">
<!--
This field is now using attrs and events for consistency.
This approach offers greater flexibility and avoids
potential conflicts with v-model binding.
-->
<InputText
:id="id"
class="w-full"
v-bind="attrs"
v-on="events"
/>
</div>
<code>Attributes passed: {{attrs}}</code>
</template>
</AppFormField>
</div>
<div class="col-start-1 col-end-3">
<AppFormField name="email" label="Email">
<template #default="{ attrs, events, id }">
<InputText
:id="id"
class="w-full"
v-bind="attrs"
v-on="events"
/>
<code>Events bound: {{Object.keys(events)}}</code>
</template>
</AppFormField>
</div>
<div class="col-start-1 cols-end-2">
<AppFormField name="address.country" label="Country">
<template #default="{ attrs, events, id }">
<InputText
:id="id"
class="w-full"
v-bind="attrs"
v-on="events"
/>
</template>
</AppFormField>
</div>
<div class="col-start-2 cols-end-3">
<AppFormField name="address.city" label="City">
<template #default="{ attrs, events, id }">
<InputText
:id="id"
class="w-full"
v-bind="attrs"
v-on="events"
/>
</template>
</AppFormField>
</div>
<div class="col-start-1 col-end-3">
<AppFormField name="willing_to_relocate">
<template #default="{ value, attrs, events, id }">
<div class="flex align-center">
<ToggleSwitch
:id="id"
class="me-2"
:model-value="value"
:true-value="true"
:false-value="false"
v-bind="attrs"
v-on="events"
/>
<span @click="formCtrl.setFieldValue('willing_to_relocate', !value)">Willing to relocate</span>
</div>
</template>
</AppFormField>
</div>
<div class="col-start-1 cols-end-2">
<label class="block">Salary</label>
<div class="flex align-items center gap-2">
<AppFormField name="salary.min">
<template #default="{ attrs, events, id }">
<InputText
:id="id"
placeholder="Min"
style="width: 100px"
v-bind="attrs"
v-on="events"
/>
</template>
</AppFormField>
<AppFormField name="salary.max">
<template #default="{ attrs, events, id }">
<InputText
:id="id"
placeholder="Max"
style="width: 100px"
v-bind="attrs"
v-on="events"
/>
</template>
</AppFormField>
</div>
<div v-if="formCtrl['salary.min.$errors'] || formCtrl['salary.max.errors']"
class="text-red-500">
{{ formCtrl['salary.min.$errors'][0] || formCtrl['salary.max.$errors'][0] }}
</div>
</div>
<div class="col-start-2 col-end-3">
<AppFormField name="start_date" label="Available date">
<template #default="{ value, attrs, events, id }">
<DatePicker
:id="id"
:model-value="value"
date-format="yy-mm-dd"
fluid
v-bind="attrs"
v-on="events"
@update:model-value="(date) => events.change({value: date})"
/>
</template>
</AppFormField>
</div>
<div class="col-start-1 col-end-2">
<AppFormField name="linkedin_profile" label="LinkedIn profile">
<template #default="{ attrs, events, id }">
<div class="flex">
<InputText
:id="id"
class="w-full"
v-bind="attrs"
v-on="events"
/>
</div>
</template>
</AppFormField>
</div>
<div class="col-start-2 col-end-3">
<AppFormField name="personal_site" label="Personal site">
<template #default="{ attrs, events, id }">
<div class="flex">
<InputText
:id="id"
class="w-full"
v-bind="attrs"
v-on="events"
/>
</div>
</template>
</AppFormField>
</div>
<h3 class="col-start-1 col-end-5 text-xl font-bold">Skills</h3>
<div class="col-start-1 col-end-5">
<HeadlessRepeatable name="skills" :min="0" :max="1000">
<template #default="{ value, add, remove, canAdd, canRemove, moveUp, moveDown, move, count }">
<table class="mb-4 table-auto border-spacing-2">
<thead>
<tr>
<th>Name</th>
<th>Level</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(skill, index) in value" :key="index">
<td class="p-2">
<AppFormField :name="`skills.${index}.name`">
<template #default="{ attrs, events, id }">
<InputText
:id="id"
v-bind="attrs"
v-on="events"
/>
</template>
</AppFormField>
</td>
<td class="p-2">
<AppFormField :name="`skills.${index}.level`">
<template #default="{ value, attrs, events, id }">
<Select
:label-id="id"
:model-value="value"
fluid
:options="['Beginner', 'Intermediate', 'Advanced', 'Expert']"
v-bind="attrs"
v-on="events"
/>
</template>
</AppFormField>
</td>
<td class="p-2">
<div class="flex gap-2">
<Button
severity="danger"
type="button"
:disabled="!canRemove"
@click="remove(index)"
icon="pi pi-trash"
/>
<Button
severity="secondary"
type="button"
@click="moveUp(index)"
icon="pi pi-arrow-up"
title="Move up"
/>
<Button
severity="secondary"
type="button"
@click="moveDown(index)"
icon="pi pi-arrow-down"
title="Move Down"
/>
</div>
</td>
</tr>
</tbody>
</table>
<div class="mb-4">
<Button
severity="secondary"
type="button"
icon="pi pi-plus"
label="Add Skill"
@click="add({ name: '', level: '' }, 2)"
class="mr-2"
/>
</div>
</template>
</HeadlessRepeatable>
</div>
<h3 class="col-start-1 col-end-5 text-xl font-bold">Experience</h3>
<div class="col-start-1 col-end-3">
<HeadlessRepeatable name="experience" :min="0" :max="100">
<template #default="{ value, add, remove, canAdd, canRemove, moveUp, moveDown, count }">
<div
v-for="(experience, index) in value"
:key="index"
class="grid grid-cols-2 gap-4 mb-8"
>
<AppFormField :name="`experience.${index}.company`" label="Company">
<template #default="{ attrs, events, id }">
<InputText
:id="id"
fluid
v-bind="attrs"
v-on="events"
/>
</template>
</AppFormField>
<AppFormField :name="`experience.${index}.position`" label="Position">
<template #default="{ attrs, events, id }">
<InputText
:id="id"
fluid
v-bind="attrs"
v-on="events"
/>
</template>
</AppFormField>
<AppFormField :name="`experience.${index}.start`" label="Start">
<template #default="{ value, attrs, events, id }">
<DatePicker
:id="id"
:model-value="new Date(value)"
date-format="yy-mm-dd"
fluid
v-bind="attrs"
@blur="events.blur"
@focus="events.focus"
@update:modelValue="(date) => events.change({value: date})"
/>
</template>
</AppFormField>
<ExperienceEndDateField
:name="`experience.${index}.end`"
:form="formCtrl"
/>
<div class="col-span-2 flex gap-2 items-right place-content-end">
<Button
severity="danger"
type="button"
:disabled="!canRemove"
@click="remove(index)"
icon="pi pi-trash"
/>
<Button
severity="secondary"
type="button"
@click="moveUp(index)"
icon="pi pi-arrow-up"
title="Move up"
/>
<Button
severity="secondary"
type="button"
@click="moveDown(index)"
icon="pi pi-arrow-down"
title="Move Down"
/>
</div>
</div>
<div class="mb-4">
<Button
severity="secondary"
type="button"
icon="pi pi-plus"
label="Add Experience"
@click="add({})"
:disabled="!canAdd"
class="mr-2"
/>
</div>
</template>
</HeadlessRepeatable>
</div>
<div class="col-start-1 col-end-4 flex gap-2">
<Button
severity="primary"
:loading="formCtrl.$isSubmitting.value"
:label="formCtrl.$isSubmitting.value ? 'Submitting...' : 'Submit'"
type="submit"
/>
<Button
severity="secondary"
:loading="formCtrl.$isSubmitting.value"
:label="'Reset'"
type="reset"
/>
{{formCtrl.$isSubmitting}}
</div>
</template>
</HeadlessForm>
</template>
<script setup>
import { HeadlessForm, HeadlessRepeatable } from '../../../src'
import { InputText, Select, Button, ToggleSwitch, DatePicker} from 'primevue'
import { ref } from 'vue'
import AppFormField from './AppFormField.vue'
import ExperienceEndDateField from './ExperienceEndDateField.vue'
import useFormConfig from './useFormConfig'
const {data, validator, submitHandler} = useFormConfig()
const $form = ref(null)
</script>vue
<!--
Component created to reduce the verbosity
of using <HeadlessField> in for each field
If you plan to use the <HeadlessField> components
this is the first thing to do in your app
-->
<template>
<div v-bind="$attrs">
<HeadlessField :name="name">
<template #default="{ model, value, attrs, error, events, id }">
<label
v-if="label"
:id="attrs['aria-labelledby']"
:for="id"
class="block" >
{{ label }}
</label>
<slot
:value="value"
:attrs="attrs"
:error="error"
:events="events"
:model="model"
:id="id"
/>
<div v-if="error"
:id="attrs['aria-errormessage']"
class="text-red-500">
{{ error }}
</div>
</template>
</HeadlessField>
</div>
</template>
<script setup>
import { HeadlessField } from '../../../src'
defineProps({
name: {
type: String,
required: true
},
label: {
type: String,
default: null
}
})
</script>vue
<!--
This custom field that is a wrapper for
a <HeadlessField> component that renders 2 input fields
We are nesting one HeadlessField inside each other because
1. the fields depend on each other (i.e. the datepicker's disable property
depends on the toggleswitch's value)
2. we want to show the error message for the date after the toggleswitch
-->
<template>
<div>
<!-- End date field with current field nested inside -->
<HeadlessField :name="endName">
<template #default="end">
<div>
<label :for="end.id">End</label>
<DatePicker
:id="end.id"
date-format="yy-mm-dd"
:model-value="end.value"
fluid
:disabled="isCurrentlyWorking"
v-bind="end.attrs"
v-on="end.events"
@update:modelValue="(date) => end.events.change({value: date})"
/>
<!-- Current position field nested inside end date field -->
<HeadlessField :name="currentName">
<template #default="current">
<div class="flex align-center mt-2">
<ToggleSwitch
:id="current.id"
class="me-2"
:model-value="current.value"
v-bind="current.attrs"
:true-value="true"
:false-value="false"
@change="(evt) => onChangeCurrent(evt.srcElement?.checked)"
/>
<span @click="onChangeCurrent(!current.value)">Currently working here</span>
</div>
</template>
</HeadlessField>
<!-- Error message after both fields -->
<div v-if="end.error"
:id="end.attrs['aria-errormessage']"
class="text-red-500">
{{ end.error }}
</div>
</div>
</template>
</HeadlessField>
</div>
</template>
<script setup>
import { HeadlessField } from '../../../src/index'
import { DatePicker, ToggleSwitch } from 'primevue'
import { computed } from 'vue'
const props = defineProps({
name: {
type: String,
required: true
},
form: {
type: Object,
required: true
}
})
const endName = props.name
const currentName = endName.replace('.end', '.current')
// Compute the current value to ensure it's always up-to-date
const isCurrentlyWorking = computed(() => props.form[currentName])
const onChangeCurrent = (value) => {
props.form.setFieldValue(currentName, value)
if (value) {
props.form.setFieldValue(endName, null)
props.form.validateField(endName, true)
}
}
</script>ts
import { createEncolaValidator } from '../../../src/validators/encolaValidator'
// this file contains a composable function to be reused for the examples
function getData() {
const data = {
name: "",
email: '',
address: {
country: '',
city: '',
},
available_date: null,
willing_to_relocate: false,
skills: [],
experience: [],
}
for (let i = 0; i < 3; i++) {
const level = ['Beginner', 'Intermediate', 'Advanced', 'Expert'].sort(() => Math.random() - 0.5)[0]
data.skills.push({ name: `Skill ${i + 1}`, level })
}
for (let i = 0; i < 2; i++) {
const level = ['Beginner', 'Intermediate', 'Advanced', 'Expert'].sort(() => Math.random() - 0.5)[0]
data.experience.push({
company: `Company ${i + 1}`,
position: `Job title ${i + 1}`,
start: new Date(`202${5 - i}-01-01`),
end: i === 0 ? null : new Date(`202${5 - 1}-12-31`),
current: i === 0
})
}
return data
}
// this validator is based on Encola Validator
// but it can be replaced with a validator that uses Zod, Valibot or Yup
const validator = createEncolaValidator(
{
name: 'required',
email: 'required|email',
'salary.min': 'number',
'salary.max': 'number|gt:@salary.min',
'available_date': 'required|date:yy-mm-dd|date_after:' + (new Date().toISOString().split('T')[0]),
'address.city': 'required',
'address.country': 'required',
'linkedin_profile': 'required|url',
'personal_site': 'url',
'skills.*.name': 'required',
'skills.*.level': 'required',
'experience.*.company': 'required',
'experience.*.position': 'required',
'experience.*.start': 'required|date:yy-mm-dd',
'experience.*.end': 'required_when:@experience.*.current,false|date:yy-mm-dd',
},
{
'name:required': 'You gotta have a name',
'salary.max:gt': 'The max should be greater than the min'
}
)
// For backwards compatibility, also export rules and messages separately
const rules = {
name: 'required',
email: {
'required|email': true
}
}
const messages = {
email: {
'required': 'Email is required',
'email': 'Please enter a valid email'
}
}
const submitHandler = (formData) => {
return new Promise((resolve) => {
setTimeout(() => {
alert('Data sent to server: ' + JSON.stringify(formData))
resolve(true)
}, 2000)
})
}
export default function () {
return {
data: getData(),
validator,
submitHandler
}
}