Custom Fields
Custom fields are fields that do not fit into the existing composable categories. In other words, they are a possibly complex widget that participates into your forms.
Such fields have no specific UI as they are your own creation. Those fields might be used in very specific business domains, you will see a few examples of these in the page.
Features
- Labels, descriptions, and error message displays are automatically linked to control and label elements with
aria-*
attributes. - Validation support with Standard Schema validation.
- Support for
v-model
binding.
Anatomy
Building a Custom Field Component
Now comes the fun part. Use your imagination to build a new kind of a field. This can be hard to do on demand, so we will instead build a “speak a sentence” field. Similar to what you would find in language learning apps like Duolingo.
We will start by importing the useCustomField
composable and using it in our custom field component.
The useCustomField
composable returns binding objects for the elements shown in the anatomy. You will use v-bind
to bind them to the corresponding DOM elements. The control
element is anything that qualifies as the main interactive part of the input. In other words, it is the visual part that the user interacts with to enter the value.
Now the value type of such a field could be a string
as in you detect what the user says on the microphone and you present it as a string. We can use the SpeechRecognition
API to detect what the user says on the microphone.
Alternatively, we can just build the full experience of the field and make it so that it auto checks what the user says and if it matches the sentence, it will be marked as correct. This means the value type of the field will be a boolean rather than a string. It is up to you and your requirements, but here we will go with a boolean type.
<script setup lang="ts">import { ref, computed } from 'vue';import { useCustomField, type CustomFieldProps } from '@formwerk/core';
const props = defineProps< CustomFieldProps & { sentence: string; }>();
const { labelProps, controlProps, setValue, errorMessage, errorMessageProps, setTouched, isTouched,} = useCustomField<boolean>(props);
const words = computed(() => props.sentence.split(' '));const results = ref<string[]>([]);const isListening = ref(false);const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
recognition.lang = 'en-US';recognition.interimResults = false;recognition.maxAlternatives = 1;
function onSpeakClick() { if (isListening.value) { stopListening(); return; }
results.value = []; isListening.value = true; recognition.start(); setTimeout(() => stopListening(true), 8000);}
function isAllSpoken() { return results.value.every((word) => words.value.includes(word));}
function stopListening(done = false) { recognition.stop(); isListening.value = false;
if (done) { setTouched(true); setValue(isAllSpoken()); }}
recognition.onresult = (event) => { results.value = event.results[0][0].transcript.toLowerCase().split(' '); setValue(isAllSpoken());};</script>
<template> <div class="speech-exercise"> <div class="challenge-container"> <div class="challenge-label" v-bind="labelProps">{{ label }}</div> <div class="challenge"> <span v-for="(word, idx) in words" :key="`word-${idx}`" class="challenge-word" :class="{ 'is-spoken': results.includes(word.toLowerCase()) }" > {{ word }} </span> </div> </div>
<button type="button" v-bind="controlProps" @click="onSpeakClick" :class="{ 'is-listening': isListening }" > <div class="button-content"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" /> <path d="M19 10v2a7 7 0 0 1-14 0v-2" /> <line x1="12" y1="19" x2="12" y2="23" /> <line x1="8" y1="23" x2="16" y2="23" /> </svg> {{ isListening ? 'Listening...' : 'Speak' }} </div> </button>
<div v-if="errorMessage && isTouched" v-bind="errorMessageProps" class="error" > {{ errorMessage }} </div> </div></template>
<style scoped>94 collapsed lines
.speech-exercise { background: white; border-radius: 16px;}
.challenge-container { margin-bottom: 2rem;}
.challenge { font-size: 1.2rem; font-weight: 700; color: #57534e; margin-bottom: 1rem; display: flex; flex-wrap: wrap; gap: 0.25rem;}
.challenge-label { font-size: 14px; color: #9ca3af; margin-bottom: 0.5rem;}
.challenge-word { &.is-spoken { color: #059669; }}
.result { font-size: 1.1rem; color: #4b5563;}
.result-label { color: #6b7280; font-size: 0.9rem; margin-right: 0.5rem;}
button { width: 100%; padding: 1rem; border: none; border-radius: 8px; background-color: #059669; color: white; font-size: 1.1rem; font-weight: 700; cursor: pointer; transition: all 0.2s ease;}
button:hover { background-color: #10b981; transform: translateY(-1px);}
button:active { transform: translateY(1px);}
.button-content { display: flex; align-items: center; justify-content: center; gap: 0.5rem;}
.is-listening { background-color: #ef4444; animation: pulse 2s infinite;}
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } 70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); } 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }}
.error { color: #ef4444; font-size: 14px; margin-top: 0.5rem;}</style>
Validation
useCustomField
supports Standard Schema validation through the schema
prop. This includes multiple providers like Zod, Valibot, Arktype, and more.
<script setup lang="ts">import SpeakField from './SpeakField.vue';import { z } from 'zod';
const schema = z.literal(true);</script>
<template> <SpeakField name="challenge" label="Say the following" :schema="schema" sentence="I went to the store" /></template>
Triggering Validation
Custom fields lack any signal to trigger validation. This is because we don’t know what kind of elements you will be using, so you will have to trigger validation manually via the validate
function exposed by the useCustomField
composable.
<script setup lang="ts">import { useCustomField, type CustomFieldProps } from '@formwerk/core';
const props = defineProps<CustomFieldProps>();
const { validate, setValue } = useCustomField(props);
function onValueChange(value: string) { setValue(value); validate();}</script>
Wrapping 3rd Party Components
Another use-case for custom fields is to wrap 3rd party components. Maybe you are using a UI library and would like to integrate it with Formwerk, custom fields are perfect for this since they have no expectations about the kind of markup you will use.
However, this means you will have to hook up the moving parts yourself. In the following example, we are wrapping a 3rd party text input component from PrimeVue.
The validation here is aggressive, it is up to you to decide when to show the error message.
Keep in mind that this is an inefficient use of Formwerk, here you only gain validation state and the form API, since most UI libraries already have their accessibility and interactions built in. But, if you need to, this is how you could do it.
Usage
Disabled
For custom fields, it is up to you to implement the disabled state. We don’t know what disabled means for your field, so we leave it up to you to implement it.
The disabled state is returned from useCustomField
as isDisabled
and it is one of the props accepted by the useCustomField
composable. It behaves the same as other fields’ disabled state, if a parent form or form group is disabled, the field will be disabled.
What Formwerk does however, is it will not validate the field when it is disabled and it will not submit the field when it is disabled either.
Readonly
For custom fields, it is up to you to implement the readonly state. We don’t know what readonly means for your field, so we leave it up to you to implement it.
Readonly fields are validated and submitted, but they do not accept user input. The field is still focusable, and the value is copyable in the case of text fields. So use this as your north star, make sure the field is focusable, but not editable.
API
Props
These are the properties that can be passed to the useCustomField
composable.
Name | Type | Description |
---|---|---|
description | Description text that provides additional context about the field. | |
disabled | Whether the field is disabled. | |
label | The label of the field. | |
modelValue | The v-model value of the field. | |
name | The name attribute of the input element. | |
readonly | Whether the field is readonly. | |
schema | Schema for field validation. | |
value | The initial static value of the field. |
Returns
These are the properties in the object returned by the useCustomField
composable.
Name | Type | Description |
---|---|---|
controlProps | Ref<{ 'aria-readonly': "true"; 'aria-disabled': "true"; id: string; 'aria-invalid': boolean; 'aria-errormessage': string; 'aria-describedby': string; 'aria-label'?: string; 'aria-labelledby'?: string; }> | Props for the control element/group. |
descriptionProps | Props for the description element. | |
displayError | Display the error message for the field. | |
errorMessage | The error message for the field. | |
errorMessageProps | Ref<{ 'aria-live': "polite"; 'aria-atomic': boolean; id: string; }> | Props for the error message element. |
errors | The errors for the field. | |
fieldValue | The value of the field. | |
isDirty | Whether the field is dirty. | |
isDisabled | Whether the field is disabled. | |
isTouched | Whether the field is touched. | |
isValid | Whether the field is valid. | |
labelProps | Props for the label element. | |
setErrors | (messages: Arrayable<string>) => void | Sets the errors for the field. |
setTouched | Sets the touched state for the field. | |
setValue | Sets the value for the field. | |
submitErrorMessage | The error message for the field from the last submit attempt. | |
submitErrors | The errors for the field from the last submit attempt. | |
validate | Validates the field. |