diff --git a/README.md b/README.md
index 8b298443..81dc7180 100644
--- a/README.md
+++ b/README.md
@@ -158,8 +158,8 @@ import userEvent from '@testing-library/user-event'
test('type', async () => {
render()
- await userEvent.type(screen.getByRole('textbox'), 'Hello, World!')
- expect(screen.getByRole('textbox')).toHaveAttribute('value', 'Hello, World!')
+ await userEvent.type(screen.getByRole('textbox'), 'Hello{enter}World!')
+ expect(screen.getByRole('textbox')).toHaveValue('Hello\nWorld!')
})
```
@@ -170,6 +170,25 @@ one character at the time. `false` is the default value.
are typed. By default it's 0. You can use this option if your component has a
different behavior for fast or slow users.
+#### Special characters
+
+The following special character strings are supported:
+
+| Text string | Key | Modifier | Notes |
+| ------------- | --------- | ---------- | ------------------------------------------------------ |
+| `{enter}` | Enter | N/A | Will insert a newline character (`` only). |
+| `{esc}` | Escape | N/A | |
+| `{backspace}` | Backspace | N/A | Will delete the previous character. |
+| `{shift}` | Shift | `shiftKey` | Does **not** capitalize following characters. |
+| `{ctrl}` | Control | `ctrlKey` | |
+| `{alt}` | Alt | `altKey` | |
+| `{meta}` | OS | `metaKey` | |
+
+> **A note about modifiers:** Modifier keys (`{shift}`, `{ctrl}`, `{alt}`,
+> `{meta}`) will activate their corresponding event modifiers for the duration
+> of type command. Keys are released in the same sequence that they were invoked
+> at the end of typing.
+
### `upload(element, file, [{ clickInit, changeInit }])`
Uploads file to an ``. For uploading multiple files use `` with
@@ -400,6 +419,7 @@ Thanks goes to these people ([emoji key][emojis]):
+
This project follows the [all-contributors][all-contributors] specification.
diff --git a/package-lock.json b/package-lock.json
index 008ec271..45877be3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,5 +1,5 @@
{
- "name": "testing-library-user-event",
+ "name": "@testing-library/user-event",
"version": "0.0.0-semantically-released",
"lockfileVersion": 1,
"requires": true,
@@ -1945,12 +1945,30 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
+ "diff-sequences": {
+ "version": "25.2.6",
+ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz",
+ "integrity": "sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg==",
+ "dev": true
+ },
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
+ "jest-diff": {
+ "version": "25.5.0",
+ "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.5.0.tgz",
+ "integrity": "sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A==",
+ "dev": true,
+ "requires": {
+ "chalk": "^3.0.0",
+ "diff-sequences": "^25.2.6",
+ "jest-get-type": "^25.2.6",
+ "pretty-format": "^25.5.0"
+ }
+ },
"jest-matcher-utils": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-25.5.0.tgz",
@@ -2101,6 +2119,76 @@
"requires": {
"jest-diff": "^25.2.1",
"pretty-format": "^25.2.1"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
+ "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
+ "dev": true,
+ "requires": {
+ "@types/color-name": "^1.1.1",
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
+ "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "diff-sequences": {
+ "version": "25.2.6",
+ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz",
+ "integrity": "sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "jest-diff": {
+ "version": "25.5.0",
+ "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.5.0.tgz",
+ "integrity": "sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A==",
+ "dev": true,
+ "requires": {
+ "chalk": "^3.0.0",
+ "diff-sequences": "^25.2.6",
+ "jest-get-type": "^25.2.6",
+ "pretty-format": "^25.5.0"
+ }
+ },
+ "supports-color": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
+ "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
}
},
"@types/json-schema": {
@@ -4415,9 +4503,9 @@
"dev": true
},
"diff-sequences": {
- "version": "25.2.6",
- "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz",
- "integrity": "sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg==",
+ "version": "26.0.0",
+ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.0.0.tgz",
+ "integrity": "sha512-JC/eHYEC3aSS0vZGjuoc4vHA0yAQTzhQQldXMeMF+JlxLGJlCO38Gma82NV9gk1jGFz8mDzUMeaKXvjRRdJ2dg==",
"dev": true
},
"diffie-hellman": {
@@ -7360,17 +7448,29 @@
}
},
"jest-diff": {
- "version": "25.5.0",
- "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.5.0.tgz",
- "integrity": "sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A==",
+ "version": "26.0.1",
+ "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.0.1.tgz",
+ "integrity": "sha512-odTcHyl5X+U+QsczJmOjWw5tPvww+y9Yim5xzqxVl/R1j4z71+fHW4g8qu1ugMmKdFdxw+AtQgs5mupPnzcIBQ==",
"dev": true,
"requires": {
- "chalk": "^3.0.0",
- "diff-sequences": "^25.2.6",
- "jest-get-type": "^25.2.6",
- "pretty-format": "^25.5.0"
+ "chalk": "^4.0.0",
+ "diff-sequences": "^26.0.0",
+ "jest-get-type": "^26.0.0",
+ "pretty-format": "^26.0.1"
},
"dependencies": {
+ "@jest/types": {
+ "version": "26.0.1",
+ "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.0.1.tgz",
+ "integrity": "sha512-IbtjvqI9+eS1qFnOIEL7ggWmT+iK/U+Vde9cGWtYb/b6XgKb3X44ZAe/z9YZzoAAZ/E92m0DqrilF934IGNnQA==",
+ "dev": true,
+ "requires": {
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "@types/istanbul-reports": "^1.1.1",
+ "@types/yargs": "^15.0.0",
+ "chalk": "^4.0.0"
+ }
+ },
"ansi-styles": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
@@ -7381,16 +7481,6 @@
"color-convert": "^2.0.1"
}
},
- "chalk": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
- "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
- "dev": true,
- "requires": {
- "ansi-styles": "^4.1.0",
- "supports-color": "^7.1.0"
- }
- },
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -7406,19 +7496,22 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
- "has-flag": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
- "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "jest-get-type": {
+ "version": "26.0.0",
+ "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.0.0.tgz",
+ "integrity": "sha512-zRc1OAPnnws1EVfykXOj19zo2EMw5Hi6HLbFCSjpuJiXtOWAYIjNsHVSbpQ8bDX7L5BGYGI8m+HmKdjHYFF0kg==",
"dev": true
},
- "supports-color": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
- "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==",
+ "pretty-format": {
+ "version": "26.0.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.0.1.tgz",
+ "integrity": "sha512-SWxz6MbupT3ZSlL0Po4WF/KujhQaVehijR2blyRDCzk9e45EaYMVhMBn49fnRuHxtkSpXTes1GxNpVmH86Bxfw==",
"dev": true,
"requires": {
- "has-flag": "^4.0.0"
+ "@jest/types": "^26.0.1",
+ "ansi-regex": "^5.0.0",
+ "ansi-styles": "^4.0.0",
+ "react-is": "^16.12.0"
}
}
}
@@ -11185,12 +11278,30 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
+ "diff-sequences": {
+ "version": "25.2.6",
+ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz",
+ "integrity": "sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg==",
+ "dev": true
+ },
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
+ "jest-diff": {
+ "version": "25.5.0",
+ "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.5.0.tgz",
+ "integrity": "sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A==",
+ "dev": true,
+ "requires": {
+ "chalk": "^3.0.0",
+ "diff-sequences": "^25.2.6",
+ "jest-get-type": "^25.2.6",
+ "pretty-format": "^25.5.0"
+ }
+ },
"memory-fs": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
diff --git a/package.json b/package.json
index 6f3eb51e..5e25cacf 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"@testing-library/dom": "^7.5.7",
"@testing-library/jest-dom": "^5.8.0",
"@testing-library/react": "^10.0.4",
+ "jest-diff": "^26.0.1",
"kcd-scripts": "^6.0.1",
"react": "^16.13.1",
"react-dom": "^16.13.1"
diff --git a/src/__tests__/type.js b/src/__tests__/type.js
index 153ef0a4..e5ff8159 100644
--- a/src/__tests__/type.js
+++ b/src/__tests__/type.js
@@ -2,6 +2,8 @@ import React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '../../src'
+/* eslint-disable max-lines-per-function */
+
test.each(['input', 'textarea'])('should type text in <%s>', type => {
const onChange = jest.fn()
render(
@@ -256,3 +258,324 @@ test.each(['input', 'textarea'])(
expect(onKeyUp).not.toHaveBeenCalled()
},
)
+
+describe('special characters', () => {
+ afterEach(jest.clearAllMocks)
+
+ const onChange = jest.fn()
+ const onKeyDown = jest.fn()
+ const onKeyPress = jest.fn()
+ const onKeyUp = jest.fn()
+
+ it.each(['a{bc', 'a{bc}', 'a{backspacee}c'])(
+ 'properly parses %s',
+ async text => {
+ const {getByTestId} = render(
+ React.createElement('input', {
+ 'data-testid': 'input',
+ }),
+ )
+
+ const inputEl = getByTestId('input')
+
+ await userEvent.type(inputEl, text)
+
+ expect(inputEl).toHaveProperty('value', text)
+ },
+ )
+
+ describe('{enter}', () => {
+ describe('input', () => {
+ it('should record key up/down/press events from {enter}', async () => {
+ const {getByTestId} = render(
+ React.createElement('input', {
+ 'data-testid': 'input',
+ onChange,
+ onKeyDown,
+ onKeyPress,
+ onKeyUp,
+ }),
+ )
+
+ const text = 'abc{enter}'
+
+ const inputEl = getByTestId('input')
+
+ await userEvent.type(inputEl, text)
+
+ const expectedText = 'abc'
+
+ expect(inputEl).toHaveProperty('value', expectedText)
+ expect(onChange).toHaveBeenCalledTimes(3)
+ expect(onKeyPress).toHaveBeenCalledTimes(4)
+ expect(onKeyDown).toHaveBeenCalledTimes(4)
+ expect(onKeyUp).toHaveBeenCalledTimes(4)
+ })
+ })
+
+ describe('textarea', () => {
+ it('should be able to type newlines with {enter}', async () => {
+ const {getByTestId} = render(
+ React.createElement('textarea', {
+ 'data-testid': 'input',
+ onChange,
+ onKeyDown,
+ onKeyPress,
+ onKeyUp,
+ }),
+ )
+
+ const text = 'a{enter}{enter}b{enter}'
+
+ const inputEl = getByTestId('input')
+
+ await userEvent.type(inputEl, text)
+
+ const expectedText = 'a\n\nb\n'
+
+ expect(inputEl).toHaveProperty('value', expectedText)
+ expect(onChange).toHaveBeenCalledTimes(5)
+ expect(onKeyPress).toHaveBeenCalledTimes(5)
+ expect(onKeyDown).toHaveBeenCalledTimes(5)
+ expect(onKeyUp).toHaveBeenCalledTimes(5)
+ })
+ })
+ })
+
+ describe('{esc}', () => {
+ describe.each(['input', 'textarea'])('%s', elementType => {
+ it('should record up/down/press events from {esc}', async () => {
+ const {getByTestId} = render(
+ React.createElement(elementType, {
+ 'data-testid': 'input',
+ onChange,
+ onKeyDown,
+ onKeyPress,
+ onKeyUp,
+ }),
+ )
+
+ const text = 'a{esc}'
+
+ const inputEl = getByTestId('input')
+
+ await userEvent.type(inputEl, text)
+
+ const expectedText = 'a'
+
+ expect(inputEl).toHaveProperty('value', expectedText)
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onKeyPress).toHaveBeenCalledTimes(1)
+ expect(onKeyDown).toHaveBeenCalledTimes(2)
+ expect(onKeyUp).toHaveBeenCalledTimes(2)
+ })
+ })
+ })
+
+ describe('{backspace}', () => {
+ describe.each(['input', 'textarea'])('%s', elementType => {
+ it.each([
+ [
+ 'ab{backspace}c',
+ 'ac',
+ {keyDown: 4, keyUp: 4, keyPress: 3, change: 4},
+ ],
+ [
+ 'a{backspace}{backspace}bc',
+ 'bc',
+ {keyDown: 5, keyUp: 5, keyPress: 3, change: 4},
+ ],
+ [
+ 'a{{backspace}}',
+ 'a}',
+ {keyDown: 4, keyUp: 4, keyPress: 3, change: 4},
+ ],
+ ])(
+ 'input `%s` should output `%s` and have the correct number of fired events',
+ async (
+ typeText,
+ expectedText,
+ {
+ keyDown: numKeyDownEvents,
+ keyUp: numKeyUpEvents,
+ keyPress: numKeyPressEvents,
+ change: numOnChangeEvents,
+ },
+ ) => {
+ const {getByTestId} = render(
+ React.createElement(elementType, {
+ 'data-testid': 'input',
+ onChange,
+ onKeyDown,
+ onKeyPress,
+ onKeyUp,
+ }),
+ )
+
+ const inputEl = getByTestId('input')
+
+ await userEvent.type(inputEl, typeText)
+
+ expect(inputEl).toHaveProperty('value', expectedText)
+ expect(onChange).toHaveBeenCalledTimes(numOnChangeEvents)
+ expect(onKeyDown).toHaveBeenCalledTimes(numKeyDownEvents)
+ expect(onKeyUp).toHaveBeenCalledTimes(numKeyUpEvents)
+ expect(onKeyPress).toHaveBeenCalledTimes(numKeyPressEvents)
+ },
+ )
+ })
+ })
+
+ describe('modifiers', () => {
+ describe.each([
+ ['shift', 'Shift', 'shiftKey'],
+ ['ctrl', 'Control', 'ctrlKey'],
+ ['alt', 'Alt', 'altKey'],
+ ['meta', 'OS', 'metaKey'],
+ ])('%s', (modifierText, modifierKey, modifierProperty) => {
+ describe.each(['input', 'textarea'])('%s', elementType => {
+ it('only adds modifier to following keystroke', async () => {
+ const handler = jest.fn().mockImplementation(e => e.persist())
+
+ const {getByTestId} = render(
+ React.createElement(elementType, {
+ 'data-testid': 'input',
+ onKeyDown: handler,
+ onKeyPress: handler,
+ onKeyUp: handler,
+ }),
+ )
+
+ const inputEl = getByTestId('input')
+
+ await userEvent.type(inputEl, `{${modifierText}}ab`)
+
+ expect(inputEl).toHaveProperty('value', 'ab')
+
+ expect(handler).toHaveBeenCalledWithEventAtIndex(0, {
+ type: 'keydown',
+ key: modifierKey,
+ [modifierProperty]: false,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(1, {
+ type: 'keydown',
+ key: 'a',
+ [modifierProperty]: true,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(2, {
+ type: 'keypress',
+ key: 'a',
+ [modifierProperty]: true,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(3, {
+ type: 'keyup',
+ key: 'a',
+ [modifierProperty]: true,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(4, {
+ type: 'keydown',
+ key: 'b',
+ [modifierProperty]: true,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(5, {
+ type: 'keypress',
+ key: 'b',
+ [modifierProperty]: true,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(6, {
+ type: 'keyup',
+ key: 'b',
+ [modifierProperty]: true,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(7, {
+ type: 'keyup',
+ key: modifierKey,
+ [modifierProperty]: false,
+ })
+ })
+ })
+ })
+
+ it('can handle multiple held modifiers', async () => {
+ const handler = jest.fn().mockImplementation(e => e.persist())
+
+ const {getByTestId} = render(
+ React.createElement('input', {
+ 'data-testid': 'input',
+ onKeyDown: handler,
+ onKeyPress: handler,
+ onKeyUp: handler,
+ }),
+ )
+
+ const inputEl = getByTestId('input')
+
+ await userEvent.type(inputEl, '{ctrl}{shift}ab')
+
+ expect(inputEl).toHaveProperty('value', 'ab')
+
+ expect(handler).toHaveBeenCalledTimes(10)
+
+ expect(handler).toHaveBeenCalledWithEventAtIndex(0, {
+ type: 'keydown',
+ key: 'Control',
+ ctrlKey: false,
+ shiftKey: false,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(1, {
+ type: 'keydown',
+ key: 'Shift',
+ ctrlKey: true,
+ shiftKey: false,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(2, {
+ type: 'keydown',
+ key: 'a',
+ ctrlKey: true,
+ shiftKey: true,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(3, {
+ type: 'keypress',
+ key: 'a',
+ ctrlKey: true,
+ shiftKey: true,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(4, {
+ type: 'keyup',
+ key: 'a',
+ ctrlKey: true,
+ shiftKey: true,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(5, {
+ type: 'keydown',
+ key: 'b',
+ ctrlKey: true,
+ shiftKey: true,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(6, {
+ type: 'keypress',
+ key: 'b',
+ ctrlKey: true,
+ shiftKey: true,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(7, {
+ type: 'keyup',
+ key: 'b',
+ ctrlKey: true,
+ shiftKey: true,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(8, {
+ type: 'keyup',
+ key: 'Control',
+ ctrlKey: false,
+ shiftKey: true,
+ })
+ expect(handler).toHaveBeenCalledWithEventAtIndex(9, {
+ type: 'keyup',
+ key: 'Shift',
+ ctrlKey: false,
+ shiftKey: false,
+ })
+ })
+ })
+})
diff --git a/src/index.js b/src/index.js
index 4d74d6cd..647e971a 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,20 +1,22 @@
import {fireEvent} from '@testing-library/dom'
-
-function wait(time) {
- return new Promise(resolve => setTimeout(() => resolve(), time))
-}
+import type from './type'
function isMousePressEvent(event) {
- return event === 'mousedown' || event === 'mouseup' || event === 'click' || event === 'dblclick';
+ return (
+ event === 'mousedown' ||
+ event === 'mouseup' ||
+ event === 'click' ||
+ event === 'dblclick'
+ )
}
function invert(map) {
- const res = {};
+ const res = {}
for (const key of Object.keys(map)) {
- res[map[key]] = key;
+ res[map[key]] = key
}
- return res;
+ return res
}
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
@@ -22,48 +24,51 @@ const BUTTONS_TO_NAMES = {
0: 'none',
1: 'primary',
2: 'secondary',
- 4: 'auxiliary'
-};
-const NAMES_TO_BUTTONS = invert(BUTTONS_TO_NAMES);
+ 4: 'auxiliary',
+}
+const NAMES_TO_BUTTONS = invert(BUTTONS_TO_NAMES)
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button
const BUTTON_TO_NAMES = {
0: 'primary',
1: 'auxiliary',
- 2: 'secondary'
-};
+ 2: 'secondary',
+}
-const NAMES_TO_BUTTON = invert(BUTTON_TO_NAMES);
+const NAMES_TO_BUTTON = invert(BUTTON_TO_NAMES)
function convertMouseButtons(event, init, property, mapping) {
if (!isMousePressEvent(event)) {
- return 0;
+ return 0
}
if (init[property] != null) {
- return init[property];
+ return init[property]
}
if (init.buttons != null) {
- return mapping[BUTTONS_TO_NAMES[init.buttons]] || 0;
+ return mapping[BUTTONS_TO_NAMES[init.buttons]] || 0
}
if (init.button != null) {
- return mapping[BUTTON_TO_NAMES[init.button]] || 0;
+ return mapping[BUTTON_TO_NAMES[init.button]] || 0
}
- return property != 'button' && isMousePressEvent(event) ? 1 : 0;
+ return property != 'button' && isMousePressEvent(event) ? 1 : 0
}
function getMouseEventOptions(event, init, clickCount = 0) {
- init = init || {};
+ init = init || {}
return {
...init,
// https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail
- detail: event === 'mousedown' || event === 'mouseup' ? 1 + clickCount : clickCount,
+ detail:
+ event === 'mousedown' || event === 'mouseup'
+ ? 1 + clickCount
+ : clickCount,
buttons: convertMouseButtons(event, init, 'buttons', NAMES_TO_BUTTONS),
button: convertMouseButtons(event, init, 'button', NAMES_TO_BUTTON),
- };
+ }
}
function clickLabel(label, init) {
@@ -93,7 +98,10 @@ function clickBooleanElement(element, init) {
function clickElement(element, previousElement, init) {
fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init))
fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init))
- const continueDefaultHandling = fireEvent.mouseDown(element, getMouseEventOptions('mousedown', init))
+ const continueDefaultHandling = fireEvent.mouseDown(
+ element,
+ getMouseEventOptions('mousedown', init),
+ )
const shouldFocus = element.ownerDocument.activeElement !== element
if (continueDefaultHandling) {
if (previousElement) previousElement.blur()
@@ -108,7 +116,10 @@ function clickElement(element, previousElement, init) {
function dblClickElement(element, previousElement, init) {
fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init))
fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init))
- const continueDefaultHandling = fireEvent.mouseDown(element, getMouseEventOptions('mousedown', init))
+ const continueDefaultHandling = fireEvent.mouseDown(
+ element,
+ getMouseEventOptions('mousedown', init),
+ )
const shouldFocus = element.ownerDocument.activeElement !== element
if (continueDefaultHandling) {
if (previousElement) previousElement.blur()
@@ -220,8 +231,14 @@ function getPreviouslyFocusedElement(element) {
function click(element, init) {
const previouslyFocusedElement = getPreviouslyFocusedElement(element)
if (previouslyFocusedElement) {
- fireEvent.mouseMove(previouslyFocusedElement, getMouseEventOptions('mousemove', init))
- fireEvent.mouseLeave(previouslyFocusedElement, getMouseEventOptions('mouseleave', init))
+ fireEvent.mouseMove(
+ previouslyFocusedElement,
+ getMouseEventOptions('mousemove', init),
+ )
+ fireEvent.mouseLeave(
+ previouslyFocusedElement,
+ getMouseEventOptions('mouseleave', init),
+ )
}
switch (element.tagName) {
@@ -242,8 +259,14 @@ function click(element, init) {
function dblClick(element, init) {
const previouslyFocusedElement = getPreviouslyFocusedElement(element)
if (previouslyFocusedElement) {
- fireEvent.mouseMove(previouslyFocusedElement, getMouseEventOptions('mousemove', init))
- fireEvent.mouseLeave(previouslyFocusedElement, getMouseEventOptions('mouseleave', init))
+ fireEvent.mouseMove(
+ previouslyFocusedElement,
+ getMouseEventOptions('mousemove', init),
+ )
+ fireEvent.mouseLeave(
+ previouslyFocusedElement,
+ getMouseEventOptions('mouseleave', init),
+ )
}
switch (element.tagName) {
@@ -261,8 +284,14 @@ function dblClick(element, init) {
function selectOptions(element, values, init) {
const previouslyFocusedElement = getPreviouslyFocusedElement(element)
if (previouslyFocusedElement) {
- fireEvent.mouseMove(previouslyFocusedElement, getMouseEventOptions('mousemove', init))
- fireEvent.mouseLeave(previouslyFocusedElement, getMouseEventOptions('mouseleave', init))
+ fireEvent.mouseMove(
+ previouslyFocusedElement,
+ getMouseEventOptions('mousemove', init),
+ )
+ fireEvent.mouseLeave(
+ previouslyFocusedElement,
+ getMouseEventOptions('mouseleave', init),
+ )
}
clickElement(element, previouslyFocusedElement, init)
@@ -288,70 +317,6 @@ function clear(element) {
backspace(element)
}
-async function type(element, text, {allAtOnce = false, delay} = {}) {
- if (element.disabled) return
- const previousText = element.value
-
- const computedText =
- element.maxLength > 0
- ? text.slice(0, Math.max(element.maxLength - previousText.length, 0))
- : text
-
- if (allAtOnce) {
- if (!element.readOnly) {
- fireEvent.input(element, {
- target: {value: previousText + computedText},
- })
- }
- } else {
- let actuallyTyped = previousText
- for (let index = 0; index < text.length; index++) {
- const char = text[index]
- const key = char // TODO: check if this also valid for characters with diacritic markers e.g. úé etc
- const keyCode = char.charCodeAt(0)
-
- // eslint-disable-next-line no-await-in-loop
- if (delay > 0) await wait(delay)
-
- const downEvent = fireEvent.keyDown(element, {
- key,
- keyCode,
- which: keyCode,
- })
-
- if (downEvent) {
- const pressEvent = fireEvent.keyPress(element, {
- key,
- keyCode,
- charCode: keyCode,
- })
-
- const isTextPastThreshold =
- (actuallyTyped + key).length > (previousText + computedText).length
-
- if (pressEvent && !isTextPastThreshold) {
- actuallyTyped += key
- if (!element.readOnly) {
- fireEvent.input(element, {
- target: {
- value: actuallyTyped,
- },
- bubbles: true,
- cancelable: true,
- })
- }
- }
- }
-
- fireEvent.keyUp(element, {
- key,
- keyCode,
- which: keyCode,
- })
- }
- }
-}
-
function upload(element, fileOrFiles, {clickInit, changeInit} = {}) {
if (element.disabled) return
const focusedElement = element.ownerDocument.activeElement
diff --git a/src/type.js b/src/type.js
new file mode 100644
index 00000000..8c4f42c5
--- /dev/null
+++ b/src/type.js
@@ -0,0 +1,185 @@
+import {fireEvent} from '@testing-library/dom'
+
+const wait = time => {
+ return new Promise(resolve => {
+ setTimeout(() => resolve(), time)
+ })
+}
+
+const specialKeyMap = {
+ '{enter}': {
+ key: 'Enter',
+ code: 13,
+ typed: '\n',
+ },
+ '{esc}': {
+ key: 'Escape',
+ code: 27,
+ skipPressEvent: true,
+ },
+ '{backspace}': {
+ key: 'Backspace',
+ code: 8,
+ inputType: 'deleteWordBackward',
+ skipPressEvent: true,
+ },
+ '{shift}': {
+ key: 'Shift',
+ code: 16,
+ modifier: 'shiftKey',
+ },
+ '{ctrl}': {
+ key: 'Control',
+ code: 17,
+ modifier: 'ctrlKey',
+ },
+ '{alt}': {
+ key: 'Alt',
+ code: 18,
+ modifier: 'altKey',
+ },
+ '{meta}': {
+ key: 'OS',
+ code: 91,
+ modifier: 'metaKey',
+ },
+}
+
+const parseIntoKeys = text =>
+ text
+ .split(/({[^{}]+?})/)
+ .map(part => {
+ if (specialKeyMap[part]) {
+ return {
+ typed: '',
+ skipPressEvent: false,
+ ...specialKeyMap[part],
+ }
+ }
+
+ return Array.from(part).map(char => {
+ const code = char.charCodeAt(0)
+
+ return {key: char, code, typed: char, skipPressEvent: false}
+ })
+ })
+ .reduce((acc, next) => acc.concat(next), [])
+
+const commitKeyPress = (
+ inputString = '',
+ {element, key: {typed = '', inputType}},
+) => {
+ if (inputType === 'deleteWordBackward') {
+ return inputString.slice(0, -1)
+ }
+
+ if (typed === '\n' && element.tagName === 'INPUT') {
+ return inputString
+ }
+
+ return inputString + typed
+}
+
+const getModifiersFromKeys = keys =>
+ keys.reduce((acc, next) => ({...acc, [next.modifier]: true}), {})
+
+const makeKeyEvent = ({key, heldKeys = []}) => ({
+ charCode: key.code,
+ key: key.key,
+ keyCode: key.code,
+ which: key.code,
+ ...getModifiersFromKeys(heldKeys),
+})
+
+const releaseHeldKeys = ({element, heldKeys}) => {
+ heldKeys.forEach((heldKey, i) => {
+ fireEvent.keyUp(
+ element,
+ makeKeyEvent({key: heldKey, heldKeys: heldKeys.slice(i + 1)}),
+ )
+ })
+}
+
+const fireTypeEvents = async ({element, text, opts, computedText}) => {
+ const previousText = element.value
+ let actuallyTyped = previousText
+ const heldKeys = []
+ const keys = parseIntoKeys(text)
+
+ for (const key of keys) {
+ // eslint-disable-next-line no-await-in-loop
+ if (opts.delay > 0) await wait(opts.delay)
+
+ const downEvent = fireEvent.keyDown(element, makeKeyEvent({key, heldKeys}))
+
+ if (key.modifier) {
+ heldKeys.push(key)
+
+ // eslint-disable-next-line no-continue
+ continue
+ }
+
+ if (downEvent) {
+ const pressEvent = key.skipPressEvent
+ ? true
+ : fireEvent.keyPress(element, makeKeyEvent({key, heldKeys}))
+
+ const isTextPastThreshold =
+ (actuallyTyped + key.typed).length >
+ (previousText + computedText).length
+
+ if (pressEvent && !isTextPastThreshold) {
+ const lastTyped = actuallyTyped
+ actuallyTyped = commitKeyPress(actuallyTyped, {
+ element,
+ key,
+ })
+
+ if (!element.readOnly && lastTyped !== actuallyTyped) {
+ fireEvent.input(element, {
+ target: {
+ value: actuallyTyped,
+ },
+ inputType: key.inputType,
+ bubbles: true,
+ cancelable: true,
+ })
+ }
+ }
+ }
+
+ fireEvent.keyUp(element, makeKeyEvent({key, heldKeys}))
+ }
+
+ releaseHeldKeys({element, heldKeys})
+}
+
+const type = async (element, text, userOpts = {}) => {
+ if (element.disabled) return
+
+ const defaultOpts = {
+ allAtOnce: false,
+ delay: 0,
+ }
+ const opts = Object.assign(defaultOpts, userOpts)
+
+ const previousText = element.value
+ const computedText =
+ element.maxLength > 0
+ ? text.slice(0, Math.max(element.maxLength - previousText.length, 0))
+ : text
+
+ if (opts.allAtOnce) {
+ if (!element.readOnly) {
+ fireEvent.input(element, {
+ target: {value: previousText + computedText},
+ })
+ }
+
+ return
+ }
+
+ await fireTypeEvents({element, text, opts, computedText})
+}
+
+export default type
diff --git a/tests/setup-env.js b/tests/setup-env.js
index 9c33c513..3df76f5d 100644
--- a/tests/setup-env.js
+++ b/tests/setup-env.js
@@ -1,4 +1,5 @@
import '@testing-library/jest-dom/extend-expect'
+import diff from 'jest-diff'
// prevent console calls from making it out into the wild
beforeEach(() => {
@@ -30,6 +31,41 @@ afterEach(() => {
console.info.mockRestore()
})
+const pick = (obj, keys) =>
+ keys.reduce((acc, key) => ({...acc, [key]: obj[key]}), {})
+
+expect.extend({
+ toHaveBeenCalledWithEventAtIndex(received, callIndex, matchEvent) {
+ const event = received.mock.calls[callIndex][0]
+ const keys = Object.keys(matchEvent)
+
+ for (const key of keys) {
+ if (event[key] !== matchEvent[key]) {
+ const diffString = diff(matchEvent, pick(event, keys), {
+ expand: this.expand,
+ })
+
+ return {
+ actual: received,
+ message: () =>
+ `Expected event at call index ${callIndex} to be called with matching event properties.\n\n` +
+ `Difference:\n\n${diffString}`,
+ pass: false,
+ }
+ }
+ }
+
+ return {
+ actual: received,
+ message: () =>
+ `Expected event at call index ${callIndex} not to have any matching properties\n\n` +
+ `Expected: not ${this.utils.printExpected(matchEvent)}\n` +
+ `Received: ${this.utils.printReceived(pick(event, keys))}`,
+ pass: true,
+ }
+ },
+})
+
/*
eslint
no-console: "off",