mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-17 21:48:07 +01:00
feat(Form): new component (#439)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
class="form-checkbox"
|
||||
:class="inputClass"
|
||||
v-bind="$attrs"
|
||||
@change="onChange"
|
||||
>
|
||||
</div>
|
||||
<div v-if="label || $slots.label" class="ms-3 text-sm">
|
||||
@@ -33,6 +34,7 @@ import { computed, defineComponent } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { defu } from 'defu'
|
||||
import { classNames } from '../../utils'
|
||||
import { useFormEvents } from '../../composables/useFormEvents'
|
||||
import { useAppConfig } from '#imports'
|
||||
// TODO: Remove
|
||||
// @ts-expect-error
|
||||
@@ -91,13 +93,15 @@ export default defineComponent({
|
||||
default: () => appConfig.ui.checkbox
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
emits: ['update:modelValue', 'change'],
|
||||
setup (props, { emit }) {
|
||||
// TODO: Remove
|
||||
const appConfig = useAppConfig()
|
||||
|
||||
const ui = computed<Partial<typeof appConfig.ui.checkbox>>(() => defu({}, props.ui, appConfig.ui.checkbox))
|
||||
|
||||
const { emitFormBlur } = useFormEvents()
|
||||
|
||||
const toggle = computed({
|
||||
get () {
|
||||
return props.modelValue
|
||||
@@ -107,6 +111,11 @@ export default defineComponent({
|
||||
}
|
||||
})
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
emit('change', event)
|
||||
emitFormBlur()
|
||||
}
|
||||
|
||||
const inputClass = computed(() => {
|
||||
return classNames(
|
||||
ui.value.base,
|
||||
@@ -122,7 +131,8 @@ export default defineComponent({
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
ui,
|
||||
toggle,
|
||||
inputClass
|
||||
inputClass,
|
||||
onChange
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
160
src/runtime/components/forms/Form.ts
Normal file
160
src/runtime/components/forms/Form.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { provide, ref, type PropType, h, defineComponent } from 'vue'
|
||||
import { useEventBus } from '@vueuse/core'
|
||||
import type { ZodSchema, ZodError } from 'zod'
|
||||
import type { ValidationError as JoiError, Schema as JoiSchema } from 'joi'
|
||||
import type { ObjectSchema as YupObjectSchema, ValidationError as YupError } from 'yup'
|
||||
import type { FormError, FormEvent } from '../../types'
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
schema: {
|
||||
type: Object as
|
||||
| PropType<ZodSchema>
|
||||
| PropType<YupObjectSchema<any>>
|
||||
| PropType<JoiSchema>,
|
||||
default: undefined
|
||||
},
|
||||
state: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
validate: {
|
||||
type: Function as PropType<(state: any) => Promise<FormError[]>> | PropType<(state: any) => FormError[]>,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
setup (props, { slots, expose }) {
|
||||
const seed = Math.random().toString(36).substring(7)
|
||||
const bus = useEventBus<FormEvent>(`form-${seed}`)
|
||||
|
||||
bus.on(async (event) => {
|
||||
if (event.type === 'blur') {
|
||||
const otherErrors = errors.value.filter(
|
||||
(error) => error.path !== event.path
|
||||
)
|
||||
const pathErrors = (await getErrors()).filter(
|
||||
(error) => error.path === event.path
|
||||
)
|
||||
errors.value = otherErrors.concat(pathErrors)
|
||||
}
|
||||
})
|
||||
|
||||
const errors = ref<FormError[]>([])
|
||||
provide('form-errors', errors)
|
||||
provide('form-events', bus)
|
||||
|
||||
async function getErrors (): Promise<FormError[]> {
|
||||
let errs = await props.validate(props.state)
|
||||
|
||||
if (props.schema) {
|
||||
if (isZodSchema(props.schema)) {
|
||||
errs = errs.concat(await getZodErrors(props.state, props.schema))
|
||||
} else if (isYupSchema(props.schema)) {
|
||||
errs = errs.concat(await getYupErrors(props.state, props.schema))
|
||||
} else if (isJoiSchema(props.schema)) {
|
||||
errs = errs.concat(await getJoiErrors(props.state, props.schema))
|
||||
} else {
|
||||
throw new Error('Form validation failed: Unsupported form schema')
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
async function validate () {
|
||||
errors.value = await getErrors()
|
||||
if (errors.value.length > 0) {
|
||||
throw new Error(
|
||||
`Form validation failed: ${JSON.stringify(errors.value, null, 2)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
expose({
|
||||
validate
|
||||
})
|
||||
|
||||
return () => h('form', slots.default?.())
|
||||
}
|
||||
})
|
||||
|
||||
function isYupSchema (schema: any): schema is YupObjectSchema<any> {
|
||||
return schema.validate && schema.__isYupSchema__
|
||||
}
|
||||
|
||||
function isYupError (error: any): error is YupError {
|
||||
return error.inner !== undefined
|
||||
}
|
||||
|
||||
async function getYupErrors (
|
||||
state: any,
|
||||
schema: YupObjectSchema<any>
|
||||
): Promise<FormError[]> {
|
||||
try {
|
||||
await schema.validate(state, { abortEarly: false })
|
||||
return []
|
||||
} catch (error) {
|
||||
if (isYupError(error)) {
|
||||
return error.inner.map((issue) => ({
|
||||
path: issue.path ?? '',
|
||||
message: issue.message
|
||||
}))
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isZodSchema (schema: any): schema is ZodSchema {
|
||||
return schema.parse !== undefined
|
||||
}
|
||||
|
||||
function isZodError (error: any): error is ZodError {
|
||||
return error.issues !== undefined
|
||||
}
|
||||
|
||||
async function getZodErrors (
|
||||
state: any,
|
||||
schema: ZodSchema
|
||||
): Promise<FormError[]> {
|
||||
try {
|
||||
schema.parse(state)
|
||||
return []
|
||||
} catch (error) {
|
||||
if (isZodError(error)) {
|
||||
return error.issues.map((issue) => ({
|
||||
path: issue.path.join('.'),
|
||||
message: issue.message
|
||||
}))
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isJoiSchema (schema: any): schema is JoiSchema {
|
||||
return schema.validateAsync !== undefined && schema.id !== undefined
|
||||
}
|
||||
|
||||
function isJoiError (error: any): error is JoiError {
|
||||
return error.isJoi === true
|
||||
}
|
||||
|
||||
async function getJoiErrors (
|
||||
state: any,
|
||||
schema: JoiSchema
|
||||
): Promise<FormError[]> {
|
||||
try {
|
||||
await schema.validateAsync(state, { abortEarly: false })
|
||||
return []
|
||||
} catch (error) {
|
||||
if (isJoiError(error)) {
|
||||
return error.details.map((detail) => ({
|
||||
path: detail.path.join('.'),
|
||||
message: detail.message
|
||||
}))
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { h, cloneVNode, computed, defineComponent } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { defu } from 'defu'
|
||||
import { getSlotsChildren } from '../../utils'
|
||||
import type { FormError } from '../../types'
|
||||
import { useAppConfig } from '#imports'
|
||||
|
||||
// TODO: Remove
|
||||
// @ts-expect-error
|
||||
import appConfig from '#build/app.config'
|
||||
@@ -57,12 +59,20 @@ export default defineComponent({
|
||||
|
||||
const ui = computed<Partial<typeof appConfig.ui.formGroup>>(() => defu({}, props.ui, appConfig.ui.formGroup))
|
||||
|
||||
provide('form-path', props.name)
|
||||
const formErrors = inject<Ref<FormError[]> | null>('form-errors', null)
|
||||
const errorMessage = computed(() => {
|
||||
return props.error && typeof props.error === 'string'
|
||||
? props.error
|
||||
: formErrors?.value?.find((error) => error.path === props.name)?.message
|
||||
})
|
||||
|
||||
const children = computed(() => getSlotsChildren(slots))
|
||||
|
||||
const clones = computed(() => children.value.map((node) => {
|
||||
const vProps: any = {}
|
||||
|
||||
if (props.error) {
|
||||
if (errorMessage.value) {
|
||||
vProps.oldColor = node.props?.color
|
||||
vProps.color = 'red'
|
||||
} else if (vProps.oldColor) {
|
||||
@@ -89,7 +99,7 @@ export default defineComponent({
|
||||
] }, props.description),
|
||||
h('div', { class: [!!props.label && ui.value.container] }, [
|
||||
...clones.value,
|
||||
props.error && typeof props.error === 'string' ? h('p', { class: [ui.value.error, ui.value.size[props.size]] }, props.error) : props.help ? h('p', { class: [ui.value.help, ui.value.size[props.size]] }, props.help) : null
|
||||
errorMessage.value ? h('p', { class: [ui.value.error, ui.value.size[props.size]] }, errorMessage.value) : props.help ? h('p', { class: [ui.value.help, ui.value.size[props.size]] }, props.help) : null
|
||||
])
|
||||
])
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
:class="inputClass"
|
||||
v-bind="$attrs"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -35,6 +36,7 @@ import { ref, computed, onMounted, defineComponent } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { defu } from 'defu'
|
||||
import UIcon from '../elements/Icon.vue'
|
||||
import { useFormEvents } from '../../composables/useFormEvents'
|
||||
import { classNames } from '../../utils'
|
||||
import { useAppConfig } from '#imports'
|
||||
// TODO: Remove
|
||||
@@ -138,13 +140,15 @@ export default defineComponent({
|
||||
default: () => appConfig.ui.input
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
emits: ['update:modelValue', 'blur'],
|
||||
setup (props, { emit, slots }) {
|
||||
// TODO: Remove
|
||||
const appConfig = useAppConfig()
|
||||
|
||||
const ui = computed<Partial<typeof appConfig.ui.input>>(() => defu({}, props.ui, appConfig.ui.input))
|
||||
|
||||
const { emitFormBlur } = useFormEvents()
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const autoFocus = () => {
|
||||
@@ -157,6 +161,11 @@ export default defineComponent({
|
||||
emit('update:modelValue', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
const onBlur = (event: FocusEvent) => {
|
||||
emitFormBlur()
|
||||
emit('blur', event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autoFocus()
|
||||
@@ -249,7 +258,8 @@ export default defineComponent({
|
||||
trailingIconName,
|
||||
trailingIconClass,
|
||||
trailingWrapperIconClass,
|
||||
onInput
|
||||
onInput,
|
||||
onBlur
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
class="form-radio"
|
||||
:class="inputClass"
|
||||
v-bind="$attrs"
|
||||
@change="onChange"
|
||||
>
|
||||
</div>
|
||||
<div v-if="label || $slots.label" class="ms-3 text-sm">
|
||||
@@ -31,6 +32,7 @@ import { computed, defineComponent } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { defu } from 'defu'
|
||||
import { classNames } from '../../utils'
|
||||
import { useFormEvents } from '../../composables/useFormEvents'
|
||||
import { useAppConfig } from '#imports'
|
||||
// TODO: Remove
|
||||
// @ts-expect-error
|
||||
@@ -81,19 +83,24 @@ export default defineComponent({
|
||||
default: () => appConfig.ui.radio
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
emits: ['update:modelValue', 'change'],
|
||||
setup (props, { emit }) {
|
||||
// TODO: Remove
|
||||
const appConfig = useAppConfig()
|
||||
|
||||
const ui = computed<Partial<typeof appConfig.ui.radio>>(() => defu({}, props.ui, appConfig.ui.radio))
|
||||
|
||||
const { emitFormBlur } = useFormEvents()
|
||||
|
||||
const pick = computed({
|
||||
get () {
|
||||
return props.modelValue
|
||||
},
|
||||
set (value) {
|
||||
emit('update:modelValue', value)
|
||||
if (value) {
|
||||
emitFormBlur()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<input
|
||||
:id="name"
|
||||
@@ -12,6 +12,7 @@
|
||||
type="range"
|
||||
:class="[inputClass, thumbClass, trackClass]"
|
||||
v-bind="$attrs"
|
||||
@change="onChange"
|
||||
>
|
||||
|
||||
<span :class="progressClass" :style="progressStyle" />
|
||||
@@ -23,6 +24,7 @@ import { computed, defineComponent } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { defu } from 'defu'
|
||||
import { classNames } from '../../utils'
|
||||
import { useFormEvents } from '../../composables/useFormEvents'
|
||||
import { useAppConfig } from '#imports'
|
||||
// TODO: Remove
|
||||
// @ts-expect-error
|
||||
@@ -74,13 +76,15 @@ export default defineComponent({
|
||||
default: () => appConfig.ui.range
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
emits: ['update:modelValue', 'change'],
|
||||
setup (props, { emit }) {
|
||||
// TODO: Remove
|
||||
const appConfig = useAppConfig()
|
||||
|
||||
const ui = computed<Partial<typeof appConfig.ui.range>>(() => defu({}, props.ui, appConfig.ui.range))
|
||||
|
||||
const { emitFormBlur } = useFormEvents()
|
||||
|
||||
const value = computed({
|
||||
get () {
|
||||
return props.modelValue
|
||||
@@ -90,6 +94,11 @@ export default defineComponent({
|
||||
}
|
||||
})
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
emit('change', event)
|
||||
emitFormBlur()
|
||||
}
|
||||
|
||||
const wrapperClass = computed(() => {
|
||||
return classNames(
|
||||
ui.value.wrapper,
|
||||
@@ -154,7 +163,8 @@ export default defineComponent({
|
||||
thumbClass,
|
||||
trackClass,
|
||||
progressClass,
|
||||
progressStyle
|
||||
progressStyle,
|
||||
onChange
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
:class="selectClass"
|
||||
v-bind="$attrs"
|
||||
@input="onInput"
|
||||
@change="onChange"
|
||||
>
|
||||
<template v-for="(option, index) in normalizedOptionsWithPlaceholder">
|
||||
<optgroup
|
||||
@@ -59,6 +60,7 @@ import { get } from 'lodash-es'
|
||||
import { defu } from 'defu'
|
||||
import UIcon from '../elements/Icon.vue'
|
||||
import { classNames } from '../../utils'
|
||||
import { useFormEvents } from '../../composables/useFormEvents'
|
||||
import { useAppConfig } from '#imports'
|
||||
// TODO: Remove
|
||||
// @ts-expect-error
|
||||
@@ -165,17 +167,24 @@ export default defineComponent({
|
||||
default: () => appConfig.ui.select
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
emits: ['update:modelValue', 'change'],
|
||||
setup (props, { emit, slots }) {
|
||||
// TODO: Remove
|
||||
const appConfig = useAppConfig()
|
||||
|
||||
const ui = computed<Partial<typeof appConfig.ui.select>>(() => defu({}, props.ui, appConfig.ui.select))
|
||||
|
||||
const { emitFormBlur } = useFormEvents()
|
||||
|
||||
const onInput = (event: InputEvent) => {
|
||||
emit('update:modelValue', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
emitFormBlur()
|
||||
emit('change', event)
|
||||
}
|
||||
|
||||
const guessOptionValue = (option: any) => {
|
||||
return get(option, props.valueAttribute, get(option, props.optionAttribute))
|
||||
}
|
||||
@@ -314,7 +323,8 @@ export default defineComponent({
|
||||
trailingIconName,
|
||||
trailingIconClass,
|
||||
trailingWrapperIconClass,
|
||||
onInput
|
||||
onInput,
|
||||
onChange
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -135,6 +135,7 @@ import UIcon from '../elements/Icon.vue'
|
||||
import UAvatar from '../elements/Avatar.vue'
|
||||
import { classNames } from '../../utils'
|
||||
import { usePopper } from '../../composables/usePopper'
|
||||
import { useFormEvents } from '../../composables/useFormEvents'
|
||||
import type { PopperOptions } from '../../types'
|
||||
import { useAppConfig } from '#imports'
|
||||
// TODO: Remove
|
||||
@@ -292,7 +293,7 @@ export default defineComponent({
|
||||
default: () => appConfig.ui.selectMenu
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'open', 'close'],
|
||||
emits: ['update:modelValue', 'open', 'close', 'change'],
|
||||
setup (props, { emit, slots }) {
|
||||
// TODO: Remove
|
||||
const appConfig = useAppConfig()
|
||||
@@ -303,6 +304,7 @@ export default defineComponent({
|
||||
const popper = computed<PopperOptions>(() => defu({}, props.popper, uiMenu.value.popper as PopperOptions))
|
||||
|
||||
const [trigger, container] = usePopper(popper.value)
|
||||
const { emitFormBlur } = useFormEvents()
|
||||
|
||||
const query = ref('')
|
||||
const searchInput = ref<ComponentPublicInstance<HTMLElement>>()
|
||||
@@ -409,6 +411,7 @@ export default defineComponent({
|
||||
emit('open')
|
||||
} else {
|
||||
emit('close')
|
||||
emitFormBlur()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -419,6 +422,8 @@ export default defineComponent({
|
||||
searchInput.value.$el.value = ''
|
||||
}
|
||||
emit('update:modelValue', event)
|
||||
emit('change', event)
|
||||
emitFormBlur()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
:class="textareaClass"
|
||||
v-bind="$attrs"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,6 +23,7 @@ import { ref, computed, watch, onMounted, nextTick, defineComponent } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { defu } from 'defu'
|
||||
import { classNames } from '../../utils'
|
||||
import { useFormEvents } from '../../composables/useFormEvents'
|
||||
import { useAppConfig } from '#imports'
|
||||
// TODO: Remove
|
||||
// @ts-expect-error
|
||||
@@ -101,7 +103,7 @@ export default defineComponent({
|
||||
default: () => appConfig.ui.textarea
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
emits: ['update:modelValue', 'blur'],
|
||||
setup (props, { emit }) {
|
||||
const textarea = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
@@ -110,6 +112,8 @@ export default defineComponent({
|
||||
|
||||
const ui = computed<Partial<typeof appConfig.ui.textarea>>(() => defu({}, props.ui, appConfig.ui.textarea))
|
||||
|
||||
const { emitFormBlur } = useFormEvents()
|
||||
|
||||
const autoFocus = () => {
|
||||
if (props.autofocus) {
|
||||
textarea.value?.focus()
|
||||
@@ -144,6 +148,17 @@ export default defineComponent({
|
||||
emit('update:modelValue', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
const onBlur = (event: FocusEvent) => {
|
||||
emitFormBlur()
|
||||
emit('blur', event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autoFocus()
|
||||
}, 100)
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
nextTick(autoResize)
|
||||
})
|
||||
@@ -174,7 +189,8 @@ export default defineComponent({
|
||||
ui,
|
||||
textarea,
|
||||
textareaClass,
|
||||
onInput
|
||||
onInput,
|
||||
onBlur
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ import { defu } from 'defu'
|
||||
import { Switch as HSwitch } from '@headlessui/vue'
|
||||
import UIcon from '../elements/Icon.vue'
|
||||
import { classNames } from '../../utils'
|
||||
import { useFormEvents } from '../../composables/useFormEvents'
|
||||
import { useAppConfig } from '#imports'
|
||||
// TODO: Remove
|
||||
// @ts-expect-error
|
||||
@@ -75,12 +76,15 @@ export default defineComponent({
|
||||
|
||||
const ui = computed<Partial<typeof appConfig.ui.toggle>>(() => defu({}, props.ui, appConfig.ui.toggle))
|
||||
|
||||
const { emitFormBlur } = useFormEvents()
|
||||
|
||||
const active = computed({
|
||||
get () {
|
||||
return props.modelValue
|
||||
},
|
||||
set (value) {
|
||||
emit('update:modelValue', value)
|
||||
emitFormBlur()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user