feat(Form): new component (#4)

Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
Romain Hamel
2024-03-19 16:09:12 +01:00
committed by GitHub
parent 1cec712fb8
commit de62676647
35 changed files with 2735 additions and 69 deletions

View File

@@ -33,9 +33,19 @@ export interface ButtonProps extends LinkProps {
}
export interface ButtonSlots {
leading(props: { disabled?: boolean; loading?: boolean, icon?: string, class: string }): any
leading(props: {
disabled?: boolean
loading?: boolean
icon?: string
class: string
}): any
default(): any
trailing(props: { disabled?: boolean; loading?: boolean, icon?: string, class: string }): any
trailing(props: {
disabled?: boolean
loading?: boolean
icon?: string
class: string
}): any
}
</script>

View File

@@ -0,0 +1,196 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/form'
import { getYupErrors, isYupSchema, getValibotError, isValibotSchema, getZodErrors, isZodSchema, getJoiErrors, isJoiSchema } from '../utils/form'
import type { FormSchema, FormError, FormInputEvents, FormErrorEvent, FormSubmitEvent, FormEvent, InjectedFormOptions, Form } from '../types/form'
const appConfig = _appConfig as AppConfig & { ui: { form: Partial<typeof theme> } }
const form = tv({ extend: tv(theme), ...(appConfig.ui?.form || {}) })
export interface FormProps<T extends object> {
schema?: FormSchema<T>
state: Partial<T>
validate?: (state: Partial<T>) => Promise<FormError[] | void>
validateOn?: FormInputEvents[]
disabled?: boolean
validateOnInputDelay?: number
class?: any
}
export interface FormEmits<T extends object> {
(e: 'submit', payload: FormSubmitEvent<T>): void
(e: 'error', payload: FormErrorEvent): void
}
export interface FormSlots {
default(): any
}
export class FormException extends Error {
constructor (message: string) {
super(message)
this.message = message
Object.setPrototypeOf(this, FormException.prototype)
}
}
</script>
<script lang="ts" setup generic="T extends object">
import { provide, ref, onUnmounted, onMounted, computed } from 'vue'
import { useEventBus } from '@vueuse/core'
import { useId } from '#imports'
const props = withDefaults(defineProps<FormProps<T>>(), {
validateOn () {
return ['input', 'blur', 'change'] as FormInputEvents[]
},
validateOnInputDelay: 300
})
const emit = defineEmits<FormEmits<T>>()
defineSlots<FormSlots>()
const formId = useId()
const bus = useEventBus<FormEvent>(`form-${formId}`)
onMounted(() => {
bus.on(async (event) => {
if (
event.type !== 'submit' &&
props.validateOn?.includes(event.type as FormInputEvents)
) {
await _validate(event.name, { silent: true })
}
})
})
onUnmounted(() => {
bus.reset()
})
const options = {
disabled: computed(() => props.disabled),
validateOnInputDelay: computed(() => props.validateOnInputDelay)
}
provide<InjectedFormOptions>('form-options', options)
const errors = ref<FormError[]>([])
provide('form-errors', errors)
provide('form-events', bus)
const inputs = ref<Record<string, string>>({})
provide('form-inputs', inputs)
async function getErrors (): Promise<FormError[]> {
let errs = props.validate ? (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 if (isValibotSchema(props.schema)) {
errs = errs.concat(await getValibotError(props.state, props.schema))
} else {
throw new Error('Form validation failed: Unsupported form schema')
}
}
return errs
}
async function _validate (
name?: string | string[],
opts: { silent?: boolean } = { silent: false }
): Promise<T | false> {
let paths = name
if (name && !Array.isArray(name)) {
paths = [name]
}
if (paths) {
const otherErrors = errors.value.filter(
(error) => !paths!.includes(error.name)
)
const pathErrors = (await getErrors()).filter((error) =>
paths!.includes(error.name)
)
errors.value = otherErrors.concat(pathErrors)
} else {
errors.value = await getErrors()
}
if (errors.value.length > 0) {
if (opts.silent) return false
throw new FormException(`Form validation failed: ${JSON.stringify(errors.value, null, 2)}`)
}
return props.state as T
}
async function onSubmit (payload: Event) {
const event = payload as SubmitEvent
try {
await _validate()
const submitEvent: FormSubmitEvent<any> = {
...event,
data: props.state
}
emit('submit', submitEvent)
} catch (error) {
if (!(error instanceof FormException)) {
throw error
}
const errorEvent: FormErrorEvent = {
...event,
errors: errors.value.map((err) => ({
...err,
id: inputs.value[err.name]
}))
}
emit('error', errorEvent)
}
}
defineExpose<Form<T>>({
validate: _validate,
errors,
setErrors (errs: FormError[], name?: string) {
errors.value = errs
if (name) {
errors.value = errors.value
.filter((error) => error.name !== name)
.concat(errs)
} else {
errors.value = errs
}
},
async submit () {
await onSubmit(new Event('submit'))
},
getErrors (name?: string) {
if (name) {
return errors.value.filter((err) => err.name === name)
}
return errors.value
},
clear (name?: string) {
if (name) {
errors.value = errors.value.filter((err) => err.name !== name)
} else {
errors.value = []
}
},
...options
})
</script>
<template>
<form :class="form({ class: props.class })" @submit.prevent="onSubmit">
<slot />
</form>
</template>

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/formField'
const appConfig = _appConfig as AppConfig & { ui: { formField: Partial<typeof theme> } }
const formField = tv({ extend: tv(theme), ...(appConfig.ui?.formField || {}) })
type FormFieldVariants = VariantProps<typeof formField>
export interface FormFieldProps {
name?: string
label?: string
description?: string
help?: string
error?: string
hint?: string
size?: FormFieldVariants['size']
required?: boolean
eagerValidation?: boolean
validateOnInputDelay?: number
class?: any
ui?: Partial<typeof formField.slots>
}
export interface FormFieldSlots {
label(props: { label?: string }): any
hint(props: { hint?: string }): any
description(props: { description?: string }): any
error(props: { error?: string }): any
help(props: { help?: string }): any
default(props: { error?: string }): any
}
</script>
<script lang="ts" setup>
import { computed, ref, inject, provide, type Ref } from 'vue'
import type { FormError, InjectedFormFieldOptions } from '../types/form'
import { useId } from '#imports'
const props = defineProps<FormFieldProps>()
defineSlots<FormFieldSlots>()
const ui = computed(() => tv({ extend: formField, slots: props.ui })({
size: props.size,
required: props.required
}))
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.name === props.name)?.message
})
const inputId = ref(useId())
provide<InjectedFormFieldOptions>('form-field', {
error,
inputId,
name: computed(() => props.name),
size: computed(() => props.size),
eagerValidation: computed(() => props.eagerValidation),
validateOnInputDelay: computed(() => props.validateOnInputDelay)
})
</script>
<template>
<div :class="ui.root({ class: props.class })">
<div :class="ui.wrapper()">
<div v-if="label || $slots.label" :class="ui.labelWrapper()">
<label :for="inputId" :class="ui.label()">
<slot name="label" :label="label">
{{ label }}
</slot>
</label>
<span v-if="hint || $slots.hint" :class="ui.hint()">
<slot name="hint" :hint="hint">
{{ hint }}
</slot>
</span>
</div>
<p v-if="description || $slots.description" :class="ui.description()">
<slot name="description" :description="description">
{{ description }}
</slot>
</p>
</div>
<div :class="label ? ui.container() : ''">
<slot :error="error" />
<Transition name="slide-fade" mode="out-in">
<p v-if="(typeof error === 'string' && error) || $slots.error" :class="ui.error()">
<slot name="error" :error="error">
{{ error }}
</slot>
</p>
<p v-else-if="help || $slots.help" :class="ui.help()">
<slot name="help" :help="help">
{{ help }}
</slot>
</p>
</Transition>
</div>
</div>
</template>
<style scoped>
.slide-fade-enter-active {
transition: all 0.15s ease-out;
}
.slide-fade-leave-active {
transition: all 0.15s ease-out;
}
.slide-fade-enter-from {
transform: translateY(-10px);
opacity: 0;
}
.slide-fade-leave-to {
transform: translateY(10px);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,176 @@
<script lang="ts">
// TODO: Add missing props / slots (e.g. icons)
import { tv, type VariantProps } from 'tailwind-variants'
import { defu } from 'defu'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/input'
import { looseToNumber } from '../utils'
const appConfig = _appConfig as AppConfig & { ui: { input: Partial<typeof theme> } }
const input = tv({ extend: tv(theme), ...(appConfig.ui?.input || {}) })
type InputVariants = VariantProps<typeof input>
export interface InputProps {
id?: string | number
name?: string
type?: string
required?: boolean
color?: InputVariants['color']
variant?: InputVariants['variant']
size?: InputVariants['size']
modelValue?: string
placeholder?: string
autofocus?: boolean
autofocusDelay?: number
modelModifiers?: {
trim?: boolean
lazy?: boolean
number?: boolean
}
disabled?: boolean
class?: any
ui?: Partial<typeof input.slots>
}
export interface InputEmits {
(e: 'update:modelValue', value: string): void
(e: 'blur', event: FocusEvent): void
}
export interface InputSlots {
leading(props: {
disabled?: boolean
loading?: boolean
icon?: string
class: string
}): any
default(): any
trailing(props: {
disabled?: boolean
loading?: boolean
icon?: string
class: string
}): any
}
</script>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { useFormField } from '../composables/useFormField'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<InputProps>(), {
type: 'text',
autofocusDelay: 100
})
const emit = defineEmits<InputEmits>()
defineSlots<InputSlots>()
const { emitFormBlur, emitFormInput, size, color, inputId, name, disabled } = useFormField(props)
const ui = computed(() => tv({ extend: input, slots: props.ui })({
color: color.value,
variant: props.variant,
size: size?.value
}))
// const { size: sizeButtonGroup, rounded } = useInjectButtonGroup({ ui, props })
// const size = computed(() => sizeButtonGroup.value || sizeFormGroup.value)
const modelModifiers = ref(defu({}, props.modelModifiers, { trim: false, lazy: false, number: false }))
const inputRef = ref<HTMLInputElement | null>(null)
const autoFocus = () => {
if (props.autofocus) {
inputRef.value?.focus()
}
}
// Custom function to handle the v-model properties
const updateInput = (value: string) => {
if (modelModifiers.value.trim) {
value = value.trim()
}
if (modelModifiers.value.number || props.type === 'number') {
value = looseToNumber(value)
}
emit('update:modelValue', value)
emitFormInput()
}
const onInput = (event: Event) => {
if (!modelModifiers.value.lazy) {
updateInput((event.target as HTMLInputElement).value)
}
}
const onChange = (event: Event) => {
const value = (event.target as HTMLInputElement).value
if (modelModifiers.value.lazy) {
updateInput(value)
}
// Update trimmed input so that it has same behavior as native input https://github.com/vuejs/core/blob/5ea8a8a4fab4e19a71e123e4d27d051f5e927172/packages/runtime-dom/src/directives/vModel.ts#L63
if (modelModifiers.value.trim) {
(event.target as HTMLInputElement).value = value.trim()
}
}
const onBlur = (event: FocusEvent) => {
emitFormBlur()
emit('blur', event)
}
onMounted(() => {
setTimeout(() => {
autoFocus()
}, props.autofocusDelay)
})
</script>
<template>
<div :class="ui.root({ class: props.class })">
<input
:id="inputId"
ref="inputRef"
:type="type"
:value="modelValue"
:name="name"
:placeholder="placeholder"
:class="ui.base()"
:disabled="disabled"
v-bind="$attrs"
@input="onInput"
@blur="onBlur"
@change="onChange"
>
<slot />
<!-- span
v-if="(isLeading && leadingIconName) || $slots.leading"
:class="leadingWrapperIconClass"
>
<slot name="leading" :disabled="disabled" :loading="loading">
<UIcon :name="leadingIconName" :class="leadingIconClass" />
</slot>
</span>
<span
v-if="(isTrailing && trailingIconName) || $slots.trailing"
:class="trailingWrapperIconClass"
>
<slot name="trailing" :disabled="disabled" :loading="loading">
<UIcon :name="trailingIconName" :class="trailingIconClass" />
</slot>
</span -->
</div>
</template>

View File

@@ -17,16 +17,21 @@ export interface TabsItem {
content?: string
}
export interface TabsProps extends Omit<TabsRootProps, 'asChild'> {
items: TabsItem[]
export interface TabsProps<T extends TabsItem> extends Omit<TabsRootProps, 'asChild'> {
items: T[]
class?: any
ui?: Partial<typeof tabs.slots>
}
export interface TabsEmits extends TabsRootEmits {}
export interface TabsSlots {
type SlotFunction<T> = (props: { item: T, index: number }) => any
export type TabsSlots<T extends TabsItem> = {
default(): any
item(): SlotFunction<T>
} & {
[key in T['slot'] as string]?: SlotFunction<T>
}
</script>
@@ -34,16 +39,9 @@ export interface TabsSlots {
import { computed } from 'vue'
import { TabsRoot, TabsList, TabsIndicator, TabsTrigger, TabsContent, useForwardPropsEmits } from 'radix-vue'
const props = withDefaults(defineProps<TabsProps & { items: T[] }>(), { defaultValue: '0' })
const props = withDefaults(defineProps<TabsProps<T>>(), { defaultValue: '0' })
const emits = defineEmits<TabsEmits>()
type SlotFunction<T> = (props: { item: T, index: number }) => any
defineSlots<TabsSlots & {
item(): SlotFunction<T>
} & {
[key in T['slot'] as string]?: SlotFunction<T>
}>()
defineSlots<TabsSlots<T>>()
const rootProps = useForwardPropsEmits(props, emits)

View File

@@ -0,0 +1,70 @@
import { inject, ref, computed } from 'vue'
import { type UseEventBusReturn, useDebounceFn } from '@vueuse/core'
import type { FormEvent, FormInputEvents, InjectedFormFieldOptions, InjectedFormOptions } from '../types/form'
type InputProps = {
id?: string | number
size?: string | number | symbol
color?: string // FIXME: Replace by enum
name?: string
eagerValidation?: boolean
legend?: string | null
disabled?: boolean | null
}
export const useFormField = (inputProps?: InputProps) => {
const formOptions = inject<InjectedFormOptions | undefined>('form-options', undefined)
const formBus = inject<UseEventBusReturn<FormEvent, string> | undefined>('form-events', undefined)
const formField = inject<InjectedFormFieldOptions | undefined>('form-field', undefined)
const formInputs = inject<any>('form-inputs', undefined)
if (formField) {
if (inputProps?.id) {
// Updates for="..." attribute on label if inputProps.id is provided
formField.inputId.value = inputProps?.id
}
if (formInputs) {
formInputs.value[formField.name.value] = formField.inputId.value
}
}
const blurred = ref(false)
function emitFormEvent (type: FormInputEvents, name: string) {
if (formBus && formField) {
formBus.emit({ type, name })
}
}
function emitFormBlur () {
emitFormEvent('blur', formField?.name.value as string)
blurred.value = true
}
function emitFormChange () {
emitFormEvent('change', formField?.name.value as string)
}
const emitFormInput = useDebounceFn(
() => {
if (blurred.value || formField?.eagerValidation.value) {
emitFormEvent('input', formField?.name.value as string)
}
},
formField?.validateOnInputDelay.value ??
formOptions?.validateOnInputDelay.value ??
0
)
return {
inputId: computed(() => inputProps?.id ?? formField?.inputId.value),
name: computed(() => inputProps?.name ?? formField?.name.value),
size: computed(() => inputProps?.size ?? formField?.size?.value),
color: computed(() => formField?.error?.value ? 'red' : inputProps?.color),
disabled: computed(() => formOptions?.disabled?.value || inputProps?.disabled),
emitFormBlur,
emitFormInput,
emitFormChange
}
}

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

@@ -0,0 +1,51 @@
export interface Form<T> {
validate (path?: string | string[], opts?: { silent?: true }): Promise<T | false>
validate (path?: string | string[], opts?: { silent?: false }): Promise<T | false>
clear (path?: string): void
errors: Ref<FormError[]>
setErrors (errs: FormError[], path?: string): void
getErrors (path?: string): FormError[]
submit (): Promise<void>
disabled: ComputedRef<boolean>
}
export type FormSchema<T extends object> =
| ZodSchema
| YupObjectSchema<T>
| ValibotObjectSchema<T>
| JoiSchema<T>
export type FormInputEvents = 'input' | 'blur' | 'change'
export interface FormError<P extends string = string> {
name: P
message: string
}
export interface FormErrorWithId extends FormError {
id: string
}
export type FormSubmitEvent<T> = SubmitEvent & { data: T }
export type FormErrorEvent = SubmitEvent & { errors: FormErrorWithId[] }
export type FormEventType = FormInputEvents | 'submit'
export interface FormEvent {
type: FormEventType
name?: string
}
export interface InjectedFormFieldOptions {
inputId: Ref<string | number | undefined>
name: Computed<string>
size: Computed<string | number | symbol>
error: Computed<string | boolean | undefined>
eagerValidation: Computed<boolean>
validateOnInputDelay: Computed<number | undefined>
}
export interface InjectedFormOptions {
disabled?: Computed<boolean>
validateOnInputDelay?: Computed<number>
}

View File

@@ -0,0 +1 @@

83
src/runtime/utils/form.ts Normal file
View File

@@ -0,0 +1,83 @@
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 { ObjectSchemaAsync as ValibotObjectSchema } from 'valibot'
import type { FormError } from '../types/form'
export function isYupSchema (schema: any): schema is YupObjectSchema<any> {
return schema.validate && schema.__isYupSchema__
}
export function isYupError (error: any): error is YupError {
return error.inner !== undefined
}
export 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) => ({
name: issue.path ?? '',
message: issue.message
}))
} else {
throw error
}
}
}
export function isZodSchema (schema: any): schema is ZodSchema {
return schema.parse !== undefined
}
export async function getZodErrors (state: any, schema: ZodSchema): Promise<FormError[]> {
const result = await schema.safeParseAsync(state)
if (result.success === false) {
return result.error.issues.map((issue) => ({
name: issue.path.join('.'),
message: issue.message
}))
}
return []
}
export function isJoiSchema (schema: any): schema is JoiSchema {
return schema.validateAsync !== undefined && schema.id !== undefined
}
export function isJoiError (error: any): error is JoiError {
return error.isJoi === true
}
export 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) => ({
name: detail.path.join('.'),
message: detail.message
}))
} else {
throw error
}
}
}
export function isValibotSchema (schema: any): schema is ValibotObjectSchema<any> {
return schema._parse !== undefined
}
export async function getValibotError (state: any, schema: ValibotObjectSchema<any>): Promise<FormError[]> {
const result = await schema._parse(state)
if (result.issues) {
return result.issues.map((issue) => ({
name: issue.path?.map((p) => p.key).join('.') || '',
message: issue.message
}))
}
return []
}

View File

@@ -0,0 +1,4 @@
export function looseToNumber (val: any): any {
const n = parseFloat(val)
return isNaN(n) ? val : n
}