Skip to content

Commit

Permalink
fix(type): handle selectionRange properly (#313)
Browse files Browse the repository at this point in the history
Closes #309
  • Loading branch information
kentcdodds committed Jun 5, 2020
1 parent 35e842d commit 5c11411
Show file tree
Hide file tree
Showing 6 changed files with 257 additions and 55 deletions.
3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -57,7 +57,8 @@
"rules": {
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/tabindex-no-positive": "off",
"no-return-assign": "off"
"no-return-assign": "off",
"react/prop-types": "off"
},
"overrides": [
{
Expand Down
7 changes: 4 additions & 3 deletions src/__tests__/helpers/utils.js
Expand Up @@ -43,8 +43,8 @@ function setup(ui) {
} = render(ui)
element.previousTestData = getTestData(element)

const getEventCalls = addListeners(element)
return {element, getEventCalls}
const {getEventCalls, clearEventCalls} = addListeners(element)
return {element, getEventCalls, clearEventCalls}
}

function addListeners(element) {
Expand Down Expand Up @@ -102,7 +102,8 @@ function addListeners(element) {
})
.join('\n')
}
return getEventCalls
const clearEventCalls = () => generalListener.mockClear()
return {getEventCalls, clearEventCalls}
}

// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/toggleselectoptions.js
Expand Up @@ -46,7 +46,7 @@ test('should fire the correct events for multiple select when focus is in other

const $otherBtn = screen.getByRole('button')

const getButtonEvents = addListeners($otherBtn)
const {getEventCalls: getButtonEvents} = addListeners($otherBtn)

$otherBtn.focus()

Expand Down
58 changes: 51 additions & 7 deletions src/__tests__/type-modifiers.js
Expand Up @@ -28,9 +28,37 @@ test('{esc} triggers typing the escape character', async () => {
`)
})

test('a{backspace}', async () => {
const {element, getEventCalls} = setup(<input />)
await userEvent.type(element, 'a{backspace}')
expect(getEventCalls()).toMatchInlineSnapshot(`
focus
keydown: a (97)
keypress: a (97)
input: "{CURSOR}" -> "a"
keyup: a (97)
keydown: Backspace (8)
input: "a{CURSOR}" -> ""
keyup: Backspace (8)
`)
})

test('{backspace}a', async () => {
const {element, getEventCalls} = setup(<input />)
await userEvent.type(element, '{backspace}a')
expect(getEventCalls()).toMatchInlineSnapshot(`
focus
keydown: Backspace (8)
keyup: Backspace (8)
keydown: a (97)
keypress: a (97)
input: "{CURSOR}" -> "a"
keyup: a (97)
`)
})

test('{backspace} triggers typing the backspace character and deletes the character behind the cursor', async () => {
const {element: input, getEventCalls} = setup(<input />)
input.value = 'yo'
const {element: input, getEventCalls} = setup(<input defaultValue="yo" />)
input.setSelectionRange(1, 1)

await userEvent.type(input, '{backspace}')
Expand All @@ -44,9 +72,24 @@ test('{backspace} triggers typing the backspace character and deletes the charac
})

test('{backspace} on a readOnly input', async () => {
const {element: input, getEventCalls} = setup(<input />)
input.readOnly = true
input.value = 'yo'
const {element: input, getEventCalls} = setup(
<input readOnly defaultValue="yo" />,
)
input.setSelectionRange(1, 1)

await userEvent.type(input, '{backspace}')

expect(getEventCalls()).toMatchInlineSnapshot(`
focus
keydown: Backspace (8)
keyup: Backspace (8)
`)
})

test('{backspace} does not fire input if keydown prevents default', async () => {
const {element: input, getEventCalls} = setup(
<input defaultValue="yo" onKeyDown={e => e.preventDefault()} />,
)
input.setSelectionRange(1, 1)

await userEvent.type(input, '{backspace}')
Expand All @@ -59,8 +102,9 @@ test('{backspace} on a readOnly input', async () => {
})

test('{backspace} deletes the selected range', async () => {
const {element: input, getEventCalls} = setup(<input />)
input.value = 'Hi there'
const {element: input, getEventCalls} = setup(
<input defaultValue="Hi there" />,
)
input.setSelectionRange(1, 5)

await userEvent.type(input, '{backspace}')
Expand Down
99 changes: 99 additions & 0 deletions src/__tests__/type.js
Expand Up @@ -52,6 +52,31 @@ test('should append text all at once', async () => {
`)
})

test('does not fire input event when keypress calls prevent default', async () => {
const {element, getEventCalls} = setup(
<input onKeyPress={e => e.preventDefault()} />,
)
await userEvent.type(element, 'a')
expect(getEventCalls()).toMatchInlineSnapshot(`
focus
keydown: a (97)
keypress: a (97)
keyup: a (97)
`)
})

test('does not fire keypress or input events when keydown calls prevent default', async () => {
const {element, getEventCalls} = setup(
<input onKeyDown={e => e.preventDefault()} />,
)
await userEvent.type(element, 'a')
expect(getEventCalls()).toMatchInlineSnapshot(`
focus
keydown: a (97)
keyup: a (97)
`)
})

// TODO: Let's migrate these tests to use the setup util
test('should not type when event.preventDefault() is called', async () => {
const onChange = jest.fn()
Expand Down Expand Up @@ -396,3 +421,77 @@ test('does not continue firing events when disabled during typing', async () =>
await userEvent.type(input, 'hi there')
expect(input).toHaveValue('h')
})

function DollarInput({initialValue = ''}) {
const [value, setValue] = React.useState(initialValue)
function handleChange(event) {
const val = event.target.value
const withoutDollar = val.replace(/\$/g, '')
const num = Number(withoutDollar)
if (Number.isNaN(num)) return
setValue(`$${withoutDollar}`)
}
return <input value={value} type="text" onChange={handleChange} />
}

test('typing into a controlled input works', async () => {
const {element, getEventCalls} = setup(<DollarInput />)
await userEvent.type(element, '23')
expect(element.value).toBe('$23')
expect(getEventCalls()).toMatchInlineSnapshot(`
focus
keydown: 2 (50)
keypress: 2 (50)
input: "{CURSOR}" -> "2"
keyup: 2 (50)
keydown: 3 (51)
keypress: 3 (51)
input: "$2{CURSOR}" -> "$23"
keyup: 3 (51)
`)
})

test('typing in the middle of a controlled input works', async () => {
const {element, getEventCalls} = setup(<DollarInput initialValue="$23" />)
element.setSelectionRange(2, 2)

await userEvent.type(element, '1')

expect(element.value).toBe('$213')
expect(getEventCalls()).toMatchInlineSnapshot(`
focus
keydown: 1 (49)
keypress: 1 (49)
input: "$2{CURSOR}3" -> "$213"
keyup: 1 (49)
`)
})

test('ignored {backspace} in controlled input', async () => {
const {element, getEventCalls} = setup(<DollarInput initialValue="$23" />)
element.setSelectionRange(1, 1)

await userEvent.type(element, '{backspace}')
// this is the same behavior in the browser.
// in our case, when you try to backspace the "$", our event handler
// will ignore that change and React resets the value to what it was
// before. When the value is set programmatically to something different
// from what was expected based on the input event, the browser sets
// the selection start and end to the end of the input
expect(element.selectionStart).toBe(element.value.length)
expect(element.selectionEnd).toBe(element.value.length)
await userEvent.type(element, '4')

expect(element.value).toBe('$234')
// the backslash in the inline snapshot is to escape the $ before {CURSOR}
expect(getEventCalls()).toMatchInlineSnapshot(`
focus
keydown: Backspace (8)
input: "\${CURSOR}23" -> "23"
keyup: Backspace (8)
keydown: 4 (52)
keypress: 4 (52)
input: "$23{CURSOR}" -> "$234"
keyup: 4 (52)
`)
})

0 comments on commit 5c11411

Please sign in to comment.