Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
e20cad3
feat(useForm): init
rdjanuar Aug 15, 2025
2a6dfa6
up
rdjanuar Aug 15, 2025
08ec790
up
rdjanuar Aug 15, 2025
87feee6
up
rdjanuar Aug 15, 2025
7664bfb
up
rdjanuar Aug 15, 2025
f9f0297
up
rdjanuar Aug 15, 2025
4b212ee
up
rdjanuar Aug 15, 2025
210d7b1
Merge branch 'v3' into feat/use-form
rdjanuar Aug 15, 2025
e875624
up
rdjanuar Aug 15, 2025
8fefb30
Merge branch 'feat/use-form' of github.com-rdjanuar:rdjanuar/ui into …
rdjanuar Aug 15, 2025
9bfacfe
up
rdjanuar Aug 15, 2025
01f144f
up
rdjanuar Aug 16, 2025
29e3188
up
rdjanuar Aug 16, 2025
cdc65a8
up
rdjanuar Aug 16, 2025
f756f1d
up
rdjanuar Aug 16, 2025
aba801f
up
rdjanuar Aug 16, 2025
f812f81
up
rdjanuar Aug 16, 2025
49c71a6
Merge branch 'v3' into feat/use-form
rdjanuar Aug 16, 2025
441bc59
up
rdjanuar Aug 16, 2025
2122beb
Merge branch 'feat/use-form' of github.com-rdjanuar:rdjanuar/ui into …
rdjanuar Aug 16, 2025
570a922
up
rdjanuar Aug 16, 2025
db12e47
up
rdjanuar Aug 16, 2025
e9289ce
up
rdjanuar Aug 16, 2025
ee647b3
up
rdjanuar Aug 16, 2025
3c9580f
up
rdjanuar Aug 16, 2025
28e09cb
up
rdjanuar Aug 16, 2025
8977c0d
up
rdjanuar Aug 16, 2025
8363d7c
up
rdjanuar Aug 17, 2025
82cf4bb
up
rdjanuar Aug 17, 2025
8802865
up
rdjanuar Aug 19, 2025
33b3d21
up
rdjanuar Aug 19, 2025
f7e4622
up
rdjanuar Aug 19, 2025
fcafe84
up
rdjanuar Aug 19, 2025
2c5772d
up
rdjanuar Aug 19, 2025
598916e
up
rdjanuar Aug 19, 2025
3df055e
up
rdjanuar Aug 19, 2025
6a6307e
up
rdjanuar Aug 19, 2025
8d2cadb
up
rdjanuar Aug 19, 2025
c9514ba
up
rdjanuar Aug 19, 2025
e982643
up
rdjanuar Aug 19, 2025
7f84b77
up
rdjanuar Aug 21, 2025
074897f
up
rdjanuar Aug 21, 2025
6cedbe0
up
rdjanuar Aug 21, 2025
07d207d
up
rdjanuar Aug 21, 2025
1323f52
up
rdjanuar Aug 21, 2025
5648bff
up
rdjanuar Aug 21, 2025
1c74a41
up
rdjanuar Aug 22, 2025
1a55bee
up
rdjanuar Aug 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
Submit
</UButton>

<UButton variant="outline" @click="form?.clear()">
<UButton variant="outline" @click="form?.clearErrors()">
Clear
</UButton>
</div>
Expand Down
8 changes: 6 additions & 2 deletions src/runtime/components/Checkbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,12 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.checkbox ||
}))

function onUpdate(value: any) {
// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } })
Comment on lines -94 to -95
Copy link
Contributor Author

@rdjanuar rdjanuar Aug 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we cannot modify target because is read-only

https://developer.mozilla.org/en-US/docs/Web/API/Event/target

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@romhml haven't we discussed this before? It does ring a bell in my head πŸ€”

const event = new CustomEvent('change', {
detail: {
value
}
})

emits('change', event)
emitFormChange()
emitFormInput()
Expand Down
7 changes: 5 additions & 2 deletions src/runtime/components/CheckboxGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,11 @@ const normalizedItems = computed(() => {
})

function onUpdate(value: any) {
// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } })
const event = new CustomEvent('change', {
detail: {
value
}
})
emits('change', event)
emitFormChange()
emitFormInput()
Expand Down
7 changes: 5 additions & 2 deletions src/runtime/components/FileUpload.vue
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,11 @@ function onUpdate(files: File[], reset = false) {
modelValue.value = files?.[0] as (M extends true ? File[] : File) | null
}

// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value: modelValue.value } })
const event = new CustomEvent('change', {
detail: {
value: modelValue.value
}
})
emits('change', event)
emitFormChange()
emitFormInput()
Expand Down
232 changes: 78 additions & 154 deletions src/runtime/components/Form.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<script lang="ts">
import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/form'
import type { FormSchema, FormError, FormInputEvents, FormErrorEvent, FormSubmitEvent, FormEvent, Form, FormErrorWithId, InferInput, InferOutput, FormData } from '../types/form'
import type { FormSchema, FormError, FormInputEvents, FormErrorEvent, FormSubmitEvent, FormEvent, Form, InferInput, FormData, FormValidationException } from '../types/form'
import type { ComponentConfig } from '../types/utils'
import { useForm } from '../composables/useForm'

type FormConfig = ComponentConfig<typeof theme, AppConfig, 'form'>

Expand Down Expand Up @@ -64,16 +65,13 @@ export interface FormSlots {
</script>

<script lang="ts" setup generic="S extends FormSchema, T extends boolean = true">
import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed, useId, readonly, reactive } from 'vue'
import { provide, inject, nextTick, onUnmounted, onMounted, computed, useId, readonly } from 'vue'
import { useEventBus } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { formOptionsInjectionKey, formInputsInjectionKey, formBusInjectionKey, formLoadingInjectionKey } from '../composables/useFormField'
import { formBusInjectionKey } from '../composables/useFormField'
import { tv } from '../utils/tv'
import { validateSchema } from '../utils/form'
import { FormValidationException } from '../types/form'

type I = InferInput<S>
type O = InferOutput<S>

const props = withDefaults(defineProps<FormProps<S, T>>(), {
validateOn() {
Expand All @@ -87,13 +85,51 @@ const props = withDefaults(defineProps<FormProps<S, T>>(), {

const emits = defineEmits<FormEmits<S, T>>()
defineSlots<FormSlots>()
const formId = props.id ?? useId() as string

const {
errors,
loading,
validate: formValidate,
handleSubmit,
setErrors,
getErrors,
setFieldError,
clearErrors,
watch,
setFieldValue,
disabled: formDisabled,
dirtyFields,
touchedFields,
blurredFields,
errorBag,
bind,
registerNestedForm,
unregisterNestedForm,
getFieldValue,
reset,
resetField,
handleBlur,
handleFocus,
handleChange,
handleInput
} = useForm({
id: formId,
schema: props.schema,
validate: props.validate,
validateOn: props.validateOn,
validateOnInputDelay: props.validateOnInputDelay,
transform: props.transform,
loadingAuto: props.loadingAuto,
values: props.state,
defaultValues: props.state,
disabled: computed(() => props.disabled)
})

const appConfig = useAppConfig() as FormConfig['AppConfig']

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.form || {}) }))

const formId = props.id ?? useId() as string

const bus = useEventBus<FormEvent<I>>(`form-${formId}`)
const parentBus = props.attach && inject(
formBusInjectionKey,
Expand All @@ -102,19 +138,17 @@ const parentBus = props.attach && inject(

provide(formBusInjectionKey, bus)

const nestedForms = ref<Map<string | number, { validate: typeof _validate }>>(new Map())

onMounted(async () => {
bus.on(async (event) => {
if (event.type === 'attach') {
nestedForms.value.set(event.formId, { validate: event.validate })
registerNestedForm(event.formId, { validate: event.validate })
} else if (event.type === 'detach') {
nestedForms.value.delete(event.formId)
unregisterNestedForm(event.formId)
} else if (props.validateOn?.includes(event.type) && !loading.value) {
if (event.type !== 'input') {
await _validate({ name: event.name, silent: true, nested: false })
await formValidate({ name: event.name, silent: true, nested: false })
} else if (event.eager || blurredFields.has(event.name)) {
await _validate({ name: event.name, silent: true, nested: false })
await formValidate({ name: event.name, silent: true, nested: false })
}
}

Expand All @@ -139,7 +173,7 @@ onUnmounted(() => {
onMounted(async () => {
if (parentBus) {
await nextTick()
parentBus.emit({ type: 'attach', validate: _validate, formId })
parentBus.emit({ type: 'attach', validate: formValidate, formId })
}
})

Expand All @@ -149,162 +183,52 @@ onUnmounted(() => {
}
})

const errors = ref<FormErrorWithId[]>([])
provide('form-errors', errors)

const inputs = ref<{ [P in keyof I]?: { id?: string, pattern?: RegExp } }>({})
provide(formInputsInjectionKey, inputs as any)

const dirtyFields: Set<keyof I> = reactive(new Set<keyof I>())
const touchedFields: Set<keyof I> = reactive(new Set<keyof I>())
const blurredFields: Set<keyof I> = reactive(new Set<keyof I>())

function resolveErrorIds(errs: FormError[]): FormErrorWithId[] {
return errs.map(err => ({
...err,
id: err?.name ? inputs.value[err.name]?.id : undefined
}))
}

const transformedState = ref<O | null>(null)

async function getErrors(): Promise<FormErrorWithId[]> {
let errs = props.validate ? (await props.validate(props.state)) ?? [] : []

if (props.schema) {
const { errors, result } = await validateSchema(props.state, props.schema as FormSchema<typeof props.state>)
if (errors) {
errs = errs.concat(errors)
} else {
transformedState.value = result
}
}

return resolveErrorIds(errs)
}

type ValidateOpts<Silent extends boolean, Transform extends boolean> = { name?: keyof I | (keyof I)[], silent?: Silent, nested?: boolean, transform?: Transform }
async function _validate<T extends boolean>(opts: ValidateOpts<false, T>): Promise<FormData<S, T>>
async function _validate<T extends boolean>(opts: ValidateOpts<true, T>): Promise<FormData<S, T> | false>
async function _validate<T extends boolean>(opts: ValidateOpts<boolean, boolean> = { silent: false, nested: true, transform: false }): Promise<FormData<S, T> | false> {
const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name as (keyof O)[]

const nestedValidatePromises = !names && opts.nested
? Array.from(nestedForms.value.values()).map(
({ validate }) => validate(opts as any).then(() => undefined).catch((error: Error) => {
if (!(error instanceof FormValidationException)) {
throw error
}
return error
})
)
: []

if (names) {
const otherErrors = errors.value.filter(error => !names.some((name) => {
const pattern = inputs.value?.[name]?.pattern
return name === error.name || (pattern && error.name?.match(pattern))
}))

const pathErrors = (await getErrors()).filter(error => names.some((name) => {
const pattern = inputs.value?.[name]?.pattern
return name === error.name || (pattern && error.name?.match(pattern))
}))

errors.value = otherErrors.concat(pathErrors)
} else {
errors.value = await getErrors()
}

const childErrors = (await Promise.all(nestedValidatePromises)).filter(val => val !== undefined)

if (errors.value.length + childErrors.length > 0) {
if (opts.silent) return false
throw new FormValidationException(formId, errors.value, childErrors)
}

if (opts.transform) {
Object.assign(props.state, transformedState.value)
}

return props.state as FormData<S, T>
const onSuccess = async (data: FormData<S, T>, payload?: Event) => {
const event = payload as FormSubmitEvent<FormData<S, T>>
event.data = data
await props.onSubmit?.(event)
}

const loading = ref(false)
provide(formLoadingInjectionKey, readonly(loading))

async function onSubmitWrapper(payload: Event) {
loading.value = props.loadingAuto && true

const onError = (error: FormValidationException, payload?: Event) => {
const event = payload as FormSubmitEvent<FormData<S, T>>

try {
event.data = await _validate({ nested: true, transform: props.transform })
await props.onSubmit?.(event)
dirtyFields.clear()
} catch (error) {
if (!(error instanceof FormValidationException)) {
throw error
}

const errorEvent: FormErrorEvent = {
...event,
errors: error.errors,
children: error.children
}
emits('error', errorEvent)
} finally {
loading.value = false
const errorEvent: FormErrorEvent = {
...event,
errors: error.errors,
children: error.children
}
emits('error', errorEvent)
}

const disabled = computed(() => props.disabled || loading.value)

provide(formOptionsInjectionKey, computed(() => ({
disabled: disabled.value,
validateOnInputDelay: props.validateOnInputDelay
})))
const onSubmitWrapper = handleSubmit(onSuccess, onError)

defineExpose<Form<S>>({
validate: _validate,
getFieldValue,
validate: formValidate,
errors,

setErrors(errs: FormError[], name?: keyof I | RegExp) {
if (name) {
errors.value = errors.value
.filter(err => name instanceof RegExp ? !(err.name && name.test(err.name)) : err.name !== name)
.concat(resolveErrorIds(errs))
} else {
errors.value = resolveErrorIds(errs)
}
},

setErrors,
getErrors,
clearErrors,
async submit() {
await onSubmitWrapper(new Event('submit'))
},

getErrors(name?: keyof I | RegExp) {
if (name) {
return errors.value.filter(err => name instanceof RegExp ? err.name && name.test(err.name) : err.name === name)
}
return errors.value
},

clear(name?: keyof I | RegExp) {
if (name) {
errors.value = errors.value.filter(err => name instanceof RegExp ? !(err.name && name.test(err.name)) : err.name !== name)
} else {
errors.value = []
}
},

disabled,
disabled: formDisabled,
loading,
dirty: computed(() => !!dirtyFields.size),

dirtyFields: readonly(dirtyFields),
blurredFields: readonly(blurredFields),
touchedFields: readonly(touchedFields)
touchedFields: readonly(touchedFields),
setFieldError,
errorBag,
bind,
watch,
setFieldValue,
resetField,
handleInput,
handleChange,
handleBlur,
handleFocus,
reset
})
</script>

Expand Down
7 changes: 5 additions & 2 deletions src/runtime/components/InputMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,11 @@ function onUpdate(value: any) {
if (toRaw(props.modelValue) === value) {
return
}
// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } })
const event = new CustomEvent('change', {
detail: {
value
}
})
emits('change', event)
emitFormChange()
emitFormInput()
Expand Down
7 changes: 5 additions & 2 deletions src/runtime/components/InputNumber.vue
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,11 @@ const decrementIcon = computed(() => props.decrementIcon || (props.orientation =
const inputRef = ref<InstanceType<typeof NumberFieldInput> | null>(null)

function onUpdate(value: number) {
// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } })
const event = new CustomEvent('change', {
detail: {
value
}
})
emits('change', event)

emitFormChange()
Expand Down
Loading