diff --git a/src/runtime/components/InputMenu.vue b/src/runtime/components/InputMenu.vue index 840ba1d2b1..9f4f15df6c 100644 --- a/src/runtime/components/InputMenu.vue +++ b/src/runtime/components/InputMenu.vue @@ -4,6 +4,7 @@ import type { AppConfig } from '@nuxt/schema' import theme from '#build/ui/input-menu' import type { UseComponentIconsProps } from '../composables/useComponentIcons' import type { AvatarProps, ChipProps, IconProps, InputProps } from '../types' +import type { ModelModifiers } from '../types/input' import type { InputHTMLAttributes } from '../types/html' import type { AcceptableValue, ArrayOrNested, GetItemKeys, GetItemValue, GetModelValue, GetModelValueEmits, NestedItem, EmitsToProps } from '../types/utils' import type { ComponentConfig } from '../types/tv' @@ -129,6 +130,7 @@ export interface InputMenuProps = ArrayOr defaultValue?: GetModelValue /** The controlled value of the InputMenu. Can be binded-with with `v-model`. */ modelValue?: GetModelValue + modelModifiers?: Omit>, 'lazy'> /** Whether multiple options can be selected or not. */ multiple?: M & boolean /** Highlight the ring color like a focus state. */ @@ -201,7 +203,7 @@ import { useComponentIcons } from '../composables/useComponentIcons' import { useFormField } from '../composables/useFormField' import { useLocale } from '../composables/useLocale' import { usePortal } from '../composables/usePortal' -import { compare, get, getDisplayValue, isArrayOfArray } from '../utils' +import { compare, get, getDisplayValue, isArrayOfArray, looseToNumber } from '../utils' import { getEstimateSize } from '../utils/virtualizer' import { tv } from '../utils/tv' import UIcon from './Icon.vue' @@ -359,6 +361,23 @@ function onUpdate(value: any) { if (toRaw(props.modelValue) === value) { return } + + if (props.modelModifiers?.trim) { + value = value?.trim() ?? null + } + + if (props.modelModifiers?.number) { + value = looseToNumber(value) + } + + if (props.modelModifiers?.nullable) { + value ??= null + } + + if (props.modelModifiers?.optional) { + value ??= undefined + } + // @ts-expect-error - 'target' does not exist in type 'EventInit' const event = new Event('change', { target: { value } }) emits('change', event) diff --git a/src/runtime/components/Select.vue b/src/runtime/components/Select.vue index e4cc93b80c..0f94de832f 100644 --- a/src/runtime/components/Select.vue +++ b/src/runtime/components/Select.vue @@ -4,6 +4,7 @@ import type { AppConfig } from '@nuxt/schema' import theme from '#build/ui/select' import type { UseComponentIconsProps } from '../composables/useComponentIcons' import type { AvatarProps, ChipProps, IconProps, InputProps } from '../types' +import type { ModelModifiers } from '../types/input' import type { ButtonHTMLAttributes } from '../types/html' import type { AcceptableValue, ArrayOrNested, GetItemKeys, GetItemValue, GetModelValue, GetModelValueEmits, NestedItem, EmitsToProps } from '../types/utils' import type { ComponentConfig } from '../types/tv' @@ -97,6 +98,7 @@ export interface SelectProps = ArrayOrNested defaultValue?: GetModelValue /** The controlled value of the Select. Can be bind as `v-model`. */ modelValue?: GetModelValue + modelModifiers?: Omit>, 'lazy'> /** Whether multiple options can be selected or not. */ multiple?: M & boolean /** Highlight the ring color like a focus state. */ @@ -144,7 +146,7 @@ import { useFieldGroup } from '../composables/useFieldGroup' import { useComponentIcons } from '../composables/useComponentIcons' import { useFormField } from '../composables/useFormField' import { usePortal } from '../composables/usePortal' -import { get, getDisplayValue, isArrayOfArray } from '../utils' +import { get, getDisplayValue, isArrayOfArray, looseToNumber } from '../utils' import { tv } from '../utils/tv' import UIcon from './Icon.vue' import UAvatar from './Avatar.vue' @@ -231,6 +233,22 @@ onMounted(() => { }) function onUpdate(value: any) { + if (props.modelModifiers?.trim) { + value = value?.trim() ?? null + } + + if (props.modelModifiers?.number) { + value = looseToNumber(value) + } + + if (props.modelModifiers?.nullable) { + value ??= null + } + + if (props.modelModifiers?.optional) { + value ??= undefined + } + // @ts-expect-error - 'target' does not exist in type 'EventInit' const event = new Event('change', { target: { value } }) emits('change', event) diff --git a/src/runtime/components/SelectMenu.vue b/src/runtime/components/SelectMenu.vue index 374449c4f2..c6b96a68f2 100644 --- a/src/runtime/components/SelectMenu.vue +++ b/src/runtime/components/SelectMenu.vue @@ -4,6 +4,7 @@ import type { AppConfig } from '@nuxt/schema' import theme from '#build/ui/select-menu' import type { UseComponentIconsProps } from '../composables/useComponentIcons' import type { AvatarProps, ChipProps, IconProps, InputProps } from '../types' +import type { ModelModifiers } from '../types/input' import type { ButtonHTMLAttributes } from '../types/html' import type { AcceptableValue, ArrayOrNested, GetItemKeys, GetItemValue, GetModelValue, GetModelValueEmits, NestedItem, EmitsToProps } from '../types/utils' import type { ComponentConfig } from '../types/tv' @@ -122,6 +123,7 @@ export interface SelectMenuProps = Array defaultValue?: GetModelValue /** The controlled value of the SelectMenu. Can be binded-with with `v-model`. */ modelValue?: GetModelValue + modelModifiers?: Omit>, 'lazy'> /** Whether multiple options can be selected or not. */ multiple?: M & boolean /** Highlight the ring color like a focus state. */ @@ -193,7 +195,7 @@ import { useComponentIcons } from '../composables/useComponentIcons' import { useFormField } from '../composables/useFormField' import { useLocale } from '../composables/useLocale' import { usePortal } from '../composables/usePortal' -import { compare, get, getDisplayValue, isArrayOfArray } from '../utils' +import { compare, get, getDisplayValue, isArrayOfArray, looseToNumber } from '../utils' import { getEstimateSize } from '../utils/virtualizer' import { tv } from '../utils/tv' import UIcon from './Icon.vue' @@ -360,6 +362,23 @@ function onUpdate(value: any) { if (toRaw(props.modelValue) === value) { return } + + if (props.modelModifiers?.trim) { + value = value?.trim() ?? null + } + + if (props.modelModifiers?.number) { + value = looseToNumber(value) + } + + if (props.modelModifiers?.nullable) { + value ??= null + } + + if (props.modelModifiers?.optional) { + value ??= undefined + } + // @ts-expect-error - 'target' does not exist in type 'EventInit' const event = new Event('change', { target: { value } }) emits('change', event) diff --git a/test/components/InputMenu.spec.ts b/test/components/InputMenu.spec.ts index 2f32a3d640..9a69ec279e 100644 --- a/test/components/InputMenu.spec.ts +++ b/test/components/InputMenu.spec.ts @@ -95,6 +95,22 @@ describe('InputMenu', () => { expect(html).toMatchSnapshot() }) + it.each([ + ['with .trim modifier', { props: { modelModifiers: { trim: true } } }, { input: 'input ', expected: 'input' }], + ['with .number modifier', { props: { modelModifiers: { number: true } } }, { input: '42', expected: 42 }], + ['with .nullable modifier', { props: { modelModifiers: { nullable: true } } }, { input: null, expected: null }], + ['with .optional modifier', { props: { modelModifiers: { optional: true } } }, { input: undefined, expected: undefined }] + ])('%s works', async (_nameOrHtml: string, options: { props?: any, slots?: any }, spec: { input: any, expected: any }) => { + const wrapper = mount(InputMenu, { + ...options + }) + + const input = wrapper.findComponent({ name: 'ComboboxRoot' }) + await input.setValue(spec.input) + + expect(wrapper.emitted()).toMatchObject({ 'update:modelValue': [[spec.expected]] }) + }) + it('passes accessibility tests', async () => { const wrapper = await mountSuspended(InputMenu, { props: { diff --git a/test/components/Select.spec.ts b/test/components/Select.spec.ts index cb3886780f..76d22fead0 100644 --- a/test/components/Select.spec.ts +++ b/test/components/Select.spec.ts @@ -91,6 +91,22 @@ describe('Select', () => { expect(html).toMatchSnapshot() }) + it.each([ + ['with .trim modifier', { props: { modelModifiers: { trim: true } } }, { input: 'input ', expected: 'input' }], + ['with .number modifier', { props: { modelModifiers: { number: true } } }, { input: '42', expected: 42 }], + ['with .nullable modifier', { props: { modelModifiers: { nullable: true } } }, { input: null, expected: null }], + ['with .optional modifier', { props: { modelModifiers: { optional: true } } }, { input: undefined, expected: undefined }] + ])('%s works', async (_nameOrHtml: string, options: { props?: any, slots?: any }, spec: { input: any, expected: any }) => { + const wrapper = mount(Select, { + ...options + }) + + const select = wrapper.findComponent({ name: 'SelectRoot' }) + await select.setValue(spec.input) + + expect(wrapper.emitted()).toMatchObject({ 'update:modelValue': [[spec.expected]] }) + }) + it('passes accessibility tests', async () => { const wrapper = await mountSuspended(Select, { props: { diff --git a/test/components/SelectMenu.spec.ts b/test/components/SelectMenu.spec.ts index 99551ed76d..2efa536c86 100644 --- a/test/components/SelectMenu.spec.ts +++ b/test/components/SelectMenu.spec.ts @@ -97,6 +97,22 @@ describe('SelectMenu', () => { expect(html).toMatchSnapshot() }) + it.each([ + ['with .trim modifier', { props: { modelModifiers: { trim: true } } }, { input: 'input ', expected: 'input' }], + ['with .number modifier', { props: { modelModifiers: { number: true } } }, { input: '42', expected: 42 }], + ['with .nullable modifier', { props: { modelModifiers: { nullable: true } } }, { input: null, expected: null }], + ['with .optional modifier', { props: { modelModifiers: { optional: true } } }, { input: undefined, expected: undefined }] + ])('%s works', async (_nameOrHtml: string, options: { props?: any, slots?: any }, spec: { input: any, expected: any }) => { + const wrapper = mount(SelectMenu, { + ...options + }) + + const selectMenu = wrapper.findComponent({ name: 'ComboboxRoot' }) + await selectMenu.setValue(spec.input) + + expect(wrapper.emitted()).toMatchObject({ 'update:modelValue': [[spec.expected]] }) + }) + it('passes accessibility tests', async () => { const wrapper = await mountSuspended(SelectMenu, { props: {