mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-14 20:19:34 +01:00
feat(Form): improve form control and input validation trigger (#487)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
committed by
Benjamin Canac
parent
60bb74675c
commit
6d7973f6e1
@@ -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) {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
::
|
||||
::
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
])
|
||||
])
|
||||
}
|
||||
})
|
||||
107
src/runtime/components/forms/FormGroup.vue
Normal file
107
src/runtime/components/forms/FormGroup.vue
Normal 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>
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
38
src/runtime/composables/useFormGroup.ts
Normal file
38
src/runtime/composables/useFormGroup.ts
Normal 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
|
||||
}
|
||||
}
|
||||
12
src/runtime/types/form.d.ts
vendored
12
src/runtime/types/form.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user