Skip to content

Commit

Permalink
fix(VNumberInput): avoid native number input weird behaviours (#19605)
Browse files Browse the repository at this point in the history
fixes #19438
fixes #19558
fixes #19538
fixes #19494

Co-authored-by: John Leider <john@vuetifyjs.com>
  • Loading branch information
yuwu9145 and johnleider authored Apr 21, 2024
1 parent f4a5414 commit 42e482f
Show file tree
Hide file tree
Showing 2 changed files with 139 additions and 18 deletions.
100 changes: 82 additions & 18 deletions packages/vuetify/src/labs/VNumberInput/VNumberInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { makeFocusProps, useFocus } from '@/composables/focus'
import { useProxiedModel } from '@/composables/proxiedModel'

// Utilities
import { computed, ref } from 'vue'
import { filterInputAttrs, genericComponent, only, propsFactory, useRender } from '@/util'
import { computed, ref, watchEffect } from 'vue'
import { clamp, filterInputAttrs, genericComponent, getDecimals, only, propsFactory, useRender } from '@/util'

// Types
import type { PropType } from 'vue'
Expand All @@ -39,9 +39,18 @@ const makeVNumberInputProps = propsFactory({
},
inset: Boolean,
hideInput: Boolean,
min: Number,
max: Number,
step: Number,
min: {
type: Number,
default: -Infinity,
},
max: {
type: Number,
default: Infinity,
},
step: {
type: Number,
default: 1,
},

...only(makeVInputProps(), [
'density',
Expand Down Expand Up @@ -79,8 +88,8 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
...makeVNumberInputProps(),

modelValue: {
type: [Number, String],
default: 0,
type: Number,
default: undefined,
},
},

Expand All @@ -93,6 +102,24 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
const { isFocused, focus, blur } = useFocus(props)
const inputRef = ref<HTMLInputElement>()

const stepDecimals = computed(() => getDecimals(props.step))
const modelDecimals = computed(() => model.value != null ? getDecimals(model.value) : 0)

const canIncrease = computed(() => {
if (model.value == null) return true
return model.value + props.step <= props.max
})
const canDecrease = computed(() => {
if (model.value == null) return true
return model.value - props.step >= props.min
})

watchEffect(() => {
if (model.value != null && (model.value < props.min || model.value > props.max)) {
model.value = clamp(model.value, props.min, props.max)
}
})

function onFocus () {
if (!isFocused.value) focus()
}
Expand All @@ -101,14 +128,22 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
return props.hideInput ? 'stacked' : props.controlVariant
})

const incrementSlotProps = computed(() => ({ click: onClickUp }))

const decrementSlotProps = computed(() => ({ click: onClickDown }))

function toggleUpDown (increment = true) {
if (model.value == null) {
model.value = 0
return
}

const decimals = Math.max(modelDecimals.value, stepDecimals.value)
if (increment) {
inputRef.value?.stepUp()
if (canIncrease.value) model.value = +(((model.value + props.step).toFixed(decimals)))
} else {
inputRef.value?.stepDown()
if (canDecrease.value) model.value = +(((model.value - props.step).toFixed(decimals)))
}

if (inputRef.value) model.value = parseInt(inputRef.value.value, 10)
}

function onClickUp () {
Expand All @@ -119,9 +154,33 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
toggleUpDown(false)
}

const incrementSlotProps = computed(() => ({ click: onClickUp }))
function onKeydown (e: KeyboardEvent) {
if (
['Enter', 'ArrowLeft', 'ArrowRight', 'Backspace'].includes(e.key) ||
e.ctrlKey
) return

const decrementSlotProps = computed(() => ({ click: onClickDown }))
if (['ArrowDown'].includes(e.key)) {
e.preventDefault()
toggleUpDown(false)
return
}
if (['ArrowUp'].includes(e.key)) {
e.preventDefault()
toggleUpDown()
return
}

// Only numbers, +, - & . are allowed
if (!/^[0-9\-+.]+$/.test(e.key)) {
e.preventDefault()
}
}

function onInput (e: Event) {
const el = e.target as HTMLInputElement
model.value = el.value ? +(el.value) : undefined
}

useRender(() => {
const fieldProps = filterFieldProps(props)
Expand All @@ -135,9 +194,11 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
{
!slots.decrement ? (
<VBtn
disabled={ !canDecrease.value }
flat
key="decrement-btn"
height={ defaultHeight }
name="decrement-btn"
icon="$expand"
size="small"
onClick={ onClickDown }
Expand All @@ -147,6 +208,7 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
key="decrement-defaults"
defaults={{
VBtn: {
disabled: !canDecrease.value,
flat: true,
height: defaultHeight,
size: 'small',
Expand All @@ -166,9 +228,11 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
{
!slots.increment ? (
<VBtn
disabled={ !canIncrease.value }
flat
key="increment-btn"
height={ defaultHeight }
name="increment-btn"
icon="$collapse"
onClick={ onClickUp }
size="small"
Expand All @@ -178,6 +242,7 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
key="increment-defaults"
defaults={{
VBtn: {
disabled: !canIncrease.value,
flat: true,
height: defaultHeight,
size: 'small',
Expand Down Expand Up @@ -231,12 +296,11 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
}) => (
<input
ref={ inputRef }
type="number"
v-model={ model.value }
type="text"
value={ model.value }
onInput={ onInput }
onKeydown={ onKeydown }
class={ fieldClass }
max={ props.max }
min={ props.min }
step={ props.step }
onFocus={ onFocus }
onBlur={ blur }
{ ...inputAttrs }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/// <reference types="../../../../types/cypress" />

// Components
import { VNumberInput } from '../VNumberInput'

// Utilities
import { ref } from 'vue'

describe('VNumberInput', () => {
describe('native number input quirks', () => {
it('should not bypass min', () => {
const numberInputValue = ref(1)
cy.mount(() =>
<VNumberInput min={ 5 } max={ 15 } v-model={ numberInputValue.value } ></VNumberInput>
)
.get('.v-number-input input').should('have.value', '5')
.should(() => expect(numberInputValue.value).to.equal(5))
})

it('should not bypass max', () => {
const numberInputValue = ref(20)
cy.mount(() =>
<VNumberInput min={ 5 } max={ 15 } v-model={ numberInputValue.value } ></VNumberInput>
)
.get('.v-number-input input').should('have.value', '15')
.should(() => expect(numberInputValue.value).to.equal(15))
})

it('should support decimal step', () => {
const numberInputValue = ref(0)
cy.mount(() =>
(
<VNumberInput
step={ 0.03 }
v-model={ numberInputValue.value }
></VNumberInput>
)
)
.get('button[name="increment-btn"]')
.click()
.get('.v-number-input input').should('have.value', '0.03')
.then(() => expect(numberInputValue.value).to.equal(0.03))
.get('button[name="increment-btn"]')
.click()
.get('.v-number-input input').should('have.value', '0.06')
.then(() => expect(numberInputValue.value).to.equal(0.06))
.get('button[name="decrement-btn"]')
.click()
.get('.v-number-input input').should('have.value', '0.03')
.then(() => expect(numberInputValue.value).to.equal(0.03))
.get('button[name="decrement-btn"]')
.click()
.get('.v-number-input input').should('have.value', '0')
.then(() => expect(numberInputValue.value).to.equal(0))
})
})
})

0 comments on commit 42e482f

Please sign in to comment.