Skip to content

Create an input

An input in @duplojs/form is not directly a Vue component.

The exact sequence is:

  1. you write a Vue component compatible with the input contract;
  2. you turn it into a factory with createInput(...);
  3. you call that factory to get a FormField;
  4. that FormField is 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 a FormField, 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(...).

vue
vue
<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 dataParser or defineExpose.

Turn the component into an input with createInput

ts
ts
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.

ts
ts
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;
  • dataParser checks 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.

vue
vue
<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:

ts
ts
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 modelValue and update:modelValue;
  • applies the input template;
  • calls the check() exposed by the component if it exists;
  • then applies dataParser if one is provided;
  • manages a reactive error message for the template;
  • and finally exposes a FormField.

The important point is the order:

  1. local component validation, if it exists;
  2. validation/transformation through dataParser, if it exists.

Use inputs inside a form

ts
ts
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:

  • email is a simple input validated by dataParser;
  • terms is an input exposing its own validation through defineExpose;
  • the form composes both without any difference, because both return a FormField.

Implementation

vue
vue
<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(...):

  • defaultValue is required.
  • props defines the component's default props.
  • template changes the default rendering of the input.

For useMyInput(...):

  • label feeds the template.
  • defaultValue overrides the initial value.
  • props configures the local component instance.
  • dataParser validates or transforms the value.
  • class adds a class to the template container.
  • template locally 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;
  • dataParser is for business validation;
  • defineExpose is for component-specific behavioral validation;
  • both mechanisms can be combined.

Released under the MIT License.