Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 21 additions & 0 deletions docs/content/docs/1.getting-started/3.migration/1.v4.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,3 +352,24 @@ For more details on AI SDK v5 changes, review the **official AI SDK v5 migration
::tip{to="https://github.com/nuxt/ui/pull/4698" target="_blank"}
View all changes from AI SDK v4 to v5 **in the upgrade PR** for a detailed migration reference.
::

### Updated `modelModifiers` for `UInput` and `UTextarea`

The `modelModifiers` shape used by `UInput` and `UTextarea` has changed in v4:

- The `nullify` modifier was renamed to `nullable` (it converts empty/blank values to `null`).
- A new `optional` modifier was added (it converts empty/blank values to `undefined`).

Examples:

```diff
- <UInput v-model.nullify="value" />
+ <UInput v-model.nullable="value" />
```

```diff
- <UTextarea v-model="value" :model-modifiers="{ nullify: true }" />
+ <UTextarea v-model="value" :model-modifiers="{ nullable: true }" />
```

Use `nullable` when you want empty values as `null`, and `optional` when you prefer `undefined` for absent values.
17 changes: 8 additions & 9 deletions src/runtime/components/Input.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/input'
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import type { AvatarProps } from '../types'
import type { ModelModifiers } from '../types/input'
import type { AcceptableValue } from '../types/utils'
import type { ComponentConfig } from '../types/tv'

Expand Down Expand Up @@ -41,13 +42,7 @@ export interface InputProps<T extends AcceptableValue = AcceptableValue> extends
highlight?: boolean
modelValue?: T
defaultValue?: T
modelModifiers?: {
string?: boolean
number?: boolean
trim?: boolean
lazy?: boolean
nullify?: boolean
}
modelModifiers?: ModelModifiers
class?: any
ui?: Input['slots']
}
Expand Down Expand Up @@ -113,7 +108,7 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.input || {})
const inputRef = ref<HTMLInputElement | null>(null)

// Custom function to handle the v-model properties
function updateInput(value: string | null) {
function updateInput(value: string | null | undefined) {
if (props.modelModifiers?.trim) {
value = value?.trim() ?? null
}
Expand All @@ -122,10 +117,14 @@ function updateInput(value: string | null) {
value = looseToNumber(value)
}

if (props.modelModifiers?.nullify) {
if (props.modelModifiers?.nullable) {
value ||= null
}

if (props.modelModifiers?.optional) {
value ||= undefined
}

modelValue.value = value as T
emitFormInput()
}
Expand Down
17 changes: 14 additions & 3 deletions src/runtime/components/InputNumber.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { NumberFieldRootProps } from 'reka-ui'
import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/input-number'
import type { ButtonProps, IconProps } from '../types'
import type { ModelModifiers } from '../types/input'
import type { ComponentConfig } from '../types/tv'

type InputNumber = ComponentConfig<typeof theme, AppConfig, 'inputNumber'>
Expand Down Expand Up @@ -53,6 +54,9 @@ export interface InputNumberProps extends Pick<NumberFieldRootProps, 'modelValue
decrementDisabled?: boolean
autofocus?: boolean
autofocusDelay?: number
modelValue?: number
defaultValue?: number
modelModifiers?: Pick<ModelModifiers, 'optional'>
/**
* The locale to use for formatting and parsing numbers.
* @defaultValue UApp.locale.code
Expand All @@ -77,7 +81,7 @@ export interface InputNumberSlots {
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { NumberFieldRoot, NumberFieldInput, NumberFieldDecrement, NumberFieldIncrement, useForwardPropsEmits } from 'reka-ui'
import { reactivePick } from '@vueuse/core'
import { reactivePick, useVModel } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useFieldGroup } from '../composables/useFieldGroup'
import { useFormField } from '../composables/useFormField'
Expand All @@ -95,10 +99,12 @@ const props = withDefaults(defineProps<InputNumberProps>(), {
const emits = defineEmits<InputNumberEmits>()
defineSlots<InputNumberSlots>()

const modelValue = useVModel<InputNumberProps, 'modelValue', 'update:modelValue'>(props, 'modelValue', emits, { defaultValue: props.defaultValue })

const { t, code: codeLocale } = useLocale()
const appConfig = useAppConfig() as InputNumber['AppConfig']

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'min', 'max', 'step', 'stepSnapping', 'formatOptions', 'disableWheelChange', 'invertWheelChange', 'readonly'), emits)
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', '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 { orientation, size: fieldGroupSize } = useFieldGroup<InputNumberProps>(props)
Expand All @@ -120,7 +126,11 @@ const decrementIcon = computed(() => props.decrementIcon || (props.orientation =

const inputRef = ref<InstanceType<typeof NumberFieldInput> | null>(null)

function onUpdate(value: number) {
function onUpdate(value: number | undefined) {
if (props.modelModifiers?.optional) {
value = value ?? undefined
}

// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } })
emits('change', event)
Expand Down Expand Up @@ -155,6 +165,7 @@ defineExpose({
<NumberFieldRoot
v-bind="rootProps"
:id="id"
:model-value="modelValue"
:class="ui.root({ class: [props.ui?.root, props.class] })"
:name="name"
:disabled="disabled"
Expand Down
17 changes: 8 additions & 9 deletions src/runtime/components/Textarea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/textarea'
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import type { AvatarProps } from '../types'
import type { ModelModifiers } from '../types/input'
import type { ComponentConfig } from '../types/tv'

type Textarea = ComponentConfig<typeof theme, AppConfig, 'textarea'>
Expand Down Expand Up @@ -43,13 +44,7 @@ export interface TextareaProps<T extends TextareaValue = TextareaValue> extends
highlight?: boolean
modelValue?: T
defaultValue?: T
modelModifiers?: {
string?: boolean
number?: boolean
trim?: boolean
lazy?: boolean
nullify?: boolean
}
modelModifiers?: ModelModifiers
class?: any
ui?: Textarea['slots']
}
Expand Down Expand Up @@ -111,7 +106,7 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.textarea ||
const textareaRef = ref<HTMLTextAreaElement | null>(null)

// Custom function to handle the v-model properties
function updateInput(value: string | null) {
function updateInput(value: string | null | undefined) {
if (props.modelModifiers?.trim) {
value = value?.trim() ?? null
}
Expand All @@ -120,10 +115,14 @@ function updateInput(value: string | null) {
value = looseToNumber(value)
}

if (props.modelModifiers?.nullify) {
if (props.modelModifiers?.nullable) {
value ||= null
}

if (props.modelModifiers?.optional) {
value ||= undefined
}

modelValue.value = value as T
emitFormInput()
}
Expand Down
8 changes: 8 additions & 0 deletions src/runtime/types/input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface ModelModifiers {
string?: boolean
number?: boolean
trim?: boolean
lazy?: boolean
nullable?: boolean
optional?: boolean
}
3 changes: 2 additions & 1 deletion test/components/Input.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ describe('Input', () => {
['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 .nullify modifier', { props: { modelModifiers: { nullify: true } } }, { input: '', expected: null }]
['with .nullable modifier', { props: { modelModifiers: { nullable: true } } }, { input: '', expected: null }],
['with .optional modifier', { props: { modelModifiers: { optional: true } } }, { input: '', expected: undefined }]
])('%s works', async (_nameOrHtml: string, options: { props?: any, slots?: any }, spec: { input: any, expected: any }) => {
const wrapper = mount(Input, {
...options
Expand Down
1 change: 1 addition & 0 deletions test/components/InputNumber.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('InputNumber', () => {
['with as', { props: { as: 'section' } }],
['with class', { props: { class: 'absolute' } }],
['with ui', { props: { ui: { base: 'rounded-full' } } }],
['with .optional modifier', { props: { modelModifiers: { optional: true } } }, { input: '', expected: undefined }],
// Slots
['with increment slot', { slots: { increment: () => '+' } }],
['with decrement slot', { slots: { decrement: () => '-' } }]
Expand Down
3 changes: 2 additions & 1 deletion test/components/Textarea.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ describe('Textarea', () => {
['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 .nullify modifier', { props: { modelModifiers: { nullify: true } } }, { input: '', expected: null }]
['with .nullable modifier', { props: { modelModifiers: { nullable: true } } }, { input: '', expected: null }],
['with .optional modifier', { props: { modelModifiers: { optional: true } } }, { input: '', expected: undefined }]
])('%s works', async (_nameOrHtml: string, options: { props?: any, slots?: any }, spec: { input: any, expected: any }) => {
const wrapper = mount(Textarea, {
...options
Expand Down
14 changes: 14 additions & 0 deletions test/components/__snapshots__/InputNumber-vue.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`InputNumber > renders with .optional modifier correctly 1`] = `
"<div class="relative inline-flex items-center" role="group"><input role="spinbutton" type="text" tabindex="0" inputmode="decimal" autocomplete="off" autocorrect="off" spellcheck="false" aria-roledescription="Number field" class="w-full rounded-md border-0 placeholder:text-dimmed focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 transition-colors py-1.5 text-sm gap-1.5 text-highlighted bg-default ring ring-inset ring-accented text-center focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary px-9" value="">
<div class="absolute flex items-center inset-y-0 end-0 pe-1"><button type="button" tabindex="-1" aria-label="Increment" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors text-sm gap-1.5 text-primary hover:text-primary/75 active:text-primary/75 disabled:text-primary aria-disabled:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary p-1.5"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" class="shrink-0 size-5"></svg>
<!--v-if-->
<!--v-if-->
</button></div>
<div class="absolute flex items-center inset-y-0 start-0 ps-1"><button type="button" tabindex="-1" aria-label="Decrement" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors text-sm gap-1.5 text-primary hover:text-primary/75 active:text-primary/75 disabled:text-primary aria-disabled:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary p-1.5"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" class="shrink-0 size-5"></svg>
<!--v-if-->
<!--v-if-->
</button></div>
<!--v-if-->
</div>"
`;

exports[`InputNumber > renders with ariaLabel correctly 1`] = `
"<div class="relative inline-flex items-center" role="group"><input role="spinbutton" type="text" tabindex="0" inputmode="decimal" autocomplete="off" autocorrect="off" spellcheck="false" aria-roledescription="Number field" aria-label="Aria label" class="w-full rounded-md border-0 placeholder:text-dimmed focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 transition-colors py-1.5 text-sm gap-1.5 text-highlighted bg-default ring ring-inset ring-accented text-center focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary px-9" value="">
<div class="absolute flex items-center inset-y-0 end-0 pe-1"><button type="button" tabindex="-1" aria-label="Increment" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors text-sm gap-1.5 text-primary hover:text-primary/75 active:text-primary/75 disabled:text-primary aria-disabled:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary p-1.5"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" class="shrink-0 size-5"></svg>
Expand Down
14 changes: 14 additions & 0 deletions test/components/__snapshots__/InputNumber.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`InputNumber > renders with .optional modifier correctly 1`] = `
"<div class="relative inline-flex items-center" role="group"><input role="spinbutton" type="text" tabindex="0" inputmode="decimal" autocomplete="off" autocorrect="off" spellcheck="false" aria-roledescription="Number field" class="w-full rounded-md border-0 placeholder:text-dimmed focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 transition-colors py-1.5 text-sm gap-1.5 text-highlighted bg-default ring ring-inset ring-accented text-center focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary px-9" value="">
<div class="absolute flex items-center inset-y-0 end-0 pe-1"><button type="button" tabindex="-1" aria-label="Increment" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors text-sm gap-1.5 text-primary hover:text-primary/75 active:text-primary/75 disabled:text-primary aria-disabled:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary p-1.5"><span class="iconify i-lucide:plus shrink-0 size-5" aria-hidden="true"></span>
<!--v-if-->
<!--v-if-->
</button></div>
<div class="absolute flex items-center inset-y-0 start-0 ps-1"><button type="button" tabindex="-1" aria-label="Decrement" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors text-sm gap-1.5 text-primary hover:text-primary/75 active:text-primary/75 disabled:text-primary aria-disabled:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary p-1.5"><span class="iconify i-lucide:minus shrink-0 size-5" aria-hidden="true"></span>
<!--v-if-->
<!--v-if-->
</button></div>
<!--v-if-->
</div>"
`;

exports[`InputNumber > renders with ariaLabel correctly 1`] = `
"<div class="relative inline-flex items-center" role="group"><input role="spinbutton" type="text" tabindex="0" inputmode="decimal" autocomplete="off" autocorrect="off" spellcheck="false" aria-roledescription="Number field" aria-label="Aria label" class="w-full rounded-md border-0 placeholder:text-dimmed focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 transition-colors py-1.5 text-sm gap-1.5 text-highlighted bg-default ring ring-inset ring-accented text-center focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary px-9" value="">
<div class="absolute flex items-center inset-y-0 end-0 pe-1"><button type="button" tabindex="-1" aria-label="Increment" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors text-sm gap-1.5 text-primary hover:text-primary/75 active:text-primary/75 disabled:text-primary aria-disabled:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary p-1.5"><span class="iconify i-lucide:plus shrink-0 size-5" aria-hidden="true"></span>
Expand Down
Loading