Skip to content

Commit b8a274f

Browse files
authored
feat: make several API and implementation improvements (#348)
BREAKING CHANGE: Remove `allAtOnce` option in favor of `delay: 0` or `paste` event BREAKING CHANGE: Make `hover` and `unhover` sync BREAKING CHANGE: Remove `toggleSelectedOptions` in favor of `deselectOptions` BREAKING CHANGE: (Potentially...) improve correctness of all APIs (so we fire some additional events and improve general correctness). This may or may not break your usage depending on whether you relied on our in-correctness 😅 BREAKING CHANGE: `type` now *actually* defaults the `delay` to `0`, so it's not necessarily `async` anymore. It's only async if you pass a `delay`.
2 parents 136ac13 + e13df95 commit b8a274f

38 files changed

+3583
-2121
lines changed

README.md

Lines changed: 68 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,17 @@ change the state of the checkbox.
5252

5353
- [Installation](#installation)
5454
- [API](#api)
55-
- [`click(element)`](#clickelement)
56-
- [`dblClick(element)`](#dblclickelement)
57-
- [`async type(element, text, [options])`](#async-typeelement-text-options)
55+
- [`click(element, eventInit, options)`](#clickelement-eventinit-options)
56+
- [`dblClick(element, eventInit, options)`](#dblclickelement-eventinit-options)
57+
- [`type(element, text, [options])`](#typeelement-text-options)
5858
- [`upload(element, file, [{ clickInit, changeInit }])`](#uploadelement-file--clickinit-changeinit-)
5959
- [`clear(element)`](#clearelement)
6060
- [`selectOptions(element, values)`](#selectoptionselement-values)
61-
- [`toggleSelectOptions(element, values)`](#toggleselectoptionselement-values)
61+
- [`deselectOptions(element, values)`](#deselectoptionselement-values)
6262
- [`tab({shift, focusTrap})`](#tabshift-focustrap)
63-
- [`async hover(element)`](#async-hoverelement)
64-
- [`async unhover(element)`](#async-unhoverelement)
63+
- [`hover(element)`](#hoverelement)
64+
- [`unhover(element)`](#unhoverelement)
65+
- [`paste(element, text, eventInit, options)`](#pasteelement-text-eventinit-options)
6566
- [Issues](#issues)
6667
- [Contributors ✨](#contributors-)
6768
- [LICENSE](#license)
@@ -94,7 +95,7 @@ var userEvent = require('@testing-library/user-event')
9495

9596
## API
9697

97-
### `click(element)`
98+
### `click(element, eventInit, options)`
9899

99100
Clicks `element`, depending on what `element` is it can have different side
100101
effects.
@@ -127,7 +128,10 @@ See the
127128
[`MouseEvent`](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/MouseEvent)
128129
constructor documentation for more options.
129130

130-
### `dblClick(element)`
131+
Note that `click` will trigger hover events before clicking. To disable this,
132+
set the `skipHover` option to `true`.
133+
134+
### `dblClick(element, eventInit, options)`
131135

132136
Clicks `element` twice, depending on what `element` is it can have different
133137
side effects.
@@ -147,7 +151,7 @@ test('double click', () => {
147151
})
148152
```
149153

150-
### `async type(element, text, [options])`
154+
### `type(element, text, [options])`
151155

152156
Writes `text` inside an `<input>` or a `<textarea>`.
153157

@@ -156,38 +160,43 @@ import React from 'react'
156160
import {render, screen} from '@testing-library/react'
157161
import userEvent from '@testing-library/user-event'
158162

159-
test('type', async () => {
163+
test('type', () => {
160164
render(<textarea />)
161165

162-
await userEvent.type(screen.getByRole('textbox'), 'Hello,{enter}World!')
166+
userEvent.type(screen.getByRole('textbox'), 'Hello,{enter}World!')
163167
expect(screen.getByRole('textbox')).toHaveValue('Hello,\nWorld!')
164168
})
165169
```
166170

167-
If `options.allAtOnce` is `true`, `type` will write `text` at once rather than
168-
one character at the time. `false` is the default value.
169-
170171
`options.delay` is the number of milliseconds that pass between two characters
171172
are typed. By default it's 0. You can use this option if your component has a
172-
different behavior for fast or slow users.
173+
different behavior for fast or slow users. If you do this, you need to make sure
174+
to `await`!
175+
176+
`type` will click the element before typing. To disable this, set the
177+
`skipClick` option to `true`.
173178

174179
#### Special characters
175180

176181
The following special character strings are supported:
177182

178-
| Text string | Key | Modifier | Notes |
179-
| ------------- | --------- | ---------- | ---------------------------------------------------------------------------------- |
180-
| `{enter}` | Enter | N/A | Will insert a newline character (`<textarea />` only). |
181-
| `{esc}` | Escape | N/A | |
182-
| `{backspace}` | Backspace | N/A | Will delete the previous character (or the characters within the `selectedRange`). |
183-
| `{shift}` | Shift | `shiftKey` | Does **not** capitalize following characters. |
184-
| `{ctrl}` | Control | `ctrlKey` | |
185-
| `{alt}` | Alt | `altKey` | |
186-
| `{meta}` | OS | `metaKey` | |
183+
| Text string | Key | Modifier | Notes |
184+
| ------------- | --------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
185+
| `{enter}` | Enter | N/A | Will insert a newline character (`<textarea />` only). |
186+
| `{esc}` | Escape | N/A | |
187+
| `{backspace}` | Backspace | N/A | Will delete the previous character (or the characters within the `selectedRange`). |
188+
| `{del}` | Delete | N/A | Will delete the next character (or the characters within the `selectedRange`) |
189+
| `{selectall}` | N/A | N/A | Selects all the text of the element. Note that this will only work for elements that support selection ranges (so, not `email`, `password`, `number`, among others) |
190+
| `{shift}` | Shift | `shiftKey` | Does **not** capitalize following characters. |
191+
| `{ctrl}` | Control | `ctrlKey` | |
192+
| `{alt}` | Alt | `altKey` | |
193+
| `{meta}` | OS | `metaKey` | |
187194

188195
> **A note about modifiers:** Modifier keys (`{shift}`, `{ctrl}`, `{alt}`,
189196
> `{meta}`) will activate their corresponding event modifiers for the duration
190-
> of type command or until they are closed (via `{/shift}`, `{/ctrl}`, etc.).
197+
> of type command or until they are closed (via `{/shift}`, `{/ctrl}`, etc.). If
198+
> they are not closed explicitly, then events will be fired to close them
199+
> automatically (to disable this, set the `skipAutoClose` option to `true`).
191200
192201
<!-- space out these notes -->
193202

@@ -308,16 +317,17 @@ userEvent.selectOptions(screen.getByTestId('select-multiple'), [
308317
])
309318
```
310319

311-
### `toggleSelectOptions(element, values)`
320+
### `deselectOptions(element, values)`
312321

313-
Toggle the specified option(s) of a `<select multiple>` element.
322+
Remove the selection for the specified option(s) of a `<select multiple>`
323+
element.
314324

315325
```jsx
316326
import * as React from 'react'
317327
import {render, screen} from '@testing-library/react'
318328
import userEvent from '@testing-library/user-event'
319329

320-
test('toggleSelectOptions', () => {
330+
test('deselectOptions', () => {
321331
render(
322332
<select multiple>
323333
<option value="1">A</option>
@@ -326,14 +336,12 @@ test('toggleSelectOptions', () => {
326336
</select>,
327337
)
328338

329-
userEvent.toggleSelectOptions(screen.getByRole('listbox'), ['1', '3'])
330-
331-
expect(screen.getByText('A').selected).toBe(true)
332-
expect(screen.getByText('C').selected).toBe(true)
333-
334-
userEvent.toggleSelectOptions(screen.getByRole('listbox'), ['1'])
335-
336-
expect(screen.getByText('A').selected).toBe(false)
339+
userEvent.selectOptions(screen.getByRole('listbox'), '2')
340+
expect(screen.getByText('B').selected).toBe(true)
341+
userEvent.deselectOptions(screen.getByRole('listbox'), '2')
342+
expect(screen.getByText('B').selected).toBe(false)
343+
// can do multiple at once as well:
344+
// userEvent.deselectOptions(screen.getByRole('listbox'), ['1', '2'])
337345
})
338346
```
339347

@@ -397,7 +405,7 @@ it('should cycle elements in document tab order', () => {
397405
})
398406
```
399407

400-
### `async hover(element)`
408+
### `hover(element)`
401409

402410
Hovers over `element`.
403411

@@ -407,26 +415,43 @@ import {render, screen} from '@testing-library/react'
407415
import userEvent from '@testing-library/user-event'
408416
import Tooltip from '../tooltip'
409417

410-
test('hover', async () => {
418+
test('hover', () => {
411419
const messageText = 'Hello'
412420
render(
413421
<Tooltip messageText={messageText}>
414422
<TrashIcon aria-label="Delete" />
415423
</Tooltip>,
416424
)
417425

418-
await userEvent.hover(screen.getByLabelText(/delete/i))
426+
userEvent.hover(screen.getByLabelText(/delete/i))
419427
expect(screen.getByText(messageText)).toBeInTheDocument()
420-
await userEvent.unhover(screen.getByLabelText(/delete/i))
428+
userEvent.unhover(screen.getByLabelText(/delete/i))
421429
expect(screen.queryByText(messageText)).not.toBeInTheDocument()
422430
})
423431
```
424432

425-
### `async unhover(element)`
433+
### `unhover(element)`
426434

427435
Unhovers out of `element`.
428436

429-
> See [above](#async-hoverelement) for an example
437+
> See [above](#hoverelement) for an example
438+
439+
### `paste(element, text, eventInit, options)`
440+
441+
Allows you to simulate the user pasting some text into an input.
442+
443+
```javascript
444+
test('should paste text in input', () => {
445+
render(<MyInput />)
446+
447+
const text = 'Hello, world!'
448+
userEvent.paste(getByRole('textbox', {name: /paste your greeting/i}), text)
449+
expect(element).toHaveValue(text)
450+
})
451+
```
452+
453+
You can use the `eventInit` if what you're pasting should have `clipboardData`
454+
(like `files`).
430455

431456
## Issues
432457

@@ -503,6 +528,7 @@ Thanks goes to these people ([emoji key][emojis]):
503528

504529
<!-- markdownlint-enable -->
505530
<!-- prettier-ignore-end -->
531+
506532
<!-- ALL-CONTRIBUTORS-LIST:END -->
507533

508534
This project follows the [all-contributors][all-contributors] specification.

jest.config.js

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,4 @@ const config = require('kcd-scripts/jest')
33
module.exports = {
44
...config,
55
testEnvironment: 'jest-environment-jsdom',
6-
coverageThreshold: {
7-
global: {
8-
branches: 93,
9-
functions: 93,
10-
lines: 93,
11-
statements: 93,
12-
},
13-
},
146
}

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@
4141
"@babel/runtime": "^7.10.2"
4242
},
4343
"devDependencies": {
44-
"@testing-library/dom": "^7.9.0",
45-
"@testing-library/jest-dom": "^5.9.0",
44+
"@testing-library/dom": "^7.16.0",
45+
"@testing-library/jest-dom": "^5.10.1",
4646
"is-ci": "^2.0.0",
47+
"jest-serializer-ansi": "^1.0.3",
4748
"kcd-scripts": "^6.2.3"
4849
},
4950
"peerDependencies": {

src/.eslintrc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"rules": {
3+
// everything in this directory is intentionally running in series, not parallel
4+
// because user's cannot fire multiple events at the same time and we need
5+
// all events fired in a predictable order.
6+
"no-await-in-loop": "off"
7+
}
8+
}

src/__mocks__/@testing-library/dom.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// this helps us track what the state is before and after an event is fired
2+
// this is needed for determining the snapshot values
3+
const actual = jest.requireActual('@testing-library/dom')
4+
5+
function getTrackedElementValues(element) {
6+
return {
7+
value: element.value,
8+
checked: element.checked,
9+
selectionStart: element.selectionStart,
10+
selectionEnd: element.selectionEnd,
11+
12+
// unfortunately, changing a select option doesn't happen within fireEvent
13+
// but rather imperatively via `options.selected = newValue`
14+
// because of this we don't (currently) have a way to track before/after
15+
// in a given fireEvent call.
16+
}
17+
}
18+
19+
function wrapWithTestData(fn) {
20+
return (element, init) => {
21+
const before = getTrackedElementValues(element)
22+
const testData = {before}
23+
24+
// put it on the element so the event handler can grab it
25+
element.testData = testData
26+
const result = fn(element, init)
27+
28+
const after = getTrackedElementValues(element)
29+
Object.assign(testData, {after})
30+
31+
// elete the testData for the next event
32+
delete element.testData
33+
return result
34+
}
35+
}
36+
37+
const mockFireEvent = wrapWithTestData(actual.fireEvent)
38+
39+
for (const key of Object.keys(actual.fireEvent)) {
40+
if (typeof actual.fireEvent[key] === 'function') {
41+
mockFireEvent[key] = wrapWithTestData(actual.fireEvent[key], key)
42+
} else {
43+
mockFireEvent[key] = actual.fireEvent[key]
44+
}
45+
}
46+
47+
module.exports = {
48+
...actual,
49+
fireEvent: mockFireEvent,
50+
}

src/__tests__/blur.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {blur} from '../blur'
2+
import {focus} from '../focus'
3+
import {setup} from './helpers/utils'
4+
5+
test('blur a button', () => {
6+
const {element, getEventSnapshot, clearEventCalls} = setup(`<button />`)
7+
focus(element)
8+
clearEventCalls()
9+
blur(element)
10+
expect(getEventSnapshot()).toMatchInlineSnapshot(`
11+
Events fired on: button
12+
13+
button - blur
14+
button - focusout
15+
`)
16+
expect(element).not.toHaveFocus()
17+
})
18+
19+
test('no events fired on an unblurable input', () => {
20+
const {element, getEventSnapshot, clearEventCalls} = setup(`<div />`)
21+
focus(element)
22+
clearEventCalls()
23+
blur(element)
24+
expect(getEventSnapshot()).toMatchInlineSnapshot(
25+
`No events were fired on: div`,
26+
)
27+
expect(element).not.toHaveFocus()
28+
})
29+
30+
test('blur with tabindex', () => {
31+
const {element, getEventSnapshot, clearEventCalls} = setup(
32+
`<div tabindex="0" />`,
33+
)
34+
focus(element)
35+
clearEventCalls()
36+
blur(element)
37+
expect(getEventSnapshot()).toMatchInlineSnapshot(`
38+
Events fired on: div
39+
40+
div - blur
41+
div - focusout
42+
`)
43+
expect(element).not.toHaveFocus()
44+
})
45+
46+
test('no events fired on a disabled blurable input', () => {
47+
const {element, getEventSnapshot, clearEventCalls} = setup(
48+
`<button disabled />`,
49+
)
50+
focus(element)
51+
clearEventCalls()
52+
blur(element)
53+
expect(getEventSnapshot()).toMatchInlineSnapshot(
54+
`No events were fired on: button`,
55+
)
56+
expect(element).not.toHaveFocus()
57+
})
58+
59+
test('no events fired if the element is not focused', () => {
60+
const {element, getEventSnapshot} = setup(`<button />`)
61+
blur(element)
62+
expect(getEventSnapshot()).toMatchInlineSnapshot(
63+
`No events were fired on: button`,
64+
)
65+
expect(element).not.toHaveFocus()
66+
})

0 commit comments

Comments
 (0)