mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-21 23:40:39 +01:00
feat(Form): new component (#4)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
196
src/runtime/components/Form.vue
Normal file
196
src/runtime/components/Form.vue
Normal 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>
|
||||
132
src/runtime/components/FormField.vue
Normal file
132
src/runtime/components/FormField.vue
Normal 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>
|
||||
176
src/runtime/components/Input.vue
Normal file
176
src/runtime/components/Input.vue
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user