feat(Form): new component (#439)

Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
Romain Hamel
2023-07-31 15:22:14 +02:00
committed by GitHub
parent c37a927b4e
commit a3aba1abad
22 changed files with 945 additions and 17 deletions

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { FormError } from '@nuxthq/ui/dist/runtime/types'
const state = ref({
email: undefined,
password: undefined
})
const validate = (state: any): FormError[] => {
const errors = []
if (!state.email) errors.push({ path: 'email', message: 'Required' })
if (!state.password) errors.push({ path: 'password', message: 'Required' })
return errors
}
const form = ref()
async function submit () {
await form.value!.validate()
// Do something with state.value
}
</script>
<template>
<UForm
ref="form"
:validate="validate"
:state="state"
@submit.prevent="submit"
>
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<UButton type="submit">
Submit
</UButton>
</UForm>
</template>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref } from 'vue'
import { z } from 'zod'
import type { Form } from '@nuxthq/ui/dist/runtime/types'
const options = [
{ label: 'Option 1', value: 'option-1' },
{ label: 'Option 2', value: 'option-2' },
{ label: 'Option 3', value: 'option-3' }
]
const state = ref({
input: undefined,
textarea: undefined,
select: undefined,
selectMenu: undefined,
checkbox: undefined,
toggle: undefined,
radio: undefined,
switch: undefined,
range: undefined
})
const schema = z.object({
input: z.string().min(10),
textarea: z.string().min(10),
select: z.string().refine(value => value === 'option-2', {
message: 'Select Option 2'
}),
selectMenu: z.any().refine(option => option?.value === 'option-2', {
message: 'Select Option 2'
}),
toggle: z.boolean().refine(value => value === true, {
message: 'Toggle me'
}),
checkbox: z.boolean().refine(value => value === true, {
message: 'Check me'
}),
radio: z.string().refine(value => value === 'option-2', {
message: 'Select Option 2'
}),
range: z.number().max(20, { message: 'Must be less than 20' })
})
type Schema = z.infer<typeof schema>
const form = ref<Form<Schema>>()
async function submit () {
await form.value!.validate()
// Do something with state.value
}
</script>
<template>
<UForm
ref="form"
:schema="schema"
:state="state"
@submit.prevent="submit"
>
<UFormGroup name="input" label="Input">
<UInput v-model="state.input" />
</UFormGroup>
<UFormGroup name="textarea" label="Textarea">
<UTextarea v-model="state.textarea" />
</UFormGroup>
<UFormGroup name="select" label="Select">
<USelect v-model="state.select" placeholder="Select..." :options="options" />
</UFormGroup>
<UFormGroup name="selectMenu" label="Select Menu">
<USelectMenu v-model="state.selectMenu" placeholder="Select..." :options="options" />
</UFormGroup>
<UFormGroup name="toggle" label="Toggle">
<UToggle v-model="state.toggle" />
</UFormGroup>
<UFormGroup name="checkbox" label="Checkbox">
<UCheckbox v-model="state.checkbox" />
</UFormGroup>
<UFormGroup name="radio" label="Radio">
<URadio v-for="option in options" :key="option.value" v-model="state.radio" v-bind="option">
{{ option.label }}
</URadio>
</UFormGroup>
<UFormGroup name="range" label="Range">
<URange v-model="state.range" />
</UFormGroup>
<UButton type="submit">
Submit
</UButton>
</UForm>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { ref } from 'vue'
import Joi from 'joi'
const schema = Joi.object({
email: Joi.string().required(),
password: Joi.string()
.min(8)
.required()
})
const state = ref({
email: undefined,
password: undefined
})
const form = ref()
async function submit () {
await form.value!.validate()
// Do something with state.value
}
</script>
<template>
<UForm
ref="form"
:schema="schema"
:state="state"
@submit.prevent="submit"
>
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<UButton type="submit">
Submit
</UButton>
</UForm>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { ref } from 'vue'
import { object, string, InferType } from 'yup'
import type { Form } from '@nuxthq/ui/dist/runtime/types'
const schema = object({
email: string().email('Invalid email').required('Required'),
password: string()
.min(8, 'Must be at least 8 characters')
.required('Required')
})
type Schema = InferType<typeof schema>
const state = ref({
email: undefined,
password: undefined
})
const form = ref<Form<Schema>>()
async function submit () {
await form.value!.validate()
// Do something with state.value
}
</script>
<template>
<UForm
ref="form"
:schema="schema"
:state="state"
@submit.prevent="submit"
>
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<UButton type="submit">
Submit
</UButton>
</UForm>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { ref } from 'vue'
import { z } from 'zod'
import type { Form } from '@nuxthq/ui/dist/runtime/types'
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Must be at least 8 characters')
})
type Schema = z.output<typeof schema>
const state = ref({
email: undefined,
password: undefined
})
const form = ref<Form<Schema>>()
async function submit () {
await form.value!.validate()
// Do something with state.value
}
</script>
<template>
<UForm
ref="form"
:schema="schema"
:state="state"
@submit.prevent="submit"
>
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<UButton type="submit">
Submit
</UButton>
</UForm>
</template>

View File

@@ -0,0 +1,325 @@
---
description: Collect and validate form data.
links:
- label: GitHub
icon: i-simple-icons-github
to: https://github.com/nuxtlabs/ui/blob/dev/src/runtime/components/forms/Form.ts
navigation:
badge: Edge
---
## Usage
Use the Form component to validate form data using schema libraries such as [Yup](https://github.com/jquense/yup), [Zod](https://github.com/colinhacks/zod), [Joi](https://github.com/hapijs/joi) or your own validation logic. It works seamlessly with the FormGroup component to automatically display error messages around form elements.
The Form component requires the `validate` and `state` props for form validation.
- `state` - a reactive object that holds the current state of the form.
- `validate` - a function that takes the form's state as input and returns an array of `FormError` objects with the following fields:
- `message` - the error message to display.
- `path` - the path to the form element matching the `name`.
::component-example
#default
:form-example-basic{class="space-y-4 w-60"}
#code
```vue
<script setup lang="ts">
import type { FormError } from '@nuxthq/ui/dist/runtime/types'
const state = ref({
email: undefined,
password: undefined
})
const validate = (state: any): FormError[] => {
const errors = []
if (!state.email) errors.push({ path: 'email', message: 'Required' })
if (!state.password) errors.push({ path: 'password', message: 'Required' })
return errors
}
const form = ref()
async function submit () {
await form.value!.validate()
// Do something with state.value
}
</script>
<template>
<UForm
ref="form"
:validate="validate"
:state="state"
@submit.prevent="submit"
>
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<UButton type="submit">
Submit
</UButton>
</UForm>
</template>
```
::
## Schema
You can provide a schema from [Yup](#yup), [Zod](#zod) or [Joi](#joi) through the `schema` prop to validate the state. It's important to note that **none of these libraries are included** by default, so make sure to **install the one you need**.
### Yup
::component-example
#default
:form-example-yup{class="space-y-4 w-60"}
#code
```vue
<script setup lang="ts">
import { object, string, InferType } from 'yup'
const schema = object({
email: string().email('Invalid email').required('Required'),
password: string()
.min(8, 'Must be at least 8 characters')
.required('Required')
})
const state = ref({
email: undefined,
password: undefined
})
const form = ref()
async function submit () {
await form.value!.validate()
// Do something with state.value
}
</script>
<template>
<UForm
ref="form"
:schema="schema"
:state="state"
@submit.prevent="submit"
>
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<UButton type="submit">
Submit
</UButton>
</UForm>
</template>
```
::
### Zod
::component-example
#default
:form-example-zod{class="space-y-4 w-60"}
#code
```vue
<script setup lang="ts">
import { z } from 'zod'
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Must be at least 8 characters')
})
const state = ref({
email: undefined,
password: undefined
})
const form = ref()
async function submit () {
await form.value!.validate()
// Do something with state.value
}
</script>
<template>
<UForm
ref="form"
:schema="schema"
:state="state"
@submit.prevent="submit"
>
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<UButton type="submit">
Submit
</UButton>
</UForm>
</template>
```
::
### Joi
::component-example
#default
:form-example-joi{class="space-y-4 w-60"}
#code
```vue
<script setup lang="ts">
import Joi from 'joi'
import type { Schema } from 'joi'
const schema = Joi.object({
email: Joi.string().required(),
password: Joi.string()
.min(8)
.required()
})
const state = ref({
email: undefined,
password: undefined
})
async function submit () {
await form.value!.validate()
// Do something with state.value
}
</script>
<template>
<UForm
ref="form"
:schema="schema"
:state="state"
@submit.prevent="submit"
>
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<UButton type="submit">
Submit
</UButton>
</UForm>
</template>
```
::
## Type Inference
You can utilize Zod and Yup's type inference feature to automatically infer the types of your schema and form data. This allows for strong type checking and better code validation, reducing the likelihood of errors.
```vue
<script setup lang="ts">
import type { Form } from '@nuxthq/ui/dist/runtime/types'
// [...]
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Must be at least 8 characters')
})
const state: Partial<Schema> = ref({
email: undefined,
password: undefined
})
type Schema = z.output<typeof schema>
// For Yup, use:
// type Schema = InferType<typeof schema>
const form = ref<Form<Schema>>()
async function submit() {
const data: Schema = await form.value!.validate()
// Do something with data
}
</script>
// [...]
```
## Other libraries
For other validation libraries, you can define your own component with custom validation logic.
Here is an example with [Vuelidate](https://github.com/vuelidate/vuelidate):
```vue
<script setup lang="ts">
import useVuelidate from '@vuelidate/core';
const props = defineProps({
rules: { type: Object, required: true },
model: { type: Object, required: true },
});
const form = ref();
const v = useVuelidate(props.rules, props.model);
async function validateWithVuelidate() {
v.value.$touch();
await v.value.$validate();
return v.value.$errors.map((error) => ({
message: error.$message,
path: error.$propertyPath,
}));
}
defineExpose({
validate: async () => {
await form.value.validate();
},
});
</script>
<template>
<UForm
ref="form"
:model="model"
@validate="validateWithVuelidate"
>
<slot />
</UForm>
</template>
```
## Input events
The Form component automatically triggers validation upon input `blur` or `change` events. This ensures that any errors are displayed as soon as the user interacts with the form elements.
::component-example
#default
:form-example-elements{class="space-y-4 w-60"}
::
## Props
:component-props

View File

@@ -21,6 +21,9 @@
"nuxt-component-meta": "^0.5.3",
"nuxt-lodash": "^2.5.0",
"typescript": "^5.1.6",
"v-calendar": "^3.0.3"
"v-calendar": "^3.0.3",
"yup": "^1.2.0",
"joi": "^17.9.2",
"zod": "^3.21.4"
}
}

View File

@@ -58,6 +58,9 @@
"release-it": "^16.1.3",
"typescript": "^5.1.6",
"unbuild": "^1.2.1",
"vue-tsc": "^1.8.8"
"vue-tsc": "^1.8.8",
"yup": "^1.2.0",
"joi": "^17.9.2",
"zod": "^3.21.4"
}
}

43
pnpm-lock.yaml generated
View File

@@ -71,6 +71,9 @@ importers:
eslint:
specifier: ^8.45.0
version: 8.45.0
joi:
specifier: ^17.9.2
version: 17.9.2
nuxt:
specifier: ^3.6.5
version: 3.6.5(@types/node@20.4.5)(eslint@8.45.0)(rollup@3.26.2)(typescript@5.1.6)(vue-tsc@1.8.8)
@@ -86,6 +89,12 @@ importers:
vue-tsc:
specifier: ^1.8.8
version: 1.8.8(typescript@5.1.6)
yup:
specifier: ^1.2.0
version: 1.2.0
zod:
specifier: ^3.21.4
version: 3.21.4
docs:
dependencies:
@@ -129,6 +138,9 @@ importers:
eslint:
specifier: ^8.45.0
version: 8.45.0
joi:
specifier: ^17.9.2
version: 17.9.2
nuxt:
specifier: ^3.6.5
version: 3.6.5(@types/node@20.4.5)(eslint@8.45.0)(rollup@3.26.2)(typescript@5.1.6)(vue-tsc@1.8.8)
@@ -144,6 +156,12 @@ importers:
v-calendar:
specifier: ^3.0.3
version: 3.0.3(@popperjs/core@2.11.8)(vue@3.3.4)
yup:
specifier: ^1.2.0
version: 1.2.0
zod:
specifier: ^3.21.4
version: 3.21.4
packages:
@@ -9677,6 +9695,10 @@ packages:
sisteransi: 1.0.5
dev: true
/property-expr@2.0.5:
resolution: {integrity: sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==}
dev: true
/property-information@6.2.0:
resolution: {integrity: sha512-kma4U7AFCTwpqq5twzC1YVIDXSqg6qQK6JN0smOw8fgRy1OkMi0CYSzFmsy6dnqSenamAtj0CyXMUJ1Mf6oROg==}
dev: true
@@ -11002,6 +11024,10 @@ packages:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
dev: true
/tiny-case@1.0.3:
resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==}
dev: true
/tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
dev: true
@@ -11040,6 +11066,10 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
/toposort@2.0.2:
resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==}
dev: true
/totalist@3.0.1:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
@@ -12388,6 +12418,15 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
/yup@1.2.0:
resolution: {integrity: sha512-PPqYKSAXjpRCgLgLKVGPA33v5c/WgEx3wi6NFjIiegz90zSwyMpvTFp/uGcVnnbx6to28pgnzp/q8ih3QRjLMQ==}
dependencies:
property-expr: 2.0.5
tiny-case: 1.0.3
toposort: 2.0.2
type-fest: 2.19.0
dev: true
/zhead@2.0.9:
resolution: {integrity: sha512-Y3g6EegQc6PVrYXPq2OS7/s27UGVS5Y6NY6SY3XGH4Hg+yQWbQTtWsjCgmpR8kZnYrv8auB54sz+x5FEDrvqzQ==}
dev: true
@@ -12401,6 +12440,10 @@ packages:
readable-stream: 3.6.2
dev: true
/zod@3.21.4:
resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
dev: true
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: true

View File

@@ -14,6 +14,7 @@
class="form-checkbox"
:class="inputClass"
v-bind="$attrs"
@change="onChange"
>
</div>
<div v-if="label || $slots.label" class="ms-3 text-sm">
@@ -33,6 +34,7 @@ import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { classNames } from '../../utils'
import { useFormEvents } from '../../composables/useFormEvents'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -91,13 +93,15 @@ export default defineComponent({
default: () => appConfig.ui.checkbox
}
},
emits: ['update:modelValue'],
emits: ['update:modelValue', 'change'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.checkbox>>(() => defu({}, props.ui, appConfig.ui.checkbox))
const { emitFormBlur } = useFormEvents()
const toggle = computed({
get () {
return props.modelValue
@@ -107,6 +111,11 @@ export default defineComponent({
}
})
const onChange = (event: Event) => {
emit('change', event)
emitFormBlur()
}
const inputClass = computed(() => {
return classNames(
ui.value.base,
@@ -122,7 +131,8 @@ export default defineComponent({
// eslint-disable-next-line vue/no-dupe-keys
ui,
toggle,
inputClass
inputClass,
onChange
}
}
})

View File

@@ -0,0 +1,160 @@
import { provide, ref, type PropType, h, defineComponent } from 'vue'
import { useEventBus } from '@vueuse/core'
import type { ZodSchema, ZodError } from 'zod'
import type { ValidationError as JoiError, Schema as JoiSchema } from 'joi'
import type { ObjectSchema as YupObjectSchema, ValidationError as YupError } from 'yup'
import type { FormError, FormEvent } from '../../types'
export default defineComponent({
props: {
schema: {
type: Object as
| PropType<ZodSchema>
| PropType<YupObjectSchema<any>>
| PropType<JoiSchema>,
default: undefined
},
state: {
type: Object,
required: true
},
validate: {
type: Function as PropType<(state: any) => Promise<FormError[]>> | PropType<(state: any) => FormError[]>,
default: () => []
}
},
setup (props, { slots, expose }) {
const seed = Math.random().toString(36).substring(7)
const bus = useEventBus<FormEvent>(`form-${seed}`)
bus.on(async (event) => {
if (event.type === 'blur') {
const otherErrors = errors.value.filter(
(error) => error.path !== event.path
)
const pathErrors = (await getErrors()).filter(
(error) => error.path === event.path
)
errors.value = otherErrors.concat(pathErrors)
}
})
const errors = ref<FormError[]>([])
provide('form-errors', errors)
provide('form-events', bus)
async function getErrors (): Promise<FormError[]> {
let errs = await props.validate(props.state)
if (props.schema) {
if (isZodSchema(props.schema)) {
errs = errs.concat(await getZodErrors(props.state, props.schema))
} else if (isYupSchema(props.schema)) {
errs = errs.concat(await getYupErrors(props.state, props.schema))
} else if (isJoiSchema(props.schema)) {
errs = errs.concat(await getJoiErrors(props.state, props.schema))
} else {
throw new Error('Form validation failed: Unsupported form schema')
}
}
return errs
}
async function validate () {
errors.value = await getErrors()
if (errors.value.length > 0) {
throw new Error(
`Form validation failed: ${JSON.stringify(errors.value, null, 2)}`
)
}
}
expose({
validate
})
return () => h('form', slots.default?.())
}
})
function isYupSchema (schema: any): schema is YupObjectSchema<any> {
return schema.validate && schema.__isYupSchema__
}
function isYupError (error: any): error is YupError {
return error.inner !== undefined
}
async function getYupErrors (
state: any,
schema: YupObjectSchema<any>
): Promise<FormError[]> {
try {
await schema.validate(state, { abortEarly: false })
return []
} catch (error) {
if (isYupError(error)) {
return error.inner.map((issue) => ({
path: issue.path ?? '',
message: issue.message
}))
} else {
throw error
}
}
}
function isZodSchema (schema: any): schema is ZodSchema {
return schema.parse !== undefined
}
function isZodError (error: any): error is ZodError {
return error.issues !== undefined
}
async function getZodErrors (
state: any,
schema: ZodSchema
): Promise<FormError[]> {
try {
schema.parse(state)
return []
} catch (error) {
if (isZodError(error)) {
return error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message
}))
} else {
throw error
}
}
}
function isJoiSchema (schema: any): schema is JoiSchema {
return schema.validateAsync !== undefined && schema.id !== undefined
}
function isJoiError (error: any): error is JoiError {
return error.isJoi === true
}
async function getJoiErrors (
state: any,
schema: JoiSchema
): Promise<FormError[]> {
try {
await schema.validateAsync(state, { abortEarly: false })
return []
} catch (error) {
if (isJoiError(error)) {
return error.details.map((detail) => ({
path: detail.path.join('.'),
message: detail.message
}))
} else {
throw error
}
}
}

View File

@@ -2,7 +2,9 @@ import { h, cloneVNode, computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { getSlotsChildren } from '../../utils'
import type { FormError } from '../../types'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
@@ -57,12 +59,20 @@ export default defineComponent({
const ui = computed<Partial<typeof appConfig.ui.formGroup>>(() => defu({}, props.ui, appConfig.ui.formGroup))
provide('form-path', props.name)
const formErrors = inject<Ref<FormError[]> | null>('form-errors', null)
const errorMessage = computed(() => {
return props.error && typeof props.error === 'string'
? props.error
: formErrors?.value?.find((error) => error.path === props.name)?.message
})
const children = computed(() => getSlotsChildren(slots))
const clones = computed(() => children.value.map((node) => {
const vProps: any = {}
if (props.error) {
if (errorMessage.value) {
vProps.oldColor = node.props?.color
vProps.color = 'red'
} else if (vProps.oldColor) {
@@ -89,7 +99,7 @@ export default defineComponent({
] }, props.description),
h('div', { class: [!!props.label && ui.value.container] }, [
...clones.value,
props.error && typeof props.error === 'string' ? h('p', { class: [ui.value.error, ui.value.size[props.size]] }, props.error) : props.help ? h('p', { class: [ui.value.help, ui.value.size[props.size]] }, props.help) : null
errorMessage.value ? h('p', { class: [ui.value.error, ui.value.size[props.size]] }, errorMessage.value) : props.help ? h('p', { class: [ui.value.help, ui.value.size[props.size]] }, props.help) : null
])
])
}

View File

@@ -13,6 +13,7 @@
:class="inputClass"
v-bind="$attrs"
@input="onInput"
@blur="onBlur"
>
<slot />
@@ -35,6 +36,7 @@ import { ref, computed, onMounted, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import UIcon from '../elements/Icon.vue'
import { useFormEvents } from '../../composables/useFormEvents'
import { classNames } from '../../utils'
import { useAppConfig } from '#imports'
// TODO: Remove
@@ -138,13 +140,15 @@ export default defineComponent({
default: () => appConfig.ui.input
}
},
emits: ['update:modelValue'],
emits: ['update:modelValue', 'blur'],
setup (props, { emit, slots }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.input>>(() => defu({}, props.ui, appConfig.ui.input))
const { emitFormBlur } = useFormEvents()
const input = ref<HTMLInputElement | null>(null)
const autoFocus = () => {
@@ -157,6 +161,11 @@ export default defineComponent({
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
const onBlur = (event: FocusEvent) => {
emitFormBlur()
emit('blur', event)
}
onMounted(() => {
setTimeout(() => {
autoFocus()
@@ -249,7 +258,8 @@ export default defineComponent({
trailingIconName,
trailingIconClass,
trailingWrapperIconClass,
onInput
onInput,
onBlur
}
}
})

View File

@@ -12,6 +12,7 @@
class="form-radio"
:class="inputClass"
v-bind="$attrs"
@change="onChange"
>
</div>
<div v-if="label || $slots.label" class="ms-3 text-sm">
@@ -31,6 +32,7 @@ import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { classNames } from '../../utils'
import { useFormEvents } from '../../composables/useFormEvents'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -81,19 +83,24 @@ export default defineComponent({
default: () => appConfig.ui.radio
}
},
emits: ['update:modelValue'],
emits: ['update:modelValue', 'change'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.radio>>(() => defu({}, props.ui, appConfig.ui.radio))
const { emitFormBlur } = useFormEvents()
const pick = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
if (value) {
emitFormBlur()
}
}
})

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div :class="wrapperClass">
<input
:id="name"
@@ -12,6 +12,7 @@
type="range"
:class="[inputClass, thumbClass, trackClass]"
v-bind="$attrs"
@change="onChange"
>
<span :class="progressClass" :style="progressStyle" />
@@ -23,6 +24,7 @@ import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { classNames } from '../../utils'
import { useFormEvents } from '../../composables/useFormEvents'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -74,13 +76,15 @@ export default defineComponent({
default: () => appConfig.ui.range
}
},
emits: ['update:modelValue'],
emits: ['update:modelValue', 'change'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.range>>(() => defu({}, props.ui, appConfig.ui.range))
const { emitFormBlur } = useFormEvents()
const value = computed({
get () {
return props.modelValue
@@ -90,6 +94,11 @@ export default defineComponent({
}
})
const onChange = (event: Event) => {
emit('change', event)
emitFormBlur()
}
const wrapperClass = computed(() => {
return classNames(
ui.value.wrapper,
@@ -154,7 +163,8 @@ export default defineComponent({
thumbClass,
trackClass,
progressClass,
progressStyle
progressStyle,
onChange
}
}
})

View File

@@ -10,6 +10,7 @@
:class="selectClass"
v-bind="$attrs"
@input="onInput"
@change="onChange"
>
<template v-for="(option, index) in normalizedOptionsWithPlaceholder">
<optgroup
@@ -59,6 +60,7 @@ import { get } from 'lodash-es'
import { defu } from 'defu'
import UIcon from '../elements/Icon.vue'
import { classNames } from '../../utils'
import { useFormEvents } from '../../composables/useFormEvents'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -165,17 +167,24 @@ export default defineComponent({
default: () => appConfig.ui.select
}
},
emits: ['update:modelValue'],
emits: ['update:modelValue', 'change'],
setup (props, { emit, slots }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.select>>(() => defu({}, props.ui, appConfig.ui.select))
const { emitFormBlur } = useFormEvents()
const onInput = (event: InputEvent) => {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
const onChange = (event: Event) => {
emitFormBlur()
emit('change', event)
}
const guessOptionValue = (option: any) => {
return get(option, props.valueAttribute, get(option, props.optionAttribute))
}
@@ -314,7 +323,8 @@ export default defineComponent({
trailingIconName,
trailingIconClass,
trailingWrapperIconClass,
onInput
onInput,
onChange
}
}
})

View File

@@ -135,6 +135,7 @@ import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import { classNames } from '../../utils'
import { usePopper } from '../../composables/usePopper'
import { useFormEvents } from '../../composables/useFormEvents'
import type { PopperOptions } from '../../types'
import { useAppConfig } from '#imports'
// TODO: Remove
@@ -292,7 +293,7 @@ export default defineComponent({
default: () => appConfig.ui.selectMenu
}
},
emits: ['update:modelValue', 'open', 'close'],
emits: ['update:modelValue', 'open', 'close', 'change'],
setup (props, { emit, slots }) {
// TODO: Remove
const appConfig = useAppConfig()
@@ -303,6 +304,7 @@ export default defineComponent({
const popper = computed<PopperOptions>(() => defu({}, props.popper, uiMenu.value.popper as PopperOptions))
const [trigger, container] = usePopper(popper.value)
const { emitFormBlur } = useFormEvents()
const query = ref('')
const searchInput = ref<ComponentPublicInstance<HTMLElement>>()
@@ -409,6 +411,7 @@ export default defineComponent({
emit('open')
} else {
emit('close')
emitFormBlur()
}
})
@@ -419,6 +422,8 @@ export default defineComponent({
searchInput.value.$el.value = ''
}
emit('update:modelValue', event)
emit('change', event)
emitFormBlur()
}
return {

View File

@@ -13,6 +13,7 @@
:class="textareaClass"
v-bind="$attrs"
@input="onInput"
@blur="onBlur"
/>
</div>
</template>
@@ -22,6 +23,7 @@ import { ref, computed, watch, onMounted, nextTick, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { classNames } from '../../utils'
import { useFormEvents } from '../../composables/useFormEvents'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -101,7 +103,7 @@ export default defineComponent({
default: () => appConfig.ui.textarea
}
},
emits: ['update:modelValue'],
emits: ['update:modelValue', 'blur'],
setup (props, { emit }) {
const textarea = ref<HTMLTextAreaElement | null>(null)
@@ -110,6 +112,8 @@ export default defineComponent({
const ui = computed<Partial<typeof appConfig.ui.textarea>>(() => defu({}, props.ui, appConfig.ui.textarea))
const { emitFormBlur } = useFormEvents()
const autoFocus = () => {
if (props.autofocus) {
textarea.value?.focus()
@@ -144,6 +148,17 @@ export default defineComponent({
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
const onBlur = (event: FocusEvent) => {
emitFormBlur()
emit('blur', event)
}
onMounted(() => {
setTimeout(() => {
autoFocus()
}, 100)
})
watch(() => props.modelValue, () => {
nextTick(autoResize)
})
@@ -174,7 +189,8 @@ export default defineComponent({
ui,
textarea,
textareaClass,
onInput
onInput,
onBlur
}
}
})

View File

@@ -23,6 +23,7 @@ import { defu } from 'defu'
import { Switch as HSwitch } from '@headlessui/vue'
import UIcon from '../elements/Icon.vue'
import { classNames } from '../../utils'
import { useFormEvents } from '../../composables/useFormEvents'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -75,12 +76,15 @@ export default defineComponent({
const ui = computed<Partial<typeof appConfig.ui.toggle>>(() => defu({}, props.ui, appConfig.ui.toggle))
const { emitFormBlur } = useFormEvents()
const active = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
emitFormBlur()
}
})

View File

@@ -0,0 +1,18 @@
import { inject } from 'vue'
import { UseEventBusReturn } from '@vueuse/core'
import { FormEvent } from '../types'
export const useFormEvents = () => {
const formBus = inject<UseEventBusReturn<FormEvent, string> | undefined>('form-events', undefined)
const formPath = inject<string | undefined>('form-path', undefined)
const emitFormBlur = () => {
if (formBus && formPath) {
formBus.emit({ type: 'blur', path: formPath })
}
}
return {
emitFormBlur
}
}

13
src/runtime/types/form.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
export interface FormError {
path: string
message: string
}
export interface Form<T> {
async validate(): T
}
export interface FormEvent {
type: 'blur' // | 'change' | 'focus'
path: string
}

View File

@@ -4,6 +4,7 @@ export * from './button'
export * from './clipboard'
export * from './command-palette'
export * from './dropdown'
export * from './form'
export * from './link'
export * from './notification'
export * from './popper'