Skip to content

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

Label
Label
???
Control
Help text
Description or Error Message

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 SpeakField from './SpeakField.vue';
</script>
<template>
<SpeakField name="challenge" label="Say the following" sentence="I went to the store" />
</template>
<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.

NameTypeDescription
descriptionDescription text that provides additional context about the field.
disabledWhether the field is disabled.
labelThe label of the field.
modelValueThe v-model value of the field.
nameThe name attribute of the input element.
readonlyWhether the field is readonly.
schemaSchema for field validation.
valueThe initial static value of the field.

Returns

These are the properties in the object returned by the useCustomField composable.

NameTypeDescription
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.
descriptionPropsProps for the description element.
displayErrorDisplay the error message for the field.
errorMessageThe error message for the field.
errorMessageProps
Ref<{ 'aria-live': "polite"; 'aria-atomic': boolean; id: string; }>
Props for the error message element.
errorsThe errors for the field.
fieldValueThe value of the field.
isDirtyWhether the field is dirty.
isDisabledWhether the field is disabled.
isTouchedWhether the field is touched.
isValidWhether the field is valid.
labelPropsProps for the label element.
setErrors
(messages: Arrayable<string>) => void
Sets the errors for the field.
setTouchedSets the touched state for the field.
setValueSets the value for the field.
submitErrorMessageThe error message for the field from the last submit attempt.
submitErrorsThe errors for the field from the last submit attempt.
validateValidates the field.