feat(Form): improve form control and input validation trigger (#487)

Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
Romain Hamel
2023-08-12 16:48:53 +02:00
committed by Benjamin Canac
parent 60bb74675c
commit 6d7973f6e1
23 changed files with 529 additions and 381 deletions

View File

@@ -164,9 +164,7 @@ const code = computed(() => {
continue
}
const prop = meta?.meta?.props?.find((prop: any) => prop.name === key)
code += ` ${(prop?.type === 'boolean' && value !== true) || typeof value === 'object' ? ':' : ''}${key === 'modelValue' ? 'value' : useKebabCase(key)}${prop?.type === 'boolean' && !!value && key !== 'modelValue' ? '' : `="${typeof value === 'object' ? renderObject(value) : value}"`}`
code += ` ${(typeof value === 'boolean' && value !== true) || typeof value === 'object' ? ':' : ''}${key === 'modelValue' ? 'value' : useKebabCase(key)}${typeof value === 'boolean' && !!value && key !== 'modelValue' ? '' : `="${typeof value === 'object' ? renderObject(value) : value}"`}`
}
if (props.slots) {

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { FormError } from '@nuxthq/ui/dist/runtime/types'
import type { FormError, FormSubmitEvent } from '@nuxthq/ui/dist/runtime/types'
const state = ref({
email: undefined,
@@ -14,20 +14,17 @@ const validate = (state: any): FormError[] => {
return errors
}
const form = ref()
async function submit () {
await form.value!.validate()
// Do something with state.value
async function submit (event: FormSubmitEvent<any>) {
// Do something with data
console.log(event.data)
}
</script>
<template>
<UForm
ref="form"
:validate="validate"
:state="state"
@submit.prevent="submit"
@submit="submit"
>
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { z } from 'zod'
import type { Form } from '@nuxthq/ui/dist/runtime/types'
import type { FormSubmitEvent } from '@nuxthq/ui/dist/runtime/types'
const options = [
{ label: 'Option 1', value: 'option-1' },
@@ -44,11 +44,11 @@ const schema = z.object({
type Schema = z.infer<typeof schema>
const form = ref<Form<Schema>>()
const form = ref()
async function submit () {
await form.value!.validate()
// Do something with state.value
async function submit (event: FormSubmitEvent<Schema>) {
// Do something with event.data
console.log(event.data)
}
</script>
@@ -57,7 +57,7 @@ async function submit () {
ref="form"
:schema="schema"
:state="state"
@submit.prevent="submit"
@submit="submit"
>
<UFormGroup name="input" label="Input">
<UInput v-model="state.input" />
@@ -96,5 +96,9 @@ async function submit () {
<UButton type="submit">
Submit
</UButton>
<UButton variant="outline" class="ml-2" @click="form.clear()">
Clear
</UButton>
</UForm>
</template>

View File

@@ -1,40 +1,38 @@
<script setup lang="ts">
import { ref } from 'vue'
import Joi from 'joi'
import type { FormSubmitEvent } from '@nuxthq/ui/dist/runtime/types'
const schema = Joi.object({
emailJoi: Joi.string().required(),
passwordJoi: Joi.string()
email: Joi.string().required(),
password: Joi.string()
.min(8)
.required()
})
const state = ref({
emailJoi: undefined,
passwordJoi: undefined
email: undefined,
password: undefined
})
const form = ref()
async function submit () {
await form.value!.validate()
// Do something with state.value
async function submit (event: FormSubmitEvent<any>) {
// Do something with event.data
console.log(event.data)
}
</script>
<template>
<UForm
ref="form"
:schema="schema"
:state="state"
@submit.prevent="submit"
@submit="submit"
>
<UFormGroup label="Email" name="emailJoi">
<UInput v-model="state.emailJoi" />
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UFormGroup label="Password" name="passwordJoi">
<UInput v-model="state.passwordJoi" type="password" />
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<UButton type="submit">

View File

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

View File

@@ -1,41 +1,38 @@
<script setup lang="ts">
import { ref } from 'vue'
import { z } from 'zod'
import type { Form } from '@nuxthq/ui/dist/runtime/types'
import type { FormSubmitEvent } from '@nuxthq/ui/dist/runtime/types'
const schema = z.object({
emailZod: z.string().email('Invalid email'),
passwordZod: z.string().min(8, 'Must be at least 8 characters')
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({
emailZod: undefined,
passwordZod: undefined
email: undefined,
password: undefined
})
const form = ref<Form<Schema>>()
async function submit () {
await form.value!.validate()
// Do something with state.value
async function submit (event: FormSubmitEvent<Schema>) {
// Do something with data
console.log(event.data)
}
</script>
<template>
<UForm
ref="form"
:schema="schema"
:state="state"
@submit.prevent="submit"
@submit="submit"
>
<UFormGroup label="Email" name="emailZod">
<UInput v-model="state.emailZod" />
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UFormGroup label="Password" name="passwordZod">
<UInput v-model="state.passwordZod" type="password" />
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<UButton type="submit">

View File

@@ -0,0 +1,9 @@
<template>
<UFormGroup v-slot="{ error }" label="Email" :error="!email && 'You must enter an email'" help="This is a nice email!">
<UInput v-model="email" type="email" placeholder="Enter email" :trailing-icon="error && 'i-heroicons-exclamation-triangle-20-solid'" />
</UFormGroup>
</template>
<script setup lang="ts">
const email = ref('')
</script>

View File

@@ -26,7 +26,8 @@ The Form component requires the `validate` and `state` props for form validation
#code
```vue
<script setup lang="ts">
import type { FormError } from '@nuxthq/ui/dist/runtime/types'
import { ref } from 'vue'
import type { FormError, FormSubmitEvent } from '@nuxthq/ui/dist/runtime/types'
const state = ref({
email: undefined,
@@ -40,20 +41,17 @@ const validate = (state: any): FormError[] => {
return errors
}
const form = ref()
async function submit () {
await form.value!.validate()
// Do something with state.value
async function submit (event: FormSubmitEvent<any>) {
// Do something with data
console.log(event.data)
}
</script>
<template>
<UForm
ref="form"
:validate="validate"
:state="state"
@submit.prevent="submit"
@submit="submit"
>
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
@@ -84,7 +82,9 @@ You can provide a schema from [Yup](#yup), [Zod](#zod) or [Joi](#joi) through th
#code
```vue
<script setup lang="ts">
import { ref } from 'vue'
import { object, string, InferType } from 'yup'
import type { FormSubmitEvent } from '@nuxthq/ui/dist/runtime/types'
const schema = object({
email: string().email('Invalid email').required('Required'),
@@ -93,25 +93,24 @@ const schema = object({
.required('Required')
})
type Schema = InferType<typeof schema>
const state = ref({
email: undefined,
password: undefined
})
const form = ref()
async function submit () {
await form.value!.validate()
// Do something with state.value
async function submit (event: FormSubmitEvent<Schema>) {
// Do something with event.data
console.log(event.data)
}
</script>
<template>
<UForm
ref="form"
:schema="schema"
:state="state"
@submit.prevent="submit"
@submit="submit"
>
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
@@ -138,32 +137,33 @@ async function submit () {
#code
```vue
<script setup lang="ts">
import { ref } from 'vue'
import { z } from 'zod'
import type { FormSubmitEvent } 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()
async function submit () {
await form.value!.validate()
// Do something with state.value
async function submit (event: FormSubmitEvent<Schema>) {
// Do something with data
console.log(event.data)
}
</script>
<template>
<UForm
ref="form"
:schema="schema"
:state="state"
@submit.prevent="submit"
@submit="submit"
>
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
@@ -190,8 +190,9 @@ async function submit () {
#code
```vue
<script setup lang="ts">
import { ref } from 'vue'
import Joi from 'joi'
import type { Schema } from 'joi'
import type { FormSubmitEvent } from '@nuxthq/ui/dist/runtime/types'
const schema = Joi.object({
email: Joi.string().required(),
@@ -205,18 +206,17 @@ const state = ref({
password: undefined
})
async function submit () {
await form.value!.validate()
// Do something with state.value
async function submit (event: FormSubmitEvent<any>) {
// Do something with event.data
console.log(event.data)
}
</script>
<template>
<UForm
ref="form"
:schema="schema"
:state="state"
@submit.prevent="submit"
@submit="submit"
>
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
@@ -234,38 +234,6 @@ async function submit () {
```
::
## 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 = ref<Partial<Schema>>({
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 = 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.
@@ -274,30 +242,30 @@ Here is an example with [Vuelidate](https://github.com/vuelidate/vuelidate):
```vue
<script setup lang="ts">
import useVuelidate from '@vuelidate/core';
import useVuelidate from '@vuelidate/core'
const props = defineProps({
rules: { type: Object, required: true },
model: { type: Object, required: true },
});
model: { type: Object, required: true }
})
const form = ref();
const v = useVuelidate(props.rules, props.model);
const v = useVuelidate(props.rules, props.model)
async function validateWithVuelidate() {
v.value.$touch();
await v.value.$validate();
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();
},
});
await form.value.validate()
}
})
</script>
<template>
@@ -307,9 +275,56 @@ defineExpose({
</template>
```
## Backend validation
You can manually set errors after form submission if required. To do this, simply use the `form.setErrors` function to set the errors as needed.
```vue
<script setup lang="ts">
import type { FormError, FormSubmitEvent } from '@nuxthq/ui/dist/runtime/types'
const state = ref({
email: undefined,
password: undefined
})
const form = ref()
async function submit (event: FormSubmitEvent<any>) {
form.value.clear()
const response = await fetch('...')
if (!response.status === 422) {
const errors = await response.json()
form.value.setErrors(errors.map((err) => {
// Map validation errors to { path: string, message: string }
}))
} else {
// ...
}
}
</script>
<template>
<UForm ref="form" :state="state" @submit="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>
```
## 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.
The Form component automatically triggers validation upon `submit`, `input`, `blur` or `change` events. This ensures that any errors are displayed as soon as the user interacts with the form elements. You can control when validation happens this using the `validate-on` prop.
::component-example
#default
@@ -319,3 +334,23 @@ The Form component automatically triggers validation upon input `blur` or `chang
## Props
:component-props
## API
::field-group
::field{name="validate (path?: string, opts: { silent?: boolean })" type="Promise<T>"}
Triggers form validation. Will raise any errors unless `opts.silent` is set to true.
::
::field{name="clear (path?: string)" type="void"}
Clears form errors associated with a specific path. If no path is provided, clears all form errors.
::
::field{name="getErrors (path?: string)" type="FormError[]"}
Retrieves form errors associated with a specific path. If no path is provided, returns all form errors.
::
::field{name="setErrors (errors: FormError[], path?: string)" type="void"}
Sets form errors for a given path. If no path is provided, overrides all errors.
::
::field{name="errors" type="Ref<FormError[]>"}
A reference to the array containing validation errors. Use this to access or manipulate the error information.
::
::

View File

@@ -111,27 +111,54 @@ Use the `error` prop to display an error message below the form element.
When used together with the `help` prop, the `error` prop will take precedence.
::component-example
#default
:form-group-error-example
#code
```vue
<template>
<UFormGroup v-slot="{ error }" label="Email" :error="!email && 'You must enter an email'" help="This is a nice email!">
<UInput v-model="email" type="email" placeholder="Enter email" :trailing-icon="error && 'i-heroicons-exclamation-triangle-20-solid'" />
</UFormGroup>
</template>
<script setup lang="ts">
const email = ref('')
</script>
```
::
::callout{icon="i-heroicons-light-bulb"}
The `error` prop will automatically set the `color` prop of the form element to `red`.
::
You can also use the `error` prop as a boolean to mark the form element as invalid.
::component-card
---
baseProps:
name: 'group-error'
props:
label: 'Email'
help: 'We will never share your email with anyone else.'
error: "Not a valid email address."
error: true
ui:
error: 'hidden'
excludedProps:
- ui
- error
- label
code: >-
<UInput placeholder="you@example.com" trailing-icon="i-heroicons-exclamation-triangle-20-solid" />
<UInput placeholder="you@example.com" />
---
#default
:u-input{model-value="benjamincanac" placeholder="you@example.com" trailing-icon="i-heroicons-exclamation-triangle-20-solid"}
:u-input{model-value="benjamincanac" placeholder="you@example.com"}
::
You can also use the `error` prop as a boolean to mark the form element as invalid.
::callout{icon="i-heroicons-light-bulb"}
The `error` prop will automatically set the `color` prop of the form element to `red`.
::callout{icon="i-heroicons-light-bulb" to="/forms/form"}
Learn more about form validation in the `Form` component.
::
### Size :u-badge{label="New" class="ml-2 align-text-bottom !rounded-full" variant="subtle"}

View File

@@ -34,7 +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 { useFormGroup } from '../../composables/useFormGroup'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -100,7 +100,8 @@ export default defineComponent({
const ui = computed<Partial<typeof appConfig.ui.checkbox>>(() => defu({}, props.ui, appConfig.ui.checkbox))
const { emitFormBlur } = useFormEvents()
const { emitFormChange, formGroup } = useFormGroup()
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const toggle = computed({
get () {
@@ -113,7 +114,7 @@ export default defineComponent({
const onChange = (event: Event) => {
emit('change', event)
emitFormBlur()
emitFormChange()
}
const inputClass = computed(() => {
@@ -122,8 +123,8 @@ export default defineComponent({
ui.value.rounded,
ui.value.background,
ui.value.border,
ui.value.ring.replaceAll('{color}', props.color),
ui.value.color.replaceAll('{color}', props.color)
ui.value.ring.replaceAll('{color}', color.value),
ui.value.color.replaceAll('{color}', color.value)
)
})

View File

@@ -1,12 +1,16 @@
import { provide, ref, type PropType, h, defineComponent } from 'vue'
<template>
<form @submit.prevent="onSubmit">
<slot />
</form>
</template>
<script lang="ts">
import { provide, ref, type PropType, defineComponent } from 'vue'
import { useEventBus } from '@vueuse/core'
import type { ZodSchema } 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'
import type { ObjectSchema as YupObjectSchema, ValidationError as YupError } from 'yup'
import type { FormError, FormEvent, FormEventType, FormSubmitEvent, Form } from '../../types'
export default defineComponent({
props: {
@@ -26,21 +30,20 @@ export default defineComponent({
| PropType<(state: any) => Promise<FormError[]>>
| PropType<(state: any) => FormError[]>,
default: () => []
},
validateOn: {
type: Array as PropType<FormEventType[]>,
default: () => ['blur', 'input', 'change', 'submit']
}
},
setup (props, { slots, expose }) {
emits: ['submit'],
setup (props, { expose, emit }) {
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)
if (event.type !== 'submit' && props.validateOn?.includes(event.type)) {
await validate(event.path, { silent: true })
}
})
@@ -66,22 +69,67 @@ export default defineComponent({
return errs
}
async function validate () {
errors.value = await getErrors()
if (errors.value.length > 0) {
async function validate (path?: string, opts: { silent?: boolean } = { silent: false }) {
if (path) {
const otherErrors = errors.value.filter(
(error) => error.path !== path
)
const pathErrors = (await getErrors()).filter(
(error) => error.path === path
)
errors.value = otherErrors.concat(pathErrors)
} else {
errors.value = await getErrors()
}
if (!opts.silent && errors.value.length > 0) {
throw new Error(
`Form validation failed: ${JSON.stringify(errors.value, null, 2)}`
)
}
return props.state
}
expose({
validate
})
async function onSubmit (event: SubmitEvent) {
if (props.validateOn?.includes('submit')) {
await validate()
}
const submitEvent = event as FormSubmitEvent<any>
submitEvent.data = props.state
emit('submit', event)
}
return () => h('form', slots.default?.())
expose({
validate,
errors,
setErrors (errs: FormError[], path?: string) {
errors.value = errs
if (path) {
errors.value = errors.value.filter(
(error) => error.path !== path
).concat(errs)
} else {
errors.value = errs
}
},
getErrors (path?: string) {
if (path) {
return errors.value.filter((err) => err.path === path)
}
return errors.value
},
clear (path?: string) {
if (path) {
errors.value = errors.value.filter((err) => err.path === path)
} else {
errors.value = []
}
}
} as Form<any>)
return {
onSubmit
}
}
})
@@ -156,3 +204,4 @@ async function getJoiErrors (
}
}
}
</script>

View File

@@ -1,108 +0,0 @@
import { h, cloneVNode, computed, defineComponent, provide, inject } 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'
// const appConfig = useAppConfig()
export default defineComponent({
props: {
name: {
type: String,
default: null
},
size: {
type: String,
default: null,
validator (value: string) {
return Object.keys(appConfig.ui.formGroup.size).includes(value)
}
},
label: {
type: String,
default: null
},
description: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
},
help: {
type: String,
default: null
},
error: {
type: [String, Boolean],
default: null
},
hint: {
type: String,
default: null
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.formGroup>>,
default: () => appConfig.ui.formGroup
}
},
setup (props, { slots }) {
// TODO: Remove
const appConfig = useAppConfig()
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 (errorMessage.value) {
vProps.oldColor = node.props?.color
vProps.color = 'red'
} else if (vProps.oldColor) {
vProps.color = vProps.oldColor
}
if (props.name) {
vProps.name = props.name
}
if (props.size) {
vProps.size = props.size
}
return cloneVNode(node, vProps)
}))
const size = computed(() => ui.value.size[props.size ?? appConfig.ui.input.default.size])
return () => h('div', { class: [ui.value.wrapper] }, [
props.label && h('div', { class: [ui.value.label.wrapper, size.value] }, [
h('label', { for: props.name, class: [ui.value.label.base, props.required && ui.value.label.required] }, props.label),
props.hint && h('span', { class: [ui.value.hint] }, props.hint)
]),
props.description && h('p', { class: [ui.value.description, size.value
] }, props.description),
h('div', { class: [!!props.label && ui.value.container] }, [
...clones.value,
errorMessage.value ? h('p', { class: [ui.value.error, size.value] }, errorMessage.value) : props.help ? h('p', { class: [ui.value.help, size.value] }, props.help) : null
])
])
}
})

View File

@@ -0,0 +1,107 @@
<template>
<div :class="ui.wrapper">
<label>
<div v-if="label" :class="[ui.label.wrapper, size]">
<p :class="[ui.label.base, required ? ui.label.required : '']">{{ label }}</p>
<span v-if="hint" :class="[ui.hint]">{{ hint }}</span>
</div>
<p v-if="description" :class="[ui.description, size]">{{ description }}</p>
<div :class="[label ? ui.container : '']">
<slot v-bind="{ error }" />
<p v-if="error" :class="[ui.error, size]">{{ error }}</p>
<p v-else-if="help" :class="[ui.help, size]">{{ help }}</p>
</div>
</label>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, provide, inject } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import type { FormError } from '../../types'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
// const appConfig = useAppConfig()
export default defineComponent({
props: {
name: {
type: String,
default: null
},
size: {
type: String,
default: null,
validator (value: string) {
return Object.keys(appConfig.ui.formGroup.size).includes(value)
}
},
label: {
type: String,
default: null
},
description: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
},
help: {
type: String,
default: null
},
error: {
type: [String, Boolean],
default: null
},
hint: {
type: String,
default: null
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.formGroup>>,
default: () => appConfig.ui.formGroup
}
},
setup (props) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.formGroup>>(() => defu({}, props.ui, appConfig.ui.formGroup))
const formErrors = inject<Ref<FormError[]> | null>('form-errors', null)
const error = computed(() => {
return (props.error && typeof props.error === 'string') || typeof props.error === 'boolean'
? props.error
: formErrors?.value?.find((error) => error.path === props.name)?.message
})
const size = computed(() => ui.value.size[props.size ?? appConfig.ui.input.default.size])
provide('form-group', {
error,
name: computed(() => props.name),
size: computed(() => props.size)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
// eslint-disable-next-line vue/no-dupe-keys
size,
// eslint-disable-next-line vue/no-dupe-keys
error
}
}
})
</script>

View File

@@ -1,7 +1,6 @@
<template>
<div :class="ui.wrapper">
<input
:id="name"
ref="input"
:name="name"
:value="modelValue"
@@ -36,7 +35,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 { useFormGroup } from '../../composables/useFormGroup'
import { classNames } from '../../utils'
import { useAppConfig } from '#imports'
// TODO: Remove
@@ -147,7 +146,9 @@ export default defineComponent({
const ui = computed<Partial<typeof appConfig.ui.input>>(() => defu({}, props.ui, appConfig.ui.input))
const { emitFormBlur } = useFormEvents()
const { emitFormBlur, emitFormInput, formGroup } = useFormGroup()
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const size = computed(() => formGroup?.size?.value ?? props.size)
const input = ref<HTMLInputElement | null>(null)
@@ -159,6 +160,7 @@ export default defineComponent({
const onInput = (event: InputEvent) => {
emit('update:modelValue', (event.target as HTMLInputElement).value)
emitFormInput()
}
const onBlur = (event: FocusEvent) => {
@@ -173,17 +175,17 @@ export default defineComponent({
})
const inputClass = computed(() => {
const variant = ui.value.color?.[props.color as string]?.[props.variant as string] || ui.value.variant[props.variant]
const variant = ui.value.color?.[color.value as string]?.[props.variant as string] || ui.value.variant[props.variant]
return classNames(
ui.value.base,
ui.value.rounded,
ui.value.placeholder,
ui.value.size[props.size],
props.padded ? ui.value.padding[props.size] : 'p-0',
variant?.replaceAll('{color}', props.color),
(isLeading.value || slots.leading) && ui.value.leading.padding[props.size],
(isTrailing.value || slots.trailing) && ui.value.trailing.padding[props.size]
ui.value.size[size.value],
props.padded ? ui.value.padding[size.value] : 'p-0',
variant?.replaceAll('{color}', color.value),
(isLeading.value || slots.leading) && ui.value.leading.padding[size.value],
(isTrailing.value || slots.trailing) && ui.value.trailing.padding[size.value]
)
})
@@ -215,15 +217,15 @@ export default defineComponent({
return classNames(
ui.value.icon.leading.wrapper,
ui.value.icon.leading.pointer,
ui.value.icon.leading.padding[props.size]
ui.value.icon.leading.padding[size.value]
)
})
const leadingIconClass = computed(() => {
return classNames(
ui.value.icon.base,
appConfig.ui.colors.includes(props.color) && ui.value.icon.color.replaceAll('{color}', props.color),
ui.value.icon.size[props.size],
appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && 'animate-spin'
)
})
@@ -232,15 +234,15 @@ export default defineComponent({
return classNames(
ui.value.icon.trailing.wrapper,
ui.value.icon.trailing.pointer,
ui.value.icon.trailing.padding[props.size]
ui.value.icon.trailing.padding[size.value]
)
})
const trailingIconClass = computed(() => {
return classNames(
ui.value.icon.base,
appConfig.ui.colors.includes(props.color) && ui.value.icon.color.replaceAll('{color}', props.color),
ui.value.icon.size[props.size],
appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && !isLeading.value && 'animate-spin'
)
})

View File

@@ -2,7 +2,6 @@
<div :class="ui.wrapper">
<div class="flex items-center h-5">
<input
:id="`${name}-${value}`"
v-model="pick"
:name="name"
:required="required"
@@ -12,7 +11,6 @@
class="form-radio"
:class="inputClass"
v-bind="$attrs"
@change="onChange"
>
</div>
<div v-if="label || $slots.label" class="ms-3 text-sm">
@@ -32,7 +30,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 { useFormGroup } from '../../composables/useFormGroup'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -83,14 +81,15 @@ export default defineComponent({
default: () => appConfig.ui.radio
}
},
emits: ['update:modelValue', 'change'],
emits: ['update:modelValue'],
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 { emitFormChange, formGroup } = useFormGroup()
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const pick = computed({
get () {
@@ -99,7 +98,7 @@ export default defineComponent({
set (value) {
emit('update:modelValue', value)
if (value) {
emitFormBlur()
emitFormChange()
}
}
})
@@ -109,8 +108,8 @@ export default defineComponent({
ui.value.base,
ui.value.background,
ui.value.border,
ui.value.ring.replaceAll('{color}', props.color),
ui.value.color.replaceAll('{color}', props.color)
ui.value.ring.replaceAll('{color}', color.value),
ui.value.color.replaceAll('{color}', color.value)
)
})

View File

@@ -1,7 +1,6 @@
<template>
<div :class="wrapperClass">
<input
:id="name"
ref="input"
v-model.number="value"
:name="name"
@@ -24,7 +23,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 { useFormGroup } from '../../composables/useFormGroup'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -83,7 +82,9 @@ export default defineComponent({
const ui = computed<Partial<typeof appConfig.ui.range>>(() => defu({}, props.ui, appConfig.ui.range))
const { emitFormBlur } = useFormEvents()
const { emitFormChange, formGroup } = useFormGroup()
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const size = computed(() => formGroup?.size?.value ?? props.size)
const value = computed({
get () {
@@ -96,13 +97,13 @@ export default defineComponent({
const onChange = (event: Event) => {
emit('change', event)
emitFormBlur()
emitFormChange()
}
const wrapperClass = computed(() => {
return classNames(
ui.value.wrapper,
ui.value.size[props.size]
ui.value.size[size.value]
)
})
@@ -111,8 +112,8 @@ export default defineComponent({
ui.value.base,
ui.value.background,
ui.value.rounded,
ui.value.ring.replaceAll('{color}', props.color),
ui.value.size[props.size]
ui.value.ring.replaceAll('{color}', color.value),
ui.value.size[size.value]
)
})
@@ -120,10 +121,10 @@ export default defineComponent({
return classNames(
ui.value.thumb.base,
// Intermediate class to allow thumb ring or background color (set to `current`) as it's impossible to safelist with arbitrary values
ui.value.thumb.color.replaceAll('{color}', props.color),
ui.value.thumb.color.replaceAll('{color}', color.value),
ui.value.thumb.ring,
ui.value.thumb.background,
ui.value.thumb.size[props.size]
ui.value.thumb.size[size.value]
)
})
@@ -132,7 +133,7 @@ export default defineComponent({
ui.value.track.base,
ui.value.track.background,
ui.value.track.rounded,
ui.value.track.size[props.size]
ui.value.track.size[size.value]
)
})
@@ -140,8 +141,8 @@ export default defineComponent({
return classNames(
ui.value.progress.base,
ui.value.progress.rounded,
ui.value.progress.background.replaceAll('{color}', props.color),
ui.value.progress.size[props.size]
ui.value.progress.background.replaceAll('{color}', color.value),
ui.value.progress.size[size.value]
)
})

View File

@@ -1,7 +1,6 @@
<template>
<div :class="ui.wrapper">
<select
:id="name"
:name="name"
:value="modelValue"
:required="required"
@@ -60,7 +59,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 { useFormGroup } from '../../composables/useFormGroup'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -174,14 +173,17 @@ export default defineComponent({
const ui = computed<Partial<typeof appConfig.ui.select>>(() => defu({}, props.ui, appConfig.ui.select))
const { emitFormBlur } = useFormEvents()
const { emitFormChange, formGroup } = useFormGroup()
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const size = computed(() => formGroup?.size?.value ?? props.size)
const onInput = (event: InputEvent) => {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
const onChange = (event: Event) => {
emitFormBlur()
emitFormChange()
emit('change', event)
}
@@ -238,16 +240,16 @@ export default defineComponent({
})
const selectClass = computed(() => {
const variant = ui.value.color?.[props.color as string]?.[props.variant as string] || ui.value.variant[props.variant]
const variant = ui.value.color?.[color.value as string]?.[props.variant as string] || ui.value.variant[props.variant]
return classNames(
ui.value.base,
ui.value.rounded,
ui.value.size[props.size],
props.padded ? ui.value.padding[props.size] : 'p-0',
variant?.replaceAll('{color}', props.color),
(isLeading.value || slots.leading) && ui.value.leading.padding[props.size],
(isTrailing.value || slots.trailing) && ui.value.trailing.padding[props.size]
ui.value.size[size.value],
props.padded ? ui.value.padding[size.value] : 'p-0',
variant?.replaceAll('{color}', color.value),
(isLeading.value || slots.leading) && ui.value.leading.padding[size.value],
(isTrailing.value || slots.trailing) && ui.value.trailing.padding[size.value]
)
})
@@ -279,15 +281,15 @@ export default defineComponent({
return classNames(
ui.value.icon.leading.wrapper,
ui.value.icon.leading.pointer,
ui.value.icon.leading.padding[props.size]
ui.value.icon.leading.padding[size.value]
)
})
const leadingIconClass = computed(() => {
return classNames(
ui.value.icon.base,
appConfig.ui.colors.includes(props.color) && ui.value.icon.color.replaceAll('{color}', props.color),
ui.value.icon.size[props.size],
appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && 'animate-spin'
)
})
@@ -296,15 +298,15 @@ export default defineComponent({
return classNames(
ui.value.icon.trailing.wrapper,
ui.value.icon.trailing.pointer,
ui.value.icon.trailing.padding[props.size]
ui.value.icon.trailing.padding[size.value]
)
})
const trailingIconClass = computed(() => {
return classNames(
ui.value.icon.base,
appConfig.ui.colors.includes(props.color) && ui.value.icon.color.replaceAll('{color}', props.color),
ui.value.icon.size[props.size],
appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && !isLeading.value && 'animate-spin'
)
})

View File

@@ -135,7 +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 { useFormGroup } from '../../composables/useFormGroup'
import type { PopperOptions } from '../../types'
import { useAppConfig } from '#imports'
// TODO: Remove
@@ -304,24 +304,26 @@ 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 { emitFormBlur, emitFormChange, formGroup } = useFormGroup()
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const size = computed(() => formGroup?.size?.value ?? props.size)
const query = ref('')
const searchInput = ref<ComponentPublicInstance<HTMLElement>>()
const selectClass = computed(() => {
const variant = ui.value.color?.[props.color as string]?.[props.variant as string] || ui.value.variant[props.variant]
const variant = ui.value.color?.[color.value as string]?.[props.variant as string] || ui.value.variant[props.variant]
return classNames(
ui.value.base,
ui.value.rounded,
'text-left cursor-default',
ui.value.size[props.size],
ui.value.gap[props.size],
props.padded ? ui.value.padding[props.size] : 'p-0',
variant?.replaceAll('{color}', props.color),
(isLeading.value || slots.leading) && ui.value.leading.padding[props.size],
(isTrailing.value || slots.trailing) && ui.value.trailing.padding[props.size],
ui.value.size[size.value],
ui.value.gap[size.value],
props.padded ? ui.value.padding[size.value] : 'p-0',
variant?.replaceAll('{color}', color.value),
(isLeading.value || slots.leading) && ui.value.leading.padding[size.value],
(isTrailing.value || slots.trailing) && ui.value.trailing.padding[size.value],
'inline-flex items-center'
)
})
@@ -354,15 +356,15 @@ export default defineComponent({
return classNames(
ui.value.icon.leading.wrapper,
ui.value.icon.leading.pointer,
ui.value.icon.leading.padding[props.size]
ui.value.icon.leading.padding[size.value]
)
})
const leadingIconClass = computed(() => {
return classNames(
ui.value.icon.base,
appConfig.ui.colors.includes(props.color) && ui.value.icon.color.replaceAll('{color}', props.color),
ui.value.icon.size[props.size],
appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && 'animate-spin'
)
})
@@ -371,15 +373,15 @@ export default defineComponent({
return classNames(
ui.value.icon.trailing.wrapper,
ui.value.icon.trailing.pointer,
ui.value.icon.trailing.padding[props.size]
ui.value.icon.trailing.padding[size.value]
)
})
const trailingIconClass = computed(() => {
return classNames(
ui.value.icon.base,
appConfig.ui.colors.includes(props.color) && ui.value.icon.color.replaceAll('{color}', props.color),
ui.value.icon.size[props.size],
appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && !isLeading.value && 'animate-spin'
)
})
@@ -423,7 +425,7 @@ export default defineComponent({
}
emit('update:modelValue', event)
emit('change', event)
emitFormBlur()
emitFormChange()
}
return {

View File

@@ -1,7 +1,6 @@
<template>
<div :class="ui.wrapper">
<textarea
:id="name"
ref="textarea"
:value="modelValue"
:name="name"
@@ -23,7 +22,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 { useFormGroup } from '../../composables/useFormGroup'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -112,7 +111,9 @@ export default defineComponent({
const ui = computed<Partial<typeof appConfig.ui.textarea>>(() => defu({}, props.ui, appConfig.ui.textarea))
const { emitFormBlur } = useFormEvents()
const { emitFormBlur, emitFormInput, formGroup } = useFormGroup()
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const size = computed(() => formGroup?.size?.value ?? props.size)
const autoFocus = () => {
if (props.autofocus) {
@@ -146,11 +147,12 @@ export default defineComponent({
autoResize()
emit('update:modelValue', (event.target as HTMLInputElement).value)
emitFormInput()
}
const onBlur = (event: FocusEvent) => {
emitFormBlur()
emit('blur', event)
emitFormBlur()
}
onMounted(() => {
@@ -171,15 +173,15 @@ export default defineComponent({
})
const textareaClass = computed(() => {
const variant = ui.value.color?.[props.color as string]?.[props.variant as string] || ui.value.variant[props.variant]
const variant = ui.value.color?.[color.value as string]?.[props.variant as string] || ui.value.variant[props.variant]
return classNames(
ui.value.base,
ui.value.rounded,
ui.value.placeholder,
ui.value.size[props.size],
props.padded ? ui.value.padding[props.size] : 'p-0',
variant?.replaceAll('{color}', props.color),
ui.value.size[size.value],
props.padded ? ui.value.padding[size.value] : 'p-0',
variant?.replaceAll('{color}', color.value),
!props.resize && 'resize-none'
)
})

View File

@@ -23,7 +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 { useFormGroup } from '../../composables/useFormGroup'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -76,7 +76,8 @@ export default defineComponent({
const ui = computed<Partial<typeof appConfig.ui.toggle>>(() => defu({}, props.ui, appConfig.ui.toggle))
const { emitFormBlur } = useFormEvents()
const { emitFormChange, formGroup } = useFormGroup()
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const active = computed({
get () {
@@ -84,7 +85,7 @@ export default defineComponent({
},
set (value) {
emit('update:modelValue', value)
emitFormBlur()
emitFormChange()
}
})
@@ -92,20 +93,20 @@ export default defineComponent({
return classNames(
ui.value.base,
ui.value.rounded,
ui.value.ring.replaceAll('{color}', props.color),
(active.value ? ui.value.active : ui.value.inactive).replaceAll('{color}', props.color)
ui.value.ring.replaceAll('{color}', color.value),
(active.value ? ui.value.active : ui.value.inactive).replaceAll('{color}', color.value)
)
})
const onIconClass = computed(() => {
return classNames(
ui.value.icon.on.replaceAll('{color}', props.color)
ui.value.icon.on.replaceAll('{color}', color.value)
)
})
const offIconClass = computed(() => {
return classNames(
ui.value.icon.off.replaceAll('{color}', props.color)
ui.value.icon.off.replaceAll('{color}', color.value)
)
})

View File

@@ -1,18 +0,0 @@
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
}
}

View File

@@ -0,0 +1,38 @@
import { inject } from 'vue'
import { UseEventBusReturn, useDebounceFn } from '@vueuse/core'
import { FormEvent, FormEventType } from '../types'
export const useFormGroup = () => {
const formBus = inject<UseEventBusReturn<FormEvent, string> | undefined>('form-events', undefined)
const formGroup = inject('form-group', undefined)
const blurred = ref(false)
function emitFormEvent (type: FormEventType, path: string) {
if (formBus) {
formBus.emit({ type, path })
}
}
function emitFormBlur () {
emitFormEvent('blur', formGroup?.name.value)
blurred.value = true
}
function emitFormChange () {
emitFormEvent('change', formGroup?.name.value)
}
const emitFormInput = useDebounceFn(() => {
if (blurred.value) {
emitFormEvent('input', formGroup?.name.value)
}
}, 300)
return {
emitFormBlur,
emitFormInput,
emitFormChange,
formGroup
}
}

View File

@@ -4,10 +4,18 @@ export interface FormError {
}
export interface Form<T> {
validate(): Promise<T>
validate(path?: string, opts: { silent?: boolean } = { silent: false }): Promise<T>
clear(path?: string): void
errors: Ref<FormError[]>
setErrors(errs: FormError[], path?: string): void
getErrors(path?: string): FormError[]
}
export type FormSubmitEvent<T> = SubmitEvent & { data: T }
export type FormEventType = 'blur' | 'input' | 'change' | 'submit'
export interface FormEvent {
type: 'blur' // | 'change' | 'focus'
type: FormEventType
path: string
}