From c23aafc73b516b6653453e6aad5869251d66c0e6 Mon Sep 17 00:00:00 2001 From: hywax Date: Fri, 28 Nov 2025 15:42:46 +0500 Subject: [PATCH 1/3] feat(InputMenu, Select, SelectMenu): add `modelModifiers` support --- src/runtime/components/InputMenu.vue | 21 ++++++++++++++++++++- src/runtime/components/Select.vue | 20 +++++++++++++++++++- src/runtime/components/SelectMenu.vue | 21 ++++++++++++++++++++- test/components/InputMenu.spec.ts | 16 ++++++++++++++++ test/components/Select.spec.ts | 17 +++++++++++++++++ test/components/SelectMenu.spec.ts | 16 ++++++++++++++++ 6 files changed, 108 insertions(+), 3 deletions(-) diff --git a/src/runtime/components/InputMenu.vue b/src/runtime/components/InputMenu.vue index 840ba1d2b1..24d1923d72 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..f45eafc052 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..18dba68f61 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..2d1ad5a3d9 100644 --- a/test/components/Select.spec.ts +++ b/test/components/Select.spec.ts @@ -91,6 +91,23 @@ 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 .lazy modifier', { props: { modelModifiers: { lazy: true } } }, { input: 'input', expected: 'input' }], + ['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: { From 9a5ae0d20e4f32ae77e646238248e9ae6af894b4 Mon Sep 17 00:00:00 2001 From: hywax Date: Fri, 28 Nov 2025 15:52:22 +0500 Subject: [PATCH 2/3] refactor(Select.spec): remove test case for .lazy modifier from modelModifiers tests --- test/components/Select.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/components/Select.spec.ts b/test/components/Select.spec.ts index 2d1ad5a3d9..76d22fead0 100644 --- a/test/components/Select.spec.ts +++ b/test/components/Select.spec.ts @@ -94,7 +94,6 @@ describe('Select', () => { 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 .lazy modifier', { props: { modelModifiers: { lazy: true } } }, { input: 'input', expected: 'input' }], ['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 }) => { From 27e6a0ae3dd49aa9d083155ad11cde777d3642d6 Mon Sep 17 00:00:00 2001 From: hywax Date: Fri, 28 Nov 2025 16:29:41 +0500 Subject: [PATCH 3/3] refactor(InputMenu, Select, SelectMenu): replace logical OR assignment with nullish coalescing assignment for modelModifiers handling --- src/runtime/components/InputMenu.vue | 4 ++-- src/runtime/components/Select.vue | 4 ++-- src/runtime/components/SelectMenu.vue | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/runtime/components/InputMenu.vue b/src/runtime/components/InputMenu.vue index 24d1923d72..9f4f15df6c 100644 --- a/src/runtime/components/InputMenu.vue +++ b/src/runtime/components/InputMenu.vue @@ -371,11 +371,11 @@ function onUpdate(value: any) { } if (props.modelModifiers?.nullable) { - value ||= null + value ??= null } if (props.modelModifiers?.optional) { - value ||= undefined + value ??= undefined } // @ts-expect-error - 'target' does not exist in type 'EventInit' diff --git a/src/runtime/components/Select.vue b/src/runtime/components/Select.vue index f45eafc052..0f94de832f 100644 --- a/src/runtime/components/Select.vue +++ b/src/runtime/components/Select.vue @@ -242,11 +242,11 @@ function onUpdate(value: any) { } if (props.modelModifiers?.nullable) { - value ||= null + value ??= null } if (props.modelModifiers?.optional) { - value ||= undefined + value ??= undefined } // @ts-expect-error - 'target' does not exist in type 'EventInit' diff --git a/src/runtime/components/SelectMenu.vue b/src/runtime/components/SelectMenu.vue index 18dba68f61..c6b96a68f2 100644 --- a/src/runtime/components/SelectMenu.vue +++ b/src/runtime/components/SelectMenu.vue @@ -372,11 +372,11 @@ function onUpdate(value: any) { } if (props.modelModifiers?.nullable) { - value ||= null + value ??= null } if (props.modelModifiers?.optional) { - value ||= undefined + value ??= undefined } // @ts-expect-error - 'target' does not exist in type 'EventInit'