Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: testing-library/user-event
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v14.0.0
Choose a base ref
...
head repository: testing-library/user-event
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v14.0.1
Choose a head ref
  • 2 commits
  • 8 files changed
  • 3 contributors

Commits on Mar 31, 2022

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    a7f9906 View commit details
  2. fix(pointer): support nested select (#888)

    Co-authored-by: Rob Caldecott <robert.caldecott@keyloop.com>
    robcaldecott and Rob Caldecott authored Mar 31, 2022

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    e23e559 View commit details
Showing with 196 additions and 77 deletions.
  1. +0 −6 src/document/selection.ts
  2. +73 −17 src/document/value.ts
  3. +1 −1 src/event/behavior/click.ts
  4. +3 −10 src/utils/edit/input.ts
  5. +22 −2 tests/document/index.ts
  6. +8 −0 tests/pointer/click.ts
  7. +89 −4 tests/react/{type.tsx → index.tsx}
  8. +0 −37 tests/react/keyboard.tsx
6 changes: 0 additions & 6 deletions src/document/selection.ts
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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')
@@ -14,7 +15,11 @@ declare global {
interface Element {
[UIValue]?: string
[InitialValue]?: string
[TrackChanges]?: string[]
[TrackChanges]?: {
previousValue?: string
tracked?: string[]
nextValue?: string
}
}
}

@@ -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)
}
@@ -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
}
2 changes: 1 addition & 1 deletion src/event/behavior/click.ts
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ import {dispatchEvent} from '../dispatchEvent'
import {behavior} from './registry'

behavior.click = (event, target, config) => {
const context = target.closest('button,input,label,textarea')
const context = target.closest('button,input,label,select,textarea')
const control = context && isElementType(context, 'label') && context.control
if (control) {
return () => {
13 changes: 3 additions & 10 deletions src/utils/edit/input.ts
Original file line number Diff line number Diff line change
@@ -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,
})
@@ -228,8 +228,6 @@ function calculateNewValue(
function commitInput(
config: Config,
element: EditableInputOrTextarea,
oldValue: string,
newValue: string,
newOffset: number,
inputInit: InputEventInit,
) {
@@ -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,
24 changes: 22 additions & 2 deletions tests/document/index.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
8 changes: 8 additions & 0 deletions tests/pointer/click.ts
Original file line number Diff line number Diff line change
@@ -210,6 +210,14 @@ describe('label', () => {

expect(getEvents('click')).toHaveLength(2)
})

test('click nested select per label', async () => {
const {element, getEvents, user} = setup(`<label><select/></label>`)

await user.pointer({keys: '[MouseLeft]', target: element})

expect(getEvents('click')).toHaveLength(2)
})
})

describe('check/uncheck control per click', () => {
93 changes: 89 additions & 4 deletions tests/react/type.tsx → tests/react/index.tsx
Original file line number Diff line number Diff line change
@@ -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()
@@ -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 (
@@ -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')
@@ -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',
})
@@ -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',
})
37 changes: 0 additions & 37 deletions tests/react/keyboard.tsx

This file was deleted.