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.