UNPKG

@volverjs/form-vue

Version:

Vue 3 Forms with @volverjs/ui-vue

749 lines (628 loc) 21.9 kB
<div align="center"> [![volverjs](docs/static/volverjs-form.svg)](https://volverjs.github.io/form-vue) ## @volverjs/form-vue `form` `form-field` `form-wrapper` `vue3` `zod` `validation` [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=volverjs_form-vue&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=volverjs_form-vue) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=volverjs_form-vue&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=volverjs_form-vue) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=volverjs_form-vue&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=volverjs_form-vue) [![Depfu](https://badges.depfu.com/badges/e2c464e3cb95f98ee6a9a566dd44e0a9/status.svg)](https://depfu.com) [![Depfu](https://badges.depfu.com/badges/e2c464e3cb95f98ee6a9a566dd44e0a9/overview.svg)](https://depfu.com/github/volverjs/form-vue?project_id=38569) <br> maintained with ❤️ by <br> [![8 Wave](docs/static/8wave.svg)](https://8wave.it) <br> </div> ## Install ```bash # pnpm pnpm add @volverjs/form-vue # yarn yarn add @volverjs/form-vue # npm npm install @volverjs/form-vue --save ``` ## Usage `@volverjs/form-vue` allow you to create a Vue 3 form with [`@volverjs/ui-vue`](https://github.com/volverjs/ui-vue) components from a [Zod Object](https://zod.dev/?id=objects) schema. It provides two functions: `createForm()` and `useForm()`. ## Plugin `createForm()` defines globally three components `VvForm`, `VvFormWrapper`, and `VvFormField` through a [Vue 3 Plugin](https://vuejs.org/guide/reusability/plugins.html). ```typescript import { createApp } from 'vue' import { createForm } from '@volverjs/form-vue' import { z } from 'zod/v3' const schema = z.object({ firstName: z.string(), lastName: z.string() }) const app = createApp(App) const form = createForm({ schema // lazyLoad: boolean - default false // updateThrottle: number - default 500 // continuousValidation: boolean - default false // sideEffects?: (data: any) => void // scope?: string - Defines a unique scope for the form instance (singletons) // class?: new (data?: any) => Type - Type constructor for form data // Example: // class: class User { constructor(data?: any) { Object.assign(this, data) } } }) app.use(form) app.mount('#app') ``` If the schema is omitted, the plugin only share the options to the forms created with the [composable](https://github.com/volverjs/form-vue/#composable). ### VvForm `VvForm` render a `form` tag and emit a `submit` event. Form data are validated on submit. A `valid` or `invalid` event is emitted when the form status changes. ```vue <script lang="ts" setup> function onSubmit(formData) { // ... } function onInvalid(errors) { // ... } </script> <template> <VvForm @submit="onSubmit" @invalid="onInvalid"> <!-- ... --> <button type="submit"> Submit </button> </VvForm> </template> ``` The submit can be triggered programmatically with the `submit()` method. ```vue <script lang="ts" setup> import { ref } from 'vue' import type { FormComponent } from '@volverjs/form-vue' const formEl = ref<InstanceType<FormComponent>>(null) function onSubmit(formData) { // ... } function submitForm() { formEl.value.submit() } </script> <template> <VvForm ref="formEl" @submit="onSubmit"> <!-- ... --> </VvForm> <button type="button" @click.stop="submitForm"> Submit </button> </template> ``` Use the `v-model` directive (or only `:model-value` to set the initial value of form data) or bind the form data. The form data two way binding is **throttled** by default (500ms) to avoid performance issues. The throttle can be changed with the `updateThrottle` option or prop. By default form validation **stops** when a **valid state** is reached. To activate **continuous validation** use the `continuousValidation` option or prop. ```vue <script lang="ts" setup> import { ref } from 'vue' const formData = ref({ firstName: '', lastName: '' }) </script> <template> <VvForm v-model="formData" :update-throttle="1000" continuous-validation> <!-- ... --> </VvForm> </template> ``` ## Composable `useForm()` can be used to create a form programmatically inside a Vue 3 Component. The **default settings** are **inherited** from the plugin (if it's installed). ```vue <script lang="ts" setup> import { useForm } from '@volverjs/form-vue' import { z } from 'zod/v3' const schema = z.object({ firstName: z.string(), lastName: z.string() }) const { VvForm, VvFormWrapper, VvFormField } = useForm(schema, { // lazyLoad: boolean - default false // updateThrottle: number - default 500 // continuousValidation: boolean - default false // sideEffects?: (formData: any) => void // scope?: string // class?: new (data?: any) => Type }) </script> <template> <VvForm> <VvFormField type="text" name="firstName" label="First Name" /> <VvFormField type="text" name="lastName" label="Last Name" /> </VvForm> </template> ``` ### Outside a Vue 3 Component `useForm()` can create a form also outside a Vue 3 Component, plugin settings are **not inherited**. ```ts import { useForm } from '@volverjs/form-vue' import { z } from 'zod/v3' const schema = z.object({ firstName: z.string(), lastName: z.string() }) const { VvForm, VvFormWrapper, VvFormField, VvFormFieldsGroup, VvFormTemplate, formData, status, errors, wrappers } = useForm(schema, { lazyLoad: true }) export default { VvForm, VvFormWrapper, VvFormField, VvFormFieldsGroup, VvFormTemplate, formData, status, errors, wrappers } ``` ### VvFormWrapper `VvFormWrapper` gives you the validation status of a part of your form. The wrapper status is invalid if at least one of the fields inside it is invalid. ```vue <template> <VvForm> <VvFormWrapper v-slot="{ invalid }" name="firstSection"> <div class="form-section-1"> <span v-if="invalid">There is a validation error</span> <!-- form fields of section 1 --> </div> </VvFormWrapper> <VvFormWrapper v-slot="{ invalid }" name="secondSection"> <div class="form-section-2"> <span v-if="invalid">There is a validation error</span> <!-- form fields of the section 2 --> </div> </VvFormWrapper> </VvForm> </template> ``` `VvFormWrapper` can be used recursively to create a validation tree. The wrapper status is invalid if **at least one of the fields** inside it or one of its children **is invalid**. ```vue <template> <VvForm> <!-- main VvFormWrapper --> <VvFormWrapper v-slot="{ invalid }" name="firstSection"> <!-- add VvFormFields to wrapper --> <div class="form-section"> <span v-if="invalid">There is a validation error</span> <!-- nested VvFormWrapper --> <VvFormWrapper v-slot="{ invalid: groupInvalid }"> <div class="form-section__group"> <span v-if="groupInvalid">There is a validation error</span> <!-- add VvFormFields to nested wrapper --> </div> </VvFormWrapper> </div> </VvFormWrapper> </VvForm> </template> ``` The `wrappers` map provides access to form wrapper data. This allows for better control over form validation state and data management. ```vue <script setup> const { wrappers } = useForm(schema) // Access wrapper data const isFirstSectionInvalid = computed(() => wrappers.get('firstSection').invalid) </script> ``` ### VvFormField `VvFormField` allow you to render a form field or a [`@volverjs/ui-vue`](https://github.com/volverjs/ui-vue) input component inside a form. It automatically bind the form data through the `name` attribute. For nested objects, use the `name` attribute with **dot notation**. ```vue <template> <VvForm> <VvFormField v-slot="{ modelValue, invalid, invalidLabel, onUpdate }" name="lastName" > <label for="lastName">Last Name</label> <input id="lastName" type="text" name="lastName" :value="modelValue" :aria-invalid="invalid" :aria-errormessage="invalid ? 'last-name-alert' : undefined" @input="onUpdate" > <small v-if="invalid" id="last-name-alert" role="alert"> {{ invalidLabel }} </small> </VvFormField> </VvForm> </template> ``` To render a [`@volverjs/ui-vue`](https://github.com/volverjs/ui-vue) input component, use the `type` attribute. By default UI components must be installed globally, they can be lazy-loaded with `lazyLoad` option or prop. ```vue <template> <VvForm> <VvFormField type="text" name="username" label="Username" lazy-load /> <VvFormField type="password" name="password" label="Password" lazy-load /> </VvForm> </template> ``` Check the [`VvFormField` documentation](./docs/VvFormField.md) to learn more about form fields. ### VvFormFieldsGroup `VvFormFieldsGroup` allow you to render a group of form fields inside a form. It automatically bind the form data through the `names` attribute. For nested objects, use the `names` attribute with **dot notation**. ```vue <template> <VvForm> <VvFormFieldsGroup v-slot="{ modelValue, invalids, invalidLabels, onUpdateField }" :names="['firstName', 'lastName']" > <fieldset> <p> <label for="firstName">First Name</label> <input id="firstName" type="text" name="firstName" :value="modelValue.firstName" :aria-invalid="invalids.firstName" :aria-errormessage="invalids.firstName ? 'first-name-alert' : undefined" @input="onUpdateField('firstName', $event)" > <small v-if="invalids.firstName" id="first-name-alert" role="alert"> {{ invalidLabels?.firstName }} </small> </p> <p> <label for="lastName">Last Name</label> <input id="lastName" type="text" name="lastName" :value="modelValue.lastName" :aria-invalid="invalids.lastName" :aria-errormessage="invalids.lastName ? 'last-name-alert' : undefined" @input="onUpdateField('lastName', $event)" > <small v-if="invalids.lastName" id="last-name-alert" role="alert"> {{ invalidLabels?.lastName }} </small> </p> </fieldset> </VvFormFieldsGroup> </VvForm> </template> ``` Alternatively, you can create a custom component to render the group of form fields. ```vue // MyFieldsGroup.vue <script lang="ts" setup> defineProps<{ invalids: Record<string, boolean> invalidLabels?: Record<string, string[]> }>() // v-model:first-name const firstName = defineModel<string>('firstName', { default: '' }) // v-model:last-name const lastName = defineModel<string>('lastName', { default: '' }) </script> <template> <fieldset> <p> <label for="firstName">First Name</label> <input id="firstName" v-model="firstName" type="text" name="firstName" :aria-invalid="invalids.firstName" :aria-errormessage="invalids.firstName ? 'first-name-alert' : undefined" > <small v-if="invalids.firstName" id="first-name-alert" role="alert"> {{ invalidLabels?.firstName }} </small> </p> <p> <label for="lastName">Last Name</label> <input id="lastName" v-model="lastName" type="text" name="lastName" :aria-invalid="invalids.lastName" :aria-errormessage="invalids.lastName ? 'last-name-alert' : undefined" > <small v-if="invalids.lastName" id="last-name-alert" role="alert"> {{ invalidLabels?.lastName }} </small> </p> </fieldset> </template> ``` An than use it inside the `VvFormFieldsGroup` with the `:is` attribute. ```vue <script> import MyFieldsGroup from './MyFieldsGroup.vue' </script> <template> <VvForm> <VvFormFieldsGroup :is="MyFieldsGroup" :names="['firstName', 'lastName']" /> </VvForm> </template> ``` You can also map the form fields to the components v-models. The `:names` attribute can be an object with the component v-models as keys and the form fields names as values. ```vue <script> import MyCustomComponent from './MyCustomComponent.vue' </script> <template> <VvForm> <VvFormFieldsGroup :is="MyCustomComponent" :names="{ myCustomComponentVModel: 'path.to.form.field', }" /> </VvForm> </template> ``` ## VvFormTemplate Forms can also be created using a template. A template is an **array of objects** that describes the form fields. All properties that are **not listed** below are passed to the component **as props**. ```vue <script lang="ts" setup> import { useForm } from '@volverjs/form-vue' import { z } from 'zod/v3' const schema = z.object({ firstName: z.string(), lastName: z.string(), address: z.object({ street: z.string(), number: z.string(), city: z.string(), zip: z.number() }) }) const templateSchema = [ { vvName: 'firstName', vvType: 'text', label: 'First Name' }, { vvName: 'lastName', vvType: 'text', label: 'Last Name' }, { vvIs: 'div', class: 'grid grid-col-3 gap-4', vvChildren: [ { vvName: 'address.street', vvType: 'text', label: 'Street', class: 'col-span-2' }, { vvName: 'address.number', vvType: 'text', label: 'Number' }, { vvName: 'address.city', vvType: 'text', label: 'City', class: 'col-span-2', }, { vvName: 'address.zip', vvType: 'number', label: 'Zip' } ] } ] const { VvForm, VvFormTemplate } = useForm(schema) </script> <template> <VvForm> <VvFormTemplate :schema="templateSchema" /> </VvForm> </template> ``` Template items, by default, are rendered as a `VvFormField` component but this can be changed using the `vvIs` property. The `vvIs` property can be a string or a component. `vvName` refers to the name of the field in the schema and can be a nested property using **dot notation**. `vvType` refers to the type of the field and can be any of the supported [types](./docs/VvFormField.md#ui-components). `vvDefaultValue` can be used to set default values for the form item. `vvShowValid` can be used to show the valid state of the form item. `vvSlots` can be used to pass a slots to the template item. `vvChildren` is an array of template items which will be wrapped in the parent item. Conditional rendering can be achieved using the `vvIf` and `vvElseIf` properties. ```vue <script lang="ts" setup> import { useForm } from '@volverjs/form-vue' import { z } from 'zod/v3' const schema = z.object({ firstName: z.string(), lastName: z.string(), hasUsername: z.boolean(), username: z.string().optional(), email: z.string().email().optional() }).superRefine((value, ctx) => { if (value.hasUsername && !value.username) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Username is required' }) } if (!value.hasUsername && !value.email) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Email is required' }) } }) const templateSchema = [ { vvName: 'firstName', vvType: 'text', label: 'First Name' }, { vvName: 'lastName', vvType: 'text', label: 'Last Name' }, { vvName: 'hasUsername', vvType: 'checkbox', label: 'Has Username', value: true, uncheckedValue: false }, { vvIf: 'hasUsername', vvName: 'username', vvType: 'text', label: 'Username' }, { vvElseIf: true, vvName: 'email', vvType: 'email', label: 'Email' } ] const { VvForm, VvFormTemplate } = useForm(schema) </script> <template> <VvForm> <VvFormTemplate :schema="templateSchema" /> </VvForm> </template> ``` `vvElseIf` can be used multiple times. `vvElseIf: true` is like an `else` statement and will be rendered if all previous `vvIf` and `vvElseIf` conditions are false. `vvIf` and `vvElseIf` can be a string or a function. If it is a string it will be evaluated as a **property** of the form data. If it is a function it will be called with the **form context** as the **first argument** and must return a boolean. ```ts const templateSchema = [ { vvIf: ctx => ctx.formData.value.hasUsername, vvName: 'username', vvType: 'text', label: 'Username' } ] ``` Also the template schema and all template items can be a function. The function will be called with the **form context** as the **first argument**. ```ts function templateSchema(ctx) { return [ { vvName: 'firstName', vvType: 'text', label: `Hi ${ctx.formData.value.firstName}!` } ] } ``` ```ts const templateSchema = [ ctx => ({ vvName: 'firstName', vvType: 'text', label: `Hi ${ctx.formData.value.firstName}!` }), { vvName: 'username', type: 'text', label: 'username' } ] ``` ## Default Object by Zod Schema `defaultObjectBySchema` creates an object by a [Zod Object Schema](https://zod.dev/?id=objects). It can be useful to create a **default object** for a **form**. The default object is created by the default values of the schema and can be merged with an other object passed as parameter. ```ts import { z } from 'zod/v3' import { defaultObjectBySchema } from '@volverjs/form-vue' const schema = z.object({ firstName: z.string().default('John'), lastName: z.string().default('Doe') }) const defaultObject = defaultObjectBySchema(schema) // defaultObject = { firstName: 'John', lastName: 'Doe' } const defaultObject = defaultObjectBySchema(schema, { name: 'Jane' }) // defaultObject = { firstName: 'Jane', lastName: 'Doe' } ``` `defaultObjectBySchema` can be used with nested objects. ```ts import { z } from 'zod/v3' import { defaultObjectBySchema } from '@volverjs/form-vue' const schema = z.object({ firstName: z.string().default('John'), lastName: z.string().default('Doe'), address: z.object({ street: z.string().default('Main Street'), number: z.number().default(1) }) }) const defaultObject = defaultObjectBySchema(schema) // defaultObject = { firstName: 'John', lastName: 'Doe', address: { street: 'Main Street', number: 1 } } ``` Other Zod methods are also supported: [`z.nullable()`](https://github.com/colinhacks/zod#nullable), [`z.coerce`](https://github.com/colinhacks/zod#coercion-for-primitives) and [`z.passthrough()`](https://github.com/colinhacks/zod#passthrough). ```ts import { z } from 'zod/v3' import { defaultObjectBySchema } from '@volverjs/form-vue' const schema = z .object({ firstName: z.string().default('John'), lastName: z.string().default('Doe'), address: z.object({ street: z.string().default('Main Street'), number: z.number().default(1) }), age: z.number().nullable().default(null), height: z.coerce.number().default(1.8), weight: z.number().default(80) }) .passthrough() const defaultObject = defaultObjectBySchema(schema, { height: '1.9', email: 'john.doe@test.com' }) // defaultObject = { firstName: 'John', lastName: 'Doe', address: { street: 'Main Street', number: 1 }, age: null, height: 1.9, weight: 80, email: 'john.doe@test.com' } ``` ## Zod 4 `@volverjs/form-vue` supports Zod 4 from `zod@3.25.x` and `zod@4.x.x`. All features and methods are compatible and the usage remains the same, the library automatically detects the Zod version and adapts accordingly. ```typescript import * as z from 'zod/v4' import { createForm, useForm, defaultObjectBySchema } from '@volverjs/form-vue' const schema = z.object({ firstName: z.string(), lastName: z.string() }) // Plugin const form = createForm({ schema }) // Composable const { VvForm, VvFormWrapper, VvFormField } = useForm(schema) // Default Object by Zod Schema const defaultObject = defaultObjectBySchema(schema, { firstName: 'Jane' }) ``` ## License [MIT](http://opensource.org/licenses/MIT)