Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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, ariaAttrs, required } = useFormField<FileUploadProps>(props)

const variant = computed(() => props.multiple ? 'area' : props.variant)
const layout = computed(() => props.variant === 'button' && !props.multiple ? 'grid' : props.layout)
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/FormField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export interface FormFieldProps {
class?: any
ui?: FormField['slots']
}

export interface FormFieldSlots {
label(props: { label?: string }): any
hint(props: { hint?: string }): any
Expand Down Expand Up @@ -81,6 +80,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 @@ -417,6 +417,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 @@ -257,6 +257,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 @@ -395,6 +395,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
Loading