diff --git a/.changeset/feat-numberfield-eager-commit-755.md b/.changeset/feat-numberfield-eager-commit-755.md
new file mode 100644
index 000000000..b556d5806
--- /dev/null
+++ b/.changeset/feat-numberfield-eager-commit-755.md
@@ -0,0 +1,23 @@
+---
+"@vuetify/v0": minor
+---
+
+feat(NumberField): add opt-in eager commit via `commitOn: 'input'` (#755)
+
+`NumberField` only wrote the typed value into the model on blur/Enter — any consumer
+wanting live feedback per keystroke (previews, running calculations) had no way to get
+model updates without bypassing the field's parse/clamp logic entirely.
+
+Added a `commitOn` option (`'change'` default, matching today's behavior; `'input'`
+opts in to writing on every keystroke) to `createNumberField` and `NumberField.Root`.
+Eager writes go through a new `commitDraft()` on the context, which parses but does
+**not** clamp or snap — clamping mid-type would jump a value like `1` to `min` before
+the user finishes typing `15`. Clamping/snapping still happens on the next `commit()`
+(blur/Enter), unchanged.
+
+Also fixes a real bug found while adding coverage for the above: `NumberFieldRoot`'s
+`clamp` prop is optional and boolean-typed with no explicit default, so when unset,
+Vue's boolean-prop casting resolved it to `false` rather than `undefined` — silently
+disabling the documented default-`true` clamping behavior for every consumer who
+didn't explicitly pass `:clamp="true"`. A component-level default now matches the
+composable's own `clamp: shouldClamp = true` default.
diff --git a/apps/docs/src/pages/components/forms/number-field.md b/apps/docs/src/pages/components/forms/number-field.md
index ed2214c03..c36d0f855 100644
--- a/apps/docs/src/pages/components/forms/number-field.md
+++ b/apps/docs/src/pages/components/forms/number-field.md
@@ -121,6 +121,18 @@ Increment and Decrement buttons repeat automatically when held. Configure timing
```
+### Eager Commit
+
+By default the typed value reaches the model on blur or Enter. Set `commit-on="input"` to write on every keystroke — mid-typing values are parsed but not clamped or snapped, so typing `15` into a `:min="10"` field never jumps to `10` after the first digit. Clamping still runs on blur:
+
+```vue
+
+
+
+
+
+```
+
### Mouse Wheel
Enable value adjustment via scroll wheel when the input is focused:
diff --git a/apps/docs/src/pages/composables/forms/create-number-field.md b/apps/docs/src/pages/composables/forms/create-number-field.md
index 5f64d2ef8..e9d34ea82 100644
--- a/apps/docs/src/pages/composables/forms/create-number-field.md
+++ b/apps/docs/src/pages/composables/forms/create-number-field.md
@@ -97,6 +97,8 @@ flowchart TD
| `formatValue(v)` | `(value: number) => string` | -- | Format a number |
| `parse(text)` | `(text: string) => number \| null` | -- | Parse text to number |
| `commit(next?)` | `(next?: number \| null) => void` | -- | Snap and optionally clamp |
+| `commitOn` | `'input' \| 'change'` | No | When typed input is written into `value` |
+| `commitDraft(text)` | `(text: string) => void` | -- | Parse and write without clamping or snapping |
## Examples
diff --git a/packages/0/src/components/NumberField/NumberFieldControl.vue b/packages/0/src/components/NumberField/NumberFieldControl.vue
index efa31ec71..9b56cff6c 100644
--- a/packages/0/src/components/NumberField/NumberFieldControl.vue
+++ b/packages/0/src/components/NumberField/NumberFieldControl.vue
@@ -20,7 +20,7 @@
// Utilities
import { isNull } from '#v0/utilities'
- import { mergeProps, onMounted, shallowRef, toRef, useAttrs, watch } from 'vue'
+ import { mergeProps, nextTick, onMounted, shallowRef, toRef, useAttrs, watch } from 'vue'
// Types
import type { AtomProps } from '#v0/components/Atom'
@@ -72,7 +72,20 @@
onMounted(syncText)
- watch(() => root.value.value, syncText)
+ // commitOn: 'input' writes to root.value on every keystroke via
+ // commitDraft(), which would otherwise trigger this same watcher and
+ // clobber in-progress text (e.g. a trailing "." lost to String(12.)).
+ // isEagerWrite marks writes that originated from the control's own input
+ // so the resulting sync is skipped — text.value already reflects them.
+ let isEagerWrite = false
+
+ watch(() => root.value.value, () => {
+ if (isEagerWrite) {
+ isEagerWrite = false
+ return
+ }
+ syncText()
+ })
const displayValue = toRef(() => {
return root.isFocused.value ? text.value : root.display.value
@@ -96,6 +109,16 @@
function onInput (e: Event) {
const target = e.target as HTMLInputElement
text.value = target.value
+
+ if (root.commitOn === 'input') {
+ isEagerWrite = true
+ root.commitDraft(text.value)
+ // Safety net for when commitDraft's write is a no-op (parsed value
+ // unchanged) and the watcher above never fires to consume the flag.
+ nextTick(() => {
+ isEagerWrite = false
+ })
+ }
}
function onFocus () {
diff --git a/packages/0/src/components/NumberField/NumberFieldRoot.vue b/packages/0/src/components/NumberField/NumberFieldRoot.vue
index 990fcb413..98fb1947f 100644
--- a/packages/0/src/components/NumberField/NumberFieldRoot.vue
+++ b/packages/0/src/components/NumberField/NumberFieldRoot.vue
@@ -116,6 +116,8 @@
format?: Intl.NumberFormatOptions
/** Whether commit() clamps to min/max (default: true) */
clamp?: boolean
+ /** When typed input is written into the model (default: 'change') */
+ commitOn?: 'input' | 'change'
/** Validation rules */
rules?: (FormValidationRule | RuleAlias | StandardSchemaV1)[]
/** When to trigger validation */
@@ -218,7 +220,8 @@
wrap,
locale,
format: formatOptions,
- clamp: shouldClamp,
+ clamp: shouldClamp = true,
+ commitOn = 'change',
rules = [],
validateOn = 'blur',
error = false,
@@ -239,6 +242,7 @@
locale,
format: formatOptions,
clamp: shouldClamp,
+ commitOn,
disabled: () => toValue(disabled),
readonly: () => toValue(_readonly),
min,
diff --git a/packages/0/src/components/NumberField/index.test.ts b/packages/0/src/components/NumberField/index.test.ts
index a3d65f3a8..f14172c7a 100644
--- a/packages/0/src/components/NumberField/index.test.ts
+++ b/packages/0/src/components/NumberField/index.test.ts
@@ -365,6 +365,42 @@ describe('numberField', () => {
expect(model.value).toBe(0)
})
+ it('should clamp a typed out-of-range value on blur by default', async () => {
+ // Regression test: `clamp` is an optional boolean prop with no
+ // explicit default, so when unset Vue's boolean-prop casting resolves
+ // it to `false` rather than `undefined` — silently disabling the
+ // documented default-true clamping unless a local default is set.
+ const model = ref(null)
+ const { controlEl, wait } = mountNumberField({
+ model,
+ props: { min: 10, max: 100 },
+ })
+ await wait()
+
+ await controlEl().trigger('focus')
+ await controlEl().setValue('1')
+ await controlEl().trigger('blur')
+ await wait()
+
+ expect(model.value).toBe(10)
+ })
+
+ it('should skip clamping when clamp is explicitly false', async () => {
+ const model = ref(null)
+ const { controlEl, wait } = mountNumberField({
+ model,
+ props: { min: 10, max: 100, clamp: false },
+ })
+ await wait()
+
+ await controlEl().trigger('focus')
+ await controlEl().setValue('1')
+ await controlEl().trigger('blur')
+ await wait()
+
+ expect(model.value).toBe(1)
+ })
+
it('should expose canIncrement as false at max', async () => {
const model = ref(100)
const { rootProps, wait } = mountNumberField({
@@ -1761,6 +1797,83 @@ describe('numberField', () => {
})
})
+ describe('commitOn', () => {
+ it('should not write to the model per keystroke by default', async () => {
+ const model = ref(null)
+ const { controlEl, wait } = mountNumberField({
+ model,
+ props: { min: 0, max: 100 },
+ })
+ await wait()
+
+ await controlEl().setValue('5')
+ await wait()
+
+ expect(model.value).toBeNull()
+ })
+
+ it('should write to the model per keystroke when commitOn is input', async () => {
+ const model = ref(null)
+ const { controlEl, wait } = mountNumberField({
+ model,
+ props: { min: 0, max: 100, commitOn: 'input' },
+ })
+ await wait()
+
+ await controlEl().setValue('5')
+ await wait()
+
+ expect(model.value).toBe(5)
+ })
+
+ it('should not clamp mid-typing even when the typed value is out of range', async () => {
+ const model = ref(null)
+ const { controlEl, wait } = mountNumberField({
+ model,
+ props: { min: 10, max: 100, commitOn: 'input' },
+ })
+ await wait()
+
+ await controlEl().setValue('1')
+ await wait()
+
+ // Typing "15" one digit at a time must not jump to min (10) after "1"
+ expect(model.value).toBe(1)
+ })
+
+ it('should clamp on blur even with commitOn input', async () => {
+ const model = ref(null)
+ const { controlEl, wait } = mountNumberField({
+ model,
+ props: { min: 10, max: 100, commitOn: 'input' },
+ })
+ await wait()
+
+ await controlEl().setValue('1')
+ await wait()
+ await controlEl().trigger('blur')
+ await wait()
+
+ expect(model.value).toBe(10)
+ })
+
+ it('should preserve a trailing decimal point while typing', async () => {
+ const model = ref(null)
+ const { controlEl, wait } = mountNumberField({
+ model,
+ props: { min: 0, max: 100, commitOn: 'input' },
+ })
+ await wait()
+
+ await controlEl().trigger('focus')
+ await controlEl().setValue('12.')
+ await wait()
+
+ expect(model.value).toBe(12)
+ expect((controlEl().element as HTMLInputElement).value).toBe('12.')
+ })
+ })
+
describe('decrement/increment edge cases', () => {
it('should stop spinning on pointercancel', async () => {
const model = ref(5)
diff --git a/packages/0/src/composables/createNumberField/index.test.ts b/packages/0/src/composables/createNumberField/index.test.ts
index 54efd6b61..948cbe397 100644
--- a/packages/0/src/composables/createNumberField/index.test.ts
+++ b/packages/0/src/composables/createNumberField/index.test.ts
@@ -302,6 +302,55 @@ describe('createNumberField', () => {
})
})
+ describe('commitOn', () => {
+ it('should default to change', () => {
+ const field = setup({})
+ expect(field.commitOn).toBe('change')
+ })
+
+ it('should reflect the configured value', () => {
+ const field = setup({ commitOn: 'input' })
+ expect(field.commitOn).toBe('input')
+ })
+ })
+
+ describe('commitDraft', () => {
+ it('should write the parsed value without clamping', () => {
+ const value = ref(null)
+ const field = setup({ value, min: 10, max: 100, step: 5 })
+ field.commitDraft('1')
+ expect(field.value.value).toBe(1)
+ })
+
+ it('should not snap to step', () => {
+ const value = ref(null)
+ const field = setup({ value, min: 0, max: 100, step: 5 })
+ field.commitDraft('13')
+ expect(field.value.value).toBe(13)
+ })
+
+ it('should write null for unparseable text', () => {
+ const value = ref(5)
+ const field = setup({ value })
+ field.commitDraft('')
+ expect(field.value.value).toBeNull()
+ })
+
+ it('should no-op when disabled', () => {
+ const value = ref(13)
+ const field = setup({ value, disabled: true })
+ field.commitDraft('99')
+ expect(field.value.value).toBe(13)
+ })
+
+ it('should no-op when readonly', () => {
+ const value = ref(13)
+ const field = setup({ value, readonly: true })
+ field.commitDraft('99')
+ expect(field.value.value).toBe(13)
+ })
+ })
+
describe('numeric context', () => {
it('should expose numeric properties', () => {
const field = setup({ min: 0, max: 100, step: 5 })
diff --git a/packages/0/src/composables/createNumberField/index.ts b/packages/0/src/composables/createNumberField/index.ts
index 671a79dbc..6586061aa 100644
--- a/packages/0/src/composables/createNumberField/index.ts
+++ b/packages/0/src/composables/createNumberField/index.ts
@@ -39,6 +39,23 @@ export interface NumberFieldOptions extends NumericOptions {
format?: Intl.NumberFormatOptions
/** Whether commit() clamps to min/max. @default true */
clamp?: boolean
+ /**
+ * When to write typed input into `value`. `'change'` (default) only
+ * writes on `commit()` (blur/Enter). `'input'` also writes on every
+ * keystroke via `commitDraft()`, without clamping or snapping — clamping
+ * mid-type would jump a value like `1` to `min` before the user finishes
+ * typing `15`. Clamping/snapping still happens on the next `commit()`.
+ *
+ * @default 'change'
+ *
+ * @example
+ * ```ts
+ * const field = createNumberField({ min: 10, max: 100, commitOn: 'input' })
+ * field.commitDraft('1')
+ * field.value.value // 1 — no jump to min while typing
+ * ```
+ */
+ commitOn?: 'input' | 'change'
/** Disabled state. */
disabled?: MaybeRefOrGetter
/** Readonly state. */
@@ -84,6 +101,21 @@ export interface NumberFieldContext {
parse: (text: string) => number | null
/** Snap and optionally clamp the current value. Pass `next` to avoid reading the stale model on the same tick as a write. */
commit: (next?: number | null) => void
+ /** When typed input is written into `value` — see {@link NumberFieldOptions.commitOn}. */
+ readonly commitOn: 'input' | 'change'
+ /**
+ * Parse `text` and write it straight into `value`, without clamping or
+ * snapping. Used by `commitOn: 'input'` consumers to get per-keystroke
+ * updates without the min/max jump `commit()` would cause mid-type.
+ *
+ * @example
+ * ```ts
+ * const field = createNumberField({ min: 10, max: 100 })
+ * field.commitDraft('1')
+ * field.value.value // 1 — clamped only on the next commit()
+ * ```
+ */
+ commitDraft: (text: string) => void
}
export function createNumberField (options: NumberFieldOptions = {}): NumberFieldContext {
@@ -92,6 +124,7 @@ export function createNumberField (options: NumberFieldOptions = {}): NumberFiel
locale = 'en-US',
format: formatOptions,
clamp: shouldClamp = true,
+ commitOn = 'change',
disabled = false,
readonly: _readonly = false,
min,
@@ -222,6 +255,11 @@ export function createNumberField (options: NumberFieldOptions = {}): NumberFiel
value.value = numeric.snap(val)
}
+ function commitDraft (text: string): void {
+ if (isLocked()) return
+ value.value = parse(text)
+ }
+
return {
value,
display,
@@ -236,5 +274,7 @@ export function createNumberField (options: NumberFieldOptions = {}): NumberFiel
formatValue,
parse,
commit,
+ commitOn,
+ commitDraft,
}
}