Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f9aaa99
feat(FormField): add required field support across form components
rdjanuar Jul 16, 2025
448b264
Merge branch 'v3' into fix/issues-4529
rdjanuar Jul 16, 2025
6889618
fix(form): simplify required field logic by moving it to useFormField
rdjanuar Jul 16, 2025
52e4dad
Merge branch 'v3' into fix/issues-4529
rdjanuar Jul 16, 2025
9846225
Merge branch 'v3' into pr/4534
benjamincanac Jul 16, 2025
1759c52
up
benjamincanac Jul 16, 2025
de43537
up
benjamincanac Jul 16, 2025
52303fe
Merge branch 'v3' into fix/issues-4529
rdjanuar Jul 17, 2025
a79bb6f
test(FormField): add required binding test
rdjanuar Jul 19, 2025
ef955d4
refactor(FormField): improve type safety for required fields
rdjanuar Jul 19, 2025
0b09606
up
rdjanuar Jul 19, 2025
d6914fb
up
rdjanuar Jul 19, 2025
e5ab984
up
rdjanuar Jul 19, 2025
27ad4b8
up
rdjanuar Jul 19, 2025
cacb095
fix(FormField): revert FieldProps
rdjanuar Jul 23, 2025
8519389
fix(docs): revert form component example
rdjanuar Jul 24, 2025
9cd9960
Merge branch 'v3' into fix/issues-4529
rdjanuar Jul 24, 2025
dc84947
Merge branch 'v3' into fix/issues-4529
rdjanuar Jul 28, 2025
778e62b
up
rdjanuar Jul 28, 2025
885bb1d
test(FormField): fix missing import
rdjanuar Jul 31, 2025
38ff468
test(FormField): update test to handle FileUpload component
rdjanuar Jul 31, 2025
720e2e2
Merge branch 'v3' into fix/issues-4529
rdjanuar Aug 1, 2025
f9884cb
Merge branch 'v3' into fix/issues-4529
rdjanuar Aug 9, 2025
eed4511
fix: lint
rdjanuar Aug 9, 2025
fdcd0fa
update: snapshot
rdjanuar Aug 9, 2025
5a13184
test: update snapshot
rdjanuar Aug 9, 2025
0090f58
Merge branch 'v3' into pr/4534
benjamincanac Aug 14, 2025
8a44e29
test: update snapshots
benjamincanac Aug 14, 2025
87c0f11
up
benjamincanac Aug 14, 2025
dd10323
up
benjamincanac Aug 14, 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
5 changes: 3 additions & 2 deletions src/runtime/components/Checkbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ const modelValue = defineModel<boolean | 'indeterminate'>({ default: undefined }

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

const rootProps = useForwardProps(reactivePick(props, 'required', 'value', 'defaultValue'))
const rootProps = useForwardProps(reactivePick(props, 'value', 'defaultValue'))

const { id: _id, emitFormChange, emitFormInput, size, color, name, disabled, ariaAttrs } = useFormField<CheckboxProps>(props)
const { emitFormChange, emitFormInput, color, name, size, id: _id, disabled, required, ariaAttrs } = useFormField<CheckboxProps>(props)
const id = _id.value ?? useId()

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.checkbox || {}) })({
Expand Down Expand Up @@ -108,6 +108,7 @@ function onUpdate(value: any) {
v-bind="{ ...rootProps, ...$attrs, ...ariaAttrs }"
v-model="modelValue"
:name="name"
:required="required"
:disabled="disabled"
:class="ui.base({ class: props.ui?.base })"
@update:model-value="onUpdate"
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/components/CheckboxGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,11 @@ const slots = defineSlots<CheckboxGroupSlots<T>>()

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

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'orientation', 'loop', 'required'), emits)
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'orientation', 'loop'), emits)
const checkboxProps = useForwardProps(reactivePick(props, 'variant', 'indicator', 'icon'))
const proxySlots = omit(slots, ['legend'])

const { emitFormChange, emitFormInput, color, name, size, id: _id, disabled, ariaAttrs } = useFormField<CheckboxGroupProps<T>>(props, { bind: false })
const { emitFormChange, emitFormInput, color, name, size, id: _id, disabled, required, ariaAttrs } = useFormField<CheckboxGroupProps<T>>(props, { bind: false })
const id = _id.value ?? useId()

const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.checkboxGroup || {}) })({
Expand Down Expand Up @@ -159,6 +159,7 @@ function onUpdate(value: any) {
<CheckboxGroupRoot
:id="id"
v-bind="rootProps"
:required="required"
:name="name"
:disabled="disabled"
:class="ui.root({ class: [props.ui?.root, props.class] })"
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/FileUpload.vue
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ const { isDragging, open, inputRef, dropzoneRef } = useFileUpload({
dropzone: props.dropzone,
onUpdate
})
const { emitFormInput, emitFormChange, id, name, disabled, ariaAttrs } = useFormField<FileUploadProps>(props)
const { emitFormInput, emitFormChange, id, name, disabled, required, ariaAttrs } = useFormField<FileUploadProps<M>>(props)

const variant = computed(() => props.multiple ? 'area' : props.variant)
const layout = computed(() => props.variant === 'button' && !props.multiple ? 'grid' : props.layout)
Expand Down
1 change: 1 addition & 0 deletions src/runtime/components/FormField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ provide(formFieldInjectionKey, computed(() => ({
name: props.name,
size: props.size,
eagerValidation: props.eagerValidation,
required: props.required,
validateOnInputDelay: props.validateOnInputDelay,
errorPattern: props.errorPattern,
hint: props.hint,
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/Input.vue
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const modelValue = useVModel<InputProps<T>, 'modelValue', 'update:modelValue'>(p

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

const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, emitFormFocus, ariaAttrs } = useFormField<InputProps<T>>(props, { deferInputValidation: true })
const { emitFormBlur, emitFormInput, emitFormChange, emitFormFocus, size: formGroupSize, color, id, name, highlight, disabled, required, ariaAttrs } = useFormField<InputProps<T>>(props, { deferInputValidation: true })
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps<T>>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)

Expand Down
5 changes: 3 additions & 2 deletions src/runtime/components/InputMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -208,12 +208,12 @@ const { t } = useLocale()
const appConfig = useAppConfig() as InputMenu['AppConfig']
const { contains } = useFilter({ sensitivity: 'base' })

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'open', 'defaultOpen', 'required', 'multiple', 'resetSearchTermOnBlur', 'resetSearchTermOnSelect', 'highlightOnHover', 'openOnClick', 'openOnFocus'), emits)
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'open', 'defaultOpen', 'multiple', 'resetSearchTermOnBlur', 'resetSearchTermOnSelect', 'highlightOnHover', 'openOnClick', 'openOnFocus'), emits)
const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8, position: 'popper' }) as ComboboxContentProps)
const arrowProps = toRef(() => props.arrow as ComboboxArrowProps)

const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, required, ariaAttrs } = useFormField<InputProps>(props)
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))

Expand Down Expand Up @@ -418,6 +418,7 @@ defineExpose({
v-slot="{ modelValue, open }"
v-bind="rootProps"
:name="name"
:required="required"
:disabled="disabled"
:class="ui.root({ class: [props.ui?.root, props.class] })"
:as-child="!!multiple"
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/components/InputNumber.vue
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const appConfig = useAppConfig() as InputNumber['AppConfig']

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'min', 'max', 'step', 'stepSnapping', 'formatOptions', 'disableWheelChange', 'invertWheelChange', 'readonly'), emits)

const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size: formGroupSize, name, highlight, disabled, ariaAttrs } = useFormField<InputNumberProps>(props)
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size: formGroupSize, name, highlight, disabled, required, ariaAttrs } = useFormField<InputNumberProps>(props)
const { orientation, size: buttonGroupSize } = useButtonGroup<InputNumberProps>(props)

const locale = computed(() => props.locale || codeLocale.value)
Expand Down Expand Up @@ -157,6 +157,7 @@ defineExpose({
:id="id"
:class="ui.root({ class: [props.ui?.root, props.class] })"
:name="name"
:required="required"
:disabled="disabled"
:locale="locale"
@update:model-value="onUpdate"
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/components/InputTags.vue
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ const slots = defineSlots<InputTagsSlots<T>>()

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

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'addOnPaste', 'addOnTab', 'addOnBlur', 'duplicate', 'delimiter', 'max', 'convertValue', 'displayValue', 'required'), emits)
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'addOnPaste', 'addOnTab', 'addOnBlur', 'duplicate', 'delimiter', 'max', 'convertValue', 'displayValue'), emits)

const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputTagsProps>(props)
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, required, ariaAttrs } = useFormField<InputTagsProps>(props)
const { orientation, size: buttonGroupSize } = useButtonGroup<InputTagsProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)

Expand Down Expand Up @@ -154,6 +154,7 @@ defineExpose({
:default-value="defaultValue"
:class="ui.root({ class: [ui.base({ class: props.ui?.base }), props.ui?.root, props.class] })"
v-bind="rootProps"
:required="required"
:name="name"
:disabled="disabled"
@update:model-value="onUpdate"
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/components/PinInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ const emits = defineEmits<PinInputEmits<T>>()

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

const rootProps = useForwardPropsEmits(reactivePick(props, 'disabled', 'id', 'mask', 'name', 'otp', 'required', 'type'), emits)
const rootProps = useForwardPropsEmits(reactivePick(props, 'id', 'mask', 'name', 'otp', 'required', 'type'), emits)

const { emitFormInput, emitFormFocus, emitFormChange, emitFormBlur, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<PinInputProps>(props)
const { emitFormInput, emitFormFocus, emitFormChange, emitFormBlur, size, color, id, name, highlight, disabled, required, ariaAttrs } = useFormField<PinInputProps>(props)

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.pinInput || {}) })({
color: color.value,
Expand Down Expand Up @@ -116,6 +116,7 @@ defineExpose({
v-bind="{ ...rootProps, ...ariaAttrs }"
:id="id"
:name="name"
:required="required"
:placeholder="placeholder"
:model-value="(modelValue as PinInputValue<T>)"
:default-value="(defaultValue as PinInputValue<T>[])"
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/components/RadioGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ const slots = defineSlots<RadioGroupSlots<T>>()

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

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'orientation', 'loop', 'required'), emits)
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'orientation', 'loop'), emits)

const { emitFormChange, emitFormInput, color, name, size, id: _id, disabled, ariaAttrs } = useFormField<RadioGroupProps<T>>(props, { bind: false })
const { emitFormChange, emitFormInput, color, name, size, id: _id, disabled, required, ariaAttrs } = useFormField<RadioGroupProps<T>>(props, { bind: false })
const id = _id.value ?? useId()

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.radioGroup || {}) })({
Expand Down Expand Up @@ -168,6 +168,7 @@ function onUpdate(value: any) {
:id="id"
v-slot="{ modelValue }"
v-bind="rootProps"
:required="required"
:name="name"
:disabled="disabled"
:class="ui.root({ class: [props.ui?.root, props.class] })"
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/components/Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,12 @@ const slots = defineSlots<SelectSlots<T, VK, M>>()

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

const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'disabled', 'autocomplete', 'required', 'multiple'), emits)
const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'disabled', 'autocomplete', 'multiple'), emits)
const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8, position: 'popper' }) as SelectContentProps)
const arrowProps = toRef(() => props.arrow as SelectArrowProps)

const { emitFormChange, emitFormInput, emitFormBlur, emitFormFocus, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
const { emitFormChange, emitFormInput, emitFormBlur, emitFormFocus, size: formGroupSize, color, id, name, highlight, disabled, required, ariaAttrs } = useFormField<InputProps>(props)
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))

Expand Down Expand Up @@ -265,6 +265,7 @@ defineExpose({
v-slot="{ modelValue, open }"
:name="name"
v-bind="rootProps"
:required="required"
:autocomplete="autocomplete"
:disabled="disabled"
:default-value="(defaultValue as (AcceptableValue | AcceptableValue[]))"
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/components/SelectMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,13 @@ const { t } = useLocale()
const appConfig = useAppConfig() as SelectMenu['AppConfig']
const { contains } = useFilter({ sensitivity: 'base' })

const rootProps = useForwardPropsEmits(reactivePick(props, 'modelValue', 'defaultValue', 'open', 'defaultOpen', 'required', 'multiple', 'resetSearchTermOnBlur', 'resetSearchTermOnSelect', 'highlightOnHover'), emits)
const rootProps = useForwardPropsEmits(reactivePick(props, 'modelValue', 'defaultValue', 'open', 'defaultOpen', 'multiple', 'resetSearchTermOnBlur', 'resetSearchTermOnSelect', 'highlightOnHover'), emits)
const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8, position: 'popper' }) as ComboboxContentProps)
const arrowProps = toRef(() => props.arrow as ComboboxArrowProps)
const searchInputProps = toRef(() => defu(props.searchInput, { placeholder: t('selectMenu.search'), variant: 'none' }) as InputProps)

const { emitFormBlur, emitFormFocus, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
const { emitFormBlur, emitFormFocus, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, required, ariaAttrs } = useFormField<InputProps>(props)
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))

Expand Down Expand Up @@ -403,6 +403,7 @@ defineExpose({
v-slot="{ modelValue, open }"
v-bind="{ ...rootProps, ...$attrs, ...ariaAttrs }"
ignore-filter
:required="required"
as-child
:name="name"
:disabled="disabled"
Expand Down
4 changes: 3 additions & 1 deletion src/runtime/components/Slider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface SliderProps extends Pick<SliderRootProps, 'name' | 'disabled' |
tooltip?: boolean | TooltipProps
/** The value of the slider when initially rendered. Use when you do not need to control the state of the slider. */
defaultValue?: number | number[]
required?: boolean
class?: any
ui?: Slider['slots']
}
Expand Down Expand Up @@ -67,7 +68,7 @@ const appConfig = useAppConfig() as Slider['AppConfig']

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'orientation', 'min', 'max', 'step', 'minStepsBetweenThumbs', 'inverted'), emits)

const { id, emitFormChange, emitFormInput, size, color, name, disabled, ariaAttrs } = useFormField<SliderProps>(props)
const { id, emitFormChange, emitFormInput, size, color, name, disabled, required, ariaAttrs } = useFormField<SliderProps>(props)

const defaultSliderValue = computed(() => {
if (typeof props.defaultValue === 'number') {
Expand Down Expand Up @@ -112,6 +113,7 @@ function onChange(value: any) {
v-model="sliderValue"
:name="name"
:disabled="disabled"
:required="required"
:class="ui.root({ class: [props.ui?.root, props.class] })"
:default-value="defaultSliderValue"
@update:model-value="emitFormInput()"
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/components/Switch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ const modelValue = defineModel<boolean>({ default: undefined })

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

const rootProps = useForwardProps(reactivePick(props, 'required', 'value', 'defaultValue'))
const rootProps = useForwardProps(reactivePick(props, 'value', 'defaultValue'))

const { id: _id, emitFormChange, emitFormInput, size, color, name, disabled, ariaAttrs } = useFormField<SwitchProps>(props)
const { emitFormChange, emitFormInput, color, name, size, id: _id, disabled, required, ariaAttrs } = useFormField<SwitchProps>(props)
const id = _id.value ?? useId()

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.switch || {}) })({
Expand All @@ -102,6 +102,7 @@ function onUpdate(value: any) {
:id="id"
v-bind="{ ...rootProps, ...$attrs, ...ariaAttrs }"
v-model="modelValue"
:required="required"
:name="name"
:disabled="disabled || loading"
:class="ui.base({ class: props.ui?.base })"
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/Textarea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ const modelValue = useVModel<TextareaProps<T>, 'modelValue', 'update:modelValue'

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

const { emitFormFocus, emitFormBlur, emitFormInput, emitFormChange, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<TextareaProps<T>>(props, { deferInputValidation: true })
const { emitFormFocus, emitFormBlur, emitFormInput, emitFormChange, size, color, id, name, highlight, disabled, required, ariaAttrs } = useFormField<TextareaProps<T>>(props, { deferInputValidation: true })
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.textarea || {}) })({
Expand Down
2 changes: 2 additions & 0 deletions src/runtime/composables/useFormField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type Props<T> = {
size?: GetObjectField<T, 'size'>
color?: GetObjectField<T, 'color'>
highlight?: boolean
required?: boolean
disabled?: boolean
}

Expand Down Expand Up @@ -77,6 +78,7 @@ export function useFormField<T>(props?: Props<T>, opts?: { bind?: boolean, defer
size: computed(() => props?.size ?? formField?.value.size),
color: computed(() => formField?.value.error ? 'error' : props?.color),
highlight: computed(() => formField?.value.error ? true : props?.highlight),
required: computed(() => props?.required || formField?.value.required),
disabled: computed(() => formOptions?.value.disabled || props?.disabled),
emitFormBlur,
emitFormInput,
Expand Down
1 change: 1 addition & 0 deletions src/runtime/types/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export interface FormFieldInjectedOptions<T> {
eagerValidation?: boolean
validateOnInputDelay?: number
errorPattern?: RegExp
required?: boolean
hint?: string
description?: string
help?: string
Expand Down
43 changes: 34 additions & 9 deletions test/components/FormField.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
USlider,
UPinInput,
UFormField,
UForm,
UFileUpload
} from '#components'

Expand All @@ -27,18 +28,29 @@ async function renderFormField(options: {
props: Partial<FormFieldProps>
inputComponent: typeof inputComponents[number]
}) {
return await mountSuspended(UFormField, {
props: options.props,
let modelValue: any = '0'
if ((options.inputComponent as any).__name === 'FileUpload') {
modelValue = new File([''], 'test-file.txt', { type: 'text/plain' })
}

return await mountSuspended(UForm, {
slots: {
default: {
// @ts-expect-error - Object literal may only specify known properties, and setup does not exist in type
setup: () => ({ inputComponent: options.inputComponent }),
setup: () => ({
formFieldProps: options.props,
inputComponent: options.inputComponent,
modelValue
}),
components: {
UFormField,
UForm,
...inputComponents
},
template: `
<component :is="inputComponent" />
<UFormField v-bind="formFieldProps">
<component :is="inputComponent" :model-value="modelValue" />
</UFormField>
`
}
}
Expand All @@ -52,11 +64,12 @@ const FormFieldWrapper = defineComponent({
UFormField
},
template: `
<UFormField>
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
</UFormField>`
<UFormField>
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
</UFormField>
`
})

describe('FormField', () => {
Expand Down Expand Up @@ -118,6 +131,18 @@ describe('FormField', () => {
expect(input.exists()).toBe(true)
})
}
test('binds required', async () => {
const wrapper = await renderFormField({
props: {
required: true,
name
},
inputComponent
})

const requiredInput = wrapper.find('[required], [aria-required=true]')
expect(requiredInput.exists()).toBe(true)
})

test('binds hints with aria-describedby', async () => {
const wrapper = await renderFormField({
Expand Down
Loading