diff --git a/docs/components/content/examples/FormExampleBasic.vue b/docs/components/content/examples/FormExampleBasic.vue
new file mode 100644
index 00000000..d60d356d
--- /dev/null
+++ b/docs/components/content/examples/FormExampleBasic.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit
+
+
+
diff --git a/docs/components/content/examples/FormExampleElements.vue b/docs/components/content/examples/FormExampleElements.vue
new file mode 100644
index 00000000..b399f3a9
--- /dev/null
+++ b/docs/components/content/examples/FormExampleElements.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+
+
+
+
+
+
+ Submit
+
+
+
diff --git a/docs/components/content/examples/FormExampleJoi.vue b/docs/components/content/examples/FormExampleJoi.vue
new file mode 100644
index 00000000..7519a90f
--- /dev/null
+++ b/docs/components/content/examples/FormExampleJoi.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit
+
+
+
diff --git a/docs/components/content/examples/FormExampleYup.vue b/docs/components/content/examples/FormExampleYup.vue
new file mode 100644
index 00000000..44b11447
--- /dev/null
+++ b/docs/components/content/examples/FormExampleYup.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit
+
+
+
diff --git a/docs/components/content/examples/FormExampleZod.vue b/docs/components/content/examples/FormExampleZod.vue
new file mode 100644
index 00000000..5053f7ea
--- /dev/null
+++ b/docs/components/content/examples/FormExampleZod.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit
+
+
+
diff --git a/docs/content/3.forms/10.form.md b/docs/content/3.forms/10.form.md
new file mode 100644
index 00000000..6a180296
--- /dev/null
+++ b/docs/content/3.forms/10.form.md
@@ -0,0 +1,325 @@
+---
+description: Collect and validate form data.
+links:
+ - label: GitHub
+ icon: i-simple-icons-github
+ to: https://github.com/nuxtlabs/ui/blob/dev/src/runtime/components/forms/Form.ts
+navigation:
+ badge: Edge
+---
+
+## Usage
+
+Use the Form component to validate form data using schema libraries such as [Yup](https://github.com/jquense/yup), [Zod](https://github.com/colinhacks/zod), [Joi](https://github.com/hapijs/joi) or your own validation logic. It works seamlessly with the FormGroup component to automatically display error messages around form elements.
+
+The Form component requires the `validate` and `state` props for form validation.
+
+- `state` - a reactive object that holds the current state of the form.
+- `validate` - a function that takes the form's state as input and returns an array of `FormError` objects with the following fields:
+ - `message` - the error message to display.
+ - `path` - the path to the form element matching the `name`.
+
+::component-example
+#default
+:form-example-basic{class="space-y-4 w-60"}
+
+#code
+```vue
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit
+
+
+
+```
+::
+
+## Schema
+
+You can provide a schema from [Yup](#yup), [Zod](#zod) or [Joi](#joi) through the `schema` prop to validate the state. It's important to note that **none of these libraries are included** by default, so make sure to **install the one you need**.
+
+### Yup
+
+::component-example
+#default
+:form-example-yup{class="space-y-4 w-60"}
+
+#code
+```vue
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit
+
+
+
+```
+::
+
+### Zod
+
+::component-example
+#default
+:form-example-zod{class="space-y-4 w-60"}
+
+#code
+```vue
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit
+
+
+
+```
+::
+
+### Joi
+
+::component-example
+#default
+:form-example-joi{class="space-y-4 w-60"}
+
+#code
+```vue
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+// [...]
+```
+
+## Other libraries
+
+For other validation libraries, you can define your own component with custom validation logic.
+
+Here is an example with [Vuelidate](https://github.com/vuelidate/vuelidate):
+
+```vue
+
+
+
+
+
+
+
+```
+
+## Input events
+
+The Form component automatically triggers validation upon input `blur` or `change` events. This ensures that any errors are displayed as soon as the user interacts with the form elements.
+
+::component-example
+#default
+:form-example-elements{class="space-y-4 w-60"}
+::
+
+## Props
+
+:component-props
diff --git a/docs/package.json b/docs/package.json
index 77030e84..50164bd7 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -21,6 +21,9 @@
"nuxt-component-meta": "^0.5.3",
"nuxt-lodash": "^2.5.0",
"typescript": "^5.1.6",
- "v-calendar": "^3.0.3"
+ "v-calendar": "^3.0.3",
+ "yup": "^1.2.0",
+ "joi": "^17.9.2",
+ "zod": "^3.21.4"
}
}
diff --git a/package.json b/package.json
index bd181395..02e1fd77 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,9 @@
"release-it": "^16.1.3",
"typescript": "^5.1.6",
"unbuild": "^1.2.1",
- "vue-tsc": "^1.8.8"
+ "vue-tsc": "^1.8.8",
+ "yup": "^1.2.0",
+ "joi": "^17.9.2",
+ "zod": "^3.21.4"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 740ba673..1f961899 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -71,6 +71,9 @@ importers:
eslint:
specifier: ^8.45.0
version: 8.45.0
+ joi:
+ specifier: ^17.9.2
+ version: 17.9.2
nuxt:
specifier: ^3.6.5
version: 3.6.5(@types/node@20.4.5)(eslint@8.45.0)(rollup@3.26.2)(typescript@5.1.6)(vue-tsc@1.8.8)
@@ -86,6 +89,12 @@ importers:
vue-tsc:
specifier: ^1.8.8
version: 1.8.8(typescript@5.1.6)
+ yup:
+ specifier: ^1.2.0
+ version: 1.2.0
+ zod:
+ specifier: ^3.21.4
+ version: 3.21.4
docs:
dependencies:
@@ -129,6 +138,9 @@ importers:
eslint:
specifier: ^8.45.0
version: 8.45.0
+ joi:
+ specifier: ^17.9.2
+ version: 17.9.2
nuxt:
specifier: ^3.6.5
version: 3.6.5(@types/node@20.4.5)(eslint@8.45.0)(rollup@3.26.2)(typescript@5.1.6)(vue-tsc@1.8.8)
@@ -144,6 +156,12 @@ importers:
v-calendar:
specifier: ^3.0.3
version: 3.0.3(@popperjs/core@2.11.8)(vue@3.3.4)
+ yup:
+ specifier: ^1.2.0
+ version: 1.2.0
+ zod:
+ specifier: ^3.21.4
+ version: 3.21.4
packages:
@@ -9677,6 +9695,10 @@ packages:
sisteransi: 1.0.5
dev: true
+ /property-expr@2.0.5:
+ resolution: {integrity: sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==}
+ dev: true
+
/property-information@6.2.0:
resolution: {integrity: sha512-kma4U7AFCTwpqq5twzC1YVIDXSqg6qQK6JN0smOw8fgRy1OkMi0CYSzFmsy6dnqSenamAtj0CyXMUJ1Mf6oROg==}
dev: true
@@ -11002,6 +11024,10 @@ packages:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
dev: true
+ /tiny-case@1.0.3:
+ resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==}
+ dev: true
+
/tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
dev: true
@@ -11040,6 +11066,10 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
+ /toposort@2.0.2:
+ resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==}
+ dev: true
+
/totalist@3.0.1:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
@@ -12388,6 +12418,15 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ /yup@1.2.0:
+ resolution: {integrity: sha512-PPqYKSAXjpRCgLgLKVGPA33v5c/WgEx3wi6NFjIiegz90zSwyMpvTFp/uGcVnnbx6to28pgnzp/q8ih3QRjLMQ==}
+ dependencies:
+ property-expr: 2.0.5
+ tiny-case: 1.0.3
+ toposort: 2.0.2
+ type-fest: 2.19.0
+ dev: true
+
/zhead@2.0.9:
resolution: {integrity: sha512-Y3g6EegQc6PVrYXPq2OS7/s27UGVS5Y6NY6SY3XGH4Hg+yQWbQTtWsjCgmpR8kZnYrv8auB54sz+x5FEDrvqzQ==}
dev: true
@@ -12401,6 +12440,10 @@ packages:
readable-stream: 3.6.2
dev: true
+ /zod@3.21.4:
+ resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
+ dev: true
+
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: true
diff --git a/src/runtime/components/forms/Checkbox.vue b/src/runtime/components/forms/Checkbox.vue
index 99b3396e..7bfa57f2 100644
--- a/src/runtime/components/forms/Checkbox.vue
+++ b/src/runtime/components/forms/Checkbox.vue
@@ -14,6 +14,7 @@
class="form-checkbox"
:class="inputClass"
v-bind="$attrs"
+ @change="onChange"
>
@@ -33,6 +34,7 @@ import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { classNames } from '../../utils'
+import { useFormEvents } from '../../composables/useFormEvents'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -91,13 +93,15 @@ export default defineComponent({
default: () => appConfig.ui.checkbox
}
},
- emits: ['update:modelValue'],
+ emits: ['update:modelValue', 'change'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed
>(() => defu({}, props.ui, appConfig.ui.checkbox))
+ const { emitFormBlur } = useFormEvents()
+
const toggle = computed({
get () {
return props.modelValue
@@ -107,6 +111,11 @@ export default defineComponent({
}
})
+ const onChange = (event: Event) => {
+ emit('change', event)
+ emitFormBlur()
+ }
+
const inputClass = computed(() => {
return classNames(
ui.value.base,
@@ -122,7 +131,8 @@ export default defineComponent({
// eslint-disable-next-line vue/no-dupe-keys
ui,
toggle,
- inputClass
+ inputClass,
+ onChange
}
}
})
diff --git a/src/runtime/components/forms/Form.ts b/src/runtime/components/forms/Form.ts
new file mode 100644
index 00000000..9a5c8b38
--- /dev/null
+++ b/src/runtime/components/forms/Form.ts
@@ -0,0 +1,160 @@
+import { provide, ref, type PropType, h, defineComponent } from 'vue'
+import { useEventBus } from '@vueuse/core'
+import type { ZodSchema, ZodError } from 'zod'
+import type { ValidationError as JoiError, Schema as JoiSchema } from 'joi'
+import type { ObjectSchema as YupObjectSchema, ValidationError as YupError } from 'yup'
+import type { FormError, FormEvent } from '../../types'
+
+export default defineComponent({
+ props: {
+ schema: {
+ type: Object as
+ | PropType
+ | PropType>
+ | PropType,
+ default: undefined
+ },
+ state: {
+ type: Object,
+ required: true
+ },
+ validate: {
+ type: Function as PropType<(state: any) => Promise> | PropType<(state: any) => FormError[]>,
+ default: () => []
+ }
+ },
+ setup (props, { slots, expose }) {
+ const seed = Math.random().toString(36).substring(7)
+ const bus = useEventBus(`form-${seed}`)
+
+ bus.on(async (event) => {
+ if (event.type === 'blur') {
+ const otherErrors = errors.value.filter(
+ (error) => error.path !== event.path
+ )
+ const pathErrors = (await getErrors()).filter(
+ (error) => error.path === event.path
+ )
+ errors.value = otherErrors.concat(pathErrors)
+ }
+ })
+
+ const errors = ref([])
+ provide('form-errors', errors)
+ provide('form-events', bus)
+
+ async function getErrors (): Promise {
+ let errs = await props.validate(props.state)
+
+ if (props.schema) {
+ if (isZodSchema(props.schema)) {
+ errs = errs.concat(await getZodErrors(props.state, props.schema))
+ } else if (isYupSchema(props.schema)) {
+ errs = errs.concat(await getYupErrors(props.state, props.schema))
+ } else if (isJoiSchema(props.schema)) {
+ errs = errs.concat(await getJoiErrors(props.state, props.schema))
+ } else {
+ throw new Error('Form validation failed: Unsupported form schema')
+ }
+ }
+
+ return errs
+ }
+
+ async function validate () {
+ errors.value = await getErrors()
+ if (errors.value.length > 0) {
+ throw new Error(
+ `Form validation failed: ${JSON.stringify(errors.value, null, 2)}`
+ )
+ }
+ }
+
+ expose({
+ validate
+ })
+
+ return () => h('form', slots.default?.())
+ }
+})
+
+function isYupSchema (schema: any): schema is YupObjectSchema {
+ return schema.validate && schema.__isYupSchema__
+}
+
+function isYupError (error: any): error is YupError {
+ return error.inner !== undefined
+}
+
+async function getYupErrors (
+ state: any,
+ schema: YupObjectSchema
+): Promise {
+ try {
+ await schema.validate(state, { abortEarly: false })
+ return []
+ } catch (error) {
+ if (isYupError(error)) {
+ return error.inner.map((issue) => ({
+ path: issue.path ?? '',
+ message: issue.message
+ }))
+ } else {
+ throw error
+ }
+ }
+}
+
+function isZodSchema (schema: any): schema is ZodSchema {
+ return schema.parse !== undefined
+}
+
+function isZodError (error: any): error is ZodError {
+ return error.issues !== undefined
+}
+
+async function getZodErrors (
+ state: any,
+ schema: ZodSchema
+): Promise {
+ try {
+ schema.parse(state)
+ return []
+ } catch (error) {
+ if (isZodError(error)) {
+ return error.issues.map((issue) => ({
+ path: issue.path.join('.'),
+ message: issue.message
+ }))
+ } else {
+ throw error
+ }
+ }
+}
+
+function isJoiSchema (schema: any): schema is JoiSchema {
+ return schema.validateAsync !== undefined && schema.id !== undefined
+}
+
+function isJoiError (error: any): error is JoiError {
+ return error.isJoi === true
+}
+
+async function getJoiErrors (
+ state: any,
+ schema: JoiSchema
+): Promise {
+ try {
+ await schema.validateAsync(state, { abortEarly: false })
+ return []
+ } catch (error) {
+ if (isJoiError(error)) {
+ return error.details.map((detail) => ({
+ path: detail.path.join('.'),
+ message: detail.message
+ }))
+ } else {
+ throw error
+ }
+ }
+}
diff --git a/src/runtime/components/forms/FormGroup.ts b/src/runtime/components/forms/FormGroup.ts
index 2aad8d3b..ad6a9901 100644
--- a/src/runtime/components/forms/FormGroup.ts
+++ b/src/runtime/components/forms/FormGroup.ts
@@ -2,7 +2,9 @@ import { h, cloneVNode, computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { getSlotsChildren } from '../../utils'
+import type { FormError } from '../../types'
import { useAppConfig } from '#imports'
+
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
@@ -57,12 +59,20 @@ export default defineComponent({
const ui = computed>(() => defu({}, props.ui, appConfig.ui.formGroup))
+ provide('form-path', props.name)
+ const formErrors = inject[ | null>('form-errors', null)
+ const errorMessage = computed(() => {
+ return props.error && typeof props.error === 'string'
+ ? props.error
+ : formErrors?.value?.find((error) => error.path === props.name)?.message
+ })
+
const children = computed(() => getSlotsChildren(slots))
const clones = computed(() => children.value.map((node) => {
const vProps: any = {}
- if (props.error) {
+ if (errorMessage.value) {
vProps.oldColor = node.props?.color
vProps.color = 'red'
} else if (vProps.oldColor) {
@@ -89,7 +99,7 @@ export default defineComponent({
] }, props.description),
h('div', { class: [!!props.label && ui.value.container] }, [
...clones.value,
- props.error && typeof props.error === 'string' ? h('p', { class: [ui.value.error, ui.value.size[props.size]] }, props.error) : props.help ? h('p', { class: [ui.value.help, ui.value.size[props.size]] }, props.help) : null
+ errorMessage.value ? h('p', { class: [ui.value.error, ui.value.size[props.size]] }, errorMessage.value) : props.help ? h('p', { class: [ui.value.help, ui.value.size[props.size]] }, props.help) : null
])
])
}
diff --git a/src/runtime/components/forms/Input.vue b/src/runtime/components/forms/Input.vue
index c992cdeb..0ac13efc 100644
--- a/src/runtime/components/forms/Input.vue
+++ b/src/runtime/components/forms/Input.vue
@@ -13,6 +13,7 @@
:class="inputClass"
v-bind="$attrs"
@input="onInput"
+ @blur="onBlur"
>
@@ -35,6 +36,7 @@ import { ref, computed, onMounted, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import UIcon from '../elements/Icon.vue'
+import { useFormEvents } from '../../composables/useFormEvents'
import { classNames } from '../../utils'
import { useAppConfig } from '#imports'
// TODO: Remove
@@ -138,13 +140,15 @@ export default defineComponent({
default: () => appConfig.ui.input
}
},
- emits: ['update:modelValue'],
+ emits: ['update:modelValue', 'blur'],
setup (props, { emit, slots }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed>(() => defu({}, props.ui, appConfig.ui.input))
+ const { emitFormBlur } = useFormEvents()
+
const input = ref(null)
const autoFocus = () => {
@@ -157,6 +161,11 @@ export default defineComponent({
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
+ const onBlur = (event: FocusEvent) => {
+ emitFormBlur()
+ emit('blur', event)
+ }
+
onMounted(() => {
setTimeout(() => {
autoFocus()
@@ -249,7 +258,8 @@ export default defineComponent({
trailingIconName,
trailingIconClass,
trailingWrapperIconClass,
- onInput
+ onInput,
+ onBlur
}
}
})
diff --git a/src/runtime/components/forms/Radio.vue b/src/runtime/components/forms/Radio.vue
index 3d401af4..650c6158 100644
--- a/src/runtime/components/forms/Radio.vue
+++ b/src/runtime/components/forms/Radio.vue
@@ -12,6 +12,7 @@
class="form-radio"
:class="inputClass"
v-bind="$attrs"
+ @change="onChange"
>
]
@@ -31,6 +32,7 @@ import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { classNames } from '../../utils'
+import { useFormEvents } from '../../composables/useFormEvents'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -81,19 +83,24 @@ export default defineComponent({
default: () => appConfig.ui.radio
}
},
- emits: ['update:modelValue'],
+ emits: ['update:modelValue', 'change'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed
>(() => defu({}, props.ui, appConfig.ui.radio))
+ const { emitFormBlur } = useFormEvents()
+
const pick = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
+ if (value) {
+ emitFormBlur()
+ }
}
})
diff --git a/src/runtime/components/forms/Range.vue b/src/runtime/components/forms/Range.vue
index 41da29cf..7d59fafa 100644
--- a/src/runtime/components/forms/Range.vue
+++ b/src/runtime/components/forms/Range.vue
@@ -1,4 +1,4 @@
-
+
@@ -23,6 +24,7 @@ import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { classNames } from '../../utils'
+import { useFormEvents } from '../../composables/useFormEvents'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -74,13 +76,15 @@ export default defineComponent({
default: () => appConfig.ui.range
}
},
- emits: ['update:modelValue'],
+ emits: ['update:modelValue', 'change'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed
>(() => defu({}, props.ui, appConfig.ui.range))
+ const { emitFormBlur } = useFormEvents()
+
const value = computed({
get () {
return props.modelValue
@@ -90,6 +94,11 @@ export default defineComponent({
}
})
+ const onChange = (event: Event) => {
+ emit('change', event)
+ emitFormBlur()
+ }
+
const wrapperClass = computed(() => {
return classNames(
ui.value.wrapper,
@@ -154,7 +163,8 @@ export default defineComponent({
thumbClass,
trackClass,
progressClass,
- progressStyle
+ progressStyle,
+ onChange
}
}
})
diff --git a/src/runtime/components/forms/Select.vue b/src/runtime/components/forms/Select.vue
index 976095aa..588a2198 100644
--- a/src/runtime/components/forms/Select.vue
+++ b/src/runtime/components/forms/Select.vue
@@ -10,6 +10,7 @@
:class="selectClass"
v-bind="$attrs"
@input="onInput"
+ @change="onChange"
>
@@ -22,6 +23,7 @@ import { ref, computed, watch, onMounted, nextTick, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { classNames } from '../../utils'
+import { useFormEvents } from '../../composables/useFormEvents'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -101,7 +103,7 @@ export default defineComponent({
default: () => appConfig.ui.textarea
}
},
- emits: ['update:modelValue'],
+ emits: ['update:modelValue', 'blur'],
setup (props, { emit }) {
const textarea = ref(null)
@@ -110,6 +112,8 @@ export default defineComponent({
const ui = computed>(() => defu({}, props.ui, appConfig.ui.textarea))
+ const { emitFormBlur } = useFormEvents()
+
const autoFocus = () => {
if (props.autofocus) {
textarea.value?.focus()
@@ -144,6 +148,17 @@ export default defineComponent({
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
+ const onBlur = (event: FocusEvent) => {
+ emitFormBlur()
+ emit('blur', event)
+ }
+
+ onMounted(() => {
+ setTimeout(() => {
+ autoFocus()
+ }, 100)
+ })
+
watch(() => props.modelValue, () => {
nextTick(autoResize)
})
@@ -174,7 +189,8 @@ export default defineComponent({
ui,
textarea,
textareaClass,
- onInput
+ onInput,
+ onBlur
}
}
})
diff --git a/src/runtime/components/forms/Toggle.vue b/src/runtime/components/forms/Toggle.vue
index c8150d66..86be3b1b 100644
--- a/src/runtime/components/forms/Toggle.vue
+++ b/src/runtime/components/forms/Toggle.vue
@@ -23,6 +23,7 @@ import { defu } from 'defu'
import { Switch as HSwitch } from '@headlessui/vue'
import UIcon from '../elements/Icon.vue'
import { classNames } from '../../utils'
+import { useFormEvents } from '../../composables/useFormEvents'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
@@ -75,12 +76,15 @@ export default defineComponent({
const ui = computed>(() => defu({}, props.ui, appConfig.ui.toggle))
+ const { emitFormBlur } = useFormEvents()
+
const active = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
+ emitFormBlur()
}
})
diff --git a/src/runtime/composables/useFormEvents.ts b/src/runtime/composables/useFormEvents.ts
new file mode 100644
index 00000000..647be295
--- /dev/null
+++ b/src/runtime/composables/useFormEvents.ts
@@ -0,0 +1,18 @@
+import { inject } from 'vue'
+import { UseEventBusReturn } from '@vueuse/core'
+import { FormEvent } from '../types'
+
+export const useFormEvents = () => {
+ const formBus = inject | undefined>('form-events', undefined)
+ const formPath = inject('form-path', undefined)
+
+ const emitFormBlur = () => {
+ if (formBus && formPath) {
+ formBus.emit({ type: 'blur', path: formPath })
+ }
+ }
+
+ return {
+ emitFormBlur
+ }
+}
diff --git a/src/runtime/types/form.d.ts b/src/runtime/types/form.d.ts
new file mode 100644
index 00000000..5bd37b78
--- /dev/null
+++ b/src/runtime/types/form.d.ts
@@ -0,0 +1,13 @@
+export interface FormError {
+ path: string
+ message: string
+}
+
+export interface Form {
+ async validate(): T
+}
+
+export interface FormEvent {
+ type: 'blur' // | 'change' | 'focus'
+ path: string
+}
diff --git a/src/runtime/types/index.d.ts b/src/runtime/types/index.d.ts
index b6e9e572..584cbd07 100644
--- a/src/runtime/types/index.d.ts
+++ b/src/runtime/types/index.d.ts
@@ -4,6 +4,7 @@ export * from './button'
export * from './clipboard'
export * from './command-palette'
export * from './dropdown'
+export * from './form'
export * from './link'
export * from './notification'
export * from './popper'