Skip to content

Commit

Permalink
fix: maintain UI value on controlled number input (#889)
Browse files Browse the repository at this point in the history
  • Loading branch information
ph-fritsche committed Mar 31, 2022
1 parent f209a6f commit a7f9906
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 76 deletions.
6 changes: 0 additions & 6 deletions src/document/selection.ts
Expand Up @@ -131,9 +131,3 @@ export function getUISelection(
endOffset: Math.max(sel.anchorOffset, sel.focusOffset),
}
}

export function clearUISelection(
element: HTMLInputElement | HTMLTextAreaElement,
) {
element[UISelection] = undefined
}
90 changes: 73 additions & 17 deletions src/document/value.ts
@@ -1,5 +1,6 @@
import {isElementType} from '../utils'
import {prepareInterceptor} from './interceptor'
import {clearUISelection} from './selection'
import {setUISelection} from './selection'

const UIValue = Symbol('Displayed value in UI')
const InitialValue = Symbol('Initial value to compare on blur')
Expand All @@ -14,7 +15,11 @@ declare global {
interface Element {
[UIValue]?: string
[InitialValue]?: string
[TrackChanges]?: string[]
[TrackChanges]?: {
previousValue?: string
tracked?: string[]
nextValue?: string
}
}
}

Expand All @@ -24,23 +29,35 @@ function valueInterceptor(
) {
const isUI = typeof v === 'object' && v[UIValue]

this[UIValue] = isUI ? String(v) : undefined
if (!isUI) {
trackValue(this, String(v))

this[InitialValue] = String(v)

// Programmatically setting the value property
// moves the cursor to the end of the input.
clearUISelection(this)
if (isUI) {
this[UIValue] = String(v)
setPreviousValue(this, String(this.value))
} else {
trackOrSetValue(this, String(v))
}

return {
applyNative: !!isUI,
realArgs: String(v),
realArgs: sanitizeValue(this, v),
}
}

function sanitizeValue(
element: HTMLInputElement | HTMLTextAreaElement,
v: Value | string,
) {
// Workaround for JSDOM
if (
isElementType(element, 'input', {type: 'number'}) &&
String(v) !== '' &&
!Number.isNaN(Number(v))
) {
// Setting value to "1." results in `null` in JSDOM
return String(Number(v))
}
return String(v)
}

export function prepareValueInterceptor(element: HTMLInputElement) {
prepareInterceptor(element, 'value', valueInterceptor)
}
Expand Down Expand Up @@ -73,23 +90,62 @@ export function getInitialValue(
return element[InitialValue]
}

function setPreviousValue(
element: HTMLInputElement | HTMLTextAreaElement,
v: string,
) {
element[TrackChanges] = {...element[TrackChanges], previousValue: v}
}

export function startTrackValue(
element: HTMLInputElement | HTMLTextAreaElement,
) {
element[TrackChanges] = []
element[TrackChanges] = {
...element[TrackChanges],
nextValue: String(element.value),
tracked: [],
}
}

function trackOrSetValue(
element: HTMLInputElement | HTMLTextAreaElement,
v: string,
) {
element[TrackChanges]?.tracked?.push(v)

if (!element[TrackChanges]?.tracked) {
setCleanValue(element, v)
}
}

function trackValue(
function setCleanValue(
element: HTMLInputElement | HTMLTextAreaElement,
v: string,
) {
element[TrackChanges]?.push(v)
element[UIValue] = undefined
element[InitialValue] = v

// Programmatically setting the value property
// moves the cursor to the end of the input.
setUISelection(element, {focusOffset: v.length})
}

/**
* @returns `true` if we recognize a React state reset and update
*/
export function endTrackValue(element: HTMLInputElement | HTMLTextAreaElement) {
const tracked = element[TrackChanges]
const changes = element[TrackChanges]

element[TrackChanges] = undefined

return tracked
const isJustReactStateUpdate =
changes?.tracked?.length === 2 &&
changes.tracked[0] === changes.previousValue &&
changes.tracked[1] === changes.nextValue

if (changes?.tracked?.length && !isJustReactStateUpdate) {
setCleanValue(element, changes.tracked[changes.tracked.length - 1])
}

return isJustReactStateUpdate
}
13 changes: 3 additions & 10 deletions src/utils/edit/input.ts
Expand Up @@ -167,11 +167,11 @@ function editInputElement(

if (isDateOrTime(element)) {
if (isValidDateOrTimeValue(element, newValue)) {
commitInput(config, element, oldValue, newValue, newOffset, {})
commitInput(config, element, newOffset, {})
dispatchUIEvent(config, element, 'change')
}
} else {
commitInput(config, element, oldValue, newValue, newOffset, {
commitInput(config, element, newOffset, {
data,
inputType,
})
Expand Down Expand Up @@ -228,8 +228,6 @@ function calculateNewValue(
function commitInput(
config: Config,
element: EditableInputOrTextarea,
oldValue: string,
newValue: string,
newOffset: number,
inputInit: InputEventInit,
) {
Expand All @@ -244,12 +242,7 @@ function commitInput(

dispatchUIEvent(config, element, 'input', inputInit)

const tracked = endTrackValue(element as HTMLInputElement)
if (
tracked?.length === 2 &&
tracked[0] === oldValue &&
tracked[1] === newValue
) {
if (endTrackValue(element as HTMLInputElement)) {
setSelection({
focusNode: element,
anchorOffset: newOffset,
Expand Down
24 changes: 22 additions & 2 deletions tests/document/index.ts
Expand Up @@ -15,19 +15,39 @@ function prepare(element: Element) {
}

test('keep track of value in UI', async () => {
// JSDOM implements the `value` property differently than the browser.
// In the browser it is always a `string`.
// In JSDOM it is `null` or `number` for `<input type="number"/>`
const {element} = render<HTMLInputElement>(`<input type="number"/>`)

prepare(element)

setUIValue(element, '2e-')
setUIValue(element, '2')
expect(element).toHaveValue(2)

setUIValue(element, '2e')
expect(element).toHaveValue(null)
expect(getUIValue(element)).toBe('2e')

setUIValue(element, '2e-')
expect(element).toHaveValue(null)
expect(getUIValue(element)).toBe('2e-')

element.value = '3'
setUIValue(element, '2e-5')
expect(element).toHaveValue(2e-5)
expect(getUIValue(element)).toBe('2e-5')

element.value = '3'
expect(element).toHaveValue(3)
expect(getUIValue(element)).toBe('3')

setUIValue(element, '3.')
expect(element).toHaveValue(3)
expect(getUIValue(element)).toBe('3.')

setUIValue(element, '3.5')
expect(element).toHaveValue(3.5)
expect(getUIValue(element)).toBe('3.5')
})

test('trigger `change` event if value changed since focus/set', async () => {
Expand Down
93 changes: 89 additions & 4 deletions tests/react/type.tsx → tests/react/index.tsx
@@ -1,8 +1,93 @@
import React, {useState} from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '#src'
import {getUIValue} from '#src/document'
import {addListeners} from '#testHelpers'

// Run twice to verify we handle this correctly no matter
// if React applies its magic before or after our document preparation.
test.each([0, 1])('maintain cursor position on controlled input', async () => {
function Input({initialValue}: {initialValue: string}) {
const [val, setVal] = useState(initialValue)

return <input value={val} onChange={e => setVal(e.target.value)} />
}

render(<Input initialValue="acd" />)
screen.getByRole('textbox').focus()
screen.getByRole<HTMLInputElement>('textbox').setSelectionRange(1, 1)
await userEvent.keyboard('b')

expect(screen.getByRole('textbox')).toHaveValue('abcd')
expect(screen.getByRole('textbox')).toHaveProperty('selectionStart', 2)
expect(screen.getByRole('textbox')).toHaveProperty('selectionEnd', 2)
})

test('trigger Synthetic `keypress` event for printable characters', async () => {
const onKeyPress = jest.fn<unknown, [React.KeyboardEvent]>()
render(<input onKeyPress={onKeyPress} />)
const user = userEvent.setup()
screen.getByRole('textbox').focus()

await user.keyboard('a')
expect(onKeyPress).toHaveBeenCalledTimes(1)
expect(onKeyPress.mock.calls[0][0]).toHaveProperty('charCode', 97)

await user.keyboard('[Enter]')
expect(onKeyPress).toHaveBeenCalledTimes(2)
expect(onKeyPress.mock.calls[1][0]).toHaveProperty('charCode', 13)
})

test.each(['1.5', '1e5'])(
'insert number with invalid intermediate values into controlled `<input type="number"/>`: %s',
async input => {
function Input() {
const [val, setVal] = useState('')

return (
<input
type="number"
value={val}
onChange={e => setVal(e.target.value)}
/>
)
}
render(<Input />)
const user = userEvent.setup()
screen.getByRole('spinbutton').focus()

await user.keyboard(input)
expect(getUIValue(screen.getByRole('spinbutton'))).toBe(input)
expect(screen.getByRole('spinbutton')).toHaveValue(Number(input))
},
)

test('detect value change in event handler', async () => {
function Input() {
const [val, setVal] = useState('')

return (
<input
type="number"
value={val}
onChange={e => {
if (Number(e.target.value) == 12) {
e.target.value = '34'
}
setVal(e.target.value)
}}
/>
)
}
render(<Input />)
const user = userEvent.setup()
screen.getByRole('spinbutton').focus()

await user.keyboard('125')
expect(getUIValue(screen.getByRole('spinbutton'))).toBe('345')
expect(screen.getByRole('spinbutton')).toHaveValue(345)
})

test('trigger onChange SyntheticEvent on input', async () => {
const inputHandler = jest.fn()
const changeHandler = jest.fn()
Expand All @@ -16,7 +101,7 @@ test('trigger onChange SyntheticEvent on input', async () => {
expect(changeHandler).toHaveBeenCalledTimes(6)
})

describe('typing in a controlled input', () => {
describe('typing in a formatted input', () => {
function DollarInput({initialValue = ''}) {
const [val, setVal] = useState(initialValue)
return (
Expand Down Expand Up @@ -45,7 +130,7 @@ describe('typing in a controlled input', () => {
}
}

test('typing in empty controlled input', async () => {
test('typing in empty formatted input', async () => {
const {element, getEventSnapshot, user} = setupDollarInput()

await user.type(element, '23')
Expand Down Expand Up @@ -81,7 +166,7 @@ describe('typing in a controlled input', () => {
`)
})

test('typing in the middle of a controlled input', async () => {
test('typing in the middle of a formatted input', async () => {
const {element, getEventSnapshot, user} = setupDollarInput({
initialValue: '$23',
})
Expand Down Expand Up @@ -120,7 +205,7 @@ describe('typing in a controlled input', () => {
`)
})

test('ignored {backspace} in controlled input', async () => {
test('ignored {backspace} in formatted input', async () => {
const {element, getEventSnapshot, user} = setupDollarInput({
initialValue: '$23',
})
Expand Down
37 changes: 0 additions & 37 deletions tests/react/keyboard.tsx

This file was deleted.

0 comments on commit a7f9906

Please sign in to comment.