Create an input
An input in @duplojs/form is not directly a Vue component.
The exact sequence is:
- you write a Vue component compatible with the input contract;
- you turn it into a factory with
createInput(...); - you call that factory to get a
FormField; - that
FormFieldis then composed into a form.
This separation matters:
- the Vue component handles the UI and, if needed, local validation;
createInput(...)builds a reusable factory;useMyInput(...)returns aFormField, which means a form building block.
The minimal contract of an input component
A compatible component must at least:
- accept
modelValue; - emit
update:modelValue; - let the library inject an
id.
In practice, with Vue, the easiest way is to use defineModel(...).
<script setup lang="ts">
export interface Props {
placeholder?: string;
}
const props = defineProps<Props>();
const model = defineModel<string>({ required: true });
</script>
<template>
<input
v-model="model"
type="text"
:placeholder="props.placeholder"
/>
</template>This component is intentionally minimal:
- it does not validate anything by itself;
- it only relays the value;
- validation can be added later through
dataParserordefineExpose.
Turn the component into an input with createInput
import { createInput } from "@duplojs/form/vue";
import BasicTextInput from "./BasicTextInput.vue";
export const useBasicTextInput = createInput(
BasicTextInput,
{
defaultValue: "",
},
);createInput(component, defaultParams) returns a function of type useBasicTextInput(...).
The important defaultParams entries are:
defaultValue: default value of the input. This is required.props: default props injected into the component.template: default input template to use for rendering.
defaultValue can be a direct value or a function. Use a function whenever the value is dynamic or should be recreated cleanly.
Instantiate an input
Once the factory is created, you call it to produce a FormField.
import { DP } from "@duplojs/utils";
import { useBasicTextInput } from "./basicTextInput";
export const emailField = useBasicTextInput({
label: "Email",
props: {
placeholder: "[email protected]",
},
dataParser: DP.string()
.addChecker(
DP.checkerStringMin(
5,
{ errorMessage: "Email is too short." },
),
),
});The important useBasicTextInput(...) parameters are:
label: label forwarded to the input template.defaultValue: local override for the default value.props: props passed to the component.dataParser: validation and transformation of the value.class: CSS class added to the input template.template: local override of the rendering template.
Where validation happens
There are two possible levels of validation.
External validation with dataParser
If your component already emits the right value shape, let dataParser handle business validation.
Common example:
- a text component emits a
string; dataParserchecks length, format, then returns the validated value.
When dataParser fails:
check()returns an error;- the message of the first issue is exposed to the template through
getErrorMessage().
Internal validation with defineExpose
If the component needs to control its own validity, it can expose check, reset, and dispose.
<script setup lang="ts">
import * as EE from "@duplojs/utils/either";
import { type ExposeInputProperties } from "@duplojs/form/vue";
import { ref } from "vue";
export interface Props {
id: string;
label: string;
required?: boolean;
errorMessage?: string;
}
const props = withDefaults(
defineProps<Props>(),
{
required: false,
errorMessage: "You must accept this condition.",
},
);
const model = defineModel<boolean>({ default: false });
const currentError = ref<string | null>(null);
defineExpose<ExposeInputProperties>({
check: () => {
if (!props.required || model.value) {
currentError.value = null;
return EE.success(model.value);
}
currentError.value = props.errorMessage;
return EE.error([{ key: props.id }]);
},
reset: () => {
currentError.value = null;
},
});
</script>
<template>
<div>
<label>
<input
v-model="model"
type="checkbox"
:id="props.id"
/>
{{ props.label }}
</label>
<small v-if="currentError">
{{ currentError }}
</small>
</div>
</template>Here, the component:
- keeps its own local error message;
- blocks
check()until the required checkbox is checked; - clears its error state in
reset().
Then the input factory remains very simple:
import { createInput } from "@duplojs/form/vue";
import PolicyCheckbox from "./PolicyCheckbox.vue";
export const usePolicyCheckbox = createInput(
PolicyCheckbox,
{
defaultValue: false,
props: {
label: "Accept terms",
},
},
);What createInput really does
When an input is instantiated, the library:
- determines the effective
defaultValue; - mounts the Vue component with
modelValueandupdate:modelValue; - applies the input template;
- calls the
check()exposed by the component if it exists; - then applies
dataParserif one is provided; - manages a reactive error message for the template;
- and finally exposes a
FormField.
The important point is the order:
- local component validation, if it exists;
- validation/transformation through
dataParser, if it exists.
Use inputs inside a form
import { useMultiLayout, type GetCheckedValue } from "@duplojs/form/vue";
import * as DP from "@duplojs/utils/dataParser";
import { useForm } from "./init";
import { useBasicTextInput } from "./basicTextInput";
import { usePolicyCheckbox } from "./policyCheckbox";
export function useContactForm() {
const { component, check, reset, currentValue, dispose } = useForm(
useMultiLayout({
email: useBasicTextInput({
label: "Email",
props: {
placeholder: "[email protected]",
},
dataParser: DP.string()
.addChecker(
DP.checkerStringMin(
5,
{ errorMessage: "Email is too short." },
),
),
}),
terms: usePolicyCheckbox({
props: {
label: "I accept the terms of use",
required: true,
},
}),
}),
);
return {
ContactForm: component,
checkContactForm: check,
currentContactValue: currentValue,
resetContactForm: reset,
disposeContactForm: dispose,
};
}
export type ContactFormSubmitValue = GetCheckedValue<
ReturnType<typeof useContactForm>["checkContactForm"]
>;In this example:
emailis a simple input validated bydataParser;termsis an input exposing its own validation throughdefineExpose;- the form composes both without any difference, because both return a
FormField.
Implementation
<script setup lang="ts">
import * as EE from "@duplojs/utils/either";
import { unwrap } from "@duplojs/utils";
import { OutlineButton, PrimaryButton } from "@duplojs/form/vueDesignSystem";
import { onBeforeUnmount, ref } from "vue";
import {
type ContactFormSubmitValue,
useContactForm,
} from "./contactForm";
const {
ContactForm,
checkContactForm,
currentContactValue,
resetContactForm,
disposeContactForm,
} = useContactForm();
const submitResult = ref<ContactFormSubmitValue | null>(null);
function submit() {
const result = checkContactForm();
if (EE.isRight(result)) {
submitResult.value = unwrap(result);
}
}
function reset() {
resetContactForm();
submitResult.value = null;
}
onBeforeUnmount(disposeContactForm);
</script>
<template>
<ContactForm @submit="submit">
<div style="display: flex; gap: 8px; margin-top: 16px;">
<PrimaryButton
type="submit"
label="Submit"
/>
<OutlineButton
type="button"
label="Reset"
@click="reset"
/>
</div>
</ContactForm>
<pre>currentValue: {{ currentContactValue }}</pre>
<pre>checkedValue: {{ submitResult }}</pre>
</template>Result
currentValue: {
"email": "",
"terms": false
}checkedValue:
Parameters to remember
For createInput(...):
defaultValueis required.propsdefines the component's default props.templatechanges the default rendering of the input.
For useMyInput(...):
labelfeeds the template.defaultValueoverrides the initial value.propsconfigures the local component instance.dataParservalidates or transforms the value.classadds a class to the template container.templatelocally replaces the input template.
What to remember
- a Vue input component is not yet a
FormField; createInput(...)builds a factory;- calling that factory returns a
FormField; dataParseris for business validation;defineExposeis for component-specific behavioral validation;- both mechanisms can be combined.
